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>
413 lines
17 KiB
C#
413 lines
17 KiB
C#
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);
|