Deploymentcenter-Integration (Schritte 0-8 des Konzepts)
Bindet Lizenz, Watchdog, Fehler-Stream, UpdateService und Erstinstallation an
das Deploymentcenter 2.5.1 an. Einbauort ist IBKRTrader.Hosting - den Host
teilen sich Shell und Daemon.
Projekt-Befunde aus dem Konzept vorab bereinigt:
P1 Echte DB-Zugangsdaten als Vorgabewerte in AppSettings -> Platzhalter.
Das alte Passwort steht weiterhin in der Git-Historie und ist als
kompromittiert zu behandeln (Rotation ist Nutzer-Aktion).
P2 AppPaths fiel unter Windows auf /etc/ibkrtrader zurueck, was .NET zu
C:\etc\ibkrtrader aufloest. Jetzt %ProgramData%\IBKRTrader.
P3 Globale Ausnahmebehandler (AppDomain / TaskScheduler) - vorher gab es
keinen Logeintrag, wenn der Prozess unbehandelt wegbrach.
P4 Version einmal zentral in Directory.Build.props statt zweimal hartkodiert.
Bewusste Abweichungen vom DC-Leitfaden, beide fuer ein handelndes System:
- Lizenz-Urteil fuehrt zum Sperrbetrieb (TradingEnabled=false) statt zu
Environment.Exit(1). Keine neuen Einstiege, aber Risiko-, Exit- und
Buchhaltungslogik laufen weiter.
- exitCurrentApp bleibt immer false; der Aufrufer beendet geordnet.
Das SDK haengt als Cross-Repo-ProjectReference am Schwester-Repo
Deploymentcenter (Interim, siehe Konzept 2.2). Damit ist P5 offen: die
Gitea-CI checkt das Schwester-Repo nicht aus und wird rot, bis der Bezug auf
ein NuGet-Paket umgestellt ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ internal static class Program
|
||||
|
||||
var logger = host.Services.GetRequiredService<LoggingService>();
|
||||
logger.Info("Core", "=== IBKRTrader startet ===");
|
||||
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version}");
|
||||
logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version}");
|
||||
|
||||
host.Start();
|
||||
StartModules(modules, logger);
|
||||
|
||||
@@ -15,8 +15,13 @@ namespace IBKRTrader.Core.Configuration;
|
||||
/// hat immer Vorrang, damit ein Betreiber die Ablage frei bestimmen kann.</item>
|
||||
/// <item>Das Verzeichnis der Binärdatei, <b>wenn dort geschrieben werden darf</b>. Das hält das
|
||||
/// bisherige Verhalten unter Windows und beim Entwickeln unter Linux unverändert.</item>
|
||||
/// <item>Sonst die FHS-Konvention: <c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>,
|
||||
/// <c>/var/log/ibkrtrader</c>.</item>
|
||||
/// <item>Sonst plattformabhängig: unter Linux/macOS die FHS-Konvention
|
||||
/// (<c>/etc/ibkrtrader</c>, <c>/var/lib/ibkrtrader</c>, <c>/var/log/ibkrtrader</c>), unter
|
||||
/// Windows <c>%ProgramData%\IBKRTrader</c>. <b>Der FHS-Pfad darf hier nicht als
|
||||
/// plattformübergreifender Rückfall dienen</b> – .NET löst <c>/etc/ibkrtrader</c> unter
|
||||
/// Windows zu <c>C:\etc\ibkrtrader</c> auf. Genau dieser Fall tritt ein, sobald eine
|
||||
/// Installation nach <c>Program Files</c> zeigt, wo der Dienst- bzw. Nutzerkonto-Kontext
|
||||
/// keinen Schreibzugriff neben der Binärdatei hat.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Die Prüfung läuft einmal beim ersten Zugriff; die Verzeichnisse werden dabei angelegt.</para>
|
||||
@@ -26,8 +31,8 @@ public static class AppPaths
|
||||
private const string AppFolder = "ibkrtrader";
|
||||
|
||||
private static readonly Lazy<string> _config = new(() => Resolve("IBKRTRADER_CONFIG_DIR", $"/etc/{AppFolder}"));
|
||||
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}"));
|
||||
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs"));
|
||||
private static readonly Lazy<string> _data = new(() => Resolve("IBKRTRADER_DATA_DIR", $"/var/lib/{AppFolder}", windowsSubDir: "Data"));
|
||||
private static readonly Lazy<string> _logs = new(() => Resolve("IBKRTRADER_LOG_DIR", $"/var/log/{AppFolder}", "Logs", windowsSubDir: "Logs"));
|
||||
|
||||
/// <summary>Konfiguration und Schlüsselmaterial: <c>settings.json</c>, <c>master.key</c>, <c>openrouter.key</c>.</summary>
|
||||
public static string Config => _config.Value;
|
||||
@@ -48,13 +53,18 @@ public static class AppPaths
|
||||
public static string Describe() => $"config={Config}, data={Data}, logs={Logs}";
|
||||
|
||||
/// <param name="envVar">Umgebungsvariable, die alles überstimmt.</param>
|
||||
/// <param name="fhsFallback">FHS-Pfad, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
|
||||
/// <param name="fhsFallback">FHS-Pfad unter Linux/macOS, wenn neben der Binärdatei nicht geschrieben werden darf.</param>
|
||||
/// <param name="localSubDir">
|
||||
/// Unterverzeichnis im Binärverzeichnis. Für Logs ist das <c>Logs/</c>; Konfiguration und Daten
|
||||
/// lagen bisher direkt daneben und bleiben dort, damit bestehende Installationen unverändert
|
||||
/// weiterlaufen.
|
||||
/// </param>
|
||||
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null)
|
||||
/// <param name="windowsSubDir">
|
||||
/// Unterverzeichnis unter <c>%ProgramData%\IBKRTrader</c>, wenn unter Windows neben der
|
||||
/// Binärdatei nicht geschrieben werden darf (z. B. Installation nach <c>Program Files</c>).
|
||||
/// <c>null</c> = direkt in <c>%ProgramData%\IBKRTrader</c>, wie bei der Konfiguration.
|
||||
/// </param>
|
||||
private static string Resolve(string envVar, string fhsFallback, string? localSubDir = null, string? windowsSubDir = null)
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
@@ -67,6 +77,16 @@ public static class AppPaths
|
||||
if (IsWritable(AppContext.BaseDirectory))
|
||||
return Ensure(local);
|
||||
|
||||
// Binärverzeichnis nicht beschreibbar - der FHS-Pfad taugt nur unter Linux/macOS als
|
||||
// Rückfall. Unter Windows löst .NET "/etc/ibkrtrader" zu "C:\etc\ibkrtrader" auf; der
|
||||
// richtige Rückfall dort ist das maschinenweite Anwendungsdatenverzeichnis.
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
var winBase = Path.Combine(programData, "IBKRTrader");
|
||||
return Ensure(windowsSubDir is null ? winBase : Path.Combine(winBase, windowsSubDir));
|
||||
}
|
||||
|
||||
return Ensure(fhsFallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ public class DatabaseSettings
|
||||
[Category("Database")]
|
||||
[DisplayName("Host")]
|
||||
[Description("MySQL-Serveradresse (Hostname oder IP)")]
|
||||
public string Host { get; set; } = "larx.your-database.de";
|
||||
public string Host { get; set; } = "your-db-host";
|
||||
|
||||
[Category("Database")]
|
||||
[DisplayName("Port")]
|
||||
@@ -21,18 +21,18 @@ public class DatabaseSettings
|
||||
[Category("Database")]
|
||||
[DisplayName("Database")]
|
||||
[Description("Name der MySQL-Datenbank")]
|
||||
public string Database { get; set; } = "bergisnu_db0";
|
||||
public string Database { get; set; } = "your-db-name";
|
||||
|
||||
[Category("Database")]
|
||||
[DisplayName("User")]
|
||||
[Description("MySQL-Benutzername")]
|
||||
public string User { get; set; } = "bergisnu_0";
|
||||
public string User { get; set; } = "your-db-user";
|
||||
|
||||
[Category("Database")]
|
||||
[DisplayName("Password")]
|
||||
[Description("MySQL-Passwort")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string Password { get; set; } = "o5/A49+f}:Da";
|
||||
public string Password { get; set; } = "CHANGE_ME";
|
||||
|
||||
public string BuildConnectionString() =>
|
||||
$"Server={Host};Port={Port};Database={Database};" +
|
||||
@@ -299,6 +299,61 @@ public class SupervisorSettings
|
||||
public override string ToString() => Model;
|
||||
}
|
||||
|
||||
// ─── Deploymentcenter ────────────────────────────────────────────────────────
|
||||
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public class DeploymentcenterSettings
|
||||
{
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("API Token")]
|
||||
[Description("Authorization: Bearer <Token>. Ohne Token: keine Heartbeats, keine Fehlermeldungen.")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string ApiToken { get; set; } = "";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Heartbeat aktiv")]
|
||||
[Description("Dead-Man's-Switch an/aus")]
|
||||
public bool HeartbeatEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Monitor Source")]
|
||||
[Description("Monitor-Name im Dashboard. Leer = Rechnername (Auto-Registrierung beim ersten Ping)")]
|
||||
public string MonitorSource { get; set; } = "";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Monitor Instance")]
|
||||
[Description("Für mehrere Instanzen desselben Dienstes (z. B. EU/US)")]
|
||||
public string MonitorInstance { get; set; } = "default";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Heartbeat-Intervall (Sekunden)")]
|
||||
[Description("warning nach 2×, down nach 4× dieses Intervalls")]
|
||||
public int HeartbeatIntervalSeconds { get; set; } = 60;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Fehler melden")]
|
||||
[Description("Error/Fatal an den Fehler-Stream (/api/errors/v1/report)")]
|
||||
public bool ReportErrors { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Update-Prüfung beim Start")]
|
||||
[Description("Prüft nur, installiert nichts von selbst")]
|
||||
public bool CheckUpdatesOnStart { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Update-Kanal")]
|
||||
[Description("prod, beta oder dev")]
|
||||
public string UpdateChannel { get; set; } = "prod";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Lizenzschlüssel")]
|
||||
[Description("Nur Rückfall, falls noch kein Schlüssel im verschlüsselten Cache liegt")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
public override string ToString() => HeartbeatEnabled ? "Aktiv" : "Deaktiviert";
|
||||
}
|
||||
|
||||
// ─── Root ────────────────────────────────────────────────────────────────────
|
||||
|
||||
public class AppSettings
|
||||
@@ -347,4 +402,9 @@ public class AppSettings
|
||||
[DisplayName("Supervisor")]
|
||||
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
|
||||
public SupervisorSettings Supervisor { get; set; } = new();
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Deploymentcenter")]
|
||||
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
|
||||
public DeploymentcenterSettings Deploymentcenter { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Deploymentcenter.Client;
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Hosting;
|
||||
using IBKRTrader.Hosting.Deploymentcenter;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
@@ -21,6 +24,16 @@ internal static class Program
|
||||
if (HasFlag(args, "--db-version"))
|
||||
return DbVersion.Print();
|
||||
|
||||
// Lizenz-Konvention aus dem Deploymentcenter-Leitfaden: das SDK bringt keine eigene CLI mit,
|
||||
// diese beiden Schalter sind hier die vom Leitfaden empfohlene Umsetzung. Kein Host nötig -
|
||||
// beide arbeiten nur mit dem lokalen Lizenz-Cache bzw. einer einzelnen Validierung.
|
||||
if (HasFlag(args, "--license-status"))
|
||||
return PrintLicenseStatus();
|
||||
|
||||
var setKeyIndex = Array.IndexOf(args, "--license-set-key");
|
||||
if (setKeyIndex >= 0 && setKeyIndex + 1 < args.Length)
|
||||
return await SetLicenseKeyAsync(args[setKeyIndex + 1]).ConfigureAwait(false);
|
||||
|
||||
// Trockenlauf: Host bauen, Startprüfungen fahren, Dienste NICHT starten. Für Deployment
|
||||
// und CI – prüft Konfiguration, DB-Verbindungszeichenfolge, Master-Key und Ablageorte,
|
||||
// ohne eine einzige Verbindung zur Börse aufzubauen.
|
||||
@@ -40,7 +53,7 @@ internal static class Program
|
||||
}
|
||||
|
||||
logger.Info("Core", "=== IBKRTrader (kopflos) startet ===");
|
||||
logger.Info("Core", $"Version: 1.0.0 | .NET {Environment.Version} | {RuntimeDescription()}");
|
||||
logger.Info("Core", $"Version: {IBKRTrader.Hosting.BuildInfo.Summary} | .NET {Environment.Version} | {RuntimeDescription()}");
|
||||
|
||||
await host.StartAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -73,6 +86,41 @@ internal static class Program
|
||||
private static bool HasFlag(string[] args, string flag) =>
|
||||
args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ─── Lizenz-CLI (kein Host nötig) ──────────────────────────────────────────
|
||||
|
||||
private static int PrintLicenseStatus()
|
||||
{
|
||||
LicenseConfig.SetStorageDirectory(AppPaths.Config);
|
||||
|
||||
var hw = HardwareId.GetHardwareId(DcConfig.ProductSlug);
|
||||
var state = LicenseClient.TryGetCachedState(DcConfig.ProductSlug);
|
||||
|
||||
Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})");
|
||||
Console.WriteLine($"Schlüssel: {LicenseClient.TryGetCachedKey(DcConfig.ProductSlug) ?? "keiner"}");
|
||||
Console.WriteLine($"Cache: {(state == null ? "leer" : state.Status)}");
|
||||
|
||||
if (state is { CacheExpiresAt: > 0 })
|
||||
{
|
||||
var until = DateTimeOffset.FromUnixTimeSeconds(state.CacheExpiresAt);
|
||||
Console.WriteLine($"Offline noch bis: {until:u}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> SetLicenseKeyAsync(string key)
|
||||
{
|
||||
LicenseConfig.SetStorageDirectory(AppPaths.Config);
|
||||
|
||||
// ValidateAsync legt den Schluessel bei Erfolg selbst im verschluesselten Cache ab - eine
|
||||
// eigene Ablage braucht es dafuer nicht.
|
||||
var client = new LicenseClient();
|
||||
var result = await client.ValidateAsync(DcConfig.ProductSlug, key, DcConfig.BaseUrl, DcConfig.AppVersion)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine(result.Message);
|
||||
return result.IsValid ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string RuntimeDescription() =>
|
||||
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription.Trim()} " +
|
||||
$"({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
|
||||
|
||||
@@ -14,6 +14,7 @@ using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Trading.Ibkr;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using IBKRTrader.Core.Workers.BuiltIn;
|
||||
using IBKRTrader.Hosting.Deploymentcenter;
|
||||
using IBKRTrader.Modules.Accounting;
|
||||
using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.Supervisor;
|
||||
@@ -92,9 +93,45 @@ public static class AppHostBuilder
|
||||
// 4. Transportverschlüsselung der DB prüfen.
|
||||
WarnIfDbTlsNotEnforced(services, logger);
|
||||
|
||||
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService braucht die Engine (vor dem Start).
|
||||
services.GetRequiredService<WebApiService>()
|
||||
.SetEngine(services.GetRequiredService<WorkerEngine>());
|
||||
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService und der Watchdog-Heartbeat brauchen die
|
||||
// Engine (vor dem Start), sind aber selbst Teil von deren IEnumerable<IWorker>.
|
||||
var workerEngine = services.GetRequiredService<WorkerEngine>();
|
||||
services.GetRequiredService<WebApiService>().SetEngine(workerEngine);
|
||||
services.GetRequiredService<DcHeartbeatWorker>().SetWorkerEngine(workerEngine);
|
||||
|
||||
// 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine
|
||||
// "fatal"-Meldungen und keinen Logeintrag, wenn der Prozess unbehandelt wegbrach.
|
||||
var errorReporter = services.GetRequiredService<DcErrorReporter>();
|
||||
new DcErrorSink(errorReporter).Attach(logger);
|
||||
DcCrashHandlers.Register(logger, errorReporter);
|
||||
|
||||
// 7. Lizenz pruefen - NACH der Fehlermeldung (ein Sperrbetrieb-Urteil soll sich melden
|
||||
// koennen) und ohne Eingabeaufforderung: weder Shell noch Daemon duerfen hier auf eine
|
||||
// Konsoleneingabe warten. Blockierend, weil RunStartupChecks synchron aufgerufen wird und
|
||||
// vor dem Start der Worker abgeschlossen sein muss.
|
||||
services.GetRequiredService<LicenseGuard>().CheckAtStartupAsync().GetAwaiter().GetResult();
|
||||
|
||||
// 8. Update-Pruefung - NACH der Lizenz, weil der Zugang zur Release-Ablage seit 2.4 am
|
||||
// Lizenzschluessel haengt. Findet nur, installiert nichts von selbst.
|
||||
var settingsService = services.GetRequiredService<SettingsService>();
|
||||
if (settingsService.Settings.Deploymentcenter.CheckUpdatesOnStart)
|
||||
{
|
||||
try
|
||||
{
|
||||
var check = DcUpdateService.CheckAsync(settingsService).GetAwaiter().GetResult();
|
||||
if (check.Unauthorized)
|
||||
logger.Warn("Core", $"Update-Prüfung: Zugangsdaten abgelehnt (Lizenz abgelaufen/widerrufen?) - {check.Message}");
|
||||
else if (check.UpdateAvailable)
|
||||
logger.Warn("Core", $"Update verfügbar: v{check.LatestRelease?.Version}{(check.IsCritical ? " (KRITISCH)" : "")} - {check.Message}");
|
||||
else
|
||||
logger.Info("Core", "Update-Prüfung: kein Update verfügbar.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein nicht erreichbares Deploymentcenter darf den Start niemals verhindern.
|
||||
logger.Warn("Core", $"Update-Prüfung fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registriert alle Core-Services im DI-Container.</summary>
|
||||
@@ -114,6 +151,16 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<LoggingService>();
|
||||
services.AddSingleton<CoreSettingsService>(); // core_settings via EF
|
||||
|
||||
// Deploymentcenter: Fehler-Stream. Als DI-Factory registriert (nicht eager erzeugt), damit
|
||||
// sie sich denselben LoggingService- und SettingsService-Singleton aus demselben Container
|
||||
// holt, statt einen zweiten Container dafür aufzubauen.
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var token = sp.GetRequiredService<SettingsService>().Settings.Deploymentcenter.ApiToken;
|
||||
return new DcErrorReporter(token, msg => sp.GetRequiredService<LoggingService>().Warn("Deploymentcenter", msg));
|
||||
});
|
||||
services.AddSingleton<LicenseGuard>();
|
||||
|
||||
services.AddSingleton<IBKRGatewayService>();
|
||||
services.AddSingleton<IBKRMarketDataRepository>();
|
||||
|
||||
@@ -152,6 +199,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<WebApiService>();
|
||||
services.AddSingleton<IBKRInstrumentSyncWorker>();
|
||||
services.AddSingleton<IBKRPriceHistoryWorker>();
|
||||
services.AddSingleton<DcHeartbeatWorker>();
|
||||
|
||||
// Als IWorker registrieren → die WorkerEngine erhält alle über IEnumerable<IWorker>.
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
|
||||
@@ -159,6 +207,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DcHeartbeatWorker>());
|
||||
|
||||
// Lebenszyklus über den Generic Host (jeder Worker ist ein IHostedService).
|
||||
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
|
||||
@@ -166,6 +215,7 @@ public static class AppHostBuilder
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<DcHeartbeatWorker>());
|
||||
|
||||
services.AddSingleton<WorkerEngine>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Fehlgeschlagener Deploymentcenter-Aufruf. <see cref="Code"/> ist der stabile, maschinenlesbare
|
||||
/// Fehlercode aus dem API-Umschlag ("unauthorized", "rate_limited", ...) - reagiert wird auf den
|
||||
/// Code, nicht auf den Text.
|
||||
/// </summary>
|
||||
public sealed class DcApiException : Exception
|
||||
{
|
||||
public DcApiException(HttpStatusCode statusCode, string? code, string body)
|
||||
: base($"Deployment Center HTTP {(int)statusCode}{(code is null ? "" : $" ({code})")}: {Shorten(body)}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Code = code;
|
||||
}
|
||||
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
public string? Code { get; }
|
||||
|
||||
/// <summary>Wahr für Fehler, die eine Wiederholung mit denselben Daten nicht beheben kann (Token falsch/fehlt).</summary>
|
||||
public bool IsPermanent =>
|
||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden ||
|
||||
Code is "unauthorized" or "project_forbidden";
|
||||
|
||||
private static string Shorten(string body) =>
|
||||
body.Length <= 300 ? body : body[..300] + "…";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schlanker JSON-Client für Watchdog-Heartbeat und Fehler-Stream. Lizenz und UpdateService bringen
|
||||
/// ihren eigenen Client mit (Deploymentcenter.Client).
|
||||
///
|
||||
/// <para><b>Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen</b> - jeder
|
||||
/// Aufruf ist best effort mit kurzer Zeitgrenze; Fehler werden dem Aufrufer nur als Exception
|
||||
/// gemeldet, nie geworfen in einem Kontext, der den Trading-Kern mitreißen könnte.</para>
|
||||
/// </summary>
|
||||
public sealed class DcApiClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _token;
|
||||
|
||||
public DcApiClient(string token, TimeSpan? timeout = null)
|
||||
{
|
||||
_token = token ?? "";
|
||||
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
public async Task<string> PostJsonAsync(string path, object payload, CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, DcConfig.BaseUrl + path);
|
||||
if (!string.IsNullOrWhiteSpace(_token))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_token}");
|
||||
}
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new DcApiException(response.StatusCode, ExtractErrorCode(body), body);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Liest error.code aus {"status":"error","error":{"code":"…"}}.</summary>
|
||||
private static string? ExtractErrorCode(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
doc.RootElement.TryGetProperty("error", out var error) &&
|
||||
error.ValueKind == JsonValueKind.Object &&
|
||||
error.TryGetProperty("code", out var code))
|
||||
{
|
||||
return code.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Nicht jeder Fehlerpfad antwortet mit dem Umschlag (z. B. eine Proxy-Fehlerseite).
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Fest verdrahtete Eckdaten der Deploymentcenter-Anbindung (https://dc.mhdf.de), das Lizenz,
|
||||
/// Watchdog, UpdateService, Fehler-Stream und Bugtracker in einem Server bündelt.
|
||||
///
|
||||
/// <para><b>Die Basis-URL ist bewusst KEINE Einstellung.</b> Sie entscheidet, wohin die
|
||||
/// Lizenzprüfung geht und woher ausführbarer Code (Update-Pakete) kommt. Ein einstellbarer
|
||||
/// Endpunkt wäre eine Einladung, die Anwendung auf einen gefälschten Server zu zeigen.</para>
|
||||
/// </summary>
|
||||
public static class DcConfig
|
||||
{
|
||||
public const string BaseUrl = "https://dc.mhdf.de";
|
||||
|
||||
/// <summary>Slug in dc_projects - Lizenz, UpdateService, Fehler-Stream und Bugtracker teilen ihn.</summary>
|
||||
public const string ProductSlug = "ibkrtrader";
|
||||
|
||||
/// <summary>Gruppierung des Watchdog-Monitors im Dashboard.</summary>
|
||||
public const string MonitorGroup = "Trading";
|
||||
|
||||
/// <summary>
|
||||
/// Gemeldete Version - Lizenz-Aktivierungsliste, Watchdog-Monitorspalte und Fehler-Stream.
|
||||
/// Erzeugt von Deploymentcenter.BuildInfo.targets aus <Version> in Directory.Build.props;
|
||||
/// beim Release dort hochziehen, das ist auch, wogegen der UpdateService vergleicht.
|
||||
/// </summary>
|
||||
public static string AppVersion => BuildInfo.Version;
|
||||
|
||||
/// <summary>Commit dieses Builds - reist im Fehler-Stream mit.</summary>
|
||||
public static string GitCommitShort => BuildInfo.GitCommitShort;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Globale Ausnahmebehandler - ohne sie gab es keine "fatal"-Meldungen und, vor dieser
|
||||
/// Integration, nicht einmal einen Logeintrag, wenn der Prozess unbehandelt wegbrach.
|
||||
/// </summary>
|
||||
public static class DcCrashHandlers
|
||||
{
|
||||
public static void Register(LoggingService logger, DcErrorReporter reporter)
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
var ex = e.ExceptionObject as Exception;
|
||||
logger.Error("Core", $"Unbehandelte Ausnahme - Prozess beendet sich (IsTerminating={e.IsTerminating}).", ex);
|
||||
|
||||
// Blockierend: der Prozess ist gleich weg, ein fire-and-forget-Task käme nie mehr zur
|
||||
// Ausführung.
|
||||
reporter.Report(
|
||||
level: "fatal",
|
||||
module: "Core",
|
||||
message: "Unbehandelte Ausnahme - Prozess beendet sich.",
|
||||
exception: ex,
|
||||
blocking: true);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
logger.Error("Core", "Unbeobachtete Task-Ausnahme.", e.Exception);
|
||||
reporter.Report(level: "error", module: "Core", message: "Unbeobachtete Task-Ausnahme.", exception: e.Exception);
|
||||
e.SetObserved();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet Laufzeitfehler an POST /api/errors/v1/report.
|
||||
///
|
||||
/// <para><b>Bremse ist Pflicht, nicht Kür.</b> Ein Fehler in einer Handelsschleife könnte binnen
|
||||
/// Sekunden das serverseitige Rate-Limit verbrennen. Deshalb: höchstens 20 Meldungen pro Minute
|
||||
/// insgesamt, derselbe Fehler höchstens alle 5 Minuten, und bei 401/403 wird dauerhaft
|
||||
/// abgeschaltet - ein abgelehntes Token wird durch Wiederholen nicht gültig.</para>
|
||||
///
|
||||
/// <para>Serverseitig werden gleiche Fehler ohnehin gruppiert und hochgezählt; betriebsbedingtes
|
||||
/// Rauschen gehört in die Ignore-Regeln des WebUI, nicht in eine noch härtere lokale Bremse.</para>
|
||||
/// </summary>
|
||||
public sealed class DcErrorReporter
|
||||
{
|
||||
#if DEBUG
|
||||
private const string Environment_ = "development";
|
||||
#else
|
||||
private const string Environment_ = "production";
|
||||
#endif
|
||||
|
||||
private const int MaxPerMinute = 20;
|
||||
private static readonly TimeSpan SameErrorCooldown = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly string _token;
|
||||
private readonly Action<string> _debugLog;
|
||||
private readonly object _lock = new();
|
||||
private readonly Queue<DateTime> _recentSends = new();
|
||||
private readonly Dictionary<string, DateTime> _lastSentByFingerprint = new();
|
||||
private volatile bool _disabled;
|
||||
|
||||
public DcErrorReporter(string token, Action<string>? debugLog = null)
|
||||
{
|
||||
_token = (token ?? "").Trim();
|
||||
_debugLog = debugLog ?? (_ => { });
|
||||
}
|
||||
|
||||
public bool Enabled => !_disabled && !string.IsNullOrWhiteSpace(_token);
|
||||
|
||||
/// <summary>
|
||||
/// Meldet einen Fehler. <paramref name="blocking"/> gilt für den Fall "fatal": der Prozess
|
||||
/// beendet sich gleich, ein fire-and-forget-Task käme nie mehr zur Ausführung.
|
||||
/// </summary>
|
||||
public void Report(string level, string module, string message, Exception? exception,
|
||||
string? filePath = null, int? lineNo = null, string? correlationId = null,
|
||||
bool blocking = false)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
|
||||
var fingerprint = module + "|" + (exception?.GetType().FullName ?? message);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disabled) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
while (_recentSends.Count > 0 && now - _recentSends.Peek() > TimeSpan.FromMinutes(1))
|
||||
_recentSends.Dequeue();
|
||||
if (_recentSends.Count >= MaxPerMinute) return;
|
||||
|
||||
if (_lastSentByFingerprint.TryGetValue(fingerprint, out var last) && now - last < SameErrorCooldown)
|
||||
return;
|
||||
|
||||
_recentSends.Enqueue(now);
|
||||
_lastSentByFingerprint[fingerprint] = now;
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
project_slug = DcConfig.ProductSlug,
|
||||
exception = exception?.GetType().FullName ?? "LoggedError",
|
||||
message = exception != null ? $"{message} | {exception.Message}" : message,
|
||||
stack_trace = exception?.ToString(),
|
||||
level,
|
||||
build = DcConfig.AppVersion,
|
||||
environment = Environment_,
|
||||
file = filePath,
|
||||
line = lineNo,
|
||||
context = new { git_commit = DcConfig.GitCommitShort, correlation_id = correlationId, module }
|
||||
};
|
||||
|
||||
var client = new DcApiClient(_token, TimeSpan.FromSeconds(blocking ? 5 : 10));
|
||||
var task = SendAsync(client, payload);
|
||||
|
||||
if (blocking)
|
||||
{
|
||||
// AppDomain.UnhandledException: der Prozess ist gleich weg, ein fire-and-forget-Task
|
||||
// käme nie mehr zur Ausführung. Kurze Zeitgrenze, damit das Beenden nicht unnötig hängt.
|
||||
try { task.Wait(TimeSpan.FromSeconds(6)); } catch { /* best effort */ }
|
||||
client.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = task.ContinueWith(_ => client.Dispose(), TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendAsync(DcApiClient client, object payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.PostJsonAsync("/api/errors/v1/report", payload).ConfigureAwait(false);
|
||||
}
|
||||
catch (DcApiException ex) when (ex.IsPermanent)
|
||||
{
|
||||
_disabled = true;
|
||||
_debugLog($"[Deploymentcenter] Fehler-Stream dauerhaft deaktiviert: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein nicht erreichbares Deploymentcenter darf den Handel niemals beeinflussen.
|
||||
_debugLog($"[Deploymentcenter] Fehlermeldung fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using IBKRTrader.Core.Logging;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Hängt sich an <see cref="LoggingService.EntryWritten"/> und meldet Error-Einträge an den
|
||||
/// Fehler-Stream. Ergänzt die globalen Ausnahmebehandler (<see cref="DcCrashHandlers"/>): die
|
||||
/// decken den Absturz des Prozesses ab, dieser Sink jedes behandelte <c>logger.Error(...)</c> aus
|
||||
/// Workern, Modulen und Kestrel.
|
||||
///
|
||||
/// <para>Bewusst nicht blockierend - ein hängender Sink darf den schreibenden Worker nicht
|
||||
/// mitreißen. Die Bremse gegen Log-Fluten sitzt in <see cref="DcErrorReporter"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class DcErrorSink
|
||||
{
|
||||
private readonly DcErrorReporter _reporter;
|
||||
|
||||
public DcErrorSink(DcErrorReporter reporter) => _reporter = reporter;
|
||||
|
||||
public void Attach(LoggingService logger) => logger.EntryWritten += OnEntryWritten;
|
||||
|
||||
private void OnEntryWritten(LogEntry entry)
|
||||
{
|
||||
if (entry.Level != AppLogLevel.Error) return;
|
||||
|
||||
try
|
||||
{
|
||||
_reporter.Report(level: "error", module: entry.Module, message: entry.Message, exception: entry.Exception);
|
||||
}
|
||||
catch { /* Fehlermeldung darf das Logging niemals stören */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using IBKRTrader.Core.IBKR;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Modularity;
|
||||
using IBKRTrader.Core.Persistence;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using IBKRTrader.Core.Workers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Dead-Man's-Switch: meldet in festem Takt POST /api/watchdog/v1/ping.
|
||||
///
|
||||
/// <para>Bewusst <b>nicht</b> über <see cref="WorkerBase"/>: die Basisklasse schreibt bei jedem Run
|
||||
/// zwei Zeilen nach <c>core_worker_log</c> - bei einem 60-Sekunden-Takt wären das über 2800
|
||||
/// Schreibvorgänge am Tag für reine Lebenszeichen. Ein eigener, schlanker Loop passt hier besser;
|
||||
/// <see cref="Info"/> wird trotzdem gepflegt, damit der Worker in der Workers-Ansicht auftaucht.</para>
|
||||
///
|
||||
/// <para>Ein Heartbeat beweist nur, dass ein Thread läuft - deshalb schickt er den selbst
|
||||
/// ermittelten Zustand mit (<c>checks</c>). Ein nicht erreichbares Deploymentcenter darf den Handel
|
||||
/// niemals beeinflussen: jeder Aufruf ist best effort mit kurzer Zeitgrenze.</para>
|
||||
/// </summary>
|
||||
public sealed class DcHeartbeatWorker : IWorker, IHostedService
|
||||
{
|
||||
public string Name => "DcHeartbeatWorker";
|
||||
public string Module => "Core";
|
||||
public WorkerType Type => WorkerType.Service;
|
||||
public WorkerInfo Info { get; } = new();
|
||||
|
||||
private readonly LoggingService _logger;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IDbContextFactory<CoreDbContext> _dbf;
|
||||
private readonly IBKRGatewayService _gateway;
|
||||
private readonly IPortfolioService _portfolio;
|
||||
private readonly IOrderEventLog _orderEvents;
|
||||
private readonly IEnumerable<IModule> _modules;
|
||||
private readonly LicenseGuard _licenseGuard;
|
||||
|
||||
private readonly DateTime _startedAtUtc = DateTime.UtcNow;
|
||||
private readonly SemaphoreSlim _wake = new(0, 1);
|
||||
private static readonly TimeSpan LicenseRevalidationInterval = TimeSpan.FromHours(12);
|
||||
private DateTime _lastLicenseRevalidationUtc = DateTime.UtcNow;
|
||||
|
||||
private WorkerEngine? _workerEngine;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
public DcHeartbeatWorker(
|
||||
LoggingService logger, SettingsService settings, IDbContextFactory<CoreDbContext> dbf,
|
||||
IBKRGatewayService gateway, IPortfolioService portfolio, IOrderEventLog orderEvents,
|
||||
IEnumerable<IModule> modules, LicenseGuard licenseGuard)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings;
|
||||
_dbf = dbf;
|
||||
_gateway = gateway;
|
||||
_portfolio = portfolio;
|
||||
_orderEvents = orderEvents;
|
||||
_modules = modules;
|
||||
_licenseGuard = licenseGuard;
|
||||
|
||||
Info.WorkerName = Name;
|
||||
Info.Module = Module;
|
||||
Info.Type = Type.ToString();
|
||||
Info.Active = settings.Settings.Deploymentcenter.HeartbeatEnabled;
|
||||
Info.Status = WorkerStatus.Idle;
|
||||
Info.Info = "Bereit";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zirkuläre Abhängigkeit auflösen (WorkerEngine registriert alle IWorker, auch diesen) - gleiches
|
||||
/// Muster wie <see cref="Workers.BuiltIn.WebApiService"/>: erst nach dem Bau des Hosts setzbar.
|
||||
/// </summary>
|
||||
public void SetWorkerEngine(WorkerEngine engine) => _workerEngine = engine;
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Info.Active)
|
||||
{
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Deaktiviert";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Info.RunEvery = $"{Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds)}s";
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = Task.Run(() => RunLoopAsync(_cts.Token), _cts.Token);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cts == null) return;
|
||||
await _cts.CancelAsync();
|
||||
if (_loop != null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
// Geordnetes Beenden ankündigen - ohne das folgt wenige Minuten später ein Fehlalarm.
|
||||
// Kurze eigene Zeitgrenze: ein hängender Monitoring-Server darf das Herunterfahren
|
||||
// nicht blockieren.
|
||||
await SendAsync("stopped", "Dienst planmäßig beendet", CancellationToken.None, timeout: TimeSpan.FromSeconds(3))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Info.Status = WorkerStatus.Stopped;
|
||||
Info.Info = "Gestoppt";
|
||||
}
|
||||
|
||||
public async Task TriggerAsync()
|
||||
{
|
||||
if (_wake.CurrentCount == 0) _wake.Release();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── Loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.Info(Module, $"{Name}: Heartbeat gestartet (Intervall {_settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds}s).");
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await RunOnceAsync(ct).ConfigureAwait(false);
|
||||
await RevalidateLicenseIfDueAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(5, _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds));
|
||||
Info.NextRuntime = DateTime.Now.Add(interval);
|
||||
|
||||
try { await _wake.WaitAsync(interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
Info.Status = WorkerStatus.Running;
|
||||
|
||||
var (checks, metrics, allOk) = await GatherStateAsync(ct).ConfigureAwait(false);
|
||||
var status = allOk ? "ok" : "warning";
|
||||
var message = allOk ? "Betrieb normal" : "Eine oder mehrere Prüfungen auffällig";
|
||||
|
||||
var sent = await SendAsync(status, message, ct, checks: checks, metrics: metrics).ConfigureAwait(false);
|
||||
|
||||
Info.LastRuntime = DateTime.Now;
|
||||
Info.Status = WorkerStatus.Idle;
|
||||
Info.Info = sent ? $"{status} – {Info.LastRuntime:HH:mm:ss}" : $"Melden fehlgeschlagen – {Info.LastRuntime:HH:mm:ss}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trittbrett für die Lizenz-Revalidierung (alle 12h) - reitet auf dem ohnehin laufenden
|
||||
/// Heartbeat-Takt statt einen eigenen Timer zu betreiben.
|
||||
/// </summary>
|
||||
private async Task RevalidateLicenseIfDueAsync(CancellationToken ct)
|
||||
{
|
||||
if (DateTime.UtcNow - _lastLicenseRevalidationUtc < LicenseRevalidationInterval) return;
|
||||
_lastLicenseRevalidationUtc = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _licenseGuard.RevalidateAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warn(Module, $"Lizenz-Revalidierung fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Zustand ermitteln ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Ein Heartbeat beweist nur, dass ein Thread läuft - nicht, dass die Anwendung ihre Arbeit
|
||||
/// tut. Jede Prüfung ist einzeln abgesichert: eine fehlschlagende Prüfung darf die übrigen
|
||||
/// (und vor allem den Heartbeat selbst) nicht verhindern.
|
||||
/// </summary>
|
||||
private async Task<(Dictionary<string, object> checks, Dictionary<string, object> metrics, bool allOk)> GatherStateAsync(CancellationToken ct)
|
||||
{
|
||||
var checks = new Dictionary<string, object>();
|
||||
var metrics = new Dictionary<string, object>();
|
||||
var allOk = true;
|
||||
|
||||
// db
|
||||
try
|
||||
{
|
||||
using var dbCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
dbCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
await using var db = await _dbf.CreateDbContextAsync(dbCts.Token).ConfigureAwait(false);
|
||||
await db.Database.ExecuteSqlRawAsync("SELECT 1", dbCts.Token).ConfigureAwait(false);
|
||||
checks["db"] = new { ok = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
checks["db"] = new { ok = false, message = Shorten(ex.Message) };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// ibkr_gateway - NullBroker (UseTwsApi = false) ist ein gewollter Zustand, kein Fehler.
|
||||
try
|
||||
{
|
||||
var useTws = _settings.Settings.IBKR.UseTwsApi;
|
||||
var gatewayOk = !useTws || _gateway.IsConnected;
|
||||
checks["ibkr_gateway"] = useTws
|
||||
? new { ok = gatewayOk, message = gatewayOk ? "verbunden" : "nicht verbunden" }
|
||||
: new { ok = true, message = "TWS-Broker deaktiviert (NullBroker)" };
|
||||
if (!gatewayOk) allOk = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
checks["ibkr_gateway"] = new { ok = false, message = Shorten(ex.Message) };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// worker_engine - kein Worker im Fehlerzustand
|
||||
try
|
||||
{
|
||||
var infos = _workerEngine?.WorkerInfos;
|
||||
var failing = infos?.Where(w => w.Status == WorkerStatus.Error).Select(w => w.WorkerName).ToList() ?? [];
|
||||
checks["worker_engine"] = new { ok = failing.Count == 0, message = failing.Count == 0 ? "alle Worker ok" : string.Join(", ", failing) };
|
||||
if (failing.Count > 0) allOk = false;
|
||||
metrics["workers_running"] = infos?.Count(w => w.Status is WorkerStatus.Idle or WorkerStatus.Running) ?? 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
checks["worker_engine"] = new { ok = false, message = Shorten(ex.Message) };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// Metriken - bewusst kein Check auf "Trading aktiv": ein abgeschalteter Handel ist ein
|
||||
// gewollter Betriebszustand und würde den Monitor sonst dauerhaft auf "warning" halten.
|
||||
metrics["uptime_sec"] = (long)(DateTime.UtcNow - _startedAtUtc).TotalSeconds;
|
||||
metrics["trading_enabled"] = _settings.Settings.Trading.TradingEnabled ? 1 : 0;
|
||||
metrics["memory_mb"] = GC.GetTotalMemory(false) / 1024 / 1024;
|
||||
|
||||
try
|
||||
{
|
||||
var todayStartUtc = DateTime.UtcNow.Date;
|
||||
metrics["orders_today"] = _orderEvents.Query(e => e.Timestamp >= todayStartUtc, limit: 5000).Count;
|
||||
}
|
||||
catch { /* best effort - Metrik darf den Heartbeat nicht verhindern */ }
|
||||
|
||||
try
|
||||
{
|
||||
var open = 0;
|
||||
foreach (var module in _modules)
|
||||
open += (await _portfolio.GetPositionsAsync(module.Name, ct).ConfigureAwait(false)).Count;
|
||||
metrics["open_positions"] = open;
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
|
||||
return (checks, metrics, allOk);
|
||||
}
|
||||
|
||||
// ─── Senden ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<bool> SendAsync(string status, string message, CancellationToken ct,
|
||||
Dictionary<string, object>? checks = null, Dictionary<string, object>? metrics = null,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
var token = _settings.Settings.Deploymentcenter.ApiToken;
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
|
||||
var payload = new
|
||||
{
|
||||
source = string.IsNullOrWhiteSpace(_settings.Settings.Deploymentcenter.MonitorSource)
|
||||
? Environment.MachineName
|
||||
: _settings.Settings.Deploymentcenter.MonitorSource,
|
||||
instance = _settings.Settings.Deploymentcenter.MonitorInstance,
|
||||
status,
|
||||
interval = _settings.Settings.Deploymentcenter.HeartbeatIntervalSeconds,
|
||||
message,
|
||||
group = DcConfig.MonitorGroup,
|
||||
os = System.Runtime.InteropServices.RuntimeInformation.OSDescription,
|
||||
version = DcConfig.AppVersion,
|
||||
checks,
|
||||
metrics
|
||||
};
|
||||
|
||||
using var client = new DcApiClient(token, timeout ?? TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
await client.PostJsonAsync("/api/watchdog/v1/ping", payload, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein nicht erreichbarer Monitoring-Server darf die Anwendung niemals stoppen.
|
||||
_logger.Warn(Module, $"{Name}: Heartbeat fehlgeschlagen: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Shorten(string s) => s.Length <= 200 ? s : s[..200] + "…";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Deploymentcenter.Client;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Fragt den UpdateService ab (GET /api/updateservice/v1/check) und übergibt bei Bedarf an den
|
||||
/// eigenständigen <c>update-agent</c>. Die Prüfung selbst liest nur Metadaten - installiert wird
|
||||
/// nichts von selbst. Ein Trading-Dienst, der sich unangekündigt neu startet, ist keine akzeptable
|
||||
/// Vorgabe.
|
||||
///
|
||||
/// <para><b>Seit 2.4 mit Zugangsdaten</b> - die Release-Ablage liegt hinter HTTP-Basic-Auth, der
|
||||
/// Zugang hängt am Lizenzschlüssel (siehe LicenseGuard). Ohne ihn liefert der Server 401 statt
|
||||
/// Updates.</para>
|
||||
/// </summary>
|
||||
public static class DcUpdateService
|
||||
{
|
||||
public static Task<UpdateCheckResult> CheckAsync(SettingsService settings, CancellationToken ct = default)
|
||||
{
|
||||
var dc = settings.Settings.Deploymentcenter;
|
||||
var key = ResolveLicenseKey(dc);
|
||||
|
||||
return new UpdateClient().CheckForUpdateAsync(
|
||||
baseUrl: DcConfig.BaseUrl,
|
||||
projectId: DcConfig.ProductSlug,
|
||||
currentVersion: DcConfig.AppVersion,
|
||||
channel: string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel,
|
||||
credentials: ReleaseCredentials.FromLicenseKey(key),
|
||||
cancellationToken: ct);
|
||||
}
|
||||
|
||||
/// <summary>Pfad des Agenten neben der laufenden Anwendung, oder null, wenn er dort nicht liegt.</summary>
|
||||
public static string? FindUpdateAgent() => UpdateClient.ResolveAgentPath(AppContext.BaseDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Übergibt die Kontrolle an den Update-Agenten.
|
||||
///
|
||||
/// <para><b><c>exitCurrentApp</c> ist bewusst immer <c>false</c>.</b> Seit SDK 2.5.0 ist
|
||||
/// <c>exitCurrentApp: true</c> ausdrücklich als harter Kill dokumentiert - laufende
|
||||
/// <c>finally</c>-Blöcke, <c>IHostApplicationLifetime</c> und Destruktoren kommen dann nicht
|
||||
/// mehr zum Zug. Für ein System mit potenziell offenem Auftragsbuch oder einer laufenden
|
||||
/// Buchungstransaktion ist das die falsche Vorgabe. Der Aufrufer beendet die Anwendung
|
||||
/// stattdessen selbst geordnet (<c>host.StopAsync(...)</c>), <b>nachdem</b> dieser Aufruf
|
||||
/// <c>true</c> geliefert hat - der Agent wartet über <c>--wait-for-pid</c> ohnehin auf das
|
||||
/// Prozessende.</para>
|
||||
/// </summary>
|
||||
public static bool LaunchAgent(SettingsService settings, LoggingService logger)
|
||||
{
|
||||
var dc = settings.Settings.Deploymentcenter;
|
||||
var agent = FindUpdateAgent();
|
||||
if (agent is null)
|
||||
{
|
||||
logger.Warn("Core", $"Update-Agent ({UpdateClient.AgentFileName}) liegt nicht neben der Anwendung - Update bitte manuell einspielen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = ResolveLicenseKey(dc);
|
||||
var channel = string.IsNullOrWhiteSpace(dc.UpdateChannel) ? "prod" : dc.UpdateChannel;
|
||||
|
||||
logger.Info("Core", $"Starte Update-Agent {agent} (Kanal {channel}).");
|
||||
return UpdateClient.LaunchUpdateAgent(
|
||||
agentPath: agent,
|
||||
projectId: DcConfig.ProductSlug,
|
||||
channel: channel,
|
||||
action: "update",
|
||||
version: "latest",
|
||||
currentVersion: DcConfig.AppVersion,
|
||||
exitCurrentApp: false,
|
||||
licenseKey: key,
|
||||
// Vorgabe 60s reicht knapp fuer EF-Flush, Kestrel, DB-Backup-Worker beim geordneten
|
||||
// Beenden nicht immer - 90s Luft, statt dass der Agent grundlos abbricht.
|
||||
waitTimeoutSeconds: 90);
|
||||
}
|
||||
|
||||
private static string? ResolveLicenseKey(DeploymentcenterSettings dc) =>
|
||||
string.IsNullOrWhiteSpace(dc.LicenseKey)
|
||||
? LicenseClient.TryGetCachedKey(DcConfig.ProductSlug)
|
||||
: dc.LicenseKey;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Deploymentcenter.Client;
|
||||
using IBKRTrader.Core.Configuration;
|
||||
using IBKRTrader.Core.Logging;
|
||||
using IBKRTrader.Core.Settings;
|
||||
|
||||
namespace IBKRTrader.Hosting.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet <see cref="ILicensePrompt"/>-Ereignisse an <see cref="LoggingService"/> statt an die
|
||||
/// Konsole - <c>ConsoleLicensePrompt</c> (die Vorgabe des SDK) passt weder für eine GUI-Anwendung
|
||||
/// noch für einen Dienst ohne angehängtes Terminal. <see cref="RequestLicenseKeyAsync"/> wird nie
|
||||
/// aufgerufen, solange <see cref="LicenseGuard"/> mit <c>allowPrompt: false</c> arbeitet - trotzdem
|
||||
/// definiert, damit die Schnittstelle vollständig implementiert ist.
|
||||
/// </summary>
|
||||
internal sealed class SilentLicensePrompt : ILicensePrompt
|
||||
{
|
||||
private readonly LoggingService _logger;
|
||||
public SilentLicensePrompt(LoggingService logger) => _logger = logger;
|
||||
|
||||
public Task<string?> RequestLicenseKeyAsync(string productSlug) => Task.FromResult<string?>(null);
|
||||
public void ShowLicenseError(string title, string message) => _logger.Warn("Core", $"[Lizenz] {title}: {message}");
|
||||
public void ShowLicenseInfo(string title, string message) => _logger.Info("Core", $"[Lizenz] {title}: {message}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz beim Start und bei Revalidierung.
|
||||
///
|
||||
/// <para><b>Weicht bewusst vom Leitfaden ab.</b> Der Deploymentcenter-Leitfaden empfiehlt bei einem
|
||||
/// endgültigen Urteil (<c>revoked</c>, <c>expired</c>, ...) <c>Environment.Exit(1)</c>. Für ein
|
||||
/// Handelssystem mit offenen Positionen ist ein sofortiges Beenden potenziell schädlicher als der
|
||||
/// unlizenzierte Weiterbetrieb. Stattdessen: <b>Sperrbetrieb</b> - <see cref="TradingSettings.TradingEnabled"/>
|
||||
/// wird hart auf <c>false</c> gesetzt (das ist bereits der zentrale, von
|
||||
/// <c>ExecutionService.ExecuteAsync</c> geprüfte Hauptschalter, siehe §5.2 im Integrationskonzept).
|
||||
/// Keine neuen Einstiege, aber Risiko-, Exit- und Buchhaltungslogik laufen unverändert weiter.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseGuard
|
||||
{
|
||||
private readonly LoggingService _logger;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly DcErrorReporter _errorReporter;
|
||||
private LicenseClient? _client;
|
||||
|
||||
public LicenseGuard(LoggingService logger, SettingsService settings, DcErrorReporter errorReporter)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings;
|
||||
_errorReporter = errorReporter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erstprüfung beim Start. Läuft ohne Eingabeaufforderung (<c>allowPrompt: false</c>) - weder
|
||||
/// die Desktop-Shell noch der Daemon dürfen hier auf eine Konsoleneingabe warten, die nie kommt.
|
||||
/// Ein eigenes Lizenzfenster für die Shell ist als Folgeschritt vorgesehen (siehe Konzept §5.2).
|
||||
/// </summary>
|
||||
public async Task CheckAtStartupAsync(CancellationToken ct = default)
|
||||
{
|
||||
// Breaking Change 2.5.1: SetStorageDirectory haengt jetzt selbst "<slug>/license" an - der
|
||||
// Aufruf bekommt hier bewusst NUR AppPaths.Config, kein zusaetzliches "license"-Unterverzeichnis
|
||||
// mehr (sonst entstuende ".../license/ibkrtrader/license").
|
||||
LicenseConfig.SetStorageDirectory(AppPaths.Config);
|
||||
LicenseClient.DefaultAppVersion = DcConfig.AppVersion;
|
||||
|
||||
_client = new LicenseClient(prompt: new SilentLicensePrompt(_logger));
|
||||
|
||||
// Liegt noch kein Schluessel im Cache, aber einer in den Settings (Erstinbetriebnahme oder
|
||||
// manuell eingetragen): einmal validieren, damit ValidateAsync ihn selbst in den
|
||||
// verschluesselten Cache legt. Danach reicht TryGetCachedKey.
|
||||
var cachedKey = LicenseClient.TryGetCachedKey(DcConfig.ProductSlug);
|
||||
var configuredKey = _settings.Settings.Deploymentcenter.LicenseKey;
|
||||
if (string.IsNullOrWhiteSpace(cachedKey) && !string.IsNullOrWhiteSpace(configuredKey))
|
||||
{
|
||||
await _client.ValidateAsync(DcConfig.ProductSlug, configuredKey, DcConfig.BaseUrl, DcConfig.AppVersion, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var result = await _client.EnsureLicensedAsync(
|
||||
DcConfig.ProductSlug, DcConfig.BaseUrl, allowPrompt: false, appVersion: DcConfig.AppVersion,
|
||||
cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
Apply(result);
|
||||
}
|
||||
|
||||
/// <summary>Erneute Prüfung zur Laufzeit (Empfehlung: alle 12h, aus einem Worker heraus aufzurufen).</summary>
|
||||
public async Task RevalidateAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_client == null) return;
|
||||
var result = await _client.RevalidateAsync(DcConfig.ProductSlug, DcConfig.BaseUrl, DcConfig.AppVersion, ct)
|
||||
.ConfigureAwait(false);
|
||||
Apply(result);
|
||||
}
|
||||
|
||||
/// <summary>Für die kopflosen Schalter (--license-status / --license-set-key) im Daemon.</summary>
|
||||
public LicenseClient Client => _client ??= new LicenseClient(prompt: new SilentLicensePrompt(_logger));
|
||||
|
||||
private void Apply(LicenseValidationResult result)
|
||||
{
|
||||
if (result.IsValid)
|
||||
{
|
||||
_logger.Info("Core", $"Lizenz: {result.Status} ({(result.IsCached ? "aus Cache" : "vom Server")}).");
|
||||
WarnIfGracePeriodExpiringSoon(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsTransient)
|
||||
{
|
||||
// Kein Urteil, nur keine Auskunft (server_unavailable, cache_expired) - weiterlaufen,
|
||||
// der nächste Durchlauf prüft erneut.
|
||||
_logger.Warn("Core", $"Lizenzprüfung derzeit nicht möglich: {result.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ein Urteil (revoked, expired, not_found, activation_limit, suspended, clock_rollback,
|
||||
// no_license): Sperrbetrieb statt Prozessende.
|
||||
var trading = _settings.Settings.Trading;
|
||||
if (trading.TradingEnabled)
|
||||
{
|
||||
trading.TradingEnabled = false;
|
||||
_settings.Save();
|
||||
_logger.Error("Core",
|
||||
$"Lizenz ungültig ({result.Status}): {result.Message}. Sperrbetrieb aktiv - " +
|
||||
"TradingEnabled auf false gesetzt, keine neuen Einstiege mehr. Bestehende Positionen " +
|
||||
"und Risiko-/Exit-Logik laufen unverändert weiter.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warn("Core", $"Lizenz weiterhin ungültig ({result.Status}): {result.Message}.");
|
||||
}
|
||||
|
||||
_errorReporter.Report(
|
||||
level: "warning", module: "Core",
|
||||
message: $"Lizenz ungültig ({result.Status}) - Sperrbetrieb aktiv, TradingEnabled=false.",
|
||||
exception: null);
|
||||
}
|
||||
|
||||
private void WarnIfGracePeriodExpiringSoon(LicenseValidationResult result)
|
||||
{
|
||||
if (result.CacheExpiresAt is not { } expiresUnix) return;
|
||||
|
||||
var expires = DateTimeOffset.FromUnixTimeSeconds(expiresUnix);
|
||||
if (expires - DateTimeOffset.UtcNow < TimeSpan.FromHours(48))
|
||||
_logger.Warn("Core", $"Offline-Gnadenfrist der Lizenz läuft in weniger als 48h ab ({expires:u}).");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
||||
<!-- Externes Schwester-Repo: J:\Softwareprojekte\Deploymentcenter muss neben dem IBKRTrader-Checkout
|
||||
liegen (..\..\..\..\ von hier aus). Liefert Lizenz, Watchdog, UpdateService, Fehler-Stream und
|
||||
Bugtracker. Interimslösung, solange kein NuGet-Paket in der Gitea-Registry liegt - siehe
|
||||
docs/konzepte/KONZEPT-Deploymentcenter-Integration.md §2.2. -->
|
||||
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -30,4 +35,15 @@
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine fehlende
|
||||
Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
|
||||
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
|
||||
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
|
||||
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem IBKRTrader-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
|
||||
</Target>
|
||||
|
||||
<!-- Erzeugt IBKRTrader.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal) zur
|
||||
Uebersetzungszeit aus <Version> (Directory.Build.props) und dem Git-Stand. -->
|
||||
<Import Project="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user