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; using System.Text.Json; using System.Threading.Tasks; using Deploymentcenter.Client.Models; using FluentFTP; namespace Deploymentcenter.Packager { /// /// Konfiguration des Packagers. /// /// Die Zugangsdaten standen zuvor als Standardwerte direkt im Quelltext und /// lagen damit im Repository. Sie kommen jetzt ausschliesslich aus /// packager.config.json (nicht versioniert) oder aus Umgebungsvariablen. /// Fehlen sie, bricht das Programm mit einer klaren Meldung ab, statt sich /// mit veralteten Werten zu verbinden. /// public class PackagerConfig { public string FtpHost { get; set; } = ""; public int FtpPort { get; set; } = 21; public string FtpUser { get; set; } = ""; public string FtpPass { get; set; } = ""; public string FtpRemoteBaseDir { get; set; } = "/public_html/releases"; public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de"; /// /// Token mit dem Recht updateservice:publish. Das Veroeffentlichen eines /// Releases ist nicht mehr unauthentifiziert moeglich. /// public string ApiToken { get; set; } = ""; public List ExcludePatterns { get; set; } = new List { "*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*" }; /// Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei. public void ApplyEnvironmentOverrides() { FtpHost = Env("DC_FTP_HOST", FtpHost); FtpUser = Env("DC_FTP_USER", FtpUser); FtpPass = Env("DC_FTP_PASS", FtpPass); ApiBaseUrl = Env("DC_API_URL", ApiBaseUrl); ApiToken = Env("DC_TOKEN", ApiToken); string port = Env("DC_FTP_PORT", ""); if (int.TryParse(port, out int parsedPort) && parsedPort > 0) { FtpPort = parsedPort; } } private static string Env(string name, string fallback) { string? value = Environment.GetEnvironmentVariable(name); return string.IsNullOrWhiteSpace(value) ? fallback : value; } } class Program { static async Task Main(string[] args) { Console.WriteLine("================================================="); Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 "); Console.WriteLine("================================================="); string project = GetArg(args, "--project", "-p") ?? "myapp"; string version = GetArg(args, "--version", "-v") ?? "1.0.0"; string channel = GetArg(args, "--channel", "-c") ?? "prod"; string publishDir = GetArg(args, "--publish-dir", "-d") ?? Directory.GetCurrentDirectory(); string changelog = GetArg(args, "--changelog") ?? $"Release v{version}"; bool isCritical = HasFlag(args, "--critical"); string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json"); PackagerConfig config = LoadConfig(configFile); config.ApplyEnvironmentOverrides(); // Reihenfolge: CLI-Argument, dann Umgebungsvariable, dann Datei. string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost; string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser; string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass; string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir; string apiToken = GetArg(args, "--token") ?? config.ApiToken; var missing = new List(); if (string.IsNullOrWhiteSpace(ftpHost)) missing.Add("FTP-Host (--ftp-host / DC_FTP_HOST / ftpHost)"); if (string.IsNullOrWhiteSpace(ftpUser)) missing.Add("FTP-Benutzer (--ftp-user / DC_FTP_USER / ftpUser)"); if (string.IsNullOrWhiteSpace(ftpPass)) missing.Add("FTP-Passwort (--ftp-pass / DC_FTP_PASS / ftpPass)"); if (missing.Count > 0) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("[FEHLER] Konfiguration unvollstaendig:"); foreach (var item in missing) { Console.WriteLine($" - {item}"); } Console.ResetColor(); Console.WriteLine(); Console.WriteLine($"Vorlage kopieren: {Path.GetFileName(configFile)}.example -> {Path.GetFileName(configFile)}"); return 1; } if (string.IsNullOrWhiteSpace(apiToken)) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("[WARNUNG] Kein API-Token gesetzt (--token / DC_TOKEN / apiToken)."); Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter"); Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0"); Console.WriteLine(" ein Token mit dem Recht updateservice:publish."); Console.ResetColor(); } publishDir = Path.GetFullPath(publishDir); if (!Directory.Exists(publishDir)) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}"); Console.ResetColor(); return 1; } Console.WriteLine($"[INFO] Packaging Project : {project}"); Console.WriteLine($"[INFO] Version : {version}"); Console.WriteLine($"[INFO] Channel : {channel}"); Console.WriteLine($"[INFO] Publish Directory : {publishDir}"); // 1. Gather files and filter exclusions var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories); var filteredFiles = new List(); foreach (var file in allFiles) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); if (IsExcluded(relPath, config.ExcludePatterns)) { Console.WriteLine($" [EXCLUDED] {relPath}"); continue; } filteredFiles.Add(file); } Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}"); // 2. Prepare staging directory string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(outputTempDir); string packageTarGzPath = Path.Combine(outputTempDir, "package.tar.gz"); string manifestJsonPath = Path.Combine(outputTempDir, "manifest.json"); string sha256FilePath = Path.Combine(outputTempDir, "package.tar.gz.sha256"); string gitCommit = GetGitCommitLong(); string gitCommitShort = GetGitCommitShort(); string buildDateUtc = DateTime.UtcNow.ToString("o"); // Build Manifest var packageManifest = new PackageManifest { ProjectId = project, Version = version, Channel = channel, BuildDate = buildDateUtc, GitCommit = gitCommit, GitCommitShort = gitCommitShort, Changelog = changelog, Files = new List() }; foreach (var file in filteredFiles) { string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/'); long size = new FileInfo(file).Length; string hash = ComputeSha256(file); packageManifest.Files.Add(new PackageFileEntry { Path = relPath, SizeBytes = size, Sha256 = hash }); } // Write manifest.json string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(manifestJsonPath, manifestJson); // 3. Create package.tar.gz Console.WriteLine("[INFO] Creating package.tar.gz archive..."); string archiveStaging = Path.Combine(outputTempDir, "archive_root"); Directory.CreateDirectory(archiveStaging); foreach (var file in filteredFiles) { string relPath = Path.GetRelativePath(publishDir, file); string targetFile = Path.Combine(archiveStaging, relPath); Directory.CreateDirectory(Path.GetDirectoryName(targetFile)!); File.Copy(file, targetFile, true); } // Also place manifest.json inside archive root File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true); // Compress to tar.gz using System.Formats.Tar + GZipStream using (var fs = File.Create(packageTarGzPath)) using (var gz = new GZipStream(fs, CompressionLevel.Optimal)) { TarFile.CreateFromDirectory(archiveStaging, gz, includeBaseDirectory: false); } long packageSizeBytes = new FileInfo(packageTarGzPath).Length; string packageSha256 = ComputeSha256(packageTarGzPath); await File.WriteAllTextAsync(sha256FilePath, packageSha256); Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)"); Console.WriteLine($"[INFO] Package SHA256: {packageSha256}"); // 4. FTP Upload to LEMP Release Server string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}"; string remoteVersionPath = $"{remoteChannelPath}/{version}"; Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})..."); bool ftpSucceeded = false; try { using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort); await ftp.Connect(); await ftp.CreateDirectory(remoteVersionPath); // Upload package.tar.gz, package.tar.gz.sha256, and manifest.json await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite); await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite); await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite); Console.WriteLine("[SUCCESS] Files uploaded to version directory!"); // 5. Update remote channel latest.json string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json"; ReleaseManifest channelManifest = new ReleaseManifest { ProjectId = project, Channel = channel, Versions = new List() }; // Read existing latest.json if present on FTP if (await ftp.FileExists(remoteLatestJsonPath)) { string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json"); var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite); if (status == FtpStatus.Success && File.Exists(tempLatestLocal)) { try { string existingJson = await File.ReadAllTextAsync(tempLatestLocal); var existingManifest = JsonSerializer.Deserialize(existingJson); if (existingManifest != null && existingManifest.Versions != null) { channelManifest.Versions = existingManifest.Versions; } } catch { } } } // Construct new version info string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz"; var newVersionInfo = new VersionInfo { Version = version, BuildDate = buildDateUtc, GitCommit = gitCommit, GitCommitShort = gitCommitShort, PackageUrl = packagePublicUrl, Sha256 = packageSha256, SizeBytes = packageSizeBytes, Changelog = changelog, IsCritical = isCritical }; // Remove duplicate version entry if re-publishing same version channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase)); channelManifest.Versions.Insert(0, newVersionInfo); // Keep last 15 releases if (channelManifest.Versions.Count > 15) { channelManifest.Versions = channelManifest.Versions.Take(15).ToList(); } channelManifest.Latest = channelManifest.Versions.FirstOrDefault(); string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true }); string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json"); await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson); await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite); Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!"); await ftp.Disconnect(); ftpSucceeded = true; } catch (Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}"); Console.WriteLine(" Das Paket wurde NICHT ausgeliefert."); Console.ResetColor(); } // 6. Deploymentcenter benachrichtigen // // Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar // nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit // unsichtbar, waehrend das Programm am Ende Erfolg meldete. bool apiNotified = false; string apiMessage = "uebersprungen (kein Token gesetzt)"; if (!string.IsNullOrWhiteSpace(apiToken)) { try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish"; var payload = new { product_slug = project, version = version, channel = channel, release_notes = changelog, download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz", sha256_hash = packageSha256, git_commit = gitCommitShort, size_bytes = packageSizeBytes, is_critical = isCritical }; var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl) { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; request.Headers.Add("Authorization", $"Bearer {apiToken}"); var response = await http.SendAsync(request); string body = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) { apiNotified = true; apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen."; string? autoResolved = ExtractJsonString(body, "auto_resolved"); if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0") { apiMessage += $" ({autoResolved} Bugtracker-Item(s) automatisch geschlossen)"; } } else { apiMessage = $"HTTP {(int)response.StatusCode}: " + (ExtractJsonString(body, "message") ?? body.Trim()); } } catch (Exception ex) { apiMessage = $"Aufruf fehlgeschlagen: {ex.Message}"; } } if (apiNotified) { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine($"[SUCCESS] {apiMessage}"); } else { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}"); } Console.ResetColor(); // Cleanup temp try { Directory.Delete(outputTempDir, true); } catch { } // Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist. // Zuvor wurde immer 0 und "successfully published" gemeldet, selbst // wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren. bool fullySucceeded = ftpSucceeded && apiNotified; Console.WriteLine(); Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow; Console.WriteLine(fullySucceeded ? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht." : $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): " + $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, " + $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}."); Console.ResetColor(); return fullySucceeded ? 0 : 2; } /// /// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein /// vollstaendiges Modell dafuer zu benoetigen. /// static string? ExtractJsonString(string json, string propertyName) { try { using var doc = JsonDocument.Parse(json); return FindProperty(doc.RootElement, propertyName); } catch { return null; } } static string? FindProperty(JsonElement element, string propertyName) { if (element.ValueKind != JsonValueKind.Object) { return null; } if (element.TryGetProperty(propertyName, out var direct)) { return direct.ValueKind == JsonValueKind.String ? direct.GetString() : direct.ToString(); } // Fehlerantworten verpacken die Nachricht in einem "error"-Objekt. foreach (var child in element.EnumerateObject()) { if (child.Value.ValueKind == JsonValueKind.Object) { string? nested = FindProperty(child.Value, propertyName); if (nested != null) { return nested; } } } return null; } static PackagerConfig LoadConfig(string path) { if (File.Exists(path)) { try { string json = File.ReadAllText(path); var cfg = JsonSerializer.Deserialize(json); if (cfg != null) return cfg; } catch { } } return new PackagerConfig(); } static bool IsExcluded(string relPath, List patterns) { string fileName = Path.GetFileName(relPath); foreach (var pattern in patterns) { if (pattern.StartsWith("*.")) { string ext = pattern.Substring(1); if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true; } else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } 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 bool HasFlag(string[] args, string flag) { return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase)); } static string GetGitCommitLong() { try { 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; } catch { } return "UNKNOWN_COMMIT"; } static string GetGitCommitShort() { try { var psi = new ProcessStartInfo("git", "rev-parse --short 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; } catch { } return "UNKNOWN"; } } }