Files
ClawdDotNet/src/ClawdDotNet.App/Services/DeploymentcenterService.cs
T
RichardandClaude Opus 5 373e1f4caf Deploymentcenter-Anbindung auf SDK 2.8.2 nachgezogen (#218)
Der Changelog seit 2.6.0 nennt sechs Fassungen, 16 action_items und fuenf
brechende Aenderungen. Davon trifft fast nichts diese Anwendung - und das
ist ein Befund, kein Versaeumnis:

- finding_status (brechend, 2.7.0): kein Aufrufer. Weder
  /api/supervisor/v1/observe noch /status werden hier angesprochen.
  Vorgemerkt fuer #528 Stufe 3 - wer den Manager baut, liest den
  Befundzustand von Anfang an aus diesem Feld.
- Nachpruefungs-Status awaiting_verification, in_verification,
  fix_insufficient (brechend, 2.6.1): Die Anwendung wertet
  bugtracker_items.status nirgends aus. Betrifft den Arbeitsablauf der
  Agenten, und der steht in CLAUDE.md bereits richtig beschrieben.
- Meldungen ohne project_slug (brechend, 2.8.1): BugtrackerClient und
  ErrorReporter schicken project_slug und build_version seit jeher mit.
- Leseweg ohne project_slug und schmales dc_roadmap (brechend, 2.8.1):
  betrifft den MCP-Adapter, nicht den Anwendungscode.

Geblieben ist die eine Zusage, die man nicht sieht, wenn man sie verletzt:
Seit 2.8.1 erbt ein Watchdog-Monitor das Projekt seines Ping-Tokens. Ohne
Bindung landen die Befunde im Scheinprojekt "default", und eine Abfrage
nach dem eigenen Projekt liefert fuer eine laufende, fleissig meldende
Instanz null Befunde - die Aufsicht sieht aus wie "nichts auffaellig". Das
ist genau die Falle, in die hier schon einmal Meldungen gelaufen sind.

Deshalb neu: TokenProvisioner.WhoAmIAsync gegen GET
/api/tokens/v1/whoami (SDK 2.7.1) und der Typ TokenIdentity mit
IsProjectBound. DeploymentcenterService fragt beim Start das eigene
Melde-Token, protokolliert das Projekt und warnt, wenn keines dransteht.
Bewusst nur eine Warnung: Monitoring, das bei unklarer Rechtelage gar nicht
erst anlaeuft, ist genau dann still, wenn man es braucht. Faellt die
Auskunft aus - aelterer Server, kein Netz -, bleibt es bei Debug.

Gemessen statt geraten: TokenProvisioner schickt keinen project_slug mit,
und ob der Server die Bindung vom Master-Token erbt, steht in keiner
Beschreibung, die vorliegt. Ein Sub-Token dafuer eigens auf dem
Produktivserver auszustellen waere zu viel Nebenwirkung gewesen; die erste
Instanz, die eines zieht, sagt es jetzt selbst ins Protokoll.

#176 ist erledigt und die Regel in CLAUDE.md gestrichen: dc_roadmap ohne
project_slug liefert jetzt das eigene Projekt (nachgemessen: 51 Eintraege,
alle clawddotnet). Wer projektuebergreifend lesen will, schickt
project_slug=all.

dc_link_project auf 2.8.2 gesetzt, CLAUDE.md-Zeile hochgesetzt.

Build ohne Warnungen, 717 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:14:19 +02:00

413 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat,
/// Fehler-Stream, Bugtracker und Update-Prüfung.
///
/// <para>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.</para>
///
/// <para>Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein
/// <c>status</c>/<c>error</c>-Umschlag), einen eigenen Zwischenspeicher und muss vor
/// allem anderen laufen. Dafür ist <see cref="LicenseGate"/> zuständig.</para>
/// </summary>
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;
/// <summary>Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden.</summary>
public BugtrackerClient? Bugtracker { get; private set; }
/// <summary>Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist.</summary>
public UpdateAvailability? Update { get; private set; }
private DeploymentcenterService(
DeploymentcenterApi api, string appToken, string build, ILogger logger)
{
_api = api;
_appToken = appToken;
_build = build;
_logger = logger;
}
/// <summary>
/// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt <c>null</c> 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.
/// </summary>
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 ───
/// <summary>
/// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist.
///
/// <para>Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über
/// das Paar <c>source</c> + <c>instance</c>. Fällt eine von mehreren aus, fällt
/// genau deren Monitor — und nur der schlägt Alarm.</para>
/// </summary>
/// <param name="saveInstanceConfig">
/// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration
/// geschrieben werden soll (dort verschlüsselt).
/// </param>
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");
}
}
/// <summary>
/// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte
/// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite.
///
/// <para>Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das
/// Master-Token liegt, sondern ein auf <c>watchdog:ping</c> und
/// <c>bugtracker:report</c> 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.</para>
/// </summary>
private async Task<string> 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;
}
}
/// <summary>
/// Prüft, ob das Melde-Token an ein Projekt gebunden ist, und sagt es, wenn nicht.
///
/// <para><b>Warum das eine eigene Anfrage wert ist.</b> 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
/// <b>null Befunde</b>. Die Aufsicht sieht aus wie „nichts auffällig", und genau
/// darauf verlässt sich niemand gern.</para>
///
/// <para>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.</para>
///
/// <para>Nur eine Warnung, kein Abbruch: Ein Monitoring, das bei unklarer Rechtelage
/// gar nicht erst anläuft, ist genau dann still, wenn man es braucht.</para>
/// </summary>
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 ───
/// <summary>
/// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das
/// Ergebnis wird protokolliert und über <see cref="Update"/> bereitgestellt; ob und
/// wann aktualisiert wird, entscheidet der Benutzer.
/// </summary>
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).");
}
}
/// <summary>
/// Meldet dem Watchdog, dass das Herunterfahren gleich ein Update ist und kein
/// Ausfall. Muss <b>vor</b> dem Herunterfahren gerufen werden — die Meldung geht
/// beim Beenden des Heartbeats raus.
/// </summary>
public void AnnounceUpdate(string version)
=> _heartbeat?.AnnounceMaintenance($"Update auf {version} wird eingespielt.");
/// <summary>
/// Startet den <c>update-agent</c> und übergibt ihm das Feld.
///
/// <para><b>Beendet die Anwendung bewusst nicht selbst.</b> <c>LaunchUpdateAgent</c>
/// kann das (<c>exitCurrentApp</c>), täte es aber über
/// <see cref="Environment.Exit"/> — 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.</para>
/// </summary>
/// <returns>Der Grund, wenn es nicht losging; sonst <c>null</c>.</returns>
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();
}
}
/// <summary>Ergebnis der Update-Prüfung.</summary>
/// <param name="DownloadUrl">
/// Adresse des Pakets — für einen späteren Anschluss des <c>update-agent</c>. Solange
/// der nicht eingebunden ist, dient sie nur der Anzeige.
/// </param>
public sealed record UpdateAvailability(
bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes,
string? DownloadUrl = null);