Files
Deploymentcenter BotandClaude Opus 5 687ee0cefc 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>
2026-08-21 20:51:37 +02:00

452 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using Spectre.Console;
namespace Deploymentcenter.UpdateAgent
{
/// <summary>
/// Prueft, ob die Laufzeit vorhanden ist, die die installierte Anwendung
/// braucht.
///
/// Ohne diese Pruefung endet eine gelungene Installation mit einer
/// Anwendung, die sich nicht starten laesst - und die Fehlersuche beginnt
/// beim Deploymentcenter, weil das der letzte Schritt war, den jemand
/// bewusst getan hat. Die Meldung des Systems ("You must install .NET to
/// run this application") sieht dann jeder, der von Hand startet, aber
/// niemand, der einen Dienst einrichtet.
///
/// Gefragt wird nicht die Anwendung, sondern ihre runtimeconfig.json: dort
/// steht, was sie tatsaechlich erwartet. Das kostet die ausliefernde Seite
/// nichts - die Datei entsteht bei jedem dotnet publish von selbst - und
/// deckt auch Pakete ab, die lange vor dieser Pruefung gebaut wurden.
///
/// Ein eigenstaendig veroeffentlichtes Paket (self-contained) bringt seine
/// Laufzeit mit; dort gibt es nichts zu pruefen und entsprechend nichts zu
/// melden.
/// </summary>
internal static class RuntimeCheck
{
/// <summary>Ein Framework, das die Anwendung erwartet.</summary>
internal sealed class Requirement
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
/// <summary>
/// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von
/// .NET ist "Minor": eine hoehere Nebenversion derselben
/// Hauptversion wird genommen, eine hoehere Hauptversion nicht.
/// </summary>
public string RollForward { get; set; } = "Minor";
}
/// <summary>Was auf dieser Maschine installiert ist.</summary>
internal sealed class InstalledRuntime
{
public string Name { get; set; } = string.Empty;
public Version Version { get; set; } = new Version(0, 0);
}
internal sealed class Result
{
/// <summary>Liess sich ueberhaupt etwas feststellen?</summary>
public bool Inspected { get; set; }
/// <summary>Das Paket bringt seine Laufzeit selbst mit.</summary>
public bool SelfContained { get; set; }
/// <summary>Ist ueberhaupt ein "dotnet" auffindbar?</summary>
public bool DotnetFound { get; set; }
public List<Requirement> Required { get; } = new List<Requirement>();
public List<InstalledRuntime> Installed { get; } = new List<InstalledRuntime>();
/// <summary>Anforderungen, die nichts Installiertes erfuellt.</summary>
public List<Requirement> Missing { get; } = new List<Requirement>();
/// <summary>Nichts zu beanstanden - oder nichts zu pruefen.</summary>
public bool Ok => SelfContained || !Inspected || Missing.Count == 0;
}
/// <summary>
/// Untersucht das Zielverzeichnis und die Maschine.
/// </summary>
public static Result Inspect(string targetDir)
{
var result = new Result();
var requirements = ReadRequirements(targetDir, result);
if (!result.Inspected || result.SelfContained)
{
return result;
}
result.Required.AddRange(requirements);
foreach (var runtime in ListInstalledRuntimes())
{
result.Installed.Add(runtime);
}
result.DotnetFound = result.Installed.Count > 0;
foreach (var requirement in requirements)
{
if (!IsSatisfied(requirement, result.Installed))
{
result.Missing.Add(requirement);
}
}
return result;
}
/// <summary>
/// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas -
/// die Anwendung wird so nicht starten.
/// </summary>
public static bool Report(Result result)
{
if (result.SelfContained)
{
AnsiConsole.MarkupLine("[grey]Laufzeit: das Paket bringt sie selbst mit.[/]");
return true;
}
if (!result.Inspected)
{
// Kein Grund zur Beunruhigung: nicht jedes Paket ist eine
// .NET-Anwendung, und ohne runtimeconfig.json gibt es hier
// schlicht nichts zu sagen.
return true;
}
if (result.Missing.Count == 0)
{
foreach (var requirement in result.Required)
{
var best = BestMatch(requirement, result.Installed);
AnsiConsole.MarkupLine($"[green] Laufzeit vorhanden:[/] "
+ $"{Markup.Escape(requirement.Name)} {Markup.Escape(best?.Version.ToString() ?? "?")} "
+ $"[grey](verlangt {Markup.Escape(requirement.Version)})[/]");
}
return true;
}
AnsiConsole.WriteLine();
if (!result.DotnetFound)
{
AnsiConsole.MarkupLine("[bold red]Auf dieser Maschine ist keine .NET-Laufzeit installiert.[/]");
}
else
{
AnsiConsole.MarkupLine("[bold red]Die benoetigte .NET-Laufzeit fehlt.[/]");
string installed = string.Join(", ", result.Installed
.GroupBy(r => r.Name)
.Select(g => $"{g.Key} {string.Join("/", g.Select(r => r.Version.ToString()).Distinct())}"));
AnsiConsole.MarkupLine($"[grey]Vorhanden: {Markup.Escape(installed)}[/]");
}
foreach (var missing in result.Missing)
{
AnsiConsole.MarkupLine($"[red]Verlangt: {Markup.Escape(missing.Name)} {Markup.Escape(missing.Version)}[/]");
}
AnsiConsole.MarkupLine("[yellow]Die Anwendung ist vollstaendig installiert, wird sich so aber nicht "
+ "starten lassen.[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[bold]Nachzuholen mit:[/]");
foreach (string hint in InstallHints(result.Missing))
{
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(hint)}[/]");
}
// Die Verwechslung ist haeufig genug, um sie hier auszuraeumen:
// zum Ausfuehren genuegt die Laufzeit. Das SDK ist zum Bauen da
// und bringt ein Vielfaches an Umfang mit.
AnsiConsole.MarkupLine("[grey]Zum Ausfuehren genuegt die Laufzeit - das SDK wird nur zum Bauen "
+ "gebraucht.[/]");
return false;
}
// ------------------------------------------------------------------
/// <summary>
/// Liest die Anforderungen aus der runtimeconfig.json im
/// Zielverzeichnis.
/// </summary>
private static List<Requirement> ReadRequirements(string targetDir, Result result)
{
var requirements = new List<Requirement>();
string[] candidates;
try
{
candidates = Directory.GetFiles(targetDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
}
catch
{
return requirements;
}
foreach (string path in candidates)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("runtimeOptions", out var options))
continue;
result.Inspected = true;
// Eigenstaendig veroeffentlicht: die Laufzeit liegt daneben
// im Verzeichnis, es gibt nichts zu installieren.
if (options.TryGetProperty("includedFrameworks", out var included)
&& included.ValueKind == JsonValueKind.Array
&& included.GetArrayLength() > 0)
{
result.SelfContained = true;
return requirements;
}
string rollForward = options.TryGetProperty("rollForward", out var roll)
&& roll.ValueKind == JsonValueKind.String
? (roll.GetString() ?? "Minor")
: "Minor";
// Einzelnes Framework oder eine Liste - beide Schreibweisen
// kommen vor, je nach Projektart und SDK-Fassung.
if (options.TryGetProperty("framework", out var single))
{
AddRequirement(requirements, single, rollForward);
}
if (options.TryGetProperty("frameworks", out var many)
&& many.ValueKind == JsonValueKind.Array)
{
foreach (var entry in many.EnumerateArray())
{
AddRequirement(requirements, entry, rollForward);
}
}
}
catch
{
// Eine unlesbare runtimeconfig.json ist kein Grund, die
// Installation zu bemaengeln - nur einer, hier nichts zu
// behaupten.
}
}
return requirements;
}
private static void AddRequirement(List<Requirement> into, JsonElement element, string rollForward)
{
if (element.ValueKind != JsonValueKind.Object)
return;
string name = element.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String
? (n.GetString() ?? string.Empty)
: string.Empty;
string version = element.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String
? (v.GetString() ?? string.Empty)
: string.Empty;
if (name.Length == 0 || version.Length == 0)
return;
if (into.Any(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase)))
return;
into.Add(new Requirement { Name = name, Version = version, RollForward = rollForward });
}
/// <summary>
/// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die
/// Antwort leer - genau das ist die Auskunft, um die es geht.
/// </summary>
private static List<InstalledRuntime> ListInstalledRuntimes()
{
var found = new List<InstalledRuntime>();
string output;
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "--list-runtimes",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (process == null)
return found;
output = process.StandardOutput.ReadToEnd();
// Ein haengendes dotnet darf die Installation nicht aufhalten.
if (!process.WaitForExit(15000))
{
try { process.Kill(entireProcessTree: true); } catch { }
return found;
}
}
catch
{
// Kein dotnet im Pfad.
return found;
}
// Zeilenform: "Microsoft.AspNetCore.App 8.0.14 [/usr/share/dotnet/shared/...]"
var pattern = new Regex(@"^(\S+)\s+(\d+\.\d+\.\d+\S*)\s+\[", RegexOptions.Compiled);
foreach (string line in output.Split('\n'))
{
var match = pattern.Match(line.Trim());
if (!match.Success)
continue;
if (TryParseVersion(match.Groups[2].Value, out var version))
{
found.Add(new InstalledRuntime { Name = match.Groups[1].Value, Version = version });
}
}
return found;
}
/// <summary>
/// Wird die Anforderung von irgendetwas Installiertem erfuellt?
/// </summary>
private static bool IsSatisfied(Requirement requirement, List<InstalledRuntime> installed)
{
return BestMatch(requirement, installed) != null;
}
private static InstalledRuntime? BestMatch(Requirement requirement, List<InstalledRuntime> installed)
{
if (!TryParseVersion(requirement.Version, out var wanted))
{
// Ohne verwertbare Angabe wird nichts bemaengelt: die Namen
// muessen dann genuegen.
return installed.FirstOrDefault(r =>
string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase));
}
string mode = (requirement.RollForward ?? "Minor").ToLowerInvariant();
var candidates = installed
.Where(r => string.Equals(r.Name, requirement.Name, StringComparison.OrdinalIgnoreCase))
.Where(r => Matches(mode, wanted, r.Version))
.OrderByDescending(r => r.Version)
.ToList();
return candidates.FirstOrDefault();
}
private static bool Matches(string mode, Version wanted, Version have)
{
switch (mode)
{
// Ausdruecklich abgeschaltet: es muss genau diese Fassung sein.
case "disable":
return have == wanted;
// Eine hoehere Hauptversion ist erlaubt.
case "major":
case "latestmajor":
return have >= wanted;
// Vorgabe und alle uebrigen Werte (Minor, LatestMinor,
// LatestPatch): dieselbe Hauptversion, mindestens die
// verlangte Fassung.
default:
return have.Major == wanted.Major && have >= wanted;
}
}
private static bool TryParseVersion(string raw, out Version version)
{
// Vorabversionen ("8.0.0-preview.3") lassen sich nicht als Version
// lesen; der Teil vor dem Bindestrich genuegt hier.
string cleaned = raw.Split('-')[0].Trim();
return Version.TryParse(cleaned, out version!);
}
/// <summary>
/// Was der Mensch davor jetzt tun muss - als Befehl, nicht als
/// Verweis auf eine Downloadseite.
/// </summary>
private static List<string> InstallHints(List<Requirement> missing)
{
var hints = new List<string>();
bool windows = OperatingSystem.IsWindows();
foreach (var requirement in missing)
{
if (!TryParseVersion(requirement.Version, out var version))
continue;
string majorMinor = $"{version.Major}.{version.Minor}";
if (windows)
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"Microsoft.DotNet.AspNetCore.{version.Major}",
"Microsoft.WindowsDesktop.App" => $"Microsoft.DotNet.DesktopRuntime.{version.Major}",
_ => $"Microsoft.DotNet.Runtime.{version.Major}",
};
hints.Add($"winget install --id {package} --source winget");
}
else
{
string package = requirement.Name switch
{
"Microsoft.AspNetCore.App" => $"aspnetcore-runtime-{majorMinor}",
_ => $"dotnet-runtime-{majorMinor}",
};
// Debian und Ubuntu fuehren die Pakete seit 22.04 in den
// eigenen Quellen; wo nicht, hilft der Verweis darunter.
hints.Add($"sudo apt-get install -y {package}");
}
}
if (hints.Count == 0)
{
hints.Add("https://dotnet.microsoft.com/download");
}
else
{
hints.Add("Falls das Paket dort nicht gefuehrt wird: https://dotnet.microsoft.com/download");
}
return hints;
}
}
}