using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Deploymentcenter.Watchdog; /// /// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende. /// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen — /// alle Sendefehler werden geloggt und verschluckt. /// /// Sauberes Beenden. Beim Herunterfahren geht ein Heartbeat mit /// status: "stopped" raus. Der Evaluator lässt einen so gemeldeten Monitor in /// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen /// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den /// Monitor-Zustand. /// public sealed class WatchdogHeartbeatService : IAsyncDisposable { private readonly IWatchdogClient _client; private readonly bool _ownsClient; private readonly IInstanceHealthProvider _health; private readonly int _intervalSeconds; private readonly ILogger _logger; private CancellationTokenSource? _cts; private Task? _loop; private string _lastState = "unknown"; private string _shutdownStatus = WatchdogStatus.Stopped; private string _shutdownMessage = "Instanz planmäßig beendet."; private string _shutdownEventKind = WatchdogEventKind.StoppedGraceful; public WatchdogHeartbeatService( IWatchdogClient client, IInstanceHealthProvider health, int intervalSeconds, ILogger logger, bool ownsClient = false) { _client = client; _health = health; _intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400); _logger = logger; _ownsClient = ownsClient; } /// /// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration /// (fehlende oder nicht-HTTPS-URL). /// public static WatchdogHeartbeatService Create( string baseUrl, string token, string source, string instance, string group, string os, string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger) { var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version); return new WatchdogHeartbeatService( client, health, intervalSeconds, logger, ownsClient: true); } public bool IsRunning => _loop is { IsCompleted: false }; /// Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige. public string LastState => _lastState; /// /// Meldet das nächste Herunterfahren als Wartung statt als planmäßiges Ende. /// /// Gedacht für das Einspielen eines Updates: Die Instanz ist gleich weg, /// kommt aber wieder. stopped wäre die falsche Auskunft — es heißt „bewusst /// beendet" und lässt den Monitor liegen, bis jemand ihn wieder anfasst. /// maintenance sagt dasselbe über den Alarm aus, trägt aber die Absicht /// mit: Im Dashboard ist zu sehen, dass hier gerade aktualisiert wird, statt dass /// eine Instanz ohne Grund verschwindet. /// public void AnnounceMaintenance(string message) { _shutdownStatus = WatchdogStatus.Maintenance; _shutdownMessage = message; _shutdownEventKind = WatchdogEventKind.MaintenanceStart; } public void Start() { if (IsRunning) return; _cts = new CancellationTokenSource(); _loop = RunAsync(_cts.Token); _logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds); } private async Task RunAsync(CancellationToken ct) { await TrySendAsync( () => _client.SendEventAsync( WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct), "Start-Ereignis").ConfigureAwait(false); // Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen // Intervall im Dashboard auftaucht. await BeatAsync(ct).ConfigureAwait(false); try { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds)); while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) await BeatAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { // Regulärer Stopp. } } private async Task BeatAsync(CancellationToken ct) { InstanceHealth health; try { health = await _health.GetAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { // Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen // rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall. _logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning."); health = new InstanceHealth( WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.", new Dictionary(), new Dictionary()); } await TrySendAsync(async () => { var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct) .ConfigureAwait(false); if (result.State != _lastState) { _logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}", _lastState, result.State, result.FailingChecks.Count > 0 ? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})" : ""); _lastState = result.State; } }, "Heartbeat").ConfigureAwait(false); } private async Task TrySendAsync(Func send, string what) { try { await send().ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem) { // Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor // für immer stumm, und niemand merkt es, weil ja nichts abstürzt. _logger.LogWarning( "Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).", what, ex.Code); } catch (Exception ex) { // Ausfall des Monitorings darf den Betrieb nie stören. _logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what); } } public async ValueTask DisposeAsync() { if (_cts is null) return; await _cts.CancelAsync().ConfigureAwait(false); if (_loop is not null) { try { await _loop.ConfigureAwait(false); } catch (OperationCanceledException) { /* erwartet */ } catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); } } // Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein // Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft. try { using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); await _client.SendHeartbeatAsync( new InstanceHealth( _shutdownStatus, _shutdownMessage, new Dictionary(), new Dictionary()), _intervalSeconds, stopCts.Token).ConfigureAwait(false); await _client.SendEventAsync( _shutdownEventKind, "info", _shutdownMessage, null, stopCts.Token) .ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert)."); } _cts.Dispose(); if (_ownsClient && _client is IDisposable disposable) disposable.Dispose(); } }