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 { /// /// 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. /// internal static class Monitoring { /// /// 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. /// public static async Task 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("[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; } /// /// Eigenstaendiger Weg fuer eine Maschine, auf der nichts installiert /// werden soll - etwa den Hypervisor unter den Anwendungen. /// public static async Task RunStandaloneAsync(AgentOptions options, System.Net.Http.HttpClient http) { AnsiConsole.Write(new FigletText("Monitor").LeftJustified().Color(Color.DodgerBlue1)); string baseUrl = AnsiConsole.Prompt( new TextPrompt("[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("[bold]Benutzer[/]:")); string password = AnsiConsole.Prompt(new TextPrompt("[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 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 [/] eines angeben.[/]"); return 1; } var chosen = AnsiConsole.Prompt( new SelectionPrompt() .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; } // ------------------------------------------------------------------ /// /// Vorschlag fuer den Namen: der Rechnername, wie ihn auch jeder /// andere sieht, der auf die Maschine schaut. /// private static string SuggestName() { try { string name = Environment.MachineName.Trim(); return name.Length > 0 ? name : "host"; } catch { return "host"; } } /// /// Legt das Skript ab - moeglichst systemweit, sonst beim Benutzer. /// Rueckgabe null heisst: nirgends schreibbar. /// 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; } /// /// Bevorzugt ein systemweiter Ort - der Cron des Systems kommt an das /// Heimatverzeichnis eines Benutzers nicht zwangslaeufig heran. /// private static IEnumerable 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"); } } /// /// Im Skript steht ein Token. Unter Unix bleibt die Datei deshalb dem /// Eigentuemer vorbehalten - und ausfuehrbar, sonst startet der Cron /// sie nicht. /// 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.[/]"); } } /// Fuehrt das Skript einmal aus und zeigt, was es sagt. 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)}[/]"); } } /// /// Traegt das Skript in Cron bzw. die Aufgabenplanung ein - auf /// Nachfrage. Klappt das nicht, bleibt der Befehl sichtbar stehen. /// 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)}[/]"); } /// /// Der Einplanungsbefehl mit eingesetztem Pfad. Die Vorlage kommt vom /// Server, damit Oberflaeche und Installer dasselbe zeigen. /// 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; } } /// /// 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. /// 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; } /// /// Legt eine geplante Aufgabe an. /F ueberschreibt eine gleichnamige - /// eine zweite Installation soll keine zweite Aufgabe hinterlassen. /// 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; } } }