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 { /// /// 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. /// internal static class RuntimeCheck { /// Ein Framework, das die Anwendung erwartet. internal sealed class Requirement { public string Name { get; set; } = string.Empty; public string Version { get; set; } = string.Empty; /// /// Wie weit die Laufzeit nach oben abweichen darf. Vorgabe von /// .NET ist "Minor": eine hoehere Nebenversion derselben /// Hauptversion wird genommen, eine hoehere Hauptversion nicht. /// public string RollForward { get; set; } = "Minor"; } /// Was auf dieser Maschine installiert ist. internal sealed class InstalledRuntime { public string Name { get; set; } = string.Empty; public Version Version { get; set; } = new Version(0, 0); } internal sealed class Result { /// Liess sich ueberhaupt etwas feststellen? public bool Inspected { get; set; } /// Das Paket bringt seine Laufzeit selbst mit. public bool SelfContained { get; set; } /// Ist ueberhaupt ein "dotnet" auffindbar? public bool DotnetFound { get; set; } public List Required { get; } = new List(); public List Installed { get; } = new List(); /// Anforderungen, die nichts Installiertes erfuellt. public List Missing { get; } = new List(); /// Nichts zu beanstanden - oder nichts zu pruefen. public bool Ok => SelfContained || !Inspected || Missing.Count == 0; } /// /// Untersucht das Zielverzeichnis und die Maschine. /// 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; } /// /// Gibt das Ergebnis aus. Rueckgabe false heisst: es fehlt etwas - /// die Anwendung wird so nicht starten. /// 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; } // ------------------------------------------------------------------ /// /// Liest die Anforderungen aus der runtimeconfig.json im /// Zielverzeichnis. /// private static List ReadRequirements(string targetDir, Result result) { var requirements = new List(); 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 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 }); } /// /// Fragt "dotnet --list-runtimes" ab. Fehlt der Befehl, ist die /// Antwort leer - genau das ist die Auskunft, um die es geht. /// private static List ListInstalledRuntimes() { var found = new List(); 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; } /// /// Wird die Anforderung von irgendetwas Installiertem erfuellt? /// private static bool IsSatisfied(Requirement requirement, List installed) { return BestMatch(requirement, installed) != null; } private static InstalledRuntime? BestMatch(Requirement requirement, List 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!); } /// /// Was der Mensch davor jetzt tun muss - als Befehl, nicht als /// Verweis auf eine Downloadseite. /// private static List InstallHints(List missing) { var hints = new List(); 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; } } }