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:
Deploymentcenter Bot
2026-09-02 23:43:37 +02:00
parent 8447b2d10a
commit 087f4392a9
26 changed files with 1582 additions and 67 deletions
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Deploymentcenter.UpdateAgent</RootNamespace>
@@ -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())
@@ -47,7 +47,7 @@ namespace Deploymentcenter.UpdateAgent
/// nicht ueberschreiben oder loeschen - genau darauf baut der
/// Selbstaustausch auf.
/// </summary>
private const string ReplacedSuffix = ".dc-old";
internal const string ReplacedSuffix = ".dc-old";
/// <summary>
/// Bestimmt, was zu tun ist, ohne etwas zu veraendern.
@@ -55,12 +55,22 @@ namespace Deploymentcenter.UpdateAgent
targetDir = Path.GetFullPath(targetDir);
// Selbststaendige Aktualisierungspruefung des Agenten beim Start
var selfUpdateResult = await TrySelfUpdateAsync(baseUrl, platform, args);
if (selfUpdateResult.HasValue)
{
return selfUpdateResult.Value;
}
string? answersPath = GetArg(args, "--answers");
var options = new AgentOptions
{
BaseUrl = baseUrl,
Project = project,
Channel = channel,
Platform = platform,
Version = version,
TargetDir = targetDir,
RestartApp = restartApp,
RequireSignature = HasFlag(args, "--require-signature"),
@@ -68,6 +78,7 @@ namespace Deploymentcenter.UpdateAgent
AgentDirectory = agentDir,
WaitForPid = ParsePid(GetArg(args, "--wait-for-pid")),
WaitTimeoutSeconds = ParseInt(GetArg(args, "--wait-timeout"), 60),
AnswersPath = answersPath,
// Die Release-Ablage verlangt Zugangsdaten. Im laufenden
// Betrieb ist das der Lizenzschluessel der Anwendung; bei
@@ -1216,9 +1227,199 @@ namespace Deploymentcenter.UpdateAgent
Console.WriteLine(" --download-password <pw> dazugehoeriges Passwort");
Console.WriteLine(" --pubkey <datei> Oeffentlicher Schluessel zur Signaturpruefung");
Console.WriteLine(" --require-signature Ohne gueltige Signatur nicht installieren");
Console.WriteLine(" --answers <datei> Antwortdatei fuer unbeaufsichtigte Installation (JSON)");
Console.WriteLine(" --no-self-update Selbstaktualisierung beim Start unterdruecken");
Console.WriteLine();
return 0;
}
/// <summary>
/// Prueft beim Start selbststaendig, ob es im Deploymentcenter eine neuere
/// Fassung des Update-Agenten gibt, und aktualisiert die eigene Binaerdatei
/// vor der Ausfuehrung.
/// </summary>
private static async Task<int?> TrySelfUpdateAsync(string baseUrl, string platform, string[] args)
{
if (HasFlag(args, "--no-self-update") || HasFlag(args, "--skip-self-update") || HasFlag(args, "--help") || HasFlag(args, "-h"))
{
return null;
}
try
{
string? currentExe = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
{
currentExe = Process.GetCurrentProcess().MainModule?.FileName;
}
if (string.IsNullOrWhiteSpace(currentExe) || !File.Exists(currentExe))
{
return null;
}
// In Entwicklungsumgebungen (Aufruf via "dotnet update-agent.dll") keinen Selbstaustausch ausfuehren
string exeName = Path.GetFileNameWithoutExtension(currentExe);
if (string.Equals(exeName, "dotnet", StringComparison.OrdinalIgnoreCase))
{
return null;
}
string ownVersion = GetOwnVersion();
string manifestUrl = $"{baseUrl.TrimEnd('/')}/installer/installer.json";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var request = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
using var response = await HttpClient.SendAsync(request, cts.Token);
if (!response.IsSuccessStatusCode)
{
return null;
}
string json = await response.Content.ReadAsStringAsync(cts.Token);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("version", out var verProp) || verProp.ValueKind != JsonValueKind.String)
{
return null;
}
string remoteVersion = verProp.GetString() ?? "";
if (string.IsNullOrWhiteSpace(remoteVersion) || !UpdateClient.IsVersionNewer(ownVersion, remoteVersion))
{
return null;
}
if (!root.TryGetProperty("binaries", out var binariesProp) || binariesProp.ValueKind != JsonValueKind.Array)
{
return null;
}
string normalizedPlatform = PlatformId.Normalize(platform);
string? remoteFileName = null;
string? expectedSha256 = null;
foreach (var bin in binariesProp.EnumerateArray())
{
string p = bin.TryGetProperty("platform", out var pProp) ? pProp.GetString() ?? "" : "";
if (string.Equals(PlatformId.Normalize(p), normalizedPlatform, StringComparison.OrdinalIgnoreCase))
{
remoteFileName = bin.TryGetProperty("file", out var fProp) ? fProp.GetString() : null;
expectedSha256 = bin.TryGetProperty("sha256", out var sProp) ? sProp.GetString() : null;
break;
}
}
if (string.IsNullOrWhiteSpace(remoteFileName) || string.IsNullOrWhiteSpace(expectedSha256))
{
return null;
}
string downloadUrl = $"{baseUrl.TrimEnd('/')}/installer/{remoteFileName}";
string tempFile = Path.Combine(Path.GetTempPath(), "dc_selfupdate_" + Guid.NewGuid().ToString("N"));
try
{
using (var downloadCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
using (var dlResp = await HttpClient.GetAsync(downloadUrl, downloadCts.Token))
{
if (!dlResp.IsSuccessStatusCode)
return null;
using (var fs = File.Create(tempFile))
{
await dlResp.Content.CopyToAsync(fs, downloadCts.Token);
}
}
string actualSha256 = ComputeSha256(tempFile);
if (!string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Binaerdatei austauschen
if (OperatingSystem.IsWindows())
{
string replaced = currentExe + Installer.ReplacedSuffix;
if (File.Exists(replaced))
{
try { File.Delete(replaced); } catch { }
}
File.Move(currentExe, replaced);
File.Copy(tempFile, currentExe, overwrite: true);
}
else
{
File.Copy(tempFile, currentExe, overwrite: true);
try
{
File.SetUnixFileMode(currentExe,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
catch { }
}
AnsiConsole.MarkupLine($"[bold green]Update-Agent wurde selbststaendig auf v{remoteVersion} aktualisiert.[/]");
// Mit den urspruenglichen Argumenten neu starten
var psi = new ProcessStartInfo
{
FileName = currentExe,
UseShellExecute = false,
WorkingDirectory = Environment.CurrentDirectory
};
foreach (string arg in args)
{
psi.ArgumentList.Add(arg);
}
psi.ArgumentList.Add("--no-self-update");
using var restarted = Process.Start(psi);
if (restarted != null)
{
await restarted.WaitForExitAsync();
return restarted.ExitCode;
}
}
finally
{
if (File.Exists(tempFile))
{
try { File.Delete(tempFile); } catch { }
}
}
}
catch
{
// Bei Netzwerkproblemen, Timeout oder Berechtigungsfehlern schlicht mit bisheriger Version fortfahren
}
return null;
}
private static string GetOwnVersion()
{
var asm = typeof(Program).Assembly;
var infoVer = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.OfType<System.Reflection.AssemblyInformationalVersionAttribute>()
.FirstOrDefault()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(infoVer))
{
return infoVer.Split('+')[0];
}
var ver = asm.GetName().Version;
if (ver != null && ver != new Version(0, 0, 0, 0))
{
return $"{ver.Major}.{ver.Minor}.{ver.Build}";
}
return DeploymentcenterSdk.Version;
}
}
/// <summary>Gebuendelte Aufrufparameter.</summary>
@@ -1229,6 +1430,9 @@ namespace Deploymentcenter.UpdateAgent
public string Channel { get; set; } = "prod";
public string Platform { get; set; } = PlatformId.Any;
public string TargetDir { get; set; } = string.Empty;
public string Version { get; set; } = "latest";
public string? AnswersPath { get; set; }
public bool Unattended => !string.IsNullOrWhiteSpace(AnswersPath);
public string RestartApp { get; set; } = string.Empty;
public bool RequireSignature { get; set; }
public string? PublicKeyPath { get; set; }