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:
1 parent
77de5059e4
commit
c5db92c6de
3 files changed
+1174
-7
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user