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);