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>
727 lines
28 KiB
C#
727 lines
28 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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 <see cref="ResolveFields"/>.
|
|
/// </summary>
|
|
internal static class Installation
|
|
{
|
|
private const string SetupFileName = "setup.json";
|
|
|
|
public static async Task<int> RunAsync(AgentOptions options, HttpClient http)
|
|
{
|
|
AnsiConsole.Write(
|
|
new FigletText("Installer")
|
|
.LeftJustified()
|
|
.Color(Color.DodgerBlue1));
|
|
|
|
// ----------------------------------------------------------
|
|
// 1. Anmeldung
|
|
// ----------------------------------------------------------
|
|
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.[/]")));
|
|
|
|
options.BaseUrl = baseUrl.TrimEnd('/');
|
|
|
|
var client = new SetupClient(options.BaseUrl, http);
|
|
SetupSession session;
|
|
|
|
while (true)
|
|
{
|
|
string username = AnsiConsole.Prompt(new TextPrompt<string>("[bold]Benutzer[/]:"));
|
|
string password = AnsiConsole.Prompt(new TextPrompt<string>("[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<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(
|
|
$"[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<CatalogEntry>()
|
|
.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<CatalogChannel>()
|
|
.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<string>("[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. Laeuft das hier ueberhaupt?
|
|
// ----------------------------------------------------------
|
|
// 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>
|
|
/// Nur die Einrichtung, ohne Dateien anzufassen. Nuetzlich, wenn die
|
|
/// Installation stand, die Konfiguration aber nachgeholt oder
|
|
/// korrigiert werden muss.
|
|
/// </summary>
|
|
public static async Task<int> 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<string>("[bold]Benutzer[/]:"));
|
|
string password = AnsiConsole.Prompt(new TextPrompt<string>("[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<int> 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<SetupTarget> { new SetupTarget { File = "appsettings.json", Format = "json" } };
|
|
|
|
foreach (var target in targets)
|
|
{
|
|
var forTarget = resolved
|
|
.Where(pair => BelongsToTarget(pair.Field, target, targets))
|
|
// IsSecret ausdruecklich weiterreichen: ein per provision
|
|
// geholtes Token traegt oft den Typ "string", muss die
|
|
// Datei aber trotzdem auf den eigenen Benutzer beschraenken.
|
|
.Select(pair => new SetupValue(
|
|
pair.Field.Key, pair.Value, pair.Field.Type, pair.Field.IsSecret))
|
|
.ToList();
|
|
|
|
if (forTarget.Count == 0)
|
|
continue;
|
|
|
|
string path = SetupPaths.Resolve(target, options.TargetDir);
|
|
|
|
try
|
|
{
|
|
SetupWriter.Write(path, target.Format, forTarget);
|
|
|
|
// Den aufgeloesten Pfad ausgeben, nicht die Angabe aus der
|
|
// setup.json: Bei location=config entscheidet die Umgebung,
|
|
// und wer den Installer unter einem anderen Konto startet
|
|
// als den Dienst, muss das sehen koennen.
|
|
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(path)} "
|
|
+ $"[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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
private static async Task<List<(SetupField Field, string Value)>?> 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))
|
|
{
|
|
// Muss denselben Pfad ergeben wie beim Schreiben - sonst
|
|
// prueft der Installer eine andere Datei, als er anlegt.
|
|
string path = SetupPaths.Resolve(target, options.TargetDir);
|
|
|
|
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<string> { "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<string>()
|
|
.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<string>($"[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<string, ValidationResult>? 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();
|
|
};
|
|
}
|
|
|
|
/// <summary>Werte, die sich aus dem System selbst ergeben.</summary>
|
|
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<SetupTarget> 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<SetupTarget> all)
|
|
{
|
|
var resolved = FindTarget(field, all);
|
|
return resolved != null && ReferenceEquals(resolved, target);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<SetupDefinition>(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"; }
|
|
}
|
|
}
|
|
}
|