feat(installer): Laufzeitpruefung, Host-Ueberwachung, Zugang fuer Adminkonten

Drei Dinge, die beim ersten Lauf des Installers auf einer Linux-Maschine
auffielen.

1. Die Release-Ablage wies Administratorkonten ab. ReleaseGuard nahm nur
   die Rolle 'installer' in die .htpasswd auf, waehrend Installskripte und
   Agent ausdruecklich sagten, ein Administratorkonto tue es auch:
   Anmeldung und Katalog gelangen, erst der Download endete mit 401 - und
   die Meldung sprach von abgelaufenen Lizenzen, die es bei einer
   Erstinstallation gar nicht geben kann. Adminkonten zaehlen jetzt zu den
   Installationskonten. FORMAT_VERSION auf 3, damit reconcile() die
   Dateien sofort neu schreibt statt erst beim naechsten turnusmaessigen
   Lauf; ein neu angelegtes Konto landet ausserdem unabhaengig von seiner
   Rolle sofort darin. Bei einem 401 mit Benutzerzugangsdaten nennt der
   Client jetzt Konto und zugangsberechtigte Rollen, und der Agent bricht
   ab, statt ueber die API weiterzusuchen und dieselbe Meldung ein paar
   Schritte spaeter ein zweites Mal zu zeigen.

2. Der Installer prueft die .NET-Laufzeit. Bisher endete eine gelungene
   Installation auf einer Maschine ohne .NET mit einer Anwendung, die sich
   nicht starten laesst - und die Fehlersuche begann beim
   Deploymentcenter, weil das der letzte bewusste Schritt war. Gelesen
   wird die runtimeconfig.json der Anwendung und mit "dotnet
   --list-runtimes" verglichen; fehlt etwas, nennt der Installer den
   Installationsbefehl fuer diese Plattform. Eigenstaendig
   veroeffentlichte Pakete werden nicht bemaengelt, rollForward wird
   beachtet.

3. Die Ueberwachung der Maschine entsteht im Installer. Zwei Fragen -
   Name im Dashboard und ob eingeplant werden soll - statt fuenf Schritten
   in der Oberflaeche an einem anderen Rechner. Monitor, Token mit genau
   watchdog:ping, Skript, Dateirechte, ein Heartbeat zur Probe und der
   Cron-Eintrag bzw. die geplante Aufgabe entstehen daraus. Fuer Maschinen
   ohne Installation: --action monitor.

Die Agent-Skripte werden jetzt in src/Modules/Watchdog/AgentScript.php
erzeugt - von Oberflaeche und Installer gemeinsam - und melden Last,
Speicher, Plattenbelegung und Laufzeit mit, statt nur "status: ok". Beim
Ausfuehren fielen zwei Fehler auf, die dort behoben sind: df -P verrutscht
bei Geraetenamen mit Leerzeichen (gezaehlt wird jetzt von hinten), und
ohne LC_ALL=C erzeugt awk auf einem deutschen System "12,5" und damit
kaputtes JSON.

Neu: POST /api/setup/v1/agent. SDK 2.6.0 mit
SetupClient.RequestWatchdogAgentAsync().

Die OpenAPI-Beschreibung des neuen Endpunkts bleibt zunaechst aussen vor:
public/api/openapi.php traegt gerade auch fremde, noch nicht committete
Aenderungen aus einer parallel laufenden Arbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-21 20:51:37 +02:00
co-authored by Claude Opus 5
parent fc9b698141
commit 687ee0cefc
15 changed files with 1836 additions and 84 deletions
@@ -132,6 +132,13 @@ namespace Deploymentcenter.UpdateAgent
case "configure":
return await Installation.ConfigureOnlyAsync(options, HttpClient);
case "monitor":
// Fuer Maschinen, auf denen nichts zu installieren ist -
// der Hypervisor unter den Anwendungen etwa - und zum
// Nachholen, wenn die Frage bei der Installation verneint
// wurde.
return await Monitoring.RunStandaloneAsync(options, HttpClient);
default:
return ShowHelp();
}
@@ -166,14 +173,23 @@ namespace Deploymentcenter.UpdateAgent
};
AnsiConsole.Write(panel);
ReleaseManifest? releaseManifest = null;
ManifestLookup lookup = new ManifestLookup();
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Lade verfuegbare Releases ...", async ctx =>
{
releaseManifest = await FetchManifestAsync(options);
lookup = await FetchManifestAsync(options);
});
// Der Grund steht schon im Bild - hier noch einmal nach fehlenden
// Releases zu suchen, waere eine zweite, falsche Faehrte.
if (lookup.Unauthorized)
{
return 1;
}
ReleaseManifest? releaseManifest = lookup.Manifest;
if (releaseManifest?.Latest == null)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Es konnte kein Release ermittelt werden.[/]");
@@ -282,7 +298,18 @@ namespace Deploymentcenter.UpdateAgent
static async Task<int> DoList(AgentOptions options)
{
var manifest = await FetchManifestAsync(options);
var lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
// Derselbe Rueckgabewert wie bei DoCheck - wer den Agenten aus
// einem Skript ruft, unterscheidet damit "kein Zugang" von
// "nichts veroeffentlicht".
Console.WriteLine("UNAUTHORIZED");
return 2;
}
var manifest = lookup.Manifest;
if (manifest == null)
{
Console.WriteLine("ERROR: Kein Release-Manifest verfuegbar.");
@@ -311,7 +338,14 @@ namespace Deploymentcenter.UpdateAgent
try
{
var releaseManifest = await FetchManifestAsync(options);
var lookup = await FetchManifestAsync(options);
if (lookup.Unauthorized)
{
return 1;
}
var releaseManifest = lookup.Manifest;
if (releaseManifest == null)
{
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte weder statisch noch ueber die API abgerufen werden.[/]");
@@ -561,18 +595,48 @@ namespace Deploymentcenter.UpdateAgent
/// klappte, sagte die Anwendung "Update verfuegbar" und der Agent "kein
/// Release gefunden". Der Rueckfall auf die API stand nur im SDK.
/// </summary>
static async Task<ReleaseManifest?> FetchManifestAsync(AgentOptions options)
/// <summary>
/// Ergebnis eines Manifest-Abrufs. Ein leeres Manifest wegen 401 ist
/// etwas anderes als ein leeres, weil nichts veroeffentlicht wurde -
/// die Aufrufer sollen dafuer nicht dieselbe Meldung zeigen.
/// </summary>
internal sealed class ManifestLookup
{
var fromStatic = await FetchStaticManifestAsync(options);
if (fromStatic?.Latest != null)
{
return fromStatic;
}
public ReleaseManifest? Manifest { get; init; }
return await FetchApiManifestAsync(options);
/// <summary>Die Ablage hat die Zugangsdaten abgelehnt.</summary>
public bool Unauthorized { get; init; }
}
static async Task<ReleaseManifest?> FetchStaticManifestAsync(AgentOptions options)
static async Task<ManifestLookup> FetchManifestAsync(AgentOptions options)
{
var (fromStatic, unauthorized) = await FetchStaticManifestAsync(options);
if (unauthorized)
{
// Ein 401 ist keine "Datei fehlt"-Lage: die Zugangsdaten
// tragen nicht. Die API wuerde zwar antworten, sie liegt nicht
// hinter dem Zugangsschutz - das Paket selbst aber schon. Der
// Lauf endete also ein paar Schritte spaeter mit genau
// derselben Meldung ein zweites Mal, dazwischen Ausgaben ueber
// Signaturen, die den eigentlichen Grund nach oben aus dem
// Bild schieben.
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return new ManifestLookup { Unauthorized = true };
}
if (fromStatic?.Latest != null)
{
return new ManifestLookup { Manifest = fromStatic };
}
return new ManifestLookup { Manifest = await FetchApiManifestAsync(options) };
}
static async Task<(ReleaseManifest? Manifest, bool Unauthorized)> FetchStaticManifestAsync(AgentOptions options)
{
// Plattformunabhaengige Releases liegen weiterhin im alten Pfad
// ohne Zwischenebene.
@@ -597,15 +661,12 @@ namespace Deploymentcenter.UpdateAgent
var resp = await HttpClient.SendAsync(request);
// Ein 401 ist keine "Datei fehlt"-Lage: die Lizenz traegt
// nicht mehr. Weiterzuprobieren wuerde die Ursache nur
// hinter einer allgemeinen Fehlermeldung verstecken.
// Ein 401 wird nach oben durchgereicht, statt hier
// gemeldet zu werden: sonst steht die Meldung schon im
// Bild, waehrend der Aufrufer noch weitersucht.
if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
AnsiConsole.MarkupLine("[bold red]"
+ Markup.Escape(ReleaseCredentials.DescribeUnauthorized(options.Credentials))
+ "[/]");
return null;
return (null, true);
}
if (!resp.IsSuccessStatusCode)
@@ -633,7 +694,7 @@ namespace Deploymentcenter.UpdateAgent
.FirstOrDefault();
if (manifest.Latest != null)
return manifest;
return (manifest, false);
}
catch
{
@@ -641,7 +702,7 @@ namespace Deploymentcenter.UpdateAgent
}
}
return null;
return (null, false);
}
/// <summary>
@@ -1140,8 +1201,8 @@ namespace Deploymentcenter.UpdateAgent
Console.WriteLine();
Console.WriteLine(" --project, -p <slug> Projekt-Slug");
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | check |");
Console.WriteLine(" update | repair | list");
Console.WriteLine(" --action, -a <aktion> interactive | install | configure | monitor |");
Console.WriteLine(" check | update | repair | list");
Console.WriteLine(" --version, -v <version> Zielversion oder 'latest'");
Console.WriteLine(" --target-dir, -t <pfad> Zu aktualisierendes Verzeichnis");
Console.WriteLine(" --platform <rid> Laufzeitkennung (Vorgabe: die des Systems)");