Trading-Plattform-Pruefung: Abendkontrolle holt die Sicherung des Docker-Hosts (Paket E9, Task 725)

abendkontrolle.py holt je Teil mit Quelle dockerhost die Sicherungen der letzten zehn Kalendertage,
prueft die Summe gegen die des Hosts, legt unter D:\Trading-Sicherung ab, bestaetigt beim Host und
schreibt zuletzt die Summendatei. Zeitbudget 1500 s ab dem Start des Laufs. abendkontrolle_einrichten.ps1
setzt den Abend-Ausloeser in Ortszeit ohne UTC-Versatz (S10). Treiber: 32 Faelle, 42 Mutanten.
Gegengeprueft: byte-gleich mit der Plankopie, Kette mit echten Archiven ueber das echte
abendkontrolle-lesen.sh gruen. SICHERUNG_ERSTER_TAG bleibt None bis zum Einspielen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-11 16:58:56 +02:00
1 parent 77de5059e4
commit c5db92c6de
3 files changed
+1174 -7

No files matched your search

@@ -29,13 +29,21 @@ Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millise
Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time,
gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank.
Seit Paket E9 holt die Aufgabe fuer jeden Teil mit Quelle dockerhost auch die Sicherung des Hosts ab
(hole_sicherungen): je Kalendertag der letzten SICHERUNG_NACHHOLEN_TAGE die Summe des Hosts fragen; wo
die Summendatei auf D: fehlt oder abweicht, <tag>-<instanz>.tar ueber denselben Schluessel holen, Summe
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).
Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>]
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost]
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel <ordner>]
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import traceback
@@ -44,6 +52,7 @@ from datetime import datetime
from datetime import time
from datetime import timedelta
from pathlib import Path
from time import monotonic
VAULT = Path(r"D:\Claude")
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
@@ -82,6 +91,34 @@ KOEPFE = {
}
# Seit Paket E9 (Trading Plattform 0.39.0): Der Host sichert werktags 16:45 New Yorker Zeit je
# Instanz Register, data/paper und artifacts/paper (sicherung-host.sh). Diese Aufgabe holt fuer
# jeden Teil mit Quelle dockerhost die Sicherung ueber denselben Schluessel ab (abendkontrolle-lesen.sh
# mit sicherung-summe, sicherung und sicherung-bestaetigt), prueft die Summe und legt sie unter
# SICHERUNG_ZIEL/<tag>/<instanz>.tar samt <instanz>.tar.sha256 ab. Massstab ist die aktuelle Summe des
# Hosts: Fehlt die Summendatei auf D: oder weicht sie ab, holt der Lauf neu und ersetzt die alte Fassung
# (Plankritik Runde 1, Befunde 5 und 24). Sonst loescht er auf D: nichts.
SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")
# Der erste Tag, den die Aufgabe abholt; None holt nichts. Gesetzt beim Einspielen von E9 (S8): Diese
# Datei laeuft schon vorher jeden Abend, der Host kennt die Lesebefehle erst danach.
SICHERUNG_ERSTER_TAG: date | None = None
# So viele Kalendertage vor heute fragt jeder Lauf, dazu heute. Das Fenster liegt in der Aufbewahrung
# des Hosts: Er haelt die neuesten zehn Sicherungstage, nur werktags gesichert, also gut zwei Kalenderwochen.
# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026
# und vom 14.3. bis 27.3.2027 schon 21:45. Die Aufgabe startet 23:15 Ortszeit (S10); fehlt die
# Sicherung von heute trotzdem noch, ist das kein Fehlschlag.
SICHERUNG_NACHHOLEN_TAGE = 10
# Je Archiv; Summe und Bestaetigung nehmen SSH_TIMEOUT_S. Die Aufgabe selbst endet nach 45 Minuten.
SICHERUNG_TIMEOUT_S = 600
# Zeitbudget je Lauf fuer die Sicherung, geprueft vor jeder Instanz: Ist es um, faengt der Lauf nichts
# mehr an, und der naechste holt den Rest (Plankritik Runde 1, Befund 12).
SICHERUNG_BUDGET_S = 1500
SICHERUNG_KOPF = "Sicherung Trading Plattform (automatisch)"
# Eine Zeile im Format von sha256sum: 64 Hexzeichen in Kleinbuchstaben, zwei Leerzeichen, Name, LF.
SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")
TAGFORM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
tage = []
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
@@ -155,6 +192,227 @@ def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict:
raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
def ssh_aufruf(befehl: str) -> list[str]:
"""Die Befehlszeile fuer einen Befehl der Sicherung, mit denselben Optionen wie fahre_dockerhost."""
return [
SSH,
"-p", SSH_PORT,
"-i", str(SSH_SCHLUESSEL),
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", f"UserKnownHostsFile={SSH_BEKANNTE}",
"-o", "ConnectTimeout=20",
SSH_ZIEL,
befehl,
]
def fehlerzeile(lauf: subprocess.CompletedProcess) -> str:
"""Exit und die letzten Zeilen von stderr, gekuerzt."""
fehler = (lauf.stderr or b"").decode("utf-8", errors="replace").strip().splitlines()[-3:]
return f"Exit {lauf.returncode}; {' | '.join(fehler)[:300]}"
def summe_von(datei: Path) -> str:
"""SHA-256 einer Datei in Kleinbuchstaben, stueckweise gelesen."""
summe = hashlib.sha256()
with datei.open("rb") as strom:
for stueck in iter(lambda: strom.read(1 << 20), b""):
summe.update(stueck)
return summe.hexdigest()
def ssh_lauf(befehl: str, timeout, stdout=None) -> subprocess.CompletedProcess:
"""Ein Befehl der Sicherung beim Host. Mit stdout (offene Datei) geht die Ausgabe dorthin.
Eigene Funktion, damit die Angriffsfaelle Zeitlimit und Dauer jedes Befehls sehen.
"""
if stdout is None:
return subprocess.run(ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER)
return subprocess.run(
ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, creationflags=KEIN_FENSTER,
)
def summe_auf_d(ordner: Path, instanz: str) -> str | None:
"""Die Summe aus <instanz>.tar.sha256 in ordner, wenn sie dort richtig steht und das Archiv daneben liegt.
Die Summendatei schreibt nur hole_sicherung, nach Pruefung und Bestaetigung: Sie steht fuer ein
geprueftes und bestaetigtes Archiv. Das Archiv selbst hasht dieser Lauf nicht nach (Plankritik
Runde 1, Befund 21); sonst laese jeder Lauf alle Archive des Fensters.
"""
archiv = ordner / f"{instanz}.tar"
try:
treffer = SUMMENZEILE.fullmatch((ordner / f"{instanz}.tar.sha256").read_bytes().decode("ascii"))
except (OSError, UnicodeDecodeError):
return None
if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():
return None
return treffer.group(1)
def sicherungstage(jetzt: datetime, tag: str | None = None) -> list[str]:
"""Die Tage, fuer die der Lauf den Host fragt, der aelteste zuerst.
Ohne SICHERUNG_ERSTER_TAG keiner. Mit tag (--tag) nur dieser. Sonst jeder Kalendertag der letzten
SICHERUNG_NACHHOLEN_TAGE bis heute ab SICHERUNG_ERSTER_TAG, auch Samstag und Sonntag (ein
nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und auch heute (Plankritik Runde 1,
Befunde 8, 16, 18 und 19). Ob das Ergebnis der Abendkontrolle fuer den Tag schon liegt, spielt
keine Rolle.
"""
erster = SICHERUNG_ERSTER_TAG
if erster is None:
return []
if tag is not None:
# Der Tag wird Teil eines Pfads und eines Befehls auf dem Host: nur JJJJ-MM-TT.
if not TAGFORM.fullmatch(tag):
raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT")
date.fromisoformat(tag)
return [tag]
tage = []
for zurueck in range(SICHERUNG_NACHHOLEN_TAGE, -1, -1):
kandidat = jetzt.date() - timedelta(days=zurueck)
if kandidat >= erster:
tage.append(kandidat.isoformat())
return tage
def summe_vom_host(name: str) -> str | None:
"""Die Summe, die der Host fuer <name> fuehrt; None bei Exit 3 (dort liegt keine Sicherung)."""
lauf = ssh_lauf(f"sicherung-summe {name}", SSH_TIMEOUT_S)
if lauf.returncode == 3:
return None
if lauf.returncode != 0:
raise RuntimeError(f"Summe nicht gelesen ({fehlerzeile(lauf)})")
treffer = SUMMENZEILE.fullmatch(lauf.stdout.decode("ascii", errors="replace"))
if treffer is None or treffer.group(2) != f"{name}.tar":
raise RuntimeError("Summendatei des Hosts in unerwarteter Form")
return treffer.group(1)
def ohne_sicherung_still(tag: str, jetzt: datetime) -> bool:
"""Ist Exit 3 fuer diesen Tag kein Fehlschlag? Ja fuer heute (und spaeter) und fuer Samstag und Sonntag.
Heute kann die Sicherung des Hosts noch ausstehen; am Wochenende sichert er nur, wenn er einen
verpassten Lauf nachholt. Fehlt sie an einem vergangenen Werktag, ist das ein Fehlschlag.
"""
kandidat = date.fromisoformat(tag)
return kandidat >= jetzt.date() or kandidat.weekday() >= 5
def sichere(tag: str, instanz: str, ziel: Path, jetzt: datetime) -> tuple[str, bool]:
"""Eine Instanz eines Tags. Liefert ihren Teil der Zeile und ob geholt wurde; Fehlschlag: Ausnahme.
Massstab ist die aktuelle Summe des Hosts (Plankritik Runde 1, Befunde 5 und 24). Steht dieselbe in
der Summendatei auf D:, ist der Tag geholt und bestaetigt. Sonst holt der Lauf neu und ersetzt die
alte Fassung, etwa nachdem der Host den Tag ein zweites Mal gesichert hat.
"""
name = f"{tag}-{instanz}"
summe = summe_vom_host(name)
if summe is None:
if ohne_sicherung_still(tag, jetzt):
return f"{instanz} auf dem Docker-Host noch nicht gesichert", False
raise RuntimeError(f"auf dem Docker-Host liegt fuer {name} noch keine Sicherung")
if summe_auf_d(ziel / tag, instanz) == summe:
return f"{instanz} lag schon vollständig", False
return hole_sicherung(tag, instanz, ziel, summe), True
def hole_sicherung(tag: str, instanz: str, ziel: Path, summe: str) -> str:
"""Holt die Sicherung <tag>-<instanz> mit der Summe des Hosts, prueft, legt ab und bestaetigt.
Reihenfolge: Archiv in die Nachbardatei <instanz>.tar.teil holen, Summe pruefen, eine alte
<instanz>.tar.sha256 entfernen, auf <instanz>.tar umbenennen (ersetzt eine alte Fassung), beim
Host bestaetigen, zuletzt <instanz>.tar.sha256 schreiben. Erst die Summendatei macht die Ablage
vollstaendig: Scheitert die Bestaetigung, fehlt sie, und der naechste Lauf holt und bestaetigt
erneut. Bei falscher Summe wird nichts umbenannt und nichts bestaetigt. Jeder Fehlschlag ist
eine Ausnahme mit dem Grund. Liefert den Teil der Zeile.
"""
name = f"{tag}-{instanz}"
ordner = ziel / tag
ordner.mkdir(parents=True, exist_ok=True)
teil = ordner / f"{instanz}.tar.teil"
archiv = ordner / f"{instanz}.tar"
summendatei = ordner / f"{instanz}.tar.sha256"
try:
with teil.open("wb") as strom:
lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)
if lauf.returncode != 0:
raise RuntimeError(f"Archiv nicht geholt ({fehlerzeile(lauf)})")
if summe_von(teil) != summe:
raise RuntimeError("Summe des geholten Archivs ist nicht die des Hosts; nicht abgelegt, nicht bestaetigt")
summendatei.unlink(missing_ok=True)
os.replace(teil, archiv)
finally:
try:
teil.unlink(missing_ok=True)
except OSError:
pass
lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)
if lauf.returncode != 0:
raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")
neu = ordner / f"{instanz}.tar.sha256.neu"
neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))
os.replace(neu, summendatei)
return f"{instanz} abgelegt und bestätigt ({archiv.stat().st_size} Bytes)"
def hole_sicherungen(jetzt: datetime, vault: Path, ablage: Path, ziel: Path, instanzen: list[str],
frist: float, tag: str | None = None) -> int:
"""Je Tag des Fensters und Instanz: Summe des Hosts fragen und holen, wo D: sie nicht hat.
Eine Zeile in der Notiz je Tag, an dem etwas geholt wurde, etwas scheiterte oder das Zeitbudget
endete; ein Fehlschlag traegt #needs-review. Ein Tag ohne Neues bekommt keine Zeile. frist ist der
Stand von monotonic(), an dem SICHERUNG_BUDGET_S ab dem Start des Laufs um ist (main); danach
faengt der Lauf keine Instanz mehr an, und der naechste holt den Rest.
"""
if not instanzen:
return 0
code = 0
erschoepft = False
for kandidat in sicherungstage(jetzt, tag):
if erschoepft:
break
uhrzeit = jetzt.strftime("%H:%M") if kandidat == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
stuecke = []
gescheitert = False
getan = False
for instanz in instanzen:
if monotonic() >= frist:
erschoepft = getan = True
stuecke.append(
f"{instanz} nicht angefangen: Zeitbudget des Laufs ({SICHERUNG_BUDGET_S} s) erschöpft, "
"der nächste Lauf holt den Rest"
)
logge(ablage, f"sicherung {kandidat}-{instanz}: Zeitbudget erschoepft")
continue
try:
stueck, geholt = sichere(kandidat, instanz, ziel, jetzt)
stuecke.append(stueck)
if geholt:
getan = True
logge(ablage, f"sicherung {kandidat}-{instanz}: abgelegt und bestaetigt")
except Exception as ausnahme:
gescheitert = True
stuecke.append(f"{instanz} gescheitert ({type(ausnahme).__name__}: {str(ausnahme)[:200]})")
logge(ablage, f"sicherung {kandidat}-{instanz}: gescheitert {ausnahme!r}")
if not (gescheitert or getan):
continue
kopf = f"- {uhrzeit} Uhr — {SICHERUNG_KOPF}"
if gescheitert:
code = 1
zeile = f"{kopf}: **gescheitert** #needs-review. {'; '.join(stuecke)}. Der nächste Lauf versucht es erneut."
else:
zeile = f"{kopf}: {'; '.join(stuecke)}."
try:
schreibe_notiz(vault, kandidat, zeile)
except Exception as ausnahme:
code = 1
logge(ablage, f"sicherung {kandidat}: Notiz nicht geschrieben {ausnahme!r}")
return code
def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
befunde = ergebnis.get("befunde") or []
@@ -284,7 +542,14 @@ def main(argv=None) -> int:
"--quelle-ibkr", choices=QUELLEN,
help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle",
)
zerleger.add_argument(
"--sicherung-ziel",
help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)",
)
argumente = zerleger.parse_args(argv)
# Seit Paket E9: Das Zeitbudget der Sicherung zaehlt ab hier, also mit der Zeit fuer die
# Ergebnisse (bis zu zwoelf Abfragen mit je SSH_TIMEOUT_S; Plankritik Runde 2, Befund 1).
frist = monotonic() + SICHERUNG_BUDGET_S
vault = Path(argumente.vault)
ablage = vault / ABLAGE_TEIL
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
@@ -313,6 +578,21 @@ def main(argv=None) -> int:
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
except Exception:
pass
# Seit Paket E9: die Sicherung jedes Teils mit Quelle dockerhost, nach den Ergebnissen beider
# Teile und unabhaengig davon, ob ein Ergebnis gescheitert ist.
instanzen = [
teil for teil in teile
if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"
]
ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL
try:
code = max(code, hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag))
except Exception as ausnahme:
code = 1
try:
logge(ablage, f"sicherung abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
except Exception:
pass
return code
@@ -9,21 +9,36 @@ bricht argparse damit ab, und das soll ein Fall sein, nicht das Ende des Laufs.
Abgedichtet (Plankritik DH, 2026-09-26): WSL zeigt in jedem Lauf ins Leere, und fahre_wsl ist
ohne eigene Attrappe des Falls eine Attrappe, die ablehnt; ein Aufruf des echten Wegs ist eine
Abweichung, kein Lauf. Zwei Faelle belegen beide Sperren.
Aufruf: python abendkontrolle_dockerhost_angriffe.py
Exit 0 ohne Abweichung, 2 mit Abweichung.
Seit Paket E9 (Sicherung): Die Attrappe kennt auch sicherung-summe, sicherung und
sicherung-bestaetigt, so streng wie abendkontrolle-lesen.sh (Task 724: Formen, Exit 0, 1, 2, 3, 4),
gegen eine Host-Ablage im Wegwerf-Ordner; ANGRIFF_SICHERUNG stoert sie gezielt. Abgedichtet:
lauf() setzt SICHERUNG_ERSTER_TAG auf None und SICHERUNG_ZIEL in den Wegwerf-Ordner;
sicherungslauf() lenkt mit --sicherung-ziel um, und das Ziel der Konstante muss dort leer bleiben.
Nichts landet unter D:\\Trading-Sicherung.
Aufruf: python abendkontrolle_dockerhost_angriffe.py [--mutanten]
Exit 0 ohne Abweichung, 2 mit Abweichung. --mutanten faehrt MUTANTEN auf Kopien von
abendkontrolle.py und MUTANTEN_AUFGABE auf Kopien von abendkontrolle_einrichten.ps1: Jeder genannte
Fall muss rot werden; Exit 0, wenn keiner ueberlebt, sonst 2. Die .ps1 liest der Treiber nur als Text.
"""
import contextlib
import hashlib
import importlib.util
import io
import json
import os
import sys
import tempfile
from datetime import date
from datetime import datetime
from pathlib import Path
HIER = Path(__file__).resolve().parent
# Seit Paket E9: das geladene Werkzeug; im Modus --mutanten eine kaputte Kopie (mutanten()).
QUELLE = HIER / "abendkontrolle.py"
# Seit der Plankritik Runde 1 von E9: das Skript der Aufgabe, nur als Text gelesen; im Modus
# --mutanten auch hier eine kaputte Kopie.
AUFGABE = HIER / "abendkontrolle_einrichten.ps1"
ATTRAPPE_PY = r'''
import json
@@ -32,6 +47,71 @@ import sys
with open(os.environ["ANGRIFF_PROTOKOLL"], "a", encoding="utf-8") as datei:
datei.write(json.dumps(sys.argv[1:]) + "\n")
befehl = sys.argv[-1]
if befehl.startswith("sicherung"):
# Seit Paket E9: die Lesebefehle der Sicherung, so streng wie abendkontrolle-lesen.sh (Task 724),
# gegen die Ablage unter ANGRIFF_HOST. ANGRIFF_SICHERUNG ist <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 ""
archiv = os.path.join(host, name + ".tar")
summendatei = archiv + ".sha256"
gemeint = archiv if lesen and lesen.group(1) == "sicherung" else summendatei
if not os.path.isfile(gemeint):
sys.stderr.write("abendkontrolle-lesen: " + os.path.basename(gemeint) + " liegt auf dem Docker-Host nicht vor\n")
sys.exit(3)
with open(gemeint, "rb") as quelle:
inhalt = quelle.read()
if lesen and lesen.group(1) == "sicherung-summe":
if gestoert == "summe_gross":
inhalt = inhalt[:64].upper() + inhalt[64:]
if gestoert == "summe_fremd":
inhalt = inhalt.replace(name.encode("ascii"), b"2026-01-02-alpaca")
sys.stdout.buffer.write(inhalt)
sys.exit(0)
if lesen:
if gestoert == "kaputt":
inhalt = inhalt[:-1] + bytes([inhalt[-1] ^ 1])
if gestoert == "abbruch":
sys.stdout.buffer.write(inhalt[: len(inhalt) // 2])
sys.stdout.buffer.flush()
sys.stderr.write("Connection reset by peer\n")
sys.exit(255)
sys.stdout.buffer.write(inhalt)
sys.stdout.buffer.flush()
if gestoert == "neu_gesichert":
# Der Host sichert denselben Tag neu, waehrend Windows noch holt.
neu = inhalt + b" neu gesichert"
with open(archiv, "wb") as ziel:
ziel.write(neu)
with open(summendatei, "wb") as ziel:
ziel.write((hashlib.sha256(neu).hexdigest() + " " + name + ".tar\n").encode("ascii"))
sys.exit(0)
summe = bestaetigen.group(2)
if inhalt.split(b"\n")[0].split(b" ")[0].decode("ascii") != summe:
sys.stderr.write("abendkontrolle-lesen: die Summe fuer " + name + " ist nicht die des Hosts, nichts bestaetigt\n")
sys.exit(4)
bestaetigt = os.path.join(host, "bestaetigt")
if gestoert == "bestaetigung_scheitert" or not os.path.isdir(bestaetigt):
sys.stderr.write("abendkontrolle-lesen: Bestaetigung fuer " + name + " nicht geschrieben\n")
sys.exit(1)
with open(os.path.join(bestaetigt, name), "wb") as ziel:
ziel.write((summe + "\n").encode("ascii"))
sys.exit(0)
art = os.environ.get("ANGRIFF_ART", "gut")
if art == "fehlt":
sys.stderr.write("abendkontrolle-lesen: fuer diesen Tag liegt noch kein Ergebnis\n")
@@ -55,7 +135,8 @@ def fall(funktion):
def lade():
spec = importlib.util.spec_from_file_location("abendkontrolle_unter_angriff", HIER / "abendkontrolle.py")
# QUELLE zeigt im Modus --mutanten auf eine kaputte Kopie (seit Paket E9).
spec = importlib.util.spec_from_file_location("abendkontrolle_unter_angriff", QUELLE)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
@@ -80,6 +161,13 @@ def lauf(art, *argumente, modul=None):
# Pfaden mitschreibt und ablehnt -- ausser ein Fall hat seine eigene gesetzt (wsl_attrappe).
modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe")
modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py"
# Abdichtung (Paket E9): Ohne ersten Sicherungstag holt kein Lauf eine Sicherung, und das
# Ziel liegt im Wegwerf-Ordner statt unter D:\Trading-Sicherung. Die Faelle der Sicherung
# laufen ueber sicherungslauf().
modul.SICHERUNG_ERSTER_TAG = None
modul.SICHERUNG_ZIEL = wurzel / "sicherung-ziel"
os.environ.pop("ANGRIFF_HOST", None)
os.environ.pop("ANGRIFF_SICHERUNG", None)
unerwartet = []
if not getattr(modul.fahre_wsl, "attrappe", False):
@@ -233,8 +321,12 @@ def eine_unbekannte_quelle_ist_ein_bedienfehler():
@fall
def die_heutige_frist_und_die_aufgabe_passen_zueinander():
quelle = (HIER / "abendkontrolle.py").read_text(encoding="utf-8")
aufgabe = (HIER / "abendkontrolle_einrichten.ps1").read_text(encoding="utf-8")
# Seit Paket E9 ueber QUELLE und AUFGABE, damit --mutanten auch diesen Fall trifft. Die Uhrzeit der
# Aufgabe gilt nur in Ortszeit, wenn das Skript StartBoundary ohne Versatz setzt (S10; die Form
# prueft der_abendausloeser_steht_in_ortszeit_ohne_versatz).
quelle = QUELLE.read_text(encoding="utf-8")
aufgabe = AUFGABE.read_text(encoding="utf-8")
assert "$Abends.StartBoundary = " in aufgabe, "Abend-Ausloeser ohne StartBoundary in Ortszeit"
assert "StrictHostKeyChecking=no" not in quelle
assert "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:" in quelle
assert aufgabe.count('[ValidateSet("wsl", "dockerhost")]') == 2
@@ -273,7 +365,797 @@ def der_echte_wsl_aufruf_findet_kein_wsl():
raise AssertionError("fahre_wsl lief ohne wsl.exe weiter")
def main() -> int:
# --- Seit Paket E9: das Abholen der Sicherung -------------------------------------------------
TAG = "2026-10-05"
KOPF = "Sicherung Trading Plattform (automatisch)"
# Bytes, die kein Text sind, auch CR, LF und Strg-Z: Die Uebertragung muss sie unveraendert lassen.
ARCHIVE = {
"alpaca": b"tar-attrappe alpaca\r\n" + bytes(range(256)),
"ibkr": b"tar-attrappe ibkr\n" + bytes(reversed(range(256))),
}
def host_ablage(wurzel, tage=(TAG,), instanzen=("alpaca", "ibkr")):
"""Die Ablage des Hosts wie nach sicherung-host.sh (Task 723): Archiv, Summendatei, bestaetigt/."""
host = wurzel / "host"
(host / "bestaetigt").mkdir(parents=True)
for tag in tage:
for instanz in instanzen:
inhalt = ARCHIVE[instanz] + tag.encode("ascii")
(host / f"{tag}-{instanz}.tar").write_bytes(inhalt)
zeile = f"{hashlib.sha256(inhalt).hexdigest()} {tag}-{instanz}.tar\n"
(host / f"{tag}-{instanz}.tar.sha256").write_bytes(zeile.encode("ascii"))
return host
def host_summe(host, instanz, tag=TAG):
"""Die Summe, die der Host fuer <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):
"""Ein Lauf von main() mit Quelle dockerhost und Sicherung, alles unter wurzel.
Vault, Host-Ablage (host_ablage) und Ziel bleiben stehen, damit ein zweiter Lauf daran
weiterarbeiten kann. Abgedichtet wie lauf(): ssh ist die Attrappe, WSL zeigt ins Leere, und
fahre_wsl lehnt ohne eigene Attrappe des Falls ab. Mit schalter geht die Sicherung ueber
--sicherung-ziel nach wurzel/ziel, und das Ziel der Konstante (wurzel/konstante-ziel) muss leer
bleiben; ohne schalter gilt die Konstante. Liefert Exit, Notizen, die Befehle der Sicherung an
den Host und ihre ganzen Befehlszeilen.
"""
vault = wurzel / "vault"
(vault / "Inbox").mkdir(parents=True, exist_ok=True)
protokoll = wurzel / "ssh.jsonl"
protokoll.unlink(missing_ok=True)
attrappe = wurzel / "ssh_attrappe.py"
attrappe.write_text(ATTRAPPE_PY, encoding="utf-8")
cmd = wurzel / "ssh_attrappe.cmd"
cmd.write_text(f'@"{sys.executable}" "{attrappe}" %*\r\n', encoding="utf-8")
modul = modul or lade()
modul.SSH = str(cmd)
modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe")
modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py"
unerwartet = []
if not getattr(modul.fahre_wsl, "attrappe", False):
def wsl_verboten(tag, teil="alpaca"):
unerwartet.append((tag, teil))
raise RuntimeError("echter WSL-Weg ohne Attrappe")
modul.fahre_wsl = wsl_verboten
modul.SICHERUNG_ERSTER_TAG = erster_tag
modul.SICHERUNG_ZIEL = wurzel / "konstante-ziel"
os.environ["ANGRIFF_PROTOKOLL"] = str(protokoll)
os.environ["ANGRIFF_ART"] = "gut"
os.environ["ANGRIFF_HOST"] = str(wurzel / "host")
os.environ["ANGRIFF_SICHERUNG"] = stoerung
vorne = ["--vault", str(vault), "--quelle", "dockerhost"]
if schalter:
vorne += ["--sicherung-ziel", str(wurzel / "ziel")]
code = modul.main([*vorne, *argumente])
if unerwartet:
raise AssertionError(f"WSL-Weg ohne Attrappe gerufen: {unerwartet}")
if schalter and (wurzel / "konstante-ziel").exists():
raise AssertionError("Sicherung unter SICHERUNG_ZIEL statt unter --sicherung-ziel")
notiz = "\n".join(n.read_text(encoding="utf-8") for n in sorted((vault / "Inbox").glob("*.md")))
zeilen = protokoll.read_text(encoding="utf-8").splitlines() if protokoll.exists() else []
ganz = [json.loads(z) for z in zeilen if z.strip()]
ganz = [argumente_ssh for argumente_ssh in ganz if argumente_ssh[-1].startswith("sicherung")]
return code, notiz, [argumente_ssh[-1] for argumente_ssh in ganz], ganz
@fall
def sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
code, notiz, befehle, ganz = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
assert code == 0, (code, notiz)
erwartet = []
for instanz in ("alpaca", "ibkr"):
erwartet += [
f"sicherung-summe {TAG}-{instanz}",
f"sicherung {TAG}-{instanz}",
f"sicherung-bestaetigt {TAG}-{instanz} {host_summe(host, instanz)}",
]
assert befehle == erwartet, befehle
for argumente_ssh in ganz:
assert argumente_ssh[:3] == ["-p", "2498", "-i"], argumente_ssh
assert argumente_ssh[3].endswith("trading-dh-kontrolle"), argumente_ssh
assert argumente_ssh[4:15] == [
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=~/.ssh/known_hosts_trading_dh",
"-o", "ConnectTimeout=20",
"[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():
for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"abbruch:{kaputt}"
)
zeile = einzige_zeile(notiz)
assert code == 1, code
assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG)
assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt")
assert not any(b.startswith(f"sicherung-bestaetigt {TAG}-{kaputt} ") for b in befehle), befehle
assert f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (Exit 255;" in zeile, zeile
assert "#needs-review" in zeile, zeile
@fall
def exit_3_heisst_noch_keine_sicherung_auf_dem_host():
for vorhanden in ((), ("ibkr",), ("alpaca",)):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel, instanzen=vorhanden)
# Seit der Plankritik Runde 1 ist Exit 3 nur fuer einen vergangenen Werktag ein Fehlschlag:
# TAG ist ein Montag, der Lauf kommt am Dienstag.
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30")
zeile = einzige_zeile(notiz)
assert code == 1, (vorhanden, code)
assert "**gescheitert** #needs-review" in zeile, zeile
for instanz in ("alpaca", "ibkr"):
if instanz in vorhanden:
assert f"{instanz} abgelegt und bestätigt" in zeile, zeile
else:
assert f"auf dem Docker-Host liegt fuer {TAG}-{instanz} noch keine Sicherung" in zeile, zeile
assert f"sicherung {TAG}-{instanz}" not in befehle, befehle
assert not (wurzel / "ziel" / TAG / f"{instanz}.tar").exists(), instanz
assert namen(host / "bestaetigt") == [f"{TAG}-{i}" for i in vorhanden], namen(host / "bestaetigt")
@fall
def exit_3_fuer_heute_und_das_wochenende_ist_still():
# Plankritik Runde 1, Befunde 8, 16 und 18: Heute kann die Sicherung des Hosts noch ausstehen, und
# am Wochenende sichert er nur, wenn er einen Lauf nachholt. Exit 3 ist dann kein Fehlschlag und
# gibt fuer sich allein keine Zeile.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel, tage=())
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-05T18:00", erster_tag=date(2026, 10, 3)
)
assert code == 0, (code, notiz)
assert befehle == [
f"sicherung-summe {t}-{i}" for t in ("2026-10-03", "2026-10-04", "2026-10-05") for i in ("alpaca", "ibkr")
], befehle
assert sicherungszeilen(notiz) == [], notiz
assert not (wurzel / "ziel").exists()
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
assert code == 0, (code, notiz)
assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle
assert sicherungszeilen(notiz) == [], notiz
# Fehlt heute nur eine Instanz, nennt die Zeile sie ohne #needs-review.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel, instanzen=("alpaca",))
code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
zeile = einzige_zeile(notiz)
assert code == 0, (code, notiz)
assert zeile.endswith("ibkr auf dem Docker-Host noch nicht gesichert."), zeile
assert "#needs-review" not in zeile, zeile
@fall
def eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
assert erster[0] == 0, erster[:2]
ziel = wurzel / "ziel" / TAG
vorher = {p.name: p.read_bytes() for p in ziel.iterdir()}
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45")
assert code == 0, code
# Seit der Plankritik Runde 1 fragt jeder Lauf die Summe des Hosts; geholt wird nur bei Abweichung.
assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle
assert {p.name: p.read_bytes() for p in ziel.iterdir()} == vorher
assert len(sicherungszeilen(notiz)) == 1, notiz
@fall
def ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt():
# Plankritik Runde 1, Befunde 5 und 24: Sichert der Host einen Tag nach dem Abholen ein zweites Mal
# (Abnahme 3 von Hand, um 16:45 der Zeitgeber), entfernt er dessen Bestaetigung (Task 723). Der
# naechste Lauf sieht die neue Summe, holt neu, ersetzt die alte Fassung auf D: und bestaetigt.
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T10:30")
assert erster[0] == 0, erster[:2]
neu = ARCHIVE["alpaca"] + b" zweiter Lauf"
summe = hashlib.sha256(neu).hexdigest()
(host / f"{TAG}-alpaca.tar").write_bytes(neu)
(host / f"{TAG}-alpaca.tar.sha256").write_bytes(f"{summe} {TAG}-alpaca.tar\n".encode("ascii"))
(host / "bestaetigt" / f"{TAG}-alpaca").unlink()
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:15")
ziel = wurzel / "ziel" / TAG
assert code == 0, (code, notiz)
assert befehle == [
f"sicherung-summe {TAG}-alpaca",
f"sicherung {TAG}-alpaca",
f"sicherung-bestaetigt {TAG}-alpaca {summe}",
f"sicherung-summe {TAG}-ibkr",
], befehle
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel)
assert (ziel / "alpaca.tar").read_bytes() == neu
assert (ziel / "alpaca.tar.sha256").read_bytes() == f"{summe} alpaca.tar\n".encode("ascii")
assert (host / "bestaetigt" / f"{TAG}-alpaca").read_bytes() == f"{summe}\n".encode("ascii")
zeilen = sicherungszeilen(notiz)
assert len(zeilen) == 2, notiz
assert "alpaca abgelegt und bestätigt" in zeilen[1] and "ibkr lag schon vollständig" in zeilen[1], zeilen[1]
@fall
def nur_die_fehlende_instanz_wird_geholt():
for da, fehlt in (("alpaca", "ibkr"), ("ibkr", "alpaca")):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
ziel = wurzel / "ziel" / TAG
ziel.mkdir(parents=True)
(ziel / f"{da}.tar").write_bytes((host / f"{TAG}-{da}.tar").read_bytes())
(ziel / f"{da}.tar.sha256").write_bytes(f"{host_summe(host, da)} {da}.tar\n".encode("ascii"))
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
zeile = einzige_zeile(notiz)
assert code == 0, (code, notiz)
summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")]
assert summen == [f"{TAG}-alpaca", f"{TAG}-ibkr"], befehle
assert [b.split(" ")[1] for b in befehle if not b.startswith("sicherung-summe ")] == [f"{TAG}-{fehlt}"] * 2, befehle
assert f"{da} lag schon vollständig" in zeile and f"{fehlt} abgelegt und bestätigt" in zeile, zeile
assert namen(host / "bestaetigt") == [f"{TAG}-{fehlt}"], namen(host / "bestaetigt")
@fall
def fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
ziel = wurzel / "ziel" / TAG
ziel.mkdir(parents=True)
(ziel / "notiz.txt").write_bytes(b"fremd\n")
(ziel / "alpaca.tar.teil").write_bytes(b"Rest eines abgebrochenen Laufs")
# alpaca: die passende Summendatei, aber kein Archiv daneben. ibkr: richtiges Archiv und richtige
# Summe, aber in der Summendatei der Name einer anderen Datei. Das Archiv selbst hasht der Lauf
# nicht nach (Plankritik Runde 1, Befund 21); beides zaehlt trotzdem nicht als Sicherung.
(ziel / "alpaca.tar.sha256").write_bytes(f"{host_summe(host, 'alpaca')} alpaca.tar\n".encode("ascii"))
(ziel / "ibkr.tar").write_bytes((host / f"{TAG}-ibkr.tar").read_bytes())
(ziel / "ibkr.tar.sha256").write_bytes(f"{host_summe(host, 'ibkr')} alpaca.tar\n".encode("ascii"))
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
assert code == 0, (code, notiz)
assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256", "notiz.txt"], namen(ziel)
assert (ziel / "notiz.txt").read_bytes() == b"fremd\n"
for instanz in ("alpaca", "ibkr"):
summe = host_summe(host, instanz)
assert (ziel / f"{instanz}.tar").read_bytes() == (host / f"{TAG}-{instanz}.tar").read_bytes(), instanz
assert (ziel / f"{instanz}.tar.sha256").read_bytes() == f"{summe} {instanz}.tar\n".encode("ascii"), instanz
@fall
def ein_tagesordner_der_eine_datei_ist_ist_ein_befund():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
(wurzel / "ziel").mkdir()
(wurzel / "ziel" / TAG).write_bytes(b"keine Sicherung\n")
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30")
zeile = einzige_zeile(notiz)
assert code == 1, code
assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle
assert (wurzel / "ziel" / TAG).read_bytes() == b"keine Sicherung\n"
assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt")
assert "**gescheitert** #needs-review" in zeile and zeile.count("FileExistsError") == 2, zeile
@fall
def eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
ziel = wurzel / "ziel" / TAG
ziel.mkdir(parents=True)
# Eine aeltere, in sich stimmige Fassung: Der Host hat den Tag seitdem neu gesichert. Ihre
# Summendatei muss vor dem Umbenennen weg, sonst laege neben dem neuen Archiv eine Summe, die
# nicht passt, und sha256sum -c schluege bei der Ruecksicherung fehl.
alt = b"aeltere Fassung"
(ziel / "alpaca.tar").write_bytes(alt)
(ziel / "alpaca.tar.sha256").write_bytes(f"{hashlib.sha256(alt).hexdigest()} alpaca.tar\n".encode("ascii"))
code, notiz, _, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="bestaetigung_scheitert"
)
zeile = einzige_zeile(notiz)
assert code == 1, code
assert namen(ziel) == ["alpaca.tar", "ibkr.tar"], namen(ziel)
assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt")
assert "#needs-review" in zeile and zeile.count("abgelegt, aber nicht bestaetigt (Exit 1;") == 2, zeile
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45")
assert code == 0, (code, notiz)
assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel)
assert namen(host / "bestaetigt") == [f"{TAG}-alpaca", f"{TAG}-ibkr"], namen(host / "bestaetigt")
@fall
def eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
alt = (host / f"{TAG}-ibkr.tar").read_bytes()
code, notiz, _, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="neu_gesichert:ibkr"
)
ziel = wurzel / "ziel" / TAG
zeile = einzige_zeile(notiz)
assert code == 1, code
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar"], namen(ziel)
assert (ziel / "ibkr.tar").read_bytes() == alt
assert namen(host / "bestaetigt") == [f"{TAG}-alpaca"], namen(host / "bestaetigt")
assert "ibkr gescheitert (RuntimeError: abgelegt, aber nicht bestaetigt (Exit 4;" in zeile, zeile
assert "#needs-review" in zeile, zeile
code, _, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45")
neu = (host / f"{TAG}-ibkr.tar").read_bytes()
assert code == 0, code
assert neu != alt and (ziel / "ibkr.tar").read_bytes() == neu
assert (host / "bestaetigt" / f"{TAG}-ibkr").read_bytes() == f"{host_summe(host, 'ibkr')}\n".encode("ascii")
@fall
def eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv():
for stoerung in ("summe_gross", "summe_fremd"):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host = host_ablage(wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=stoerung
)
zeile = einzige_zeile(notiz)
assert code == 1, (stoerung, code)
assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], (stoerung, befehle)
assert not (wurzel / "ziel" / TAG).exists(), stoerung
assert namen(host / "bestaetigt") == [], (stoerung, namen(host / "bestaetigt"))
assert "#needs-review" in zeile and zeile.count("Summendatei des Hosts in unerwarteter Form") == 2, zeile
@fall
def ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", erster_tag=None
)
assert code == 0, code
assert befehle == [], befehle
assert not (wurzel / "ziel").exists()
assert sicherungszeilen(notiz) == [], notiz
@fall
def faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters():
# Plankritik Runde 1, Befunde 16, 18 und 19: Gefragt wird jeder Kalendertag der letzten
# SICHERUNG_NACHHOLEN_TAGE bis heute, der aelteste zuerst; auch Samstag und Sonntag (ein
# nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und heute schon vor 23 Uhr.
auf_dem_host = [
"2026-10-01", "2026-10-02", "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09",
"2026-10-10", "2026-10-12",
]
fenster = [f"2026-10-{t:02d}" for t in range(2, 13)]
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel, tage=auf_dem_host)
modul = lade()
# Die Ergebnisse der Abendkontrolle liegen schon alle; die Sicherung ist trotzdem faellig.
ablage = wurzel / "vault" / modul.ABLAGE_TEIL
ablage.mkdir(parents=True)
for tag in fenster:
(ablage / f"{tag}.json").write_text("{}", encoding="utf-8")
(ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8")
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-12T10:00", modul=modul, erster_tag=date(2026, 9, 1)
)
summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")]
geholt = [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")]
assert code == 0, (code, notiz)
assert summen == [f"{t}-{i}" for t in fenster for i in ("alpaca", "ibkr")], summen
# 2026-10-01 liegt vor dem Fenster; 10-03, 10-04 und 10-11 fehlen auf dem Host (Wochenende, still).
assert geholt == [f"{t}-{i}" for t in auf_dem_host[1:] for i in ("alpaca", "ibkr")], geholt
# Der erste Sicherungstag begrenzt das Fenster von unten; schon Geholtes wird nur nachgefragt.
code, _, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-12T10:30", erster_tag=date(2026, 10, 6))
summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")]
assert code == 0, code
assert summen == [f"2026-10-{t:02d}-{i}" for t in range(6, 13) for i in ("alpaca", "ibkr")], summen
assert [b for b in befehle if not b.startswith("sicherung-summe ")] == [], befehle
@fall
def das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit():
# Plankritik Runde 1, Befund 12: Jedes Archiv hat SICHERUNG_TIMEOUT_S (600 s), der ganze Lauf
# SICHERUNG_BUDGET_S (1500 s). Runde 2, Befunde 1 und 4: Das Budget zaehlt ab dem Start des Laufs,
# die Ergebnisse eingerechnet, und ist bei genau 1500 s um. Die Uhr ist eine Attrappe: Jeder
# Ergebnisteil stellt sie 400 s vor, jede Uebertragung 700 s; vor ibkr steht sie auf 1500.
tage = ["2026-10-01", "2026-10-02", "2026-10-05"]
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel, tage=tage)
modul = lade()
echt = modul.ssh_lauf
echt_teil = modul.fahre_teil
uhr = [0.0]
zeitlimits = []
def ssh_lauf(befehl, timeout, stdout=None):
if befehl.startswith("sicherung "):
zeitlimits.append(timeout)
uhr[0] += 700
return echt(befehl, timeout, stdout)
def fahre_teil(*argumente, **benannt):
uhr[0] += 400
return echt_teil(*argumente, **benannt)
modul.ssh_lauf = ssh_lauf
modul.fahre_teil = fahre_teil
modul.monotonic = lambda: uhr[0]
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1)
)
assert code == 0, (code, notiz)
assert uhr[0] == 1500, uhr
assert zeitlimits == [600], zeitlimits
assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == ["2026-10-01-alpaca"], befehle
# Nach dem Budget fragt der Lauf auch keine Summe mehr.
assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] == [
"2026-10-01-alpaca"
], befehle
zeilen = sicherungszeilen(notiz)
assert len(zeilen) == 1, notiz
assert zeilen[0].endswith(
"ibkr nicht angefangen: Zeitbudget des Laufs (1500 s) erschöpft, der nächste Lauf holt den Rest."
), zeilen[0]
assert "#needs-review" not in "\n".join(zeilen), zeilen
# Der naechste Lauf holt den Rest; die schon geholten Tage fragt er nur nach der Summe. Seine
# Ergebnisse liegen schon, sein Ergebnisteil kostet hier keine Zeit.
modul.fahre_teil = echt_teil
uhr[0] = 0.0
zeitlimits.clear()
code, _, befehle, _ = sicherungslauf(
wurzel, "--jetzt", "2026-10-05T23:45", modul=modul, erster_tag=date(2026, 10, 1)
)
assert code == 0, code
assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == [
"2026-10-01-ibkr", "2026-10-02-alpaca", "2026-10-02-ibkr"
], befehle
@fall
def nur_teile_mit_quelle_dockerhost_holen_eine_sicherung():
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
modul = lade()
gerufen = []
wsl_attrappe(modul, gerufen)
code, notiz, befehle, _ = sicherungslauf(
wurzel, "--quelle-ibkr", "wsl", "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul
)
assert code == 0, (code, notiz)
assert gerufen == [(TAG, "ibkr")], gerufen
assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-alpaca"] * 3, befehle
assert "ibkr" not in einzige_zeile(notiz), notiz
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
code, notiz, befehle, _ = sicherungslauf(wurzel, "--teil", "ibkr", "--tag", TAG, "--jetzt", "2026-10-05T23:30")
assert code == 0, (code, notiz)
assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-ibkr"] * 3, befehle
assert "alpaca" not in einzige_zeile(notiz), notiz
@fall
def ohne_schalter_gilt_das_ziel_der_konstante():
assert lade().SICHERUNG_ZIEL == Path(r"D:\Trading-Sicherung"), "Ziel der Konstante"
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", schalter=False)
assert code == 0, (code, notiz)
ziel = wurzel / "konstante-ziel" / TAG
assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel)
assert not (wurzel / "ziel").exists()
@fall
def ein_tag_ausser_der_form_holt_keine_sicherung():
for tag in ("2026-10-5", "2026-13-05", "20261005", "../2026-10-05"):
with tempfile.TemporaryDirectory() as ordner:
wurzel = Path(ordner)
host_ablage(wurzel)
code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", tag, "--jetzt", "2026-10-05T23:30")
assert code == 1, (tag, code)
assert befehle == [], (tag, befehle)
assert not (wurzel / "ziel").exists(), tag
assert sicherungszeilen(notiz) == [], (tag, notiz)
@fall
def der_abendausloeser_steht_in_ortszeit_ohne_versatz():
# S10 (Plankritik Runde 1, Befund 8): New-ScheduledTaskTrigger -At legt StartBoundary mit UTC-Versatz
# an, und die Aufgabe liefe im Winter um 22:15 Ortszeit. Ohne Versatz gesetzt, folgt sie der Ortszeit
# (gemessen vom Koordinator am 2026-10-11). Geprueft wird nur der Text des Skripts; nichts wird
# eingetragen.
zeilen = AUFGABE.read_text(encoding="utf-8").splitlines()
zeile = "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'"
assert zeilen.count(zeile) == 1, f"StartBoundary in Ortszeit {zeilen.count(zeile)}-mal"
gesetzt = [z for z in zeilen if ".StartBoundary =" in z]
assert gesetzt == [zeile], gesetzt
ausloeser = [i for i, z in enumerate(zeilen) if z.startswith("$Abends = New-ScheduledTaskTrigger -Weekly ")]
eintragen = [i for i, z in enumerate(zeilen) if z.startswith("Register-ScheduledTask ")]
assert len(ausloeser) == 1 and len(eintragen) == 1, (ausloeser, eintragen)
assert ausloeser[0] < zeilen.index(zeile) < eintragen[0], (ausloeser, zeilen.index(zeile), eintragen)
# Mutanten fuer --mutanten: Name, Text in abendkontrolle.py (genau einmal), Ersatz und die Faelle,
# die davon rot werden muessen. MUTANTEN_AUFGABE (unten) tut dasselbe fuer abendkontrolle_einrichten.ps1.
# Ein Mutant, der das Verhalten nicht aendert, gehoert nicht hierher
# (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8).
MUTANTEN = [
("Summe ungeprueft", " if summe_von(teil) != summe:", " if False:",
["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]),
("Exit des Archivs egal",
' if lauf.returncode != 0:\n raise RuntimeError(f"Archiv nicht geholt',
' if False:\n raise RuntimeError(f"Archiv nicht geholt',
["eine_abgebrochene_uebertragung_hinterlaesst_nichts"]),
("Nachbardatei bleibt", " teil.unlink(missing_ok=True)", " pass",
["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]),
("Bestaetigung ungeprueft",
' if lauf.returncode != 0:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt',
' if False:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt',
["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt",
"eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]),
("alte Summendatei bleibt", " summendatei.unlink(missing_ok=True)", " pass",
["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt"]),
("erster Tag egal", " if erster is None:\n return []", " if False:\n return []",
["ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung", "ergebnis_vom_host_landet_in_ablage_und_notiz"]),
("Fenster 9 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 9",
["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]),
("Fenster 11 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 11",
["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]),
("heute nicht im Fenster", "range(SICHERUNG_NACHHOLEN_TAGE, -1, -1)", "range(SICHERUNG_NACHHOLEN_TAGE, 0, -1)",
["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]),
("erster Tag im Fenster egal", " if kandidat >= erster:", " if True:",
["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]),
("Wochenende nicht im Fenster", " if kandidat >= erster:",
" if kandidat >= erster and kandidat.weekday() < 5:",
["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]),
("Archiv auf D: egal", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():",
" if treffer is None or treffer.group(2) != archiv.name:",
["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]),
("Name auf D: ungeprueft", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():",
" if treffer is None or not archiv.is_file():",
["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]),
("nie vollstaendig", " if summe_auf_d(ziel / tag, instanz) == summe:", " if False:",
["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "nur_die_fehlende_instanz_wird_geholt"]),
("Summe des Hosts egal", " if summe_auf_d(ziel / tag, instanz) == summe:",
" if summe_auf_d(ziel / tag, instanz) is not None:",
["ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt"]),
("Zeile fuer jeden Tag", " if not (gescheitert or getan):\n continue",
" if False:\n continue",
["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "exit_3_fuer_heute_und_das_wochenende_ist_still"]),
("Quelle egal",
' if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"',
" if True", ["nur_teile_mit_quelle_dockerhost_holen_eine_sicherung"]),
("Exit 3 ohne Grund", " if lauf.returncode == 3:\n return None", " if False:\n return None",
["exit_3_heisst_noch_keine_sicherung_auf_dem_host", "exit_3_fuer_heute_und_das_wochenende_ist_still"]),
("Exit 3 immer still", " if ohne_sicherung_still(tag, jetzt):", " if True:",
["exit_3_heisst_noch_keine_sicherung_auf_dem_host"]),
("heute nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5",
" return kandidat > jetzt.date() or kandidat.weekday() >= 5",
["exit_3_fuer_heute_und_das_wochenende_ist_still"]),
("Wochenende nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5",
" return kandidat >= jetzt.date()", ["exit_3_fuer_heute_und_das_wochenende_ist_still"]),
("Fehlschlag ohne Markierung", "**gescheitert** #needs-review. {'; '.join(stuecke)}",
"**gescheitert**. {'; '.join(stuecke)}", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]),
("Fehlschlag reisst den Tag ab",
" except Exception as ausnahme:\n gescheitert = True",
" except ZeroDivisionError as ausnahme:\n gescheitert = True",
["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "ein_tagesordner_der_eine_datei_ist_ist_ein_befund"]),
("Zielschalter egal",
" ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL",
" ziel = SICHERUNG_ZIEL", ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]),
("Rueckfall nicht auf die Konstante",
" ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL",
' ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL / "x"',
["ohne_schalter_gilt_das_ziel_der_konstante"]),
("Ziel der Konstante", r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")',
r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung-alt")', ["ohne_schalter_gilt_das_ziel_der_konstante"]),
("Tag ungeprueft",
' if not TAGFORM.fullmatch(tag):\n raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT")\n'
" date.fromisoformat(tag)",
" pass", ["ein_tag_ausser_der_form_holt_keine_sicherung"]),
("Datum ungeprueft", " date.fromisoformat(tag)", " pass", ["ein_tag_ausser_der_form_holt_keine_sicherung"]),
("Name der Summendatei beim Host egal", ' if treffer is None or treffer.group(2) != f"{name}.tar":',
" if treffer is None:", ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]),
("Grossbuchstaben in der Summe", r'SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")',
r'SUMMENZEILE = re.compile(r"([0-9a-fA-F]{64}) (\S+)\n")',
["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]),
("Archiv direkt unter dem endgueltigen Namen", ' teil = ordner / f"{instanz}.tar.teil"',
' teil = ordner / f"{instanz}.tar"', ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]),
("Summendatei auf D: mit dem Namen des Hosts",
r' neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))',
r' neu.write_bytes(f"{summe} {tag}-{archiv.name}\n".encode("ascii"))',
["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]),
("Zeitlimit des Archivs weg", ' lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)',
' lauf = ssh_lauf(f"sicherung {name}", None, strom)',
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Zeitlimit des Archivs anders", "SICHERUNG_TIMEOUT_S = 600", "SICHERUNG_TIMEOUT_S = 6000",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Budget egal", " if monotonic() >= frist:", " if False:",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Budget anders", "SICHERUNG_BUDGET_S = 1500", "SICHERUNG_BUDGET_S = 3600",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("nach dem Budget weiter", " if erschoepft:\n break", " if False:\n break",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Budget erst nach der Grenze", " if monotonic() >= frist:", " if monotonic() > frist:",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Budget erst ab dem Holen", "hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag)",
"hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, monotonic() + SICHERUNG_BUDGET_S, argumente.tag)",
["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]),
("Summendatei vor der Bestaetigung",
' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n'
" if lauf.returncode != 0:\n"
' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n'
' neu = ordner / f"{instanz}.tar.sha256.neu"\n'
' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n'
" os.replace(neu, summendatei)\n",
' neu = ordner / f"{instanz}.tar.sha256.neu"\n'
' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n'
" os.replace(neu, summendatei)\n"
' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n'
" if lauf.returncode != 0:\n"
' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n',
["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt",
"eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]),
]
# Seit der Plankritik Runde 1: Mutanten von abendkontrolle_einrichten.ps1 (AUFGABE), gleiche Form.
MUTANTEN_AUFGABE = [
("Ausloeser mit Versatz", "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'\n", "",
["der_abendausloeser_steht_in_ortszeit_ohne_versatz", "die_heutige_frist_und_die_aufgabe_passen_zueinander"]),
("Ausloeser in UTC", "+ $Uhrzeit + ':00'", "+ $Uhrzeit + ':00Z'",
["der_abendausloeser_steht_in_ortszeit_ohne_versatz"]),
]
def mutanten() -> int:
"""Jeder Mutant auf einer Kopie seiner Datei; jeder seiner Faelle muss rot werden.
MUTANTEN aendern abendkontrolle.py (QUELLE), MUTANTEN_AUFGABE abendkontrolle_einrichten.ps1 (AUFGABE).
"""
global QUELLE, AUFGABE
faelle = {funktion.__name__: funktion for funktion in FAELLE}
alle = [("abendkontrolle.py", *mutant) for mutant in MUTANTEN]
alle += [("abendkontrolle_einrichten.ps1", *mutant) for mutant in MUTANTEN_AUFGABE]
def gruen_bleiben(namen_der_faelle):
gruen = []
for fallname in namen_der_faelle:
try:
faelle[fallname]()
except (Exception, SystemExit):
continue
gruen.append(fallname)
return gruen
genannt = sorted({fallname for *_, rot in alle for fallname in rot})
fehlend = [fallname for fallname in genannt if fallname not in faelle]
if fehlend:
print(f"FALL FEHLT: {fehlend}")
return 2
# Grundlinie: Ohne Mutant muss jeder genannte Fall gruen sein, sonst misst der Rest nichts.
rot_ohne_mutant = [fallname for fallname in genannt if not gruen_bleiben([fallname])]
if rot_ohne_mutant:
print(f"GRUNDLINIE rot: {rot_ohne_mutant}")
return 2
ueberlebt = []
for datei, name, alt, neu, rot in alle:
original = (HIER / datei).read_text(encoding="utf-8")
anzahl = original.count(alt)
if anzahl != 1:
print(f"ANKER {anzahl}-mal: {name}")
ueberlebt.append(name)
continue
with tempfile.TemporaryDirectory() as ordner:
kopie = Path(ordner) / datei
kopie.write_text(original.replace(alt, neu), encoding="utf-8", newline="\n")
if datei == "abendkontrolle.py":
QUELLE = kopie
else:
AUFGABE = kopie
try:
gruen = gruen_bleiben(rot)
finally:
QUELLE = HIER / "abendkontrolle.py"
AUFGABE = HIER / "abendkontrolle_einrichten.ps1"
if gruen:
ueberlebt.append(name)
print(f"UEBERLEBT {name}: gruen blieben {gruen}")
else:
print(f"OK {name}: rot wurden {rot}")
print(f"{len(alle)} Mutanten, {len(ueberlebt)} ueberlebt ({datetime.now():%Y-%m-%d %H:%M})")
return 2 if ueberlebt else 0
def main(argv=None) -> int:
argumente = sys.argv[1:] if argv is None else argv
if argumente == ["--mutanten"]:
return mutanten()
if argumente:
print(f"unbekannte Argumente {argumente}; erlaubt ist nur --mutanten")
return 2
abweichungen = 0
for funktion in FAELLE:
try:
@@ -41,6 +41,11 @@ if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle }
$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr) -WorkingDirectory "D:\Claude"
if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" } else { $Uhrzeit = "22:45" }
$Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At $Uhrzeit
# Seit Paket E9 (S10): -At legt den Startzeitpunkt mit UTC-Versatz an (gemessen 2026-10-11:
# ...T21:15:00Z). Eine Aufgabe mit Versatz startet im Winter eine Stunde frueher nach Ortszeit, vor
# der Abendkontrolle des Hosts. Ohne Versatz gesetzt, speichert die Aufgabenplanung den Wert so und
# startet nach Ortszeit.
$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'
$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer
$Anmeldung.Delay = "PT5M"
$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited