423 lines
18 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|