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
@@ -192,9 +192,42 @@ namespace Deploymentcenter.UpdateAgent
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 5. Einrichten
|
||||
// 5. Laeuft das hier ueberhaupt?
|
||||
// ----------------------------------------------------------
|
||||
return await ConfigureAsync(options, client, chosen.Slug, interactive: true);
|
||||
// Erst jetzt zu pruefen hat einen Grund: was die Anwendung
|
||||
// braucht, steht in ihrer runtimeconfig.json - und die liegt erst
|
||||
// vor, wenn die Dateien da sind. Vorher waere jede Aussage
|
||||
// geraten, und ein eigenstaendig veroeffentlichtes Paket bekaeme
|
||||
// eine Warnung, die auf es nicht zutrifft.
|
||||
var runtime = RuntimeCheck.Inspect(targetDir);
|
||||
bool runtimeOk = RuntimeCheck.Report(runtime);
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 6. Einrichten
|
||||
// ----------------------------------------------------------
|
||||
int configured = await ConfigureAsync(options, client, chosen.Slug, interactive: true);
|
||||
if (configured != 0)
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// 7. Ueberwachung dieser Maschine
|
||||
// ----------------------------------------------------------
|
||||
await Monitoring.OfferAsync(options, client, chosen.Slug, interactive: true);
|
||||
|
||||
// Zum Schluss noch einmal: zwischen der Meldung oben und dem Ende
|
||||
// des Ablaufs liegen inzwischen Einrichtung und Ueberwachung, und
|
||||
// eine fehlende Laufzeit ist das Einzige, was den Start dieser
|
||||
// Anwendung sicher verhindert.
|
||||
if (!runtimeOk)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold yellow]Nicht vergessen: ohne die fehlende .NET-Laufzeit startet "
|
||||
+ "die Anwendung nicht.[/]");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Deploymentcenter.UpdateAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Richtet die Ueberwachung der Maschine ein, auf der gerade installiert
|
||||
/// wurde.
|
||||
///
|
||||
/// Bisher war das ein Weg ueber die Oberflaeche an einem anderen Rechner:
|
||||
/// Monitor anlegen, Token erzeugen, Skript herunterladen, auf den Host
|
||||
/// kopieren, einplanen. Fuenf Schritte fuer etwas, das genau dann ansteht,
|
||||
/// wenn ohnehin jemand auf dem Zielsystem sitzt - entsprechend oft
|
||||
/// unterblieb es, und die frisch installierte Anwendung lief auf einem
|
||||
/// unbeobachteten Host.
|
||||
///
|
||||
/// Hier sind es zwei Fragen: Name und ob eingeplant werden soll. Alles
|
||||
/// andere - Token, Monitor, Skript, Cron-Eintrag bzw. geplante Aufgabe -
|
||||
/// entsteht daraus.
|
||||
/// </summary>
|
||||
internal static class Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Bietet die Einrichtung an und fuehrt sie durch.
|
||||
///
|
||||
/// Rueckgabe false heisst nur "nicht eingerichtet" - nie, dass die
|
||||
/// Installation gescheitert waere. Eine fehlende Ueberwachung darf
|
||||
/// eine funktionierende Anwendung nicht in Frage stellen.
|
||||
/// </summary>
|
||||
public static async Task<bool> OfferAsync(
|
||||
AgentOptions options,
|
||||
SetupClient client,
|
||||
string project,
|
||||
bool interactive)
|
||||
{
|
||||
if (!interactive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(new Rule("[bold]Ueberwachung[/]").LeftJustified());
|
||||
AnsiConsole.MarkupLine("[grey]Ein kleines Skript meldet dem Deploymentcenter minuetlich, dass diese "
|
||||
+ "Maschine laeuft, und schickt Last, Speicher und Plattenbelegung mit.[/]");
|
||||
|
||||
if (!AnsiConsole.Confirm("Diese Maschine ueberwachen?"))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]Uebersprungen. Nachholbar mit [bold]--action monitor[/].[/]");
|
||||
return false;
|
||||
}
|
||||
|
||||
string suggested = SuggestName();
|
||||
|
||||
string source = AnsiConsole.Prompt(
|
||||
new TextPrompt<string>("[bold]Name im Dashboard[/]:")
|
||||
.DefaultValue(suggested)
|
||||
.Validate(value => value.Trim().Length >= 2
|
||||
? ValidationResult.Success()
|
||||
: ValidationResult.Error("[red]Bitte einen Namen mit mindestens zwei Zeichen.[/]")));
|
||||
|
||||
string os = OperatingSystem.IsWindows() ? "windows" : "linux";
|
||||
|
||||
WatchdogAgent agent;
|
||||
|
||||
try
|
||||
{
|
||||
agent = await client.RequestWatchdogAgentAsync(project, source, os);
|
||||
}
|
||||
catch (SetupException ex) when (ex.Code == "monitor_exists")
|
||||
{
|
||||
// Denselben Namen zweimal zu vergeben ist bei einer
|
||||
// Neuinstallation derselben Maschine der Normalfall - bei zwei
|
||||
// verschiedenen Maschinen dagegen der Anfang einer Historie,
|
||||
// die zwei Rechner vermischt. Das kann nur entscheiden, wer
|
||||
// davorsitzt.
|
||||
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(ex.Message)}[/]");
|
||||
|
||||
if (!AnsiConsole.Confirm("Ist das dieselbe Maschine? Dann den bestehenden Monitor uebernehmen", false))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]Ueberwachung nicht eingerichtet.[/]");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
agent = await client.RequestWatchdogAgentAsync(project, source, os, overwrite: true);
|
||||
}
|
||||
catch (SetupException retry)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(retry.Message)}[/]");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (SetupException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Nicht eingerichtet: {Markup.Escape(ex.Message)}[/]");
|
||||
return false;
|
||||
}
|
||||
|
||||
string? scriptPath = WriteScript(agent);
|
||||
|
||||
if (scriptPath == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Abgelegt:[/] {Markup.Escape(scriptPath)}");
|
||||
AnsiConsole.MarkupLine($"[grey]Monitor \"{Markup.Escape(agent.Source)}\" ist angelegt, das Token steckt "
|
||||
+ "im Skript.[/]");
|
||||
|
||||
// Ein erster Heartbeat beantwortet sofort, ob Token, Adresse und
|
||||
// Werkzeuge auf dieser Maschine zusammenpassen. Ohne ihn faellt ein
|
||||
// Fehler erst auf, wenn der Monitor nach Minuten auf "down" geht -
|
||||
// und dann sieht es aus wie ein Ausfall, nicht wie ein Tippfehler.
|
||||
RunOnce(scriptPath);
|
||||
|
||||
OfferSchedule(agent, scriptPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eigenstaendiger Weg fuer eine Maschine, auf der nichts installiert
|
||||
/// werden soll - etwa den Hypervisor unter den Anwendungen.
|
||||
/// </summary>
|
||||
public static async Task<int> RunStandaloneAsync(AgentOptions options, System.Net.Http.HttpClient http)
|
||||
{
|
||||
AnsiConsole.Write(new FigletText("Monitor").LeftJustified().Color(Color.DodgerBlue1));
|
||||
|
||||
string baseUrl = AnsiConsole.Prompt(
|
||||
new TextPrompt<string>("[bold]Deploymentcenter[/]:")
|
||||
.DefaultValue(options.BaseUrl)
|
||||
.Validate(value => value.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|
||||
? ValidationResult.Success()
|
||||
: ValidationResult.Error("[red]Bitte eine vollstaendige Adresse angeben, mit https:// davor.[/]")));
|
||||
|
||||
options.BaseUrl = baseUrl.TrimEnd('/');
|
||||
|
||||
var client = new SetupClient(options.BaseUrl, http);
|
||||
|
||||
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
|
||||
string password = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Passwort[/]:").Secret());
|
||||
|
||||
try
|
||||
{
|
||||
await client.LoginAsync(username, password, Environment.MachineName);
|
||||
}
|
||||
catch (SetupException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
catch (System.Net.Http.HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Das Token haengt am Produkt - auch ein reiner Host-Monitor
|
||||
// braucht also eines. Welches, sagt entweder --project oder die
|
||||
// Auswahl aus dem Katalog.
|
||||
string project = options.Project;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(project) || project == "myapp")
|
||||
{
|
||||
List<CatalogEntry> catalog;
|
||||
|
||||
try
|
||||
{
|
||||
catalog = await client.GetCatalogAsync(options.Platform);
|
||||
}
|
||||
catch (SetupException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Katalog nicht abrufbar: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (catalog.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Es ist kein Produkt hinterlegt, an das sich der Monitor haengen "
|
||||
+ "liesse. Mit [bold]--project <slug>[/] eines angeben.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var chosen = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<CatalogEntry>()
|
||||
.Title("\n[bold]Zu welchem Produkt gehoert diese Maschine?[/]")
|
||||
.PageSize(12)
|
||||
.UseConverter(entry => Markup.Escape(entry.Name))
|
||||
.AddChoices(catalog));
|
||||
|
||||
project = chosen.Slug;
|
||||
}
|
||||
|
||||
return await OfferAsync(options, client, project, interactive: true) ? 0 : 1;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Vorschlag fuer den Namen: der Rechnername, wie ihn auch jeder
|
||||
/// andere sieht, der auf die Maschine schaut.
|
||||
/// </summary>
|
||||
private static string SuggestName()
|
||||
{
|
||||
try
|
||||
{
|
||||
string name = Environment.MachineName.Trim();
|
||||
return name.Length > 0 ? name : "host";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "host";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legt das Skript ab - moeglichst systemweit, sonst beim Benutzer.
|
||||
/// Rueckgabe null heisst: nirgends schreibbar.
|
||||
/// </summary>
|
||||
private static string? WriteScript(WatchdogAgent agent)
|
||||
{
|
||||
foreach (string directory in CandidateDirectories())
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
string path = Path.Combine(directory, agent.FileName);
|
||||
|
||||
// Zeilenenden passend zum Ziel: ein Shell-Skript mit CRLF
|
||||
// scheitert unter Linux an "\r: Datei oder Verzeichnis
|
||||
// nicht gefunden" - eine Meldung, die niemand mit
|
||||
// Zeilenenden in Verbindung bringt.
|
||||
string script = agent.Os == "windows"
|
||||
? agent.Script.Replace("\n", "\r\n")
|
||||
: agent.Script.Replace("\r\n", "\n");
|
||||
|
||||
File.WriteAllText(path, script);
|
||||
|
||||
Protect(path, agent.Os);
|
||||
|
||||
return path;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(directory)}: {Markup.Escape(ex.Message)}[/]");
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[red]Das Skript liess sich nirgends ablegen.[/]");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bevorzugt ein systemweiter Ort - der Cron des Systems kommt an das
|
||||
/// Heimatverzeichnis eines Benutzers nicht zwangslaeufig heran.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> CandidateDirectories()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
string programData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
|
||||
if (programData.Length > 0)
|
||||
{
|
||||
yield return Path.Combine(programData, "Deploymentcenter", "watchdog");
|
||||
}
|
||||
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (localAppData.Length > 0)
|
||||
{
|
||||
yield return Path.Combine(localAppData, "Deploymentcenter", "watchdog");
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return "/opt/deploymentcenter/watchdog";
|
||||
|
||||
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (home.Length > 0)
|
||||
{
|
||||
yield return Path.Combine(home, ".deploymentcenter", "watchdog");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Im Skript steht ein Token. Unter Unix bleibt die Datei deshalb dem
|
||||
/// Eigentuemer vorbehalten - und ausfuehrbar, sonst startet der Cron
|
||||
/// sie nicht.
|
||||
/// </summary>
|
||||
private static void Protect(string path, string os)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Dateirechte nicht gesetzt: {Markup.Escape(ex.Message)}[/] "
|
||||
+ "[grey]Bitte selbst auf 0700 setzen - die Datei enthaelt ein Token.[/]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fuehrt das Skript einmal aus und zeigt, was es sagt.</summary>
|
||||
private static void RunOnce(string scriptPath)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]Sende einen ersten Heartbeat ...[/]");
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo.FileName = "powershell";
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-File");
|
||||
startInfo.ArgumentList.Add(scriptPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = "/bin/bash";
|
||||
startInfo.ArgumentList.Add(scriptPath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
|
||||
if (process == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Der Testlauf liess sich nicht starten.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
string error = process.StandardError.ReadToEnd().Trim();
|
||||
|
||||
if (!process.WaitForExit(30000))
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
AnsiConsole.MarkupLine("[yellow]Der Testlauf hat nicht geantwortet.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[green]{Markup.Escape(output.Length > 0 ? output : "Heartbeat gesendet.")}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[yellow]Der erste Heartbeat kam nicht durch:[/]");
|
||||
|
||||
foreach (string line in new[] { output, error })
|
||||
{
|
||||
if (line.Length > 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]");
|
||||
}
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine("[grey]Das Skript liegt bereits am Ziel - es laesst sich von Hand "
|
||||
+ "nachvollziehen.[/]");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Testlauf nicht moeglich: {Markup.Escape(ex.Message)}[/]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traegt das Skript in Cron bzw. die Aufgabenplanung ein - auf
|
||||
/// Nachfrage. Klappt das nicht, bleibt der Befehl sichtbar stehen.
|
||||
/// </summary>
|
||||
private static void OfferSchedule(WatchdogAgent agent, string scriptPath)
|
||||
{
|
||||
string command = BuildScheduleCommand(agent, scriptPath);
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
if (!AnsiConsole.Confirm($"Minuetlich ausfuehren lassen?"))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[grey]Nicht eingeplant. Ohne regelmaessigen Lauf faellt der Monitor "
|
||||
+ "nach kurzer Zeit auf \"down\". Von Hand:[/]");
|
||||
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schedule(agent, scriptPath, out string problem))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[green]Eingeplant.[/] [grey]Der Monitor sollte innerhalb einer Minute "
|
||||
+ "gruen werden.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[yellow]Automatisch nicht eingerichtet: {Markup.Escape(problem)}[/]");
|
||||
AnsiConsole.MarkupLine("[grey]Von Hand - unter Windows in einer Eingabeaufforderung mit "
|
||||
+ "Administratorrechten:[/]");
|
||||
AnsiConsole.MarkupLine($" [cyan]{Markup.Escape(command)}[/]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Einplanungsbefehl mit eingesetztem Pfad. Die Vorlage kommt vom
|
||||
/// Server, damit Oberflaeche und Installer dasselbe zeigen.
|
||||
/// </summary>
|
||||
private static string BuildScheduleCommand(WatchdogAgent agent, string scriptPath)
|
||||
{
|
||||
string quoted = scriptPath.Contains(' ') ? "\"" + scriptPath + "\"" : scriptPath;
|
||||
|
||||
return agent.Schedule.Length > 0
|
||||
? agent.Schedule.Replace("{PFAD}", quoted)
|
||||
: quoted;
|
||||
}
|
||||
|
||||
private static bool Schedule(WatchdogAgent agent, string scriptPath, out string problem)
|
||||
{
|
||||
problem = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? ScheduleWindows(agent, scriptPath, out problem)
|
||||
: ScheduleCron(agent, scriptPath, out problem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
problem = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traegt eine Zeile in die Crontab des aufrufenden Benutzers ein.
|
||||
///
|
||||
/// Bestehende Zeilen zu diesem Skript fallen dabei weg - sonst
|
||||
/// sammelt jede Neuinstallation einen weiteren Eintrag an, und die
|
||||
/// Maschine schickt am Ende drei Heartbeats pro Minute.
|
||||
/// </summary>
|
||||
private static bool ScheduleCron(WatchdogAgent agent, string scriptPath, out string problem)
|
||||
{
|
||||
problem = string.Empty;
|
||||
|
||||
string line = BuildScheduleCommand(agent, scriptPath);
|
||||
|
||||
// Einfache Anfuehrungszeichen im Pfad wuerden das Shell-Literal
|
||||
// zerlegen. Sie kommen in Pfaden praktisch nicht vor - wenn doch,
|
||||
// ist ein ehrlicher Abbruch besser als ein zerschossener Crontab.
|
||||
if (scriptPath.Contains('\'') || line.Contains('\''))
|
||||
{
|
||||
problem = "Der Pfad enthaelt ein Anfuehrungszeichen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string script =
|
||||
"(crontab -l 2>/dev/null | grep -Fv '" + scriptPath + "'; " +
|
||||
"echo '" + line + "') | crontab -";
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/sh",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("-c");
|
||||
startInfo.ArgumentList.Add(script);
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
|
||||
if (process == null)
|
||||
{
|
||||
problem = "crontab liess sich nicht aufrufen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string error = process.StandardError.ReadToEnd().Trim();
|
||||
process.WaitForExit(15000);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
problem = error.Length > 0 ? error : $"crontab endete mit Code {process.ExitCode}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legt eine geplante Aufgabe an. /F ueberschreibt eine gleichnamige -
|
||||
/// eine zweite Installation soll keine zweite Aufgabe hinterlassen.
|
||||
/// </summary>
|
||||
private static bool ScheduleWindows(WatchdogAgent agent, string scriptPath, out string problem)
|
||||
{
|
||||
problem = string.Empty;
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "schtasks",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("/Create");
|
||||
startInfo.ArgumentList.Add("/SC");
|
||||
startInfo.ArgumentList.Add("MINUTE");
|
||||
startInfo.ArgumentList.Add("/MO");
|
||||
startInfo.ArgumentList.Add("1");
|
||||
startInfo.ArgumentList.Add("/TN");
|
||||
startInfo.ArgumentList.Add("Deploymentcenter Watchdog " + agent.Source);
|
||||
startInfo.ArgumentList.Add("/TR");
|
||||
startInfo.ArgumentList.Add(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File \"" + scriptPath + "\"");
|
||||
startInfo.ArgumentList.Add("/F");
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
|
||||
if (process == null)
|
||||
{
|
||||
problem = "schtasks liess sich nicht aufrufen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||
string error = process.StandardError.ReadToEnd().Trim();
|
||||
process.WaitForExit(15000);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
problem = error.Length > 0
|
||||
? error
|
||||
: (output.Length > 0 ? output : $"schtasks endete mit Code {process.ExitCode}.");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
|
||||
@@ -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