feat: UpdateAgent Selbstaktualisierung & kopflose Installation, .NET 10 Migration, Backend- & SDK-Fixes
- .NET 10 Migration: Alle .NET-Projekte auf net10.0 umgestellt (#459) - Client-SDK: ResolveAgentPath bereinigt und DLL-Pruefung ergaenzt (#458) - Packager: Commit-Ermittlung aus publishDir / Assembly InformationalVersion (#456) - BuildInfo.targets: ExitCode-Abfang und Regex-Validierung gegen Git-Fehlertexte (#363) - UpdateService: Re-Publish erhaelt bestehende Manifeste und Werte (#457) - Bugtracker API: on_roadmap-Filter in manage?action=list durchgereicht (#349) - Watchdog API: project_slug-Filter in GET /status ergaenzt (#420) - UpdateService API: no_release_for_platform mit verfuegbaren Plattformen (#421) - Bugtracker Core: Fehler-Normalisierung gegen zersplitterte error_hashes (#419) - UpdateAgent: Kopfloser Installationsmodus via --answers (#89) - Installer & Agent: Automatische Selbstaktualisierung beim Start - Tooling: AGENTS.md, CLAUDE.md und Standalone-CLI scripts/dc.py hinzugefuegt
This commit is contained in:
@@ -30,6 +30,11 @@ namespace Deploymentcenter.UpdateAgent
|
||||
|
||||
public static async Task<int> RunAsync(AgentOptions options, HttpClient http)
|
||||
{
|
||||
if (options.Unattended)
|
||||
{
|
||||
return await RunUnattendedAsync(options, http);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(
|
||||
new FigletText("Installer")
|
||||
.LeftJustified()
|
||||
@@ -237,6 +242,11 @@ namespace Deploymentcenter.UpdateAgent
|
||||
/// </summary>
|
||||
public static async Task<int> ConfigureOnlyAsync(AgentOptions options, HttpClient http)
|
||||
{
|
||||
if (options.Unattended)
|
||||
{
|
||||
return await ConfigureUnattendedOnlyAsync(options, http);
|
||||
}
|
||||
|
||||
string setupPath = Path.Combine(options.TargetDir, SetupFileName);
|
||||
|
||||
if (!File.Exists(setupPath))
|
||||
@@ -702,6 +712,344 @@ namespace Deploymentcenter.UpdateAgent
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> RunUnattendedAsync(AgentOptions options, HttpClient http)
|
||||
{
|
||||
string answersPath = options.AnswersPath!;
|
||||
if (!File.Exists(answersPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Antwortdatei nicht gefunden: {Markup.Escape(answersPath)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(answersPath);
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Fehler beim Lesen der Antwortdatei: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Antwortdatei muss ein JSON-Objekt sein.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string? baseUrl = GetStringProp(root, "base_url", "baseUrl");
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
options.BaseUrl = baseUrl.TrimEnd('/');
|
||||
}
|
||||
|
||||
string? project = GetStringProp(root, "project", "product", "product_slug", "project_slug");
|
||||
if (!string.IsNullOrWhiteSpace(project))
|
||||
{
|
||||
options.Project = project;
|
||||
}
|
||||
|
||||
string? channel = GetStringProp(root, "channel");
|
||||
if (!string.IsNullOrWhiteSpace(channel))
|
||||
{
|
||||
options.Channel = channel;
|
||||
}
|
||||
|
||||
string? platform = GetStringProp(root, "platform", "rid");
|
||||
if (!string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
options.Platform = PlatformId.Normalize(platform);
|
||||
}
|
||||
|
||||
string? targetDir = GetStringProp(root, "target_dir", "targetDir", "path");
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
options.TargetDir = Path.GetFullPath(targetDir);
|
||||
}
|
||||
|
||||
string? licenseKey = GetStringProp(root, "license_key", "licenseKey", "license");
|
||||
if (!string.IsNullOrWhiteSpace(licenseKey))
|
||||
{
|
||||
options.Credentials = ReleaseCredentials.FromLicenseKey(licenseKey);
|
||||
}
|
||||
|
||||
string version = GetStringProp(root, "version") ?? (options.Version != "latest" && !string.IsNullOrWhiteSpace(options.Version) ? options.Version : "latest");
|
||||
|
||||
var answersMap = ExtractAnswersMap(root);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Project))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Pflichtfeld 'project' fehlt in den Antworten.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.TargetDir))
|
||||
{
|
||||
options.TargetDir = Path.GetFullPath(SuggestTargetDirectory(options.Project));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(options.TargetDir);
|
||||
|
||||
AnsiConsole.MarkupLine($"[bold]Kopflose Installation:[/] {Markup.Escape(options.Project)} ({Markup.Escape(options.Channel)}) v{Markup.Escape(version)} -> {Markup.Escape(options.TargetDir)}");
|
||||
|
||||
int fileResult = await Program.DoUpdateOrRepair(options, version, null);
|
||||
if (fileResult != 0)
|
||||
{
|
||||
return fileResult;
|
||||
}
|
||||
|
||||
var runtime = RuntimeCheck.Inspect(options.TargetDir);
|
||||
RuntimeCheck.Report(runtime);
|
||||
|
||||
var client = new SetupClient(options.BaseUrl, http);
|
||||
int configResult = await ConfigureUnattendedAsync(options, client, options.Project, answersMap);
|
||||
if (configResult != 0)
|
||||
{
|
||||
return configResult;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Kopflose Installation von {Markup.Escape(options.Project)} erfolgreich abgeschlossen.[/]");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> ConfigureUnattendedOnlyAsync(AgentOptions options, HttpClient http)
|
||||
{
|
||||
string answersPath = options.AnswersPath!;
|
||||
if (!File.Exists(answersPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Antwortdatei nicht gefunden: {Markup.Escape(answersPath)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(answersPath);
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Fehler beim Lesen der Antwortdatei: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Antwortdatei muss ein JSON-Objekt sein.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string? targetDir = GetStringProp(root, "target_dir", "targetDir", "path");
|
||||
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||
{
|
||||
options.TargetDir = Path.GetFullPath(targetDir);
|
||||
}
|
||||
|
||||
string? project = GetStringProp(root, "project", "product", "product_slug", "project_slug");
|
||||
if (!string.IsNullOrWhiteSpace(project))
|
||||
{
|
||||
options.Project = project;
|
||||
}
|
||||
|
||||
var answersMap = ExtractAnswersMap(root);
|
||||
var client = new SetupClient(options.BaseUrl, http);
|
||||
return await ConfigureUnattendedAsync(options, client, options.Project, answersMap);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ConfigureUnattendedAsync(
|
||||
AgentOptions options,
|
||||
SetupClient client,
|
||||
string project,
|
||||
Dictionary<string, string> answers)
|
||||
{
|
||||
string setupPath = Path.Combine(options.TargetDir, SetupFileName);
|
||||
if (!File.Exists(setupPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Keine {SetupFileName} vorhanden - keine Konfiguration erforderlich.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var definition = ReadDefinition(setupPath);
|
||||
if (definition == null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var resolved = new List<(SetupField Field, string Value)>();
|
||||
var targets = definition.Targets;
|
||||
|
||||
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 = 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)}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. In answers angegeben?
|
||||
if (answers.TryGetValue(field.Key, out string? val) && !string.IsNullOrWhiteSpace(val))
|
||||
{
|
||||
var validator = BuildValidator(field);
|
||||
if (validator != null)
|
||||
{
|
||||
var res = validator(val);
|
||||
if (!res.Successful)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Ungueltiger Wert fuer {Markup.Escape(field.Key)}: {res.Message}[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
resolved.Add((field, val));
|
||||
AnsiConsole.MarkupLine($"[green]Aus Antworten:[/] {Markup.Escape(field.Key)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 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)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 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)}");
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (field.Required)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Token fuer {Markup.Escape(field.Key)} konnte nicht bezogen werden: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Default vorhanden?
|
||||
if (!string.IsNullOrWhiteSpace(field.Default))
|
||||
{
|
||||
resolved.Add((field, field.Default));
|
||||
AnsiConsole.MarkupLine($"[grey]Vorgabewert:[/] {Markup.Escape(field.Key)} = {Markup.Escape(field.Default)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. Fehlt Pflichtfeld?
|
||||
if (field.Required)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Pflichtfeld fehlt in der Antwortdatei: {Markup.Escape(field.Key)}[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
var writeTargets = definition.Targets.Count > 0
|
||||
? definition.Targets
|
||||
: new List<SetupTarget> { new SetupTarget { File = "appsettings.json", Format = "json" } };
|
||||
|
||||
foreach (var t in writeTargets)
|
||||
{
|
||||
var forTarget = resolved
|
||||
.Where(pair => BelongsToTarget(pair.Field, t, writeTargets))
|
||||
.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(t, options.TargetDir);
|
||||
try
|
||||
{
|
||||
SetupWriter.Write(path, t.Format, forTarget);
|
||||
AnsiConsole.MarkupLine($"[green]Geschrieben:[/] {Markup.Escape(path)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Fehler beim Schreiben von {Markup.Escape(path)}: {Markup.Escape(ex.Message)}[/]");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ExtractAnswersMap(JsonElement root)
|
||||
{
|
||||
var answersMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (root.TryGetProperty("answers", out var answersProp) && answersProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in answersProp.EnumerateObject())
|
||||
{
|
||||
answersMap[prop.Name] = prop.Value.GetString() ?? prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
if (root.TryGetProperty("fields", out var fieldsProp) && fieldsProp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in fieldsProp.EnumerateObject())
|
||||
{
|
||||
answersMap[prop.Name] = prop.Value.GetString() ?? prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
{
|
||||
string name = prop.Name.ToLowerInvariant();
|
||||
if (name is "base_url" or "baseurl" or "project" or "product" or "channel" or "platform" or "rid"
|
||||
or "target_dir" or "targetdir" or "path" or "version" or "license_key" or "licensekey" or "license"
|
||||
or "answers" or "fields")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!answersMap.ContainsKey(prop.Name))
|
||||
{
|
||||
answersMap[prop.Name] = prop.Value.GetString() ?? prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
return answersMap;
|
||||
}
|
||||
|
||||
private static string? GetStringProp(JsonElement elem, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (elem.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SuggestTargetDirectory(string slug)
|
||||
{
|
||||
if (OperatingSystemHelpers.IsWindows())
|
||||
|
||||
Reference in New Issue
Block a user