using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Deploymentcenter.Client; using Deploymentcenter.Client.Models; using Spectre.Console; namespace Deploymentcenter.UpdateAgent { /// /// Erstinstallation einer Anwendung. /// /// Der Ablauf ist bewusst so geschnitten, dass er den vorhandenen /// Update-Weg wiederverwendet: erst kommen die Dateien ins Zielverzeichnis /// (mit Pruefsumme, Signatur und Rollback wie bei jedem Update), danach /// wird konfiguriert. Ein zweiter Download-Pfad waere ein zweiter Ort, an /// dem dieselben Fehler wieder entstehen koennen. /// /// Gefragt wird nur nach dem, was sich nicht anders ermitteln laesst - /// siehe . /// internal static class Installation { private const string SetupFileName = "setup.json"; public static async Task RunAsync(AgentOptions options, HttpClient http) { AnsiConsole.Write( new FigletText("Installer") .LeftJustified() .Color(Color.DodgerBlue1)); // ---------------------------------------------------------- // 1. Anmeldung // ---------------------------------------------------------- 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.[/]"))); options.BaseUrl = baseUrl.TrimEnd('/'); var client = new SetupClient(options.BaseUrl, http); SetupSession session; while (true) { string username = AnsiConsole.Prompt(new TextPrompt("[bold]Benutzer[/]:")); string password = AnsiConsole.Prompt(new TextPrompt("[bold]Passwort[/]:").Secret()); try { session = await client.LoginAsync(username, password, Environment.MachineName); // Dieselben Zugangsdaten oeffnen die Release-Ablage. Das // Setup-Token taugt dafuer nicht: /releases/ liegt hinter // HTTP-Basic-Auth und wird von Apache geprueft, nicht von // der Anwendung. Bei einer Erstinstallation gibt es zudem // noch keinen Lizenzschluessel. options.Credentials = ReleaseCredentials.FromUser(username, password); break; } catch (SetupException ex) { AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); if (ex.Code == "locked_out" || ex.Code == "rate_limited") { return 1; } if (!AnsiConsole.Confirm("Erneut versuchen?")) { return 1; } } catch (HttpRequestException ex) { AnsiConsole.MarkupLine($"[red]Keine Verbindung zum Deploymentcenter: {Markup.Escape(ex.Message)}[/]"); return 1; } } AnsiConsole.MarkupLine($"[green]Angemeldet.[/] [grey]Die Sitzung laeuft in " + $"{(int)session.Remaining.TotalMinutes} Minuten ab.[/]"); if (session.Role == "admin") { AnsiConsole.MarkupLine( "[yellow]Hinweis: Das ist ein Administratorkonto. Fuer Zielsysteme ist ein Konto " + "der Rolle [bold]installer[/] gedacht - es kann sich nicht am WebUI anmelden.[/]"); } // ---------------------------------------------------------- // 2. Auswahl // ---------------------------------------------------------- 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( $"[yellow]Fuer die Plattform [bold]{Markup.Escape(options.Platform)}[/] ist nichts hinterlegt.[/]"); AnsiConsole.MarkupLine("[grey]Releases ohne Plattformangabe gelten als \"any\" und waeren hier " + "sichtbar. Moeglicherweise wurde nur fuer eine andere Plattform veroeffentlicht.[/]"); return 1; } var chosen = AnsiConsole.Prompt( new SelectionPrompt() .Title("\n[bold]Welche Anwendung soll installiert werden?[/]") .PageSize(12) .UseConverter(entry => { string versions = string.Join(", ", entry.Channels.Select(c => $"{c.Channel} {c.Version}")); return $"{Markup.Escape(entry.Name)} [grey]({Markup.Escape(versions)})[/]"; }) .AddChoices(catalog)); CatalogChannel channel = chosen.Channels.Count == 1 ? chosen.Channels[0] : AnsiConsole.Prompt( new SelectionPrompt() .Title($"\n[bold]Welcher Kanal von {Markup.Escape(chosen.Name)}?[/]") .UseConverter(c => { string mark = c.Signed ? "" : " [yellow](unsigniert)[/]"; return $"{c.Channel} - v{c.Version}{mark}"; }) .AddChoices(chosen.Channels)); if (!channel.Signed) { AnsiConsole.MarkupLine("[yellow]Dieses Release ist nicht signiert - die Herkunft laesst sich " + "nicht pruefen.[/]"); if (options.RequireSignature) { AnsiConsole.MarkupLine("[red]Abbruch: --require-signature ist gesetzt.[/]"); return 1; } } // ---------------------------------------------------------- // 3. Zielverzeichnis // ---------------------------------------------------------- string suggested = SuggestTargetDirectory(chosen.Slug); string targetDir = AnsiConsole.Prompt( new TextPrompt("[bold]Zielverzeichnis[/]:").DefaultValue(suggested)); targetDir = Path.GetFullPath(targetDir); if (Directory.Exists(targetDir) && Directory.GetFileSystemEntries(targetDir).Length > 0) { AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(targetDir)} ist nicht leer.[/]"); if (!AnsiConsole.Confirm("Trotzdem dorthin installieren?", false)) { return 1; } } options.Project = chosen.Slug; options.Channel = channel.Channel; options.TargetDir = targetDir; // ---------------------------------------------------------- // 4. Dateien uebertragen - ueber den regulaeren Update-Weg // ---------------------------------------------------------- AnsiConsole.MarkupLine($"\n[bold]Installiere {Markup.Escape(chosen.Name)} v{channel.Version} " + $"nach {Markup.Escape(targetDir)}[/]\n"); int fileResult = await Program.DoUpdateOrRepair(options, channel.Version, null); if (fileResult != 0) { return fileResult; } // ---------------------------------------------------------- // 5. Einrichten // ---------------------------------------------------------- return await ConfigureAsync(options, client, chosen.Slug, interactive: true); } /// /// Nur die Einrichtung, ohne Dateien anzufassen. Nuetzlich, wenn die /// Installation stand, die Konfiguration aber nachgeholt oder /// korrigiert werden muss. /// public static async Task ConfigureOnlyAsync(AgentOptions options, HttpClient http) { string setupPath = Path.Combine(options.TargetDir, SetupFileName); if (!File.Exists(setupPath)) { AnsiConsole.MarkupLine($"[yellow]In {Markup.Escape(options.TargetDir)} liegt keine " + $"{SetupFileName} - es gibt nichts einzurichten.[/]"); return 0; } var client = new SetupClient(options.BaseUrl, http); // Ein Token wird nur gebraucht, wenn die Beschreibung ein Feld mit // source = provision enthaelt. Danach richtet sich, ob ueberhaupt // nach Zugangsdaten gefragt wird. var definition = ReadDefinition(setupPath); if (definition == null) { return 1; } bool needsToken = definition.Fields.Any(f => string.Equals(f.Source, "provision", StringComparison.OrdinalIgnoreCase)); if (needsToken) { AnsiConsole.MarkupLine("[grey]Fuer ein Anwendungstoken ist eine Anmeldung noetig.[/]"); 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); options.Credentials = ReleaseCredentials.FromUser(username, password); } catch (SetupException ex) { AnsiConsole.MarkupLine($"[red]{Markup.Escape(ex.Message)}[/]"); return 1; } } return await ConfigureAsync(options, client, options.Project, interactive: true); } // ------------------------------------------------------------------ private static async Task ConfigureAsync( AgentOptions options, SetupClient client, string project, bool interactive) { string setupPath = Path.Combine(options.TargetDir, SetupFileName); if (!File.Exists(setupPath)) { AnsiConsole.MarkupLine($"\n[green]Fertig.[/] [grey]Das Paket bringt keine {SetupFileName} mit, " + "es ist also nichts einzurichten.[/]"); return 0; } var definition = ReadDefinition(setupPath); if (definition == null) { return 1; } if (definition.Schema > 1) { AnsiConsole.MarkupLine($"[yellow]Die {SetupFileName} verwendet Schema {definition.Schema}; " + "dieser Installer kennt Schema 1. Unbekannte Angaben werden uebergangen.[/]"); } AnsiConsole.Write(new Rule("[bold]Einrichtung[/]").LeftJustified()); if (definition.Description.Length > 0) { AnsiConsole.MarkupLine($"[grey]{Markup.Escape(definition.Description)}[/]\n"); } var resolved = await ResolveFields(definition, options, client, project, interactive); if (resolved == null) { return 1; } // Werte den Zieldateien zuordnen und schreiben. var targets = definition.Targets.Count > 0 ? definition.Targets : new List { new SetupTarget { File = "appsettings.json", Format = "json" } }; foreach (var target in targets) { var forTarget = resolved .Where(pair => BelongsToTarget(pair.Field, target, targets)) .Select(pair => new SetupValue(pair.Field.Key, pair.Value, pair.Field.Type)) .ToList(); if (forTarget.Count == 0) continue; string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar)); try { SetupWriter.Write(path, target.Format, forTarget); AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(target.File)} " + $"[grey]({forTarget.Count} Wert(e))[/]"); } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]{Markup.Escape(target.File)} konnte nicht geschrieben werden: " + $"{Markup.Escape(ex.Message)}[/]"); return 1; } } AnsiConsole.MarkupLine("\n[bold green]Installation abgeschlossen.[/]"); AnsiConsole.MarkupLine($"[grey]Verzeichnis: {Markup.Escape(options.TargetDir)}[/]"); return 0; } /// /// Ermittelt jeden Wert und fragt nur nach dem, was uebrig bleibt. /// /// Reihenfolge je Feld: /// 1. bereits in der Zieldatei gesetzt -> uebernehmen, nicht fragen /// 2. source = detect:... -> aus dem System ableiten /// 3. source = provision -> Token beim Server holen /// 4. sonst -> fragen, mit default vorbelegt /// private static async Task?> ResolveFields( SetupDefinition definition, AgentOptions options, SetupClient client, string project, bool interactive) { var resolved = new List<(SetupField, string)>(); var targets = definition.Targets; int answered = 0; int automatic = 0; int kept = 0; foreach (var field in definition.Fields) { if (string.IsNullOrWhiteSpace(field.Key)) continue; // 1. Schon eingerichtet? var target = FindTarget(field, targets); if (target != null && string.Equals(target.Format, "json", StringComparison.OrdinalIgnoreCase)) { string path = Path.Combine(options.TargetDir, target.File.Replace('/', Path.DirectorySeparatorChar)); string? existing = SetupWriter.ReadExistingJson(path, field.Key); if (!string.IsNullOrWhiteSpace(existing) && !IsPlaceholder(existing!)) { AnsiConsole.MarkupLine($"[grey]Bereits gesetzt:[/] {Markup.Escape(field.Key)}"); kept++; continue; } } // 2. Ableitbar? string? detected = Detect(field.Source, options, project); if (detected != null) { resolved.Add((field, detected)); AnsiConsole.MarkupLine($"[grey]Ermittelt:[/] {Markup.Escape(field.Key)} = {Markup.Escape(detected)}"); automatic++; continue; } // 3. Token anfordern? if (string.Equals(field.Source, "provision", StringComparison.OrdinalIgnoreCase)) { try { var scopes = field.Scopes.Count > 0 ? field.Scopes : new List { "watchdog:ping" }; string token = await client.RequestApplicationTokenAsync( project, scopes, Environment.MachineName); resolved.Add((field, token)); AnsiConsole.MarkupLine($"[grey]Token ausgestellt:[/] {Markup.Escape(field.Key)} " + $"[grey]({Markup.Escape(string.Join(", ", scopes))})[/]"); automatic++; continue; } catch (SetupException ex) { AnsiConsole.MarkupLine($"[red]Token fuer {Markup.Escape(field.Key)} nicht erhalten: " + $"{Markup.Escape(ex.Message)}[/]"); if (field.Required) { return null; } continue; } } // 4. Fragen. if (!interactive) { if (field.Default.Length > 0) { resolved.Add((field, field.Default)); continue; } if (field.Required) { AnsiConsole.MarkupLine($"[red]Ohne Rueckfrage nicht ermittelbar: {Markup.Escape(field.Key)}[/]"); return null; } continue; } string? answer = Ask(field); if (answer == null) { // Nicht ausgefuellt und nicht erforderlich. continue; } resolved.Add((field, answer)); answered++; } AnsiConsole.MarkupLine($"\n[grey]{automatic} Wert(e) automatisch ermittelt, {kept} bereits " + $"vorhanden, {answered} abgefragt.[/]"); return resolved.Select(r => (r.Item1, r.Item2)).ToList(); } private static string? Ask(SetupField field) { string label = field.Label.Length > 0 ? field.Label : field.Key; if (field.Help.Length > 0) { AnsiConsole.MarkupLine($"[grey]{Markup.Escape(field.Help)}[/]"); } // Auswahl aus festen Moeglichkeiten. if (string.Equals(field.Type, "enum", StringComparison.OrdinalIgnoreCase) && field.Options.Count > 0) { return AnsiConsole.Prompt( new SelectionPrompt() .Title($"[bold]{Markup.Escape(label)}[/]:") .AddChoices(field.Options)); } if (string.Equals(field.Type, "bool", StringComparison.OrdinalIgnoreCase)) { bool preset = bool.TryParse(field.Default, out bool parsed) && parsed; return AnsiConsole.Confirm($"[bold]{Markup.Escape(label)}[/]?", preset) ? "true" : "false"; } var prompt = new TextPrompt($"[bold]{Markup.Escape(label)}[/]:"); if (field.IsSecret) { prompt.Secret(); } if (field.Default.Length > 0) { prompt.DefaultValue(field.Default); // Ein vorbelegtes Geheimnis darf nicht im Klartext erscheinen. if (field.IsSecret) { prompt.HideDefaultValue(); } } else if (!field.Required) { prompt.AllowEmpty(); } var validate = BuildValidator(field); if (validate != null) { prompt.Validate(validate); } string answer = AnsiConsole.Prompt(prompt); return answer.Length == 0 && !field.Required ? null : answer; } private static Func? BuildValidator(SetupField field) { Regex? pattern = null; if (field.Validate.Length > 0) { try { pattern = new Regex(field.Validate, RegexOptions.CultureInvariant); } catch (ArgumentException) { // Ein fehlerhafter Ausdruck in der setup.json darf die // Installation nicht verhindern - dann wird eben nicht // geprueft. AnsiConsole.MarkupLine($"[yellow]Pruefmuster fuer {Markup.Escape(field.Key)} ist ungueltig " + "und wird uebergangen.[/]"); } } string type = (field.Type ?? "string").ToLowerInvariant(); if (pattern == null && type != "int" && type != "url") { return null; } string message = field.ValidationMessage.Length > 0 ? field.ValidationMessage : "Die Eingabe passt nicht zum erwarteten Format."; return value => { if (value.Length == 0 && !field.Required) { return ValidationResult.Success(); } if (type == "int" && !long.TryParse(value, out _)) { return ValidationResult.Error("[red]Bitte eine ganze Zahl eingeben.[/]"); } if (type == "url" && !Uri.TryCreate(value, UriKind.Absolute, out var uri)) { return ValidationResult.Error("[red]Bitte eine vollstaendige Adresse eingeben.[/]"); } if (pattern != null && !pattern.IsMatch(value)) { return ValidationResult.Error($"[red]{Markup.Escape(message)}[/]"); } return ValidationResult.Success(); }; } /// Werte, die sich aus dem System selbst ergeben. private static string? Detect(string source, AgentOptions options, string project) { if (string.IsNullOrWhiteSpace(source) || !source.StartsWith("detect:", StringComparison.OrdinalIgnoreCase)) { return null; } string what = source.Substring("detect:".Length).Trim().ToLowerInvariant(); switch (what) { case "hostname": return SafeMachineName(); case "platform": case "rid": return options.Platform; case "installdir": return options.TargetDir; case "project": return project; case "baseurl": return options.BaseUrl; case "username": try { return Environment.UserName; } catch { return null; } default: return null; } } private static SetupTarget? FindTarget(SetupField field, List targets) { if (targets.Count == 0) return null; if (field.Target.Length == 0) return targets[0]; return targets.FirstOrDefault(t => string.Equals(t.Id, field.Target, StringComparison.OrdinalIgnoreCase)) ?? targets[0]; } private static bool BelongsToTarget(SetupField field, SetupTarget target, List all) { var resolved = FindTarget(field, all); return resolved != null && ReferenceEquals(resolved, target); } /// /// Erkennt Platzhalter, die zwar in der Datei stehen, aber keinen /// eingerichteten Wert darstellen. Ohne diese Pruefung wuerde eine /// mitgelieferte Vorlage als "schon konfiguriert" gelten und die Frage /// entfiele - die Anwendung liefe dann mit dem Platzhalter los. /// private static bool IsPlaceholder(string value) { string trimmed = value.Trim(); if (trimmed.Length == 0) return true; string[] markers = { "changeme", "change-me", "todo", "xxx", "placeholder", "vorlage", "your-", "dein-", "<", "bitte-", "example.com", "beispiel" }; string lower = trimmed.ToLowerInvariant(); return markers.Any(marker => lower.Contains(marker)); } private static SetupDefinition? ReadDefinition(string path) { try { var definition = JsonSerializer.Deserialize(File.ReadAllText(path)); if (definition == null) { AnsiConsole.MarkupLine($"[red]{SetupFileName} ist leer oder unlesbar.[/]"); return null; } return definition; } catch (JsonException ex) { AnsiConsole.MarkupLine($"[red]{SetupFileName} ist kein gueltiges JSON: {Markup.Escape(ex.Message)}[/]"); return null; } catch (Exception ex) { AnsiConsole.MarkupLine($"[red]{SetupFileName} nicht lesbar: {Markup.Escape(ex.Message)}[/]"); return null; } } private static string SuggestTargetDirectory(string slug) { if (OperatingSystemHelpers.IsWindows()) { string programs = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); if (!string.IsNullOrWhiteSpace(programs)) { return Path.Combine(programs, slug); } return Path.Combine(@"C:\Programme", slug); } return "/opt/" + slug; } private static string SafeMachineName() { try { return Environment.MachineName; } catch { return "unbekannt"; } } } }