Files
Deploymentcenter/client-dotnet/Deploymentcenter.UpdateAgent/Program.cs
T

423 lines
18 KiB
C#

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;
}
}
}