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:
co-authored by
Claude Opus 5
parent
fc9b698141
commit
687ee0cefc
@@ -0,0 +1,451 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user