feat: integrate Bugtracker module, UpdateService enhancements & Token hierarchy
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client;
|
||||
using Deploymentcenter.Client.Models;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace Deploymentcenter.UpdateAgent
|
||||
{
|
||||
class Program
|
||||
{
|
||||
private static readonly HttpClient HttpClient = new HttpClient();
|
||||
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
string project = GetArg(args, "--project", "-p") ?? "myapp";
|
||||
string channel = GetArg(args, "--channel", "-c") ?? "prod";
|
||||
string action = GetArg(args, "--action", "-a") ?? "interactive";
|
||||
string version = GetArg(args, "--version", "-v") ?? "latest";
|
||||
string targetDir = GetArg(args, "--target-dir", "-t") ?? AppDomain.CurrentDomain.BaseDirectory;
|
||||
string baseUrl = GetArg(args, "--base-url") ?? "https://dc.mhdf.de";
|
||||
string restartApp = GetArg(args, "--restart") ?? "";
|
||||
|
||||
targetDir = Path.GetFullPath(targetDir);
|
||||
|
||||
// Read current local manifest if present
|
||||
string localManifestPath = Path.Combine(targetDir, "manifest.json");
|
||||
PackageManifest? currentManifest = null;
|
||||
if (File.Exists(localManifestPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(localManifestPath);
|
||||
currentManifest = JsonSerializer.Deserialize<PackageManifest>(json);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
string currentVersion = currentManifest?.Version ?? "Unbekannt";
|
||||
string currentBuildDate = currentManifest?.BuildDate ?? "Unbekannt";
|
||||
string currentGit = currentManifest?.GitCommitShort ?? "Unbekannt";
|
||||
|
||||
if (action.Equals("interactive", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RunInteractiveMode(baseUrl, project, channel, currentVersion, currentBuildDate, currentGit, targetDir, restartApp);
|
||||
}
|
||||
|
||||
// CLI Mode
|
||||
return action.ToLowerInvariant() switch
|
||||
{
|
||||
"check" => await DoCheck(baseUrl, project, channel, currentVersion),
|
||||
"update" => await DoUpdateOrRepair(baseUrl, project, channel, version, targetDir, currentManifest, restartApp),
|
||||
"repair" => await DoUpdateOrRepair(baseUrl, project, channel, currentVersion, targetDir, currentManifest, restartApp),
|
||||
"list" => await DoList(baseUrl, project, channel),
|
||||
_ => ShowHelp()
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<int> RunInteractiveMode(
|
||||
string baseUrl,
|
||||
string project,
|
||||
string channel,
|
||||
string currentVer,
|
||||
string currentBuildDate,
|
||||
string currentGit,
|
||||
string targetDir,
|
||||
string restartApp)
|
||||
{
|
||||
AnsiConsole.Write(
|
||||
new FigletText("UpdateAgent")
|
||||
.LeftJustified()
|
||||
.Color(Color.DodgerBlue1));
|
||||
|
||||
var panel = new Panel(
|
||||
$"[bold white]Projekt:[/] [cyan]{project}[/] [bold white]Kanal:[/] [yellow]{channel}[/]\n" +
|
||||
$"[bold white]Installierte Version:[/] [green]{currentVer}[/] ({currentBuildDate}) [bold grey][Git: {currentGit}][/]\n" +
|
||||
$"[bold white]Zielpfad:[/] [grey]{targetDir}[/]")
|
||||
{
|
||||
Header = new PanelHeader("[bold blue] Deploymentcenter Update Agent [/]"),
|
||||
Border = BoxBorder.Rounded
|
||||
};
|
||||
AnsiConsole.Write(panel);
|
||||
|
||||
AnsiConsole.MarkupLine("\n[grey]Lade verfügbare Releases...[/]");
|
||||
|
||||
ReleaseManifest? releaseManifest = null;
|
||||
await AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.StartAsync("Verbinde mit LEMP Server...", async ctx =>
|
||||
{
|
||||
releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
});
|
||||
|
||||
if (releaseManifest == null || releaseManifest.Latest == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler: Koppelung zum LEMP Release Server fehlgeschlagen oder kein Release gefunden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var latest = releaseManifest.Latest;
|
||||
bool isUpdateAvailable = UpdateClient.IsVersionNewer(currentVer, latest.Version);
|
||||
|
||||
var choices = new List<string>();
|
||||
string latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]");
|
||||
choices.Add(latestLabel);
|
||||
|
||||
int idx = 1;
|
||||
var verMap = new Dictionary<string, VersionInfo>();
|
||||
verMap["latest"] = latest;
|
||||
|
||||
foreach (var ver in releaseManifest.Versions)
|
||||
{
|
||||
string key = $"{idx++}";
|
||||
string label = $"[{key}] Version {ver.Version} - {ver.BuildDate} [grey]({ver.GitCommitShort})[/]";
|
||||
if (ver.Version.Equals(currentVer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
label += " [cyan]← aktuell installiert[/]";
|
||||
}
|
||||
choices.Add(label);
|
||||
verMap[key] = ver;
|
||||
}
|
||||
|
||||
choices.Add("[R] Reparatur der aktuellen Version");
|
||||
choices.Add("[Q] Beenden");
|
||||
|
||||
var selected = AnsiConsole.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("\n[bold white]Bitte wählen Sie eine Aktion:[/]")
|
||||
.PageSize(10)
|
||||
.AddChoices(choices));
|
||||
|
||||
if (selected.StartsWith("[Q]"))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Vorgang abgebrochen.[/]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
string targetVersion = "latest";
|
||||
if (selected.StartsWith("[R]"))
|
||||
{
|
||||
targetVersion = currentVer;
|
||||
AnsiConsole.MarkupLine($"\n[bold yellow]Starte Reparatur der Version v{targetVersion}...[/]");
|
||||
}
|
||||
else if (selected.StartsWith("[L]"))
|
||||
{
|
||||
targetVersion = "latest";
|
||||
AnsiConsole.MarkupLine($"\n[bold green]Starte Update auf Version v{latest.Version}...[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Match index
|
||||
foreach (var entry in verMap)
|
||||
{
|
||||
if (entry.Key != "latest" && selected.StartsWith($"[{entry.Key}]"))
|
||||
{
|
||||
targetVersion = entry.Value.Version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
AnsiConsole.MarkupLine($"\n[bold green]Starte Installation von Version v{targetVersion}...[/]");
|
||||
}
|
||||
|
||||
return await DoUpdateOrRepair(baseUrl, project, channel, targetVersion, targetDir, null, restartApp);
|
||||
}
|
||||
|
||||
static async Task<int> DoCheck(string baseUrl, string project, string channel, string currentVer)
|
||||
{
|
||||
var updateClient = new UpdateClient(HttpClient);
|
||||
var res = await updateClient.CheckForUpdateAsync(baseUrl, project, currentVer, channel);
|
||||
if (res.UpdateAvailable && res.LatestRelease != null)
|
||||
{
|
||||
Console.WriteLine($"UPDATE_AVAILABLE: {res.LatestRelease.Version} (Current: {currentVer})");
|
||||
return 0;
|
||||
}
|
||||
Console.WriteLine("UP_TO_DATE");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> DoList(string baseUrl, string project, string channel)
|
||||
{
|
||||
var manifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
if (manifest == null)
|
||||
{
|
||||
Console.WriteLine("ERROR: Could not fetch manifest.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Releases for {project} [{channel}]:");
|
||||
foreach (var v in manifest.Versions)
|
||||
{
|
||||
Console.WriteLine($" - v{v.Version} ({v.BuildDate}) [Git: {v.GitCommitShort}] SHA: {v.Sha256}");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> DoUpdateOrRepair(
|
||||
string baseUrl,
|
||||
string project,
|
||||
string channel,
|
||||
string targetVersion,
|
||||
string targetDir,
|
||||
PackageManifest? currentManifest,
|
||||
string restartApp)
|
||||
{
|
||||
string tempDir = Path.Combine(Path.GetTempPath(), "dc_update_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
{
|
||||
var releaseManifest = await FetchManifestAsync(baseUrl, project, channel);
|
||||
if (releaseManifest == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler: Release-Manifest konnte nicht abgerufen werden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
VersionInfo? targetRelease = null;
|
||||
if (targetVersion.Equals("latest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
targetRelease = releaseManifest.Latest;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetRelease = releaseManifest.Versions.FirstOrDefault(v => v.Version.Equals(targetVersion, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (targetRelease == null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Fehler: Version '{targetVersion}' wurde auf dem Server nicht gefunden.[/]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string pkgUrl = targetRelease.PackageUrl;
|
||||
if (string.IsNullOrEmpty(pkgUrl))
|
||||
{
|
||||
pkgUrl = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/{targetRelease.Version}/package.tar.gz";
|
||||
}
|
||||
|
||||
string localPkgPath = Path.Combine(tempDir, "package.tar.gz");
|
||||
|
||||
// 1. Download Package
|
||||
await AnsiConsole.Progress()
|
||||
.Columns(new ProgressColumn[]
|
||||
{
|
||||
new TaskDescriptionColumn(),
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn(),
|
||||
new RemainingTimeColumn(),
|
||||
new SpinnerColumn(),
|
||||
})
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
var downloadTask = ctx.AddTask($"[green]Lade Package v{targetRelease.Version} herunter...[/]");
|
||||
using var resp = await HttpClient.GetAsync(pkgUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
long? totalBytes = resp.Content.Headers.ContentLength;
|
||||
using var stream = await resp.Content.ReadAsStreamAsync();
|
||||
using var fileStream = File.Create(localPkgPath);
|
||||
|
||||
byte[] buffer = new byte[81920];
|
||||
long readBytes = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
readBytes += bytesRead;
|
||||
if (totalBytes.HasValue && totalBytes.Value > 0)
|
||||
{
|
||||
downloadTask.Value = ((double)readBytes / totalBytes.Value) * 100;
|
||||
}
|
||||
}
|
||||
downloadTask.Value = 100;
|
||||
});
|
||||
|
||||
// 2. Verify SHA256
|
||||
AnsiConsole.MarkupLine("[grey]Prüfe SHA256-Integrität des Pakets...[/]");
|
||||
string computedSha = ComputeSha256(localPkgPath);
|
||||
if (!string.Equals(computedSha, targetRelease.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Sicherheitsfehler: SHA256-Hash stimmt nicht überein![/]");
|
||||
AnsiConsole.MarkupLine($"Erwartet: {targetRelease.Sha256}");
|
||||
AnsiConsole.MarkupLine($"Erhalten: {computedSha}");
|
||||
return 1;
|
||||
}
|
||||
AnsiConsole.MarkupLine("[bold green][✔] SHA256 Hash erfolgreich verifiziert.[/]");
|
||||
|
||||
// 3. Extract to temp staging directory
|
||||
string extractDir = Path.Combine(tempDir, "extracted");
|
||||
Directory.CreateDirectory(extractDir);
|
||||
|
||||
AnsiConsole.MarkupLine("[grey]Entpacke Archiv (tar.gz)...[/]");
|
||||
using (var fs = File.OpenRead(localPkgPath))
|
||||
using (var gz = new GZipStream(fs, CompressionMode.Decompress))
|
||||
{
|
||||
TarFile.ExtractToDirectory(gz, extractDir, overwriteFiles: true);
|
||||
}
|
||||
|
||||
// 4. Verify extracted files against manifest.json
|
||||
string extractedManifestPath = Path.Combine(extractDir, "manifest.json");
|
||||
if (File.Exists(extractedManifestPath))
|
||||
{
|
||||
string mJson = await File.ReadAllTextAsync(extractedManifestPath);
|
||||
var pkgManifest = JsonSerializer.Deserialize<PackageManifest>(mJson);
|
||||
if (pkgManifest != null)
|
||||
{
|
||||
var integrity = UpdateClient.VerifyIntegrity(extractDir, pkgManifest);
|
||||
if (!integrity.IsValid)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold red]Fehler bei Dateivalidierung nach Entpacken![/]");
|
||||
foreach (var missing in integrity.MissingFiles) AnsiConsole.MarkupLine($" - Fehlt: {missing}");
|
||||
foreach (var corrupt in integrity.CorruptedFiles) AnsiConsole.MarkupLine($" - Beschädigt: {corrupt}");
|
||||
return 1;
|
||||
}
|
||||
AnsiConsole.MarkupLine($"[bold green][✔] {integrity.TotalCheckedFiles} Dateien gegen Manifest-Hashes verifiziert.[/]");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Apply Update (Atomic Replace with Backup)
|
||||
AnsiConsole.MarkupLine("[grey]Übertrage neue Dateien in Zielverzeichnis...[/]");
|
||||
ApplyFiles(extractDir, targetDir);
|
||||
|
||||
AnsiConsole.MarkupLine($"\n[bold green]🚀 Update auf Version v{targetRelease.Version} erfolgreich abgeschlossen![/]");
|
||||
|
||||
// 6. Restart App if requested
|
||||
if (!string.IsNullOrEmpty(restartApp) && File.Exists(restartApp))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Starte Hauptanwendung neu ({Path.GetFileName(restartApp)})...[/]");
|
||||
Process.Start(new ProcessStartInfo { FileName = restartApp, UseShellExecute = true });
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold red]Fehler während des Update-Vorgangs: {ex.Message}[/]");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
static void ApplyFiles(string sourceDir, string targetDir)
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
// Copy recursively, overwriting files
|
||||
foreach (string dirPath in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(dirPath.Replace(sourceDir, targetDir));
|
||||
}
|
||||
|
||||
foreach (string newPath in Directory.GetFiles(sourceDir, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
string targetPath = newPath.Replace(sourceDir, targetDir);
|
||||
File.Copy(newPath, targetPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<ReleaseManifest?> FetchManifestAsync(string baseUrl, string project, string channel)
|
||||
{
|
||||
try
|
||||
{
|
||||
string url = $"{baseUrl.TrimEnd('/')}/releases/{project}/{channel}/latest.json";
|
||||
var resp = await HttpClient.GetAsync(url);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
string json = await resp.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<ReleaseManifest>(json);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
static string ComputeSha256(string file)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var stream = File.OpenRead(file);
|
||||
byte[] hash = sha256.ComputeHash(stream);
|
||||
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
static string? GetArg(string[] args, string flagLong, string? flagShort = null)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(flagLong, StringComparison.OrdinalIgnoreCase) ||
|
||||
(flagShort != null && args[i].Equals(flagShort, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return args[i + 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static int ShowHelp()
|
||||
{
|
||||
Console.WriteLine("Deploymentcenter Update Agent");
|
||||
Console.WriteLine("Usage: update-agent [options]");
|
||||
Console.WriteLine("Options:");
|
||||
Console.WriteLine(" --project, -p <name> Project slug");
|
||||
Console.WriteLine(" --channel, -c <channel> Channel (prod, beta, dev)");
|
||||
Console.WriteLine(" --action, -a <action> interactive | check | update | repair | list");
|
||||
Console.WriteLine(" --version, -v <version> Target version or 'latest'");
|
||||
Console.WriteLine(" --target-dir, -t <dir> Directory to update");
|
||||
Console.WriteLine(" --restart <exePath> Executable to restart upon completion");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user