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;
}
}
}