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