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 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(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 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 latestLabel = $"[L] Latest ({latest.Version}) - {latest.BuildDate} " + (isUpdateAvailable ? "[bold green]← empfohlen[/]" : "[grey](aktuell)[/]"); choices.Add(latestLabel); int idx = 1; var verMap = new Dictionary(); 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() .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 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 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 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(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 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(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 Project slug"); Console.WriteLine(" --channel, -c Channel (prod, beta, dev)"); Console.WriteLine(" --action, -a interactive | check | update | repair | list"); Console.WriteLine(" --version, -v Target version or 'latest'"); Console.WriteLine(" --target-dir, -t Directory to update"); Console.WriteLine(" --restart Executable to restart upon completion"); return 0; } } }