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:
@@ -62,6 +62,7 @@
|
||||
ContinueOnError="true"
|
||||
StandardErrorImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitLong" />
|
||||
<Output TaskParameter="ExitCode" PropertyName="DcGitExitCodeLong" />
|
||||
</Exec>
|
||||
|
||||
<Exec Command="git rev-parse --short HEAD"
|
||||
@@ -70,6 +71,7 @@
|
||||
ContinueOnError="true"
|
||||
StandardErrorImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="DcGitCommitShort" />
|
||||
<Output TaskParameter="ExitCode" PropertyName="DcGitExitCodeShort" />
|
||||
</Exec>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -96,8 +98,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<DcGitCommitLong Condition="'$(DcGitCommitLong)' == ''">UNKNOWN_COMMIT</DcGitCommitLong>
|
||||
<DcGitCommitShort Condition="'$(DcGitCommitShort)' == ''">UNKNOWN</DcGitCommitShort>
|
||||
<DcGitExitCodeLong Condition="'$(DcGitExitCodeLong)' == ''">1</DcGitExitCodeLong>
|
||||
<DcGitExitCodeShort Condition="'$(DcGitExitCodeShort)' == ''">1</DcGitExitCodeShort>
|
||||
|
||||
<!--
|
||||
ConsoleToMSBuild faengt auch stderr ein. Wenn git fehlschlaegt (z. B.
|
||||
ausserhalb einer Git-Arbeitskopie, Quell-Tarball, Docker-Build ohne .git),
|
||||
landet die Fehlermeldung (z. B. "fatal: Kein Git-Repository...") in
|
||||
der Eigenschaft. Wir pruefen daher ExitCode und validieren die Ausgabe
|
||||
per Hex-Regex, um verlaesslich auf UNKNOWN_COMMIT/UNKNOWN zurueckzufallen.
|
||||
-->
|
||||
<DcGitCommitLong Condition="'$(DcGitExitCodeLong)' != '0' Or !$([System.Text.RegularExpressions.Regex]::IsMatch('$(DcGitCommitLong.Trim())', '^[0-9a-fA-F]{40}$'))">UNKNOWN_COMMIT</DcGitCommitLong>
|
||||
<DcGitCommitShort Condition="'$(DcGitExitCodeShort)' != '0' Or !$([System.Text.RegularExpressions.Regex]::IsMatch('$(DcGitCommitShort.Trim())', '^[0-9a-fA-F]{7,40}$'))">UNKNOWN</DcGitCommitShort>
|
||||
<!-- Zeilenumbrueche aus der Git-Ausgabe entfernen, sie wuerden das Literal sprengen. -->
|
||||
<DcGitCommitLong>$(DcGitCommitLong.Trim())</DcGitCommitLong>
|
||||
<DcGitCommitShort>$(DcGitCommitShort.Trim())</DcGitCommitShort>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
|
||||
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
@@ -308,20 +308,24 @@ namespace Deploymentcenter.Client
|
||||
: directory!;
|
||||
|
||||
string candidate = Path.Combine(dir, AgentFileName);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
if (!File.Exists(candidate))
|
||||
return null;
|
||||
|
||||
// Ein Paket, das fuer die jeweils andere Plattform gebaut wurde,
|
||||
// bringt den Agenten unter dem dortigen Namen mit. Lieber finden
|
||||
// als daran scheitern.
|
||||
foreach (string alternative in new[] { "update-agent", "update-agent.exe" })
|
||||
// Ist die Starterdatei ein Apphost eines Framework-abhaengigen Builds
|
||||
// (erkennbar an einer runtimeconfig.json oder deps.json daneben),
|
||||
// muss zwingend auch die verwaltete .dll vorhanden sein.
|
||||
// Ohne diese wuerde Process.Start zwar gelingen, der Prozess aber
|
||||
// unmittelbar mit "The application to execute does not exist" abbrechen.
|
||||
string dllCandidate = Path.Combine(dir, "update-agent.dll");
|
||||
string runtimeConfig = Path.Combine(dir, "update-agent.runtimeconfig.json");
|
||||
string depsJson = Path.Combine(dir, "update-agent.deps.json");
|
||||
|
||||
if ((File.Exists(runtimeConfig) || File.Exists(depsJson)) && !File.Exists(dllCandidate))
|
||||
{
|
||||
string path = Path.Combine(dir, alternative);
|
||||
if (File.Exists(path))
|
||||
return path;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Deploymentcenter.Packager</RootNamespace>
|
||||
|
||||
@@ -282,8 +282,9 @@ namespace Deploymentcenter.Packager
|
||||
string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json");
|
||||
string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256");
|
||||
|
||||
string gitCommit = GetGitCommitLong();
|
||||
string gitCommitShort = GetGitCommitShort();
|
||||
string? mainAssemblyPath = ResolveMainAssembly(publishDir, project, GetArg(args, "--main-assembly"));
|
||||
var (gitCommit, gitCommitShort) = DetermineGitCommit(publishDir, mainAssemblyPath);
|
||||
Console.WriteLine($"[INFO] Git-Commit : {gitCommitShort} ({gitCommit})");
|
||||
string buildDateUtc = DateTime.UtcNow.ToString("o");
|
||||
|
||||
var packageManifest = new PackageManifest
|
||||
@@ -1156,32 +1157,79 @@ namespace Deploymentcenter.Packager
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static string GetGitCommitLong()
|
||||
static (string CommitLong, string CommitShort) DetermineGitCommit(string publishDir, string? mainAssemblyPath)
|
||||
{
|
||||
try
|
||||
string? assemblyCommit = null;
|
||||
if (!string.IsNullOrWhiteSpace(mainAssemblyPath) && File.Exists(mainAssemblyPath))
|
||||
{
|
||||
var psi = new ProcessStartInfo("git", "rev-parse HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
||||
using var p = Process.Start(psi);
|
||||
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
p?.WaitForExit();
|
||||
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
||||
try
|
||||
{
|
||||
var info = FileVersionInfo.GetVersionInfo(mainAssemblyPath);
|
||||
string? prodVer = info.ProductVersion?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(prodVer) && prodVer.Contains('+'))
|
||||
{
|
||||
string candidate = prodVer.Split('+')[1].Trim();
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(candidate, "^[0-9a-fA-F]{7,40}$"))
|
||||
{
|
||||
assemblyCommit = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch { }
|
||||
return "UNKNOWN_COMMIT";
|
||||
}
|
||||
|
||||
static string GetGitCommitShort()
|
||||
{
|
||||
string? gitCommitFromDir = null;
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("git", "rev-parse --short HEAD") { RedirectStandardOutput = true, UseShellExecute = false };
|
||||
var psi = new ProcessStartInfo("git", "rev-parse HEAD")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Directory.Exists(publishDir) ? publishDir : Environment.CurrentDirectory
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
string outStr = p?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
p?.WaitForExit();
|
||||
if (!string.IsNullOrEmpty(outStr)) return outStr;
|
||||
if (p?.ExitCode == 0 && System.Text.RegularExpressions.Regex.IsMatch(outStr, "^[0-9a-fA-F]{40}$"))
|
||||
{
|
||||
gitCommitFromDir = outStr;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "UNKNOWN";
|
||||
|
||||
// Bevorzuge Assembly-Commit (falls vorhanden), da er den tatsaechlich kompilierten Stand repraesentiert
|
||||
string commitLong = assemblyCommit ?? gitCommitFromDir ?? "UNKNOWN_COMMIT";
|
||||
|
||||
string commitShort;
|
||||
if (commitLong != "UNKNOWN_COMMIT" && commitLong.Length >= 7)
|
||||
{
|
||||
commitShort = commitLong.Substring(0, 7);
|
||||
}
|
||||
else
|
||||
{
|
||||
commitShort = "UNKNOWN";
|
||||
try
|
||||
{
|
||||
var psiShort = new ProcessStartInfo("git", "rev-parse --short HEAD")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Directory.Exists(publishDir) ? publishDir : Environment.CurrentDirectory
|
||||
};
|
||||
using var pShort = Process.Start(psiShort);
|
||||
string outStrShort = pShort?.StandardOutput.ReadToEnd().Trim() ?? "";
|
||||
pShort?.WaitForExit();
|
||||
if (pShort?.ExitCode == 0 && System.Text.RegularExpressions.Regex.IsMatch(outStrShort, "^[0-9a-fA-F]{7,40}$"))
|
||||
{
|
||||
commitShort = outStrShort;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
return (commitLong, commitShort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AppName>Deploymentcenter Test Suite</AppName>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user