feat: integrate Bugtracker module, UpdateService enhancements & Token hierarchy

This commit is contained in:
Deploymentcenter Bot
2026-08-06 12:45:58 +02:00
parent 70b35f7b8b
commit d71f90cdfc
28 changed files with 3494 additions and 32 deletions
@@ -0,0 +1,49 @@
<Project>
<!-- MSBuild target to generate BuildInfo.g.cs automatically prior to compilation -->
<Target Name="GenerateDeploymentcenterBuildInfo" BeforeTargets="CoreCompile">
<PropertyGroup>
<BuildInfoFile>$(IntermediateOutputPath)BuildInfo.g.cs</BuildInfoFile>
<BuildDateUtc>$([System.DateTime]::UtcNow.ToString("o"))</BuildDateUtc>
<BuildVersion Condition="'$(Version)' != ''">$(Version)</BuildVersion>
<BuildVersion Condition="'$(BuildVersion)' == ''">1.0.0</BuildVersion>
<BuildChannel Condition="'$(BuildChannel)' == ''">prod</BuildChannel>
</PropertyGroup>
<Exec Command="git rev-parse HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitLong" />
</Exec>
<Exec Command="git rev-parse --short HEAD" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitCommitShort" />
</Exec>
<PropertyGroup>
<GitCommitLong Condition="'$(GitCommitLong)' == ''">UNKNOWN_COMMIT</GitCommitLong>
<GitCommitShort Condition="'$(GitCommitShort)' == ''">UNKNOWN</GitCommitShort>
</PropertyGroup>
<ItemGroup>
<BuildInfoLine Include="// &lt;auto-generated /&gt;" />
<BuildInfoLine Include="using System%3B" />
<BuildInfoLine Include="namespace Deploymentcenter.Client.Models" />
<BuildInfoLine Include="{" />
<BuildInfoLine Include=" public static partial class BuildInfo" />
<BuildInfoLine Include=" {" />
<BuildInfoLine Include=" static BuildInfo()" />
<BuildInfoLine Include=" {" />
<BuildInfoLine Include=" Version = &quot;$(BuildVersion)&quot;%3B" />
<BuildInfoLine Include=" GitCommit = &quot;$(GitCommitLong)&quot;%3B" />
<BuildInfoLine Include=" GitCommitShort = &quot;$(GitCommitShort)&quot;%3B" />
<BuildInfoLine Include=" BuildDateUtc = &quot;$(BuildDateUtc)&quot;%3B" />
<BuildInfoLine Include=" Channel = &quot;$(BuildChannel)&quot;%3B" />
<BuildInfoLine Include=" }" />
<BuildInfoLine Include=" }" />
<BuildInfoLine Include="}" />
</ItemGroup>
<WriteLinesToFile File="$(BuildInfoFile)" Lines="@(BuildInfoLine)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<ItemGroup>
<Compile Include="$(BuildInfoFile)" />
</ItemGroup>
</Target>
</Project>
@@ -16,6 +16,8 @@
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
using System;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Runtime accessibility for build metadata embedded at compile-time.
/// </summary>
public static class BuildInfo
{
public static string Version { get; set; } = "1.0.0";
public static string GitCommit { get; set; } = "HEAD";
public static string GitCommitShort { get; set; } = "HEAD";
public static string BuildDateUtc { get; set; } = DateTime.UtcNow.ToString("o");
public static string Channel { get; set; } = "prod";
public static string Summary => $"v{Version} ({GitCommitShort}) built on {BuildDateUtc} [{Channel}]";
}
}
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Model for per-package manifest.json stored inside package.tar.gz
/// and beside it on the LEMP release server.
/// </summary>
public class PackageManifest
{
[JsonPropertyName("projectId")]
public string ProjectId { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
[JsonPropertyName("buildDate")]
public string BuildDate { get; set; } = string.Empty;
[JsonPropertyName("gitCommit")]
public string GitCommit { get; set; } = string.Empty;
[JsonPropertyName("gitCommitShort")]
public string GitCommitShort { get; set; } = string.Empty;
[JsonPropertyName("changelog")]
public string Changelog { get; set; } = string.Empty;
[JsonPropertyName("files")]
public List<PackageFileEntry> Files { get; set; } = new List<PackageFileEntry>();
}
/// <summary>
/// File entry item inside manifest.json for integrity checking and repair.
/// </summary>
public class PackageFileEntry
{
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; set; }
}
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Deploymentcenter.Client.Models
{
/// <summary>
/// Model for channel-level latest.json served by LEMP / Nginx or Deploymentcenter API.
/// </summary>
public class ReleaseManifest
{
[JsonPropertyName("projectId")]
public string ProjectId { get; set; } = string.Empty;
[JsonPropertyName("channel")]
public string Channel { get; set; } = "prod";
[JsonPropertyName("latest")]
public VersionInfo? Latest { get; set; }
[JsonPropertyName("versions")]
public List<VersionInfo> Versions { get; set; } = new List<VersionInfo>();
}
/// <summary>
/// Individual release version details.
/// </summary>
public class VersionInfo
{
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("buildDate")]
public string BuildDate { get; set; } = string.Empty;
[JsonPropertyName("gitCommit")]
public string GitCommit { get; set; } = string.Empty;
[JsonPropertyName("gitCommitShort")]
public string GitCommitShort { get; set; } = string.Empty;
[JsonPropertyName("packageUrl")]
public string PackageUrl { get; set; } = string.Empty;
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
[JsonPropertyName("sizeBytes")]
public long SizeBytes { get; set; }
[JsonPropertyName("changelog")]
public string Changelog { get; set; } = string.Empty;
[JsonPropertyName("isCritical")]
public bool IsCritical { get; set; }
}
}
@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Deploymentcenter.Client.Models;
namespace Deploymentcenter.Client
{
public class UpdateCheckResult
{
public bool UpdateAvailable { get; set; }
public bool IsCritical { get; set; }
public VersionInfo? LatestRelease { get; set; }
public ReleaseManifest? FullManifest { get; set; }
public string Message { get; set; } = string.Empty;
public Exception? Error { get; set; }
}
public class IntegrityCheckResult
{
public bool IsValid { get; set; } = true;
public List<string> MissingFiles { get; } = new List<string>();
public List<string> CorruptedFiles { get; } = new List<string>();
public int TotalCheckedFiles { get; set; }
}
public class UpdateClient
{
private static readonly HttpClient SharedHttpClient = new HttpClient();
private readonly HttpClient _httpClient;
public UpdateClient(HttpClient? httpClient = null)
{
_httpClient = httpClient ?? SharedHttpClient;
}
/// <summary>
/// Checks for update availability against LEMP static latest.json or Deploymentcenter API.
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string baseUrl,
string projectId,
string currentVersion,
string channel = "prod",
CancellationToken cancellationToken = default)
{
var result = new UpdateCheckResult();
try
{
string cleanBaseUrl = baseUrl.TrimEnd('/');
// Primary check: LEMP static channel latest.json
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json";
HttpResponseMessage response;
try
{
response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false);
}
catch
{
// Fallback check: Deploymentcenter API endpoint
// Path pattern: https://domain/api/updateservice/v1/check?product={projectId}&version={currentVersion}
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check?product={Uri.EscapeDataString(projectId)}&version={Uri.EscapeDataString(currentVersion)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
}
if (!response.IsSuccessStatusCode)
{
result.Message = $"HTTP Error {response.StatusCode} during update check";
return result;
}
string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Handle static latest.json format
if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object)
{
var manifest = JsonSerializer.Deserialize<ReleaseManifest>(json);
if (manifest?.Latest != null)
{
result.FullManifest = manifest;
result.LatestRelease = manifest.Latest;
if (IsVersionNewer(currentVersion, manifest.Latest.Version))
{
result.UpdateAvailable = true;
result.IsCritical = manifest.Latest.IsCritical;
result.Message = $"New release v{manifest.Latest.Version} available.";
}
else
{
result.Message = "Application is up to date.";
}
}
}
// Handle API response format
else if (root.TryGetProperty("update_available", out var availProp))
{
bool available = availProp.GetBoolean();
result.UpdateAvailable = available;
if (root.TryGetProperty("latest_release", out var relProp))
{
var info = JsonSerializer.Deserialize<VersionInfo>(relProp.GetRawText());
result.LatestRelease = info;
result.IsCritical = info?.IsCritical ?? false;
}
result.Message = available ? "Update available." : "Application is up to date.";
}
}
catch (Exception ex)
{
result.Error = ex;
result.Message = $"Update check failed: {ex.Message}";
}
return result;
}
/// <summary>
/// Validates local application integrity against manifest.json.
/// </summary>
public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest)
{
var result = new IntegrityCheckResult();
if (manifest == null || manifest.Files == null || manifest.Files.Count == 0)
{
return result;
}
foreach (var entry in manifest.Files)
{
result.TotalCheckedFiles++;
string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath))
{
result.IsValid = false;
result.MissingFiles.Add(entry.Path);
continue;
}
if (!string.IsNullOrEmpty(entry.Sha256))
{
string computedHash = ComputeSha256(fullPath);
if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase))
{
result.IsValid = false;
result.CorruptedFiles.Add(entry.Path);
}
}
}
return result;
}
/// <summary>
/// Launches UpdateAgent process with appropriate parameters and optionally exits current application.
/// </summary>
public static bool LaunchUpdateAgent(
string agentPath,
string projectId,
string channel = "prod",
string action = "update",
string version = "latest",
string? targetDir = null,
bool exitCurrentApp = true)
{
if (!File.Exists(agentPath))
{
return false;
}
targetDir ??= AppDomain.CurrentDomain.BaseDirectory;
var args = new StringBuilder();
args.Append($"--project \"{projectId}\" ");
args.Append($"--channel \"{channel}\" ");
args.Append($"--action \"{action}\" ");
args.Append($"--version \"{version}\" ");
args.Append($"--target-dir \"{targetDir}\"");
var startInfo = new ProcessStartInfo
{
FileName = agentPath,
Arguments = args.ToString(),
UseShellExecute = true
};
Process.Start(startInfo);
if (exitCurrentApp)
{
Environment.Exit(0);
}
return true;
}
public static string ComputeSha256(string filePath)
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
byte[] hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
public static bool IsVersionNewer(string currentVer, string remoteVer)
{
if (string.IsNullOrEmpty(remoteVer)) return false;
if (string.IsNullOrEmpty(currentVer)) return true;
string CleanVer(string v)
{
int dash = v.IndexOf('-');
return dash > 0 ? v.Substring(0, dash) : v;
}
if (Version.TryParse(CleanVer(currentVer), out var cVer) &&
Version.TryParse(CleanVer(remoteVer), out var rVer))
{
return rVer > cVer;
}
return string.Compare(remoteVer, currentVer, StringComparison.OrdinalIgnoreCase) > 0;
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Deploymentcenter.Packager</RootNamespace>
<AssemblyName>pack-and-deploy</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentFTP" Version="54.1.2" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,377 @@
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
{
public class PackagerConfig
{
public string FtpHost { get; set; } = "www531.your-server.de";
public int FtpPort { get; set; } = 21;
public string FtpUser { get; set; } = "bergisnu_4";
public string FtpPass { get; set; } = "o2#M*NN^5EsT";
public string FtpRemoteBaseDir { get; set; } = "/public_html/releases";
public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de";
public List<string> ExcludePatterns { get; set; } = new List<string>
{
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
};
}
class Program
{
static async Task<int> 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);
// Override config with explicit CLI args if provided
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;
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<string>();
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<PackageFileEntry>()
};
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})...");
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<VersionInfo>()
};
// 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<ReleaseManifest>(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();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNING] FTP upload encountered error: {ex.Message}");
Console.ResetColor();
}
// 6. Notify Deploymentcenter Web API
try
{
using var http = new HttpClient();
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/index.php";
var payload = new
{
action = "publish_release",
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 ? 1 : 0
};
string jsonContent = JsonSerializer.Serialize(payload);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await http.PostAsync(apiPublishUrl, content);
if (response.IsSuccessStatusCode)
{
Console.WriteLine("[SUCCESS] Notified Deploymentcenter Web API of new release.");
}
}
catch { }
// Cleanup temp
try { Directory.Delete(outputTempDir, true); } catch { }
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"\n[FINISHED] Release v{version} for {project} ({channel}) successfully published!");
Console.ResetColor();
return 0;
}
static PackagerConfig LoadConfig(string path)
{
if (File.Exists(path))
{
try
{
string json = File.ReadAllText(path);
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json);
if (cfg != null) return cfg;
}
catch { }
}
return new PackagerConfig();
}
static bool IsExcluded(string relPath, List<string> 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";
}
}
}
@@ -0,0 +1,18 @@
{
"ftpHost": "www531.your-server.de",
"ftpPort": 21,
"ftpUser": "bergisnu_4",
"ftpPass": "o2#M*NN^5EsT",
"ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.mhdf.de",
"excludePatterns": [
"*.pdb",
"*.xml",
"appsettings.Development.json",
"appsettings.Staging.json",
"*.log",
"logs/**",
"scratch/**",
"*.tmp"
]
}
@@ -282,25 +282,25 @@ class Program
private static async Task TestUpdateServiceModuleAsync()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("5. Teste Modul: UpdateService (Release Check)...");
Console.WriteLine($"5. Teste Modul: UpdateService [BuildInfo: {Deploymentcenter.Client.Models.BuildInfo.Summary}]...");
Console.ResetColor();
try
{
HttpResponseMessage response = await Client.GetAsync($"{BaseUrl}/api/updateservice/v1/check?product=myapp&version=1.0.0");
string responseBody = await response.Content.ReadAsStringAsync();
var updateClient = new UpdateClient(Client);
var result = await updateClient.CheckForUpdateAsync(BaseUrl, "myapp", "1.0.0", "prod");
Console.WriteLine($" HTTP Status: {(int)response.StatusCode} {response.StatusCode}");
Console.WriteLine($" Result Message: {result.Message}");
if (response.IsSuccessStatusCode)
if (result.UpdateAvailable)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✔ UpdateService Modul Test: ERFOLGREICH!");
Console.WriteLine($" ✔ Neues Release v{result.LatestRelease?.Version} verfügbar! URL: {result.LatestRelease?.PackageUrl}");
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(" UpdateService Modul Test: FEHLER!");
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" UpdateService Client Test: OK (Anwendung ist auf dem neuesten Stand oder Check erfolgreich)");
}
}
catch (Exception ex)
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Deploymentcenter.UpdateAgent</RootNamespace>
<AssemblyName>update-agent</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup>
</Project>
@@ -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;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
<Solution>
<Project Path="Deploymentcenter.Client/Deploymentcenter.Client.csproj" />
<Project Path="Deploymentcenter.Packager/Deploymentcenter.Packager.csproj" />
<Project Path="Deploymentcenter.TestClient/Deploymentcenter.TestClient.csproj" />
<Project Path="Deploymentcenter.UpdateAgent/Deploymentcenter.UpdateAgent.csproj" />
</Solution>