using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Deploymentcenter; using ClawdDotNet.Core.Deploymentcenter.Watchdog; using Microsoft.Extensions.Logging; using ClawdDotNet.Licensing; namespace ClawdDotNet.App.Services; /// /// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat, /// Fehler-Stream, Bugtracker und Update-Prüfung. /// /// Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz /// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides /// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu /// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab. /// /// Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein /// status/error-Umschlag), einen eigenen Zwischenspeicher und muss vor /// allem anderen laufen. Dafür ist zuständig. /// public sealed class DeploymentcenterService : IAsyncDisposable { private readonly DeploymentcenterApi _api; private readonly string _appToken; private readonly string _build; private readonly ILogger _logger; private WatchdogHeartbeatService? _heartbeat; public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance; /// Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden. public BugtrackerClient? Bugtracker { get; private set; } /// Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist. public UpdateAvailability? Update { get; private set; } private DeploymentcenterService( DeploymentcenterApi api, string appToken, string build, ILogger logger) { _api = api; _appToken = appToken; _build = build; _logger = logger; } /// /// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt null zurück, /// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines /// dieser Module darf ein Startgrund oder ein Hindernis sein. /// public static DeploymentcenterService? TryCreate( AppSettings settings, string build, ILoggerFactory loggerFactory) { var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter"); if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl)) { logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt – Anbindung aus."); return null; } if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken)) { logger.LogInformation( "Deploymentcenter: kein Token hinterlegt – Heartbeat, Fehler-Stream und " + "Bugtracker bleiben aus."); return null; } DeploymentcenterApi api; try { api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken); } catch (ArgumentException ex) { logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar – Anbindung aus."); return null; } var service = new DeploymentcenterService( api, settings.DeploymentcenterToken, build, logger); if (settings.ErrorReportingEnabled) { service.Errors = new ErrorReporter( api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger); } service.Bugtracker = new BugtrackerClient( api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build); return service; } // ─── Watchdog ─── /// /// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist. /// /// Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über /// das Paar source + instance. Fällt eine von mehreren aus, fällt /// genau deren Monitor — und nur der schlägt Alarm. /// /// /// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration /// geschrieben werden soll (dort verschlüsselt). /// public async Task StartWatchdogAsync( InstanceConfig instance, IInstanceHealthProvider health, Action saveInstanceConfig, CancellationToken ct = default) { var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog); if (service is not { Enabled: true, AutoStart: true }) return; var watchdog = instance.Watchdog; var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct); await WarnIfTokenHasNoProjectAsync(token, ct); try { _heartbeat = WatchdogHeartbeatService.Create( _api.BaseUrl, token, watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.Group, DescribePlatform(), _build, watchdog.IntervalSeconds, health, _logger); _heartbeat.Start(); _logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s", watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds); } catch (Exception ex) { _logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden"); } } /// /// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte /// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite. /// /// Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das /// Master-Token liegt, sondern ein auf watchdog:ping und /// bugtracker:report beschränktes, das sich einzeln widerrufen lässt. /// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter /// Rechtelage läuft, ist genau dann still, wenn man es braucht. /// private async Task ResolveInstanceTokenAsync( InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct) { if (instance.Watchdog.HasToken) return instance.Watchdog.AgentToken; try { var provisioned = await new TokenProvisioner(_api).ProvisionAsync( clientName: $"ClawdDotNet {instance.InstanceName}", instanceId: instance.InstanceId, scopes: TokenProvisioner.InstanceScopes, ct: ct); instance.Watchdog.AgentToken = provisioned.Token; saveInstanceConfig(); _logger.LogInformation( "Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})", provisioned.TokenId, string.Join(", ", provisioned.Scopes)); return provisioned.Token; } catch (Exception ex) { // Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf // keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben. _logger.LogInformation( "Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) – " + "es wird mit dem hinterlegten Token gemeldet.", ex.Message); return _appToken; } } /// /// Prüft, ob das Melde-Token an ein Projekt gebunden ist, und sagt es, wenn nicht. /// /// Warum das eine eigene Anfrage wert ist. Seit SDK 2.8.1 erbt ein /// Watchdog-Monitor das Projekt seines Ping-Tokens. Fehlt dort die Bindung, legt der /// Server die Befunde im Scheinprojekt „default" ab — und eine Abfrage nach dem /// eigenen Projekt liefert für eine laufende, fleißig meldende Instanz dann /// null Befunde. Die Aufsicht sieht aus wie „nichts auffällig", und genau /// darauf verlässt sich niemand gern. /// /// Der Fall ist nicht erfunden: Genau so sind schon Meldungen in „default" /// gelandet und waren danach für ihren eigenen Melder gesperrt. Ein Heartbeat, der /// ins Leere geht, meldet sich nicht von selbst — deshalb wird hier einmal /// nachgefragt, statt es anzunehmen. /// /// Nur eine Warnung, kein Abbruch: Ein Monitoring, das bei unklarer Rechtelage /// gar nicht erst anläuft, ist genau dann still, wenn man es braucht. /// private async Task WarnIfTokenHasNoProjectAsync(string token, CancellationToken ct) { try { using var api = new DeploymentcenterApi(_api.BaseUrl, token); var identity = await new TokenProvisioner(api).WhoAmIAsync(ct); if (identity.IsProjectBound) { _logger.LogInformation( "Deploymentcenter: Meldungen gehen an Projekt {Projekt} (Token {TokenId}, {Typ}).", identity.ProjectSlug, identity.TokenId, identity.Type); return; } _logger.LogWarning( "Deploymentcenter: Das Melde-Token {TokenId} ist an kein Projekt gebunden. " + "Watchdog-Befunde dieser Instanz landen damit im Scheinprojekt \"default\" " + "und tauchen in der Projektansicht nicht auf.", identity.TokenId); } catch (Exception ex) { // Aelterer Server ohne whoami (vor SDK 2.7.1), kein Netz, kein Recht: Das ist // eine Auskunft, die ausfaellt, und kein Grund, den Heartbeat nicht zu starten. _logger.LogDebug(ex, "Deploymentcenter: Token-Auskunft nicht verfügbar."); } } private static string DescribePlatform() => $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / " + $".NET {System.Environment.Version}"; // ─── Updates ─── /// /// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das /// Ergebnis wird protokolliert und über bereitgestellt; ob und /// wann aktualisiert wird, entscheidet der Benutzer. /// public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default) { if (!settings.UpdateCheckEnabled) return; try { var client = new global::Deploymentcenter.Client.UpdateClient(); // Seit 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth; der Lizenzschlüssel // ist der Zugang. Die Anwendung kennt ihn ohnehin, es muss nichts zusätzlich // verteilt werden. Ohne ihn kommt ab dem Scharfschalten nur noch 401. // // platform bleibt offen: Das SDK setzt die Kennung des laufenden Systems ein. // Sie wegzulassen wäre keine Option — der Server liefert dann ausschließlich // plattformunabhängige Pakete. var result = await client.CheckForUpdateAsync( settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion, settings.UpdateChannel, credentials: global::Deploymentcenter.Client.ReleaseCredentials .FromLicenseKey(settings.LicenseKey), cancellationToken: ct); // Ein 401 ist kein Netzwerkfehler, sondern eine Aussage über die Lizenz. // Ohne die Unterscheidung sucht man bei einem abgelaufenen Vertrag am Netz. if (result.Unauthorized) { _logger.LogWarning( "Update-Prüfung abgewiesen: {Message} – der Lizenzschlüssel öffnet die " + "Release-Ablage nicht (mehr).", result.Message); return; } if (result.Error is not null) { _logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert)."); return; } // Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische // latest.json in camelCase, die API in snake_case, jeweils über ein eigenes // Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an — // und der ist genau der Rückfall, wenn die latest.json fehlt. Update = new UpdateAvailability( result.UpdateAvailable, result.LatestRelease?.Version ?? currentVersion, result.IsCritical, result.LatestRelease?.Changelog, result.LatestRelease?.PackageUrl); if (result.UpdateAvailable) { _logger.LogInformation("Update verfügbar: {Version}{Critical}", Update.LatestVersion, Update.IsCritical ? " (kritisch)" : ""); } else { _logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion); } } catch (Exception ex) { _logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert)."); } } /// /// Meldet dem Watchdog, dass das Herunterfahren gleich ein Update ist und kein /// Ausfall. Muss vor dem Herunterfahren gerufen werden — die Meldung geht /// beim Beenden des Heartbeats raus. /// public void AnnounceUpdate(string version) => _heartbeat?.AnnounceMaintenance($"Update auf {version} wird eingespielt."); /// /// Startet den update-agent und übergibt ihm das Feld. /// /// Beendet die Anwendung bewusst nicht selbst. LaunchUpdateAgent /// kann das (exitCurrentApp), täte es aber über /// — und das übergeht jedes Aufräumen: Der Watchdog /// meldete nichts, Instanzdatenbank und Chatverläufe blieben auf halbem Weg stehen. /// Der Aufrufer fährt deshalb erst geordnet herunter und beendet danach selbst. /// Der Agent bekommt unsere Prozesskennung und wartet, bis wir wirklich weg /// sind. /// /// Der Grund, wenn es nicht losging; sonst null. public string? StartUpdate(AppSettings settings, string currentVersion) { var agentPath = global::Deploymentcenter.Client.UpdateClient.ResolveAgentPath(); if (agentPath is null) { // Kein fest verdrahteter Name: Unter Linux trägt der Agent keine Endung. _logger.LogWarning( "Update angefordert, aber kein update-agent neben der Anwendung gefunden."); return "Der update-agent wurde nicht gefunden. Er gehört neben die " + "Programmdatei; das Installationspaket bringt ihn normalerweise mit."; } try { var started = global::Deploymentcenter.Client.UpdateClient.LaunchUpdateAgent( agentPath: agentPath, projectId: LicenseInfo.ProductSlug, channel: settings.UpdateChannel, action: "update", version: "latest", exitCurrentApp: false, currentVersion: currentVersion, waitForCurrentProcess: true, licenseKey: settings.LicenseKey, // Ohne Signatur nicht installieren. Der SHA256 stammt aus derselben // Quelle wie das Paket — wer den Webroot oder die FTP-Zugangsdaten // kontrolliert, tauscht beide gemeinsam aus. Ausgerechnet auf dem Pfad, // der anschließend fremden Code ausführt. // // Tragfähig, weil ab 0.1.2 jedes Release signiert ist. Ein Rücksprung // auf 0.1.0 oder 0.1.1 bricht damit ab — das ist gewollt: Die beiden // stammen aus der Zeit vor dem Signierschlüssel. requireSignature: true); if (!started) return "Der update-agent ließ sich nicht starten."; _logger.LogInformation("update-agent gestartet ({Path}), Anwendung wird beendet.", agentPath); return null; } catch (Exception ex) { _logger.LogError(ex, "update-agent konnte nicht gestartet werden."); return $"Der update-agent konnte nicht gestartet werden: {ex.Message}"; } } public async ValueTask DisposeAsync() { if (_heartbeat is not null) await _heartbeat.DisposeAsync(); if (Errors is IDisposable disposableReporter) disposableReporter.Dispose(); _api.Dispose(); } } /// Ergebnis der Update-Prüfung. /// /// Adresse des Pakets — für einen späteren Anschluss des update-agent. Solange /// der nicht eingebunden ist, dient sie nur der Anzeige. /// public sealed record UpdateAvailability( bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes, string? DownloadUrl = null);