L1b/2: Kultur und Dateisystem plattformunabhaengig machen

Kultur - Ausgaben und Parsen haengen nicht mehr am Host:

- PdfExporter formatierte Betraege mit ToString("N2") ohne Formatanbieter,
  also CurrentCulture. Auf dem deutschen Desktop "1.234,56", in einem
  Container mit LANG=C "1,234.56" - dieselbe Zahl, fuer einen Leser eine
  andere. Fuer ein ausdruecklich pruefbares Dokument jetzt fest de-DE.
- CapitolTradesScraper.ParseDate nutzte DateOnly.TryParse ohne
  Formatanbieter. Das ist nicht theoretisch: gemessen wurde aus
  "2026-08-04" unter th-TH das Jahr 1483 (buddhistischer Kalender), unter
  fa-IR das Jahr 2647 (persischer Kalender), unter ar-SA schlug das Parsen
  ganz fehl. de-DE und en-US kommen mit ISO klar - genau deshalb faellt so
  etwas auf dem Entwicklungsrechner nie auf. Jetzt TryParseExact mit
  InvariantCulture; ein Formatwechsel der Quelle faellt damit auf, statt
  still ein falsches Datum zu erzeugen. Regressionstest ueber vier Kulturen.
- IBKRGatewayService baute den Query-Parameter mit .ToString().ToLower()
  (Tuerkisch-I) - jetzt fest "true"/"false".

PDF-Schriften: PDFsharp 6 loest auf Nicht-Windows-Plattformen nichts von
selbst auf, "Segoe UI" gibt es dort nicht - der Export waere zur Laufzeit
gescheitert. Neuer DocumentFontResolver: unter Windows bleibt die Plattform
zustaendig (unveraenderte Optik), auf Linux wird eine freie Systemschrift
gesucht (DejaVu/Liberation/Noto/FreeSans). Bewusst keine Schrift im Repo -
das erspart eine Lizenzfrage; fehlt sie, nennt die Fehlermeldung das zu
installierende Paket.

BackupWorker:
- Suchte "mysqldump.exe" in C:\Program Files\... und splittete PATH mit ';'.
  Auf Linux ist das Trennzeichen ':' - der gesamte PATH waere als ein
  Eintrag gelesen worden. Jetzt Path.PathSeparator, plattformabhaengige
  Suchpfade und zusaetzlich "mariadb-dump" (MariaDB hat mysqldump ab 10.5
  umbenannt).
- Das DB-Passwort stand als Kommandozeilenargument im Prozessbaum. Unter
  Linux ist /proc/<pid>/cmdline fuer jeden lokalen Nutzer lesbar - das waere
  eine neue Offenlegung gewesen, die es unter Windows so nicht gab. Jetzt
  ueber MYSQL_PWD, nur an den Kindprozess vererbt. Argumente einzeln statt
  als Zeichenkette (kein Quoting-Problem bei Pfaden mit Leerzeichen).

Verifiziert: 188 Tests gruen (+5), Build 0 Fehler/0 Warnungen, Core + 3
Module + Tests bauen fuer linux-x64, --smoke-ui konstruiert alle 7 Fenster.

Offen aus L1b und nach L2 verschoben: IAppPaths (Logs/Backups/settings.json/
master.key liegen neben der Binaerdatei; unter /opt ist das nicht schreibbar).
Gehoert zum Daemon, wo die Pfade tatsaechlich gebraucht werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-06 22:58:10 +02:00
co-authored by Claude Opus 5
parent d8273c3a1e
commit 8b9b993d1d
6 changed files with 256 additions and 38 deletions
@@ -244,7 +244,7 @@ public class IBKRGatewayService
var url = $"iserver/marketdata/history?conid={conid}" +
$"&period={Uri.EscapeDataString(period)}" +
$"&bar={Uri.EscapeDataString(bar)}" +
$"&outsideRth={outsideRth.ToString().ToLower()}";
$"&outsideRth={(outsideRth ? "true" : "false")}";
var response = await _http.GetAsync(url, ct);
@@ -1,3 +1,4 @@
using System.Globalization;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
@@ -8,9 +9,9 @@ namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// BackupWorker (Core) – läuft alle 30 Minuten (konfigurierbar).
/// 1. Ruft mysqldump.exe auf → SQL-Dump in Backups\DB\
/// 2. Kopiert Logs\ → Backups\Logs\
/// Falls mysqldump nicht gefunden: Warnung und Überspringen.
/// 1. Ruft mariadb-dump bzw. mysqldump auf → SQL-Dump nach Backups/DB/
/// 2. Kopiert Logs/ → Backups/Logs/
/// Wird das Werkzeug nicht gefunden: Warnung und Überspringen, nie ein harter Fehler.
/// </summary>
public class BackupWorker : WorkerBase
{
@@ -53,39 +54,50 @@ public class BackupWorker : WorkerBase
private async Task RunMysqlDumpAsync(string dbDir, string timestamp, CancellationToken ct)
{
var dump = FindMysqldump();
var dump = FindDumpTool();
if (dump == null)
{
Logger.Warn(Module, "mysqldump.exe nicht gefunden – DB-Backup übersprungen.");
Logger.Warn(Module, $"{string.Join(" / ", ToolNames())} nicht gefunden – DB-Backup übersprungen.");
return;
}
var db = _settings.Settings.Database;
var dumpFile = Path.Combine(dbDir, $"{db.Database}_{timestamp}.sql");
var args = $"--host={db.Host} --port={db.Port} " +
$"--user={db.User} --password={db.Password} " +
$"--single-transaction --routines --triggers " +
$"{db.Database} --result-file=\"{dumpFile}\"";
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = dump,
Arguments = args,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
// Argumente einzeln statt als eine Zeichenkette: das erspart das Quoting von Pfaden mit
// Leerzeichen und ist auf beiden Plattformen dasselbe.
psi.ArgumentList.Add($"--host={db.Host}");
psi.ArgumentList.Add($"--port={db.Port.ToString(CultureInfo.InvariantCulture)}");
psi.ArgumentList.Add($"--user={db.User}");
psi.ArgumentList.Add("--single-transaction");
psi.ArgumentList.Add("--routines");
psi.ArgumentList.Add("--triggers");
psi.ArgumentList.Add(db.Database);
psi.ArgumentList.Add($"--result-file={dumpFile}");
// Passwort über die Umgebung statt über die Kommandozeile. Auf Linux ist
// /proc/<pid>/cmdline für JEDEN lokalen Nutzer lesbar – das Passwort hätte dort für die
// Dauer des Dumps offen im Prozessbaum gestanden. MYSQL_PWD ist der von MySQL/MariaDB
// dafür vorgesehene Weg und wird nur an diesen Kindprozess vererbt.
psi.Environment["MYSQL_PWD"] = db.Password;
using var proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("mysqldump konnte nicht gestartet werden.");
?? throw new InvalidOperationException($"{Path.GetFileName(dump)} konnte nicht gestartet werden.");
await proc.WaitForExitAsync(ct);
if (proc.ExitCode != 0)
{
var err = await proc.StandardError.ReadToEndAsync(ct);
Logger.Warn(Module, $"mysqldump Exitcode {proc.ExitCode}: {err}");
Logger.Warn(Module, $"{Path.GetFileName(dump)} Exitcode {proc.ExitCode}: {err}");
}
else
{
@@ -93,27 +105,57 @@ public class BackupWorker : WorkerBase
}
}
private static string? FindMysqldump()
/// <summary>
/// Namen des Dump-Werkzeugs. MariaDB hat <c>mysqldump</c> ab 10.5 in <c>mariadb-dump</c>
/// umbenannt und pflegt den alten Namen nur noch als Alias – auf neueren Distributionen ist
/// oft nur der neue vorhanden. Die Dateiendung gibt es nur unter Windows.
/// </summary>
private static string[] ToolNames() =>
OperatingSystem.IsWindows()
? ["mariadb-dump.exe", "mysqldump.exe"]
: ["mariadb-dump", "mysqldump"];
/// <summary>
/// Sucht das Dump-Werkzeug: erst die üblichen Installationspfade der jeweiligen Plattform,
/// dann PATH. Findet es nichts, wird das Backup übersprungen (nie ein harter Fehler).
/// </summary>
private static string? FindDumpTool()
{
// Häufige Installationspfade auf Windows-Server
var candidates = new[]
{
"mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqldump.exe",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin\mysqldump.exe",
@"C:\xampp\mysql\bin\mysqldump.exe"
};
var names = ToolNames();
foreach (var c in candidates)
if (File.Exists(c)) return c;
// Übliche Installationspfade – plattformabhängig.
string[] dirs = OperatingSystem.IsWindows()
?
[
@"C:\Program Files\MariaDB\bin",
@"C:\Program Files\MySQL\MySQL Server 8.0\bin",
@"C:\Program Files\MySQL\MySQL Server 8.4\bin",
@"C:\xampp\mysql\bin",
]
:
[
"/usr/bin",
"/usr/local/bin",
"/opt/mariadb/bin",
"/opt/homebrew/bin",
];
// PATH-Suche
foreach (var name in names)
foreach (var dir in dirs)
{
var full = Path.Combine(dir, name);
if (File.Exists(full)) return full;
}
// PATH-Suche. Das Trennzeichen ist plattformabhängig: ';' unter Windows, ':' unter Linux –
// ein fest verdrahtetes ';' hätte auf Linux den gesamten PATH als einen Eintrag gelesen.
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in pathVar.Split(';'))
{
var full = Path.Combine(dir.Trim(), "mysqldump.exe");
if (File.Exists(full)) return full;
}
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
foreach (var name in names)
{
var full = Path.Combine(dir.Trim(), name);
if (File.Exists(full)) return full;
}
return null;
}