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