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
@@ -29,13 +29,21 @@ Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millise
|
||||
|
||||
Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time,
|
||||
gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank.
|
||||
Seit Paket E9 holt die Aufgabe fuer jeden Teil mit Quelle dockerhost auch die Sicherung des Hosts ab
|
||||
(hole_sicherungen): je Kalendertag der letzten SICHERUNG_NACHHOLEN_TAGE die Summe des Hosts fragen; wo
|
||||
die Summendatei auf D: fehlt oder abweicht, <tag>-<instanz>.tar ueber denselben Schluessel holen, Summe
|
||||
pruefen, nach SICHERUNG_ZIEL/<tag>/<instanz>.tar umbenennen, beim Host bestaetigen, zuletzt
|
||||
<instanz>.tar.sha256 schreiben; eine Zeile in der Notiz je Tag mit Neuem. Solange SICHERUNG_ERSTER_TAG
|
||||
None ist, holt sie nichts. Den Abend-Ausloeser legt abendkontrolle_einrichten.ps1 in Ortszeit an (S10).
|
||||
Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>]
|
||||
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost]
|
||||
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel <ordner>]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
@@ -44,6 +52,7 @@ from datetime import datetime
|
||||
from datetime import time
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
VAULT = Path(r"D:\Claude")
|
||||
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
|
||||
@@ -82,6 +91,34 @@ KOEPFE = {
|
||||
}
|
||||
|
||||
|
||||
# Seit Paket E9 (Trading Plattform 0.39.0): Der Host sichert werktags 16:45 New Yorker Zeit je
|
||||
# Instanz Register, data/paper und artifacts/paper (sicherung-host.sh). Diese Aufgabe holt fuer
|
||||
# jeden Teil mit Quelle dockerhost die Sicherung ueber denselben Schluessel ab (abendkontrolle-lesen.sh
|
||||
# mit sicherung-summe, sicherung und sicherung-bestaetigt), prueft die Summe und legt sie unter
|
||||
# SICHERUNG_ZIEL/<tag>/<instanz>.tar samt <instanz>.tar.sha256 ab. Massstab ist die aktuelle Summe des
|
||||
# Hosts: Fehlt die Summendatei auf D: oder weicht sie ab, holt der Lauf neu und ersetzt die alte Fassung
|
||||
# (Plankritik Runde 1, Befunde 5 und 24). Sonst loescht er auf D: nichts.
|
||||
SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")
|
||||
# Der erste Tag, den die Aufgabe abholt; None holt nichts. Gesetzt beim Einspielen von E9 (S8): Diese
|
||||
# Datei laeuft schon vorher jeden Abend, der Host kennt die Lesebefehle erst danach.
|
||||
SICHERUNG_ERSTER_TAG: date | None = None
|
||||
# So viele Kalendertage vor heute fragt jeder Lauf, dazu heute. Das Fenster liegt in der Aufbewahrung
|
||||
# des Hosts: Er haelt die neuesten zehn Sicherungstage, nur werktags gesichert, also gut zwei Kalenderwochen.
|
||||
# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026
|
||||
# und vom 14.3. bis 27.3.2027 schon 21:45. Die Aufgabe startet 23:15 Ortszeit (S10); fehlt die
|
||||
# Sicherung von heute trotzdem noch, ist das kein Fehlschlag.
|
||||
SICHERUNG_NACHHOLEN_TAGE = 10
|
||||
# Je Archiv; Summe und Bestaetigung nehmen SSH_TIMEOUT_S. Die Aufgabe selbst endet nach 45 Minuten.
|
||||
SICHERUNG_TIMEOUT_S = 600
|
||||
# Zeitbudget je Lauf fuer die Sicherung, geprueft vor jeder Instanz: Ist es um, faengt der Lauf nichts
|
||||
# mehr an, und der naechste holt den Rest (Plankritik Runde 1, Befund 12).
|
||||
SICHERUNG_BUDGET_S = 1500
|
||||
SICHERUNG_KOPF = "Sicherung Trading Plattform (automatisch)"
|
||||
# Eine Zeile im Format von sha256sum: 64 Hexzeichen in Kleinbuchstaben, zwei Leerzeichen, Name, LF.
|
||||
SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")
|
||||
TAGFORM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
|
||||
|
||||
|
||||
def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
|
||||
tage = []
|
||||
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
|
||||
@@ -155,6 +192,227 @@ def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict:
|
||||
raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
|
||||
|
||||
|
||||
def ssh_aufruf(befehl: str) -> list[str]:
|
||||
"""Die Befehlszeile fuer einen Befehl der Sicherung, mit denselben Optionen wie fahre_dockerhost."""
|
||||
return [
|
||||
SSH,
|
||||
"-p", SSH_PORT,
|
||||
"-i", str(SSH_SCHLUESSEL),
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=yes",
|
||||
"-o", f"UserKnownHostsFile={SSH_BEKANNTE}",
|
||||
"-o", "ConnectTimeout=20",
|
||||
SSH_ZIEL,
|
||||
befehl,
|
||||
]
|
||||
|
||||
|
||||
def fehlerzeile(lauf: subprocess.CompletedProcess) -> str:
|
||||
"""Exit und die letzten Zeilen von stderr, gekuerzt."""
|
||||
fehler = (lauf.stderr or b"").decode("utf-8", errors="replace").strip().splitlines()[-3:]
|
||||
return f"Exit {lauf.returncode}; {' | '.join(fehler)[:300]}"
|
||||
|
||||
|
||||
def summe_von(datei: Path) -> str:
|
||||
"""SHA-256 einer Datei in Kleinbuchstaben, stueckweise gelesen."""
|
||||
summe = hashlib.sha256()
|
||||
with datei.open("rb") as strom:
|
||||
for stueck in iter(lambda: strom.read(1 << 20), b""):
|
||||
summe.update(stueck)
|
||||
return summe.hexdigest()
|
||||
|
||||
|
||||
def ssh_lauf(befehl: str, timeout, stdout=None) -> subprocess.CompletedProcess:
|
||||
"""Ein Befehl der Sicherung beim Host. Mit stdout (offene Datei) geht die Ausgabe dorthin.
|
||||
|
||||
Eigene Funktion, damit die Angriffsfaelle Zeitlimit und Dauer jedes Befehls sehen.
|
||||
"""
|
||||
if stdout is None:
|
||||
return subprocess.run(ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER)
|
||||
return subprocess.run(
|
||||
ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, creationflags=KEIN_FENSTER,
|
||||
)
|
||||
|
||||
|
||||
def summe_auf_d(ordner: Path, instanz: str) -> str | None:
|
||||
"""Die Summe aus <instanz>.tar.sha256 in ordner, wenn sie dort richtig steht und das Archiv daneben liegt.
|
||||
|
||||
Die Summendatei schreibt nur hole_sicherung, nach Pruefung und Bestaetigung: Sie steht fuer ein
|
||||
geprueftes und bestaetigtes Archiv. Das Archiv selbst hasht dieser Lauf nicht nach (Plankritik
|
||||
Runde 1, Befund 21); sonst laese jeder Lauf alle Archive des Fensters.
|
||||
"""
|
||||
archiv = ordner / f"{instanz}.tar"
|
||||
try:
|
||||
treffer = SUMMENZEILE.fullmatch((ordner / f"{instanz}.tar.sha256").read_bytes().decode("ascii"))
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():
|
||||
return None
|
||||
return treffer.group(1)
|
||||
|
||||
|
||||
def sicherungstage(jetzt: datetime, tag: str | None = None) -> list[str]:
|
||||
"""Die Tage, fuer die der Lauf den Host fragt, der aelteste zuerst.
|
||||
|
||||
Ohne SICHERUNG_ERSTER_TAG keiner. Mit tag (--tag) nur dieser. Sonst jeder Kalendertag der letzten
|
||||
SICHERUNG_NACHHOLEN_TAGE bis heute ab SICHERUNG_ERSTER_TAG, auch Samstag und Sonntag (ein
|
||||
nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und auch heute (Plankritik Runde 1,
|
||||
Befunde 8, 16, 18 und 19). Ob das Ergebnis der Abendkontrolle fuer den Tag schon liegt, spielt
|
||||
keine Rolle.
|
||||
"""
|
||||
erster = SICHERUNG_ERSTER_TAG
|
||||
if erster is None:
|
||||
return []
|
||||
if tag is not None:
|
||||
# Der Tag wird Teil eines Pfads und eines Befehls auf dem Host: nur JJJJ-MM-TT.
|
||||
if not TAGFORM.fullmatch(tag):
|
||||
raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT")
|
||||
date.fromisoformat(tag)
|
||||
return [tag]
|
||||
tage = []
|
||||
for zurueck in range(SICHERUNG_NACHHOLEN_TAGE, -1, -1):
|
||||
kandidat = jetzt.date() - timedelta(days=zurueck)
|
||||
if kandidat >= erster:
|
||||
tage.append(kandidat.isoformat())
|
||||
return tage
|
||||
|
||||
|
||||
def summe_vom_host(name: str) -> str | None:
|
||||
"""Die Summe, die der Host fuer <name> fuehrt; None bei Exit 3 (dort liegt keine Sicherung)."""
|
||||
lauf = ssh_lauf(f"sicherung-summe {name}", SSH_TIMEOUT_S)
|
||||
if lauf.returncode == 3:
|
||||
return None
|
||||
if lauf.returncode != 0:
|
||||
raise RuntimeError(f"Summe nicht gelesen ({fehlerzeile(lauf)})")
|
||||
treffer = SUMMENZEILE.fullmatch(lauf.stdout.decode("ascii", errors="replace"))
|
||||
if treffer is None or treffer.group(2) != f"{name}.tar":
|
||||
raise RuntimeError("Summendatei des Hosts in unerwarteter Form")
|
||||
return treffer.group(1)
|
||||
|
||||
|
||||
def ohne_sicherung_still(tag: str, jetzt: datetime) -> bool:
|
||||
"""Ist Exit 3 fuer diesen Tag kein Fehlschlag? Ja fuer heute (und spaeter) und fuer Samstag und Sonntag.
|
||||
|
||||
Heute kann die Sicherung des Hosts noch ausstehen; am Wochenende sichert er nur, wenn er einen
|
||||
verpassten Lauf nachholt. Fehlt sie an einem vergangenen Werktag, ist das ein Fehlschlag.
|
||||
"""
|
||||
kandidat = date.fromisoformat(tag)
|
||||
return kandidat >= jetzt.date() or kandidat.weekday() >= 5
|
||||
|
||||
|
||||
def sichere(tag: str, instanz: str, ziel: Path, jetzt: datetime) -> tuple[str, bool]:
|
||||
"""Eine Instanz eines Tags. Liefert ihren Teil der Zeile und ob geholt wurde; Fehlschlag: Ausnahme.
|
||||
|
||||
Massstab ist die aktuelle Summe des Hosts (Plankritik Runde 1, Befunde 5 und 24). Steht dieselbe in
|
||||
der Summendatei auf D:, ist der Tag geholt und bestaetigt. Sonst holt der Lauf neu und ersetzt die
|
||||
alte Fassung, etwa nachdem der Host den Tag ein zweites Mal gesichert hat.
|
||||
"""
|
||||
name = f"{tag}-{instanz}"
|
||||
summe = summe_vom_host(name)
|
||||
if summe is None:
|
||||
if ohne_sicherung_still(tag, jetzt):
|
||||
return f"{instanz} auf dem Docker-Host noch nicht gesichert", False
|
||||
raise RuntimeError(f"auf dem Docker-Host liegt fuer {name} noch keine Sicherung")
|
||||
if summe_auf_d(ziel / tag, instanz) == summe:
|
||||
return f"{instanz} lag schon vollständig", False
|
||||
return hole_sicherung(tag, instanz, ziel, summe), True
|
||||
|
||||
|
||||
def hole_sicherung(tag: str, instanz: str, ziel: Path, summe: str) -> str:
|
||||
"""Holt die Sicherung <tag>-<instanz> mit der Summe des Hosts, prueft, legt ab und bestaetigt.
|
||||
|
||||
Reihenfolge: Archiv in die Nachbardatei <instanz>.tar.teil holen, Summe pruefen, eine alte
|
||||
<instanz>.tar.sha256 entfernen, auf <instanz>.tar umbenennen (ersetzt eine alte Fassung), beim
|
||||
Host bestaetigen, zuletzt <instanz>.tar.sha256 schreiben. Erst die Summendatei macht die Ablage
|
||||
vollstaendig: Scheitert die Bestaetigung, fehlt sie, und der naechste Lauf holt und bestaetigt
|
||||
erneut. Bei falscher Summe wird nichts umbenannt und nichts bestaetigt. Jeder Fehlschlag ist
|
||||
eine Ausnahme mit dem Grund. Liefert den Teil der Zeile.
|
||||
"""
|
||||
name = f"{tag}-{instanz}"
|
||||
ordner = ziel / tag
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
teil = ordner / f"{instanz}.tar.teil"
|
||||
archiv = ordner / f"{instanz}.tar"
|
||||
summendatei = ordner / f"{instanz}.tar.sha256"
|
||||
try:
|
||||
with teil.open("wb") as strom:
|
||||
lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)
|
||||
if lauf.returncode != 0:
|
||||
raise RuntimeError(f"Archiv nicht geholt ({fehlerzeile(lauf)})")
|
||||
if summe_von(teil) != summe:
|
||||
raise RuntimeError("Summe des geholten Archivs ist nicht die des Hosts; nicht abgelegt, nicht bestaetigt")
|
||||
summendatei.unlink(missing_ok=True)
|
||||
os.replace(teil, archiv)
|
||||
finally:
|
||||
try:
|
||||
teil.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)
|
||||
if lauf.returncode != 0:
|
||||
raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")
|
||||
neu = ordner / f"{instanz}.tar.sha256.neu"
|
||||
neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))
|
||||
os.replace(neu, summendatei)
|
||||
return f"{instanz} abgelegt und bestätigt ({archiv.stat().st_size} Bytes)"
|
||||
|
||||
|
||||
def hole_sicherungen(jetzt: datetime, vault: Path, ablage: Path, ziel: Path, instanzen: list[str],
|
||||
frist: float, tag: str | None = None) -> int:
|
||||
"""Je Tag des Fensters und Instanz: Summe des Hosts fragen und holen, wo D: sie nicht hat.
|
||||
|
||||
Eine Zeile in der Notiz je Tag, an dem etwas geholt wurde, etwas scheiterte oder das Zeitbudget
|
||||
endete; ein Fehlschlag traegt #needs-review. Ein Tag ohne Neues bekommt keine Zeile. frist ist der
|
||||
Stand von monotonic(), an dem SICHERUNG_BUDGET_S ab dem Start des Laufs um ist (main); danach
|
||||
faengt der Lauf keine Instanz mehr an, und der naechste holt den Rest.
|
||||
"""
|
||||
if not instanzen:
|
||||
return 0
|
||||
code = 0
|
||||
erschoepft = False
|
||||
for kandidat in sicherungstage(jetzt, tag):
|
||||
if erschoepft:
|
||||
break
|
||||
uhrzeit = jetzt.strftime("%H:%M") if kandidat == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
|
||||
stuecke = []
|
||||
gescheitert = False
|
||||
getan = False
|
||||
for instanz in instanzen:
|
||||
if monotonic() >= frist:
|
||||
erschoepft = getan = True
|
||||
stuecke.append(
|
||||
f"{instanz} nicht angefangen: Zeitbudget des Laufs ({SICHERUNG_BUDGET_S} s) erschöpft, "
|
||||
"der nächste Lauf holt den Rest"
|
||||
)
|
||||
logge(ablage, f"sicherung {kandidat}-{instanz}: Zeitbudget erschoepft")
|
||||
continue
|
||||
try:
|
||||
stueck, geholt = sichere(kandidat, instanz, ziel, jetzt)
|
||||
stuecke.append(stueck)
|
||||
if geholt:
|
||||
getan = True
|
||||
logge(ablage, f"sicherung {kandidat}-{instanz}: abgelegt und bestaetigt")
|
||||
except Exception as ausnahme:
|
||||
gescheitert = True
|
||||
stuecke.append(f"{instanz} gescheitert ({type(ausnahme).__name__}: {str(ausnahme)[:200]})")
|
||||
logge(ablage, f"sicherung {kandidat}-{instanz}: gescheitert {ausnahme!r}")
|
||||
if not (gescheitert or getan):
|
||||
continue
|
||||
kopf = f"- {uhrzeit} Uhr — {SICHERUNG_KOPF}"
|
||||
if gescheitert:
|
||||
code = 1
|
||||
zeile = f"{kopf}: **gescheitert** #needs-review. {'; '.join(stuecke)}. Der nächste Lauf versucht es erneut."
|
||||
else:
|
||||
zeile = f"{kopf}: {'; '.join(stuecke)}."
|
||||
try:
|
||||
schreibe_notiz(vault, kandidat, zeile)
|
||||
except Exception as ausnahme:
|
||||
code = 1
|
||||
logge(ablage, f"sicherung {kandidat}: Notiz nicht geschrieben {ausnahme!r}")
|
||||
return code
|
||||
|
||||
|
||||
def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
|
||||
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
|
||||
befunde = ergebnis.get("befunde") or []
|
||||
@@ -284,7 +542,14 @@ def main(argv=None) -> int:
|
||||
"--quelle-ibkr", choices=QUELLEN,
|
||||
help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle",
|
||||
)
|
||||
zerleger.add_argument(
|
||||
"--sicherung-ziel",
|
||||
help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)",
|
||||
)
|
||||
argumente = zerleger.parse_args(argv)
|
||||
# Seit Paket E9: Das Zeitbudget der Sicherung zaehlt ab hier, also mit der Zeit fuer die
|
||||
# Ergebnisse (bis zu zwoelf Abfragen mit je SSH_TIMEOUT_S; Plankritik Runde 2, Befund 1).
|
||||
frist = monotonic() + SICHERUNG_BUDGET_S
|
||||
vault = Path(argumente.vault)
|
||||
ablage = vault / ABLAGE_TEIL
|
||||
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
|
||||
@@ -313,6 +578,21 @@ def main(argv=None) -> int:
|
||||
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
|
||||
except Exception:
|
||||
pass
|
||||
# Seit Paket E9: die Sicherung jedes Teils mit Quelle dockerhost, nach den Ergebnissen beider
|
||||
# Teile und unabhaengig davon, ob ein Ergebnis gescheitert ist.
|
||||
instanzen = [
|
||||
teil for teil in teile
|
||||
if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"
|
||||
]
|
||||
ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL
|
||||
try:
|
||||
code = max(code, hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag))
|
||||
except Exception as ausnahme:
|
||||
code = 1
|
||||
try:
|
||||
logge(ablage, f"sicherung abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
|
||||
except Exception:
|
||||
pass
|
||||
return code
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user