(refactor): reverting VersionInfo to standard behavior

This commit is contained in:
Maksym Sadovnychyy 2026-04-26 23:31:47 +02:00
parent d7721ff80e
commit 56174059eb
23 changed files with 367 additions and 131 deletions

View File

@ -4,25 +4,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.3.22] - 2026-04-27
### Changed
- **Release tooling / frontend image versioning:** `DockerPush` now supports per-image `versionEnvFiles` and temporarily rewrites `VITE_APP_VERSION` in `src/MaksIT.WebUI/.env` and `src/MaksIT.WebUI/.env.prod` to the release semver from `<Version>` (for example `3.3.22`) during docker build/push, then restores original files so placeholders remain unchanged in git.
- **HA / Terms of Service PDF cache:** Replaced pod-filesystem Terms of Service PDF caching with shared PostgreSQL cache table `terms_of_service_cache` (`url`, `url_hash_hex`, `etag`, `last_modified_utc`, `content_type`, `content_bytes`, `fetched_at_utc`, `expires_at_utc`). ToS retrieval now uses cache TTL/HTTP validators and no longer relies on local files.
- **Terms of Service API:** Uses only session-based endpoint `GET /api/certs/{sessionId}/terms-of-service` for interactive ACME flows (stateless `isStaging` variant removed).
## [3.3.21] - 2026-04-26
### Changed
- **FluentMigrator:** Restored FluentMigrator defaults for the migration history table (**`VersionInfo`**, columns **`Version`**, **`AppliedOn`**, **`Description`**). Removed **`CertsFluentMigratorVersionTableMetaData`**, custom **`IVersionTableMetaDataAccessor`** registration, and post-migrate verification against **`version_info`**.
## [3.3.20] - 2026-04-26
### Fixed
- **FluentMigrator DI:** **`AddFluentMigratorCore`** registers a default **`IVersionTableMetaDataAccessor`**; **`WithVersionTable`** on **`ConfigureRunner`** could still leave **`VersionInfo`** in use. Engine registration now removes any prior **`IVersionTableMetaDataAccessor`** descriptors and registers **`PassThroughVersionTableMetaDataAccessor`** for **`CertsFluentMigratorVersionTableMetaData`** so **`MigrateUp`** always targets **`version_info`**.
- **FluentMigrator DI:** Attempted to force **`version_info`** by replacing **`IVersionTableMetaDataAccessor`** registrations. **Superseded in 3.3.21:** reverted to default **`VersionInfo`** table and standard column names (see **3.3.21**).
## [3.3.19] - 2026-04-26
### Changed
- **FluentMigrator / PostgreSQL:** Version metadata table is **`public.version_info`** with snake_case columns **`version`**, **`applied_on`**, **`description`** and unique index **`uc_version`**, configured via **`CertsFluentMigratorVersionTableMetaData`** and **`WithVersionTable`** on the runner (aligned with the rest of the schema naming).
### Removed
- **Startup migrations:** Removed legacy compatibility paths: **`VersionInfo``version_info` rename**, PascalCase → snake_case column repair on the version table, and the **EF-era baseline** that created or seeded **`version_info`** when **`users`** already existed (and **`RunMigrationsService.BaselineVersion`**).
- **Startup migrations:** Removed legacy compatibility paths (EF-era baseline that seeded the version table when **`users`** already existed, **`VersionInfo``version_info` rename**, PascalCase → snake_case column repair, and **`RunMigrationsService.BaselineVersion`**).
### Upgrade notes
- **Breaking:** **Recreate the Certs engine database** (or use a new empty database). The app no longer upgrades in place from **`VersionInfo`**, mixed column casing, or preFluentMigrator-baseline layouts; **`MigrateUp`** expects a clean or fully FluentMigrator-managed schema.
- **Breaking:** **Recreate the Certs engine database** (or use a new empty database) if you still relied on those removed startup paths; **`MigrateUp`** expects a schema managed only by FluentMigrator migrations in-process.
## [3.3.18] - 2026-04-26

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7.8%">
<title>Branch Coverage: 7.8%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7.7%">
<title>Branch Coverage: 7.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">7.8%</text>
<text x="127.5" y="14" fill="#fff">7.8%</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">7.7%</text>
<text x="127.5" y="14" fill="#fff">7.7%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 19.8%">
<title>Method Coverage: 19.8%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 19.9%">
<title>Method Coverage: 19.9%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">19.8%</text>
<text x="128.75" y="14" fill="#fff">19.8%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">19.9%</text>
<text x="128.75" y="14" fill="#fff">19.9%</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -73,6 +73,17 @@ public static class CertsLinq2DbMapping {
.Property(x => x.TokenValue).HasColumnName("token_value")
.Property(x => x.CreatedAtUtc).HasColumnName("created_at_utc");
builder.Entity<TermsOfServiceCacheDto>()
.HasTableName(Table.TermsOfServiceCache.Name)
.Property(x => x.Url).HasColumnName("url").IsPrimaryKey()
.Property(x => x.UrlHashHex).HasColumnName("url_hash_hex")
.Property(x => x.ETag).HasColumnName("etag")
.Property(x => x.LastModifiedUtc).HasColumnName("last_modified_utc")
.Property(x => x.ContentType).HasColumnName("content_type")
.Property(x => x.ContentBytes).HasColumnName("content_bytes")
.Property(x => x.FetchedAtUtc).HasColumnName("fetched_at_utc")
.Property(x => x.ExpiresAtUtc).HasColumnName("expires_at_utc");
builder.Build();
return schema;
}

View File

@ -1,10 +1,15 @@
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.CertsUI.Engine.Services;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
namespace MaksIT.CertsUI.Engine.DomainServices;
@ -14,7 +19,7 @@ namespace MaksIT.CertsUI.Engine.DomainServices;
public interface ICertsFlowDomainService {
#region Terms of service
Result<string?> GetTermsOfService(Guid sessionId);
Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId);
#endregion
#region Session, orders, and certificates
@ -54,6 +59,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
private readonly IRegistrationCachePersistanceService _registrationCache;
private readonly IAgentDeploymentService _agentDeployment;
private readonly ICertsFlowEngineConfiguration _config;
private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache;
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
private readonly IRuntimeLeaseService _runtimeLease;
private readonly IRuntimeInstanceId _runtimeInstance;
@ -67,6 +73,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
IRegistrationCachePersistanceService registrationCache,
IAgentDeploymentService agentDeployment,
ICertsFlowEngineConfiguration config,
ITermsOfServiceCachePersistenceService termsOfServiceCache,
IAcmeHttpChallengePersistenceService httpChallenges,
IRuntimeLeaseService runtimeLease,
IRuntimeInstanceId runtimeInstance,
@ -77,6 +84,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
_registrationCache = registrationCache;
_agentDeployment = agentDeployment;
_config = config;
_termsOfServiceCache = termsOfServiceCache;
_httpChallenges = httpChallenges;
_runtimeLease = runtimeLease;
_runtimeInstance = runtimeInstance;
@ -86,44 +94,85 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#region Terms of service
public Result<string?> GetTermsOfService(Guid sessionId) {
if (!_primaryReplica.IsPrimary)
return Result<string?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
public async Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) {
var termsUriResult = _letsEncryptService.GetTermsOfServiceUri(sessionId);
if (!termsUriResult.IsSuccess || termsUriResult.Value == null)
return termsUriResult;
var result = _letsEncryptService.GetTermsOfServiceUri(sessionId);
if (!result.IsSuccess || result.Value == null)
return result;
var termsOfServiceUrl = result.Value;
return await GetOrFetchTermsOfServicePdfBase64Async(termsUriResult.Value);
}
private async Task<Result<string?>> GetOrFetchTermsOfServicePdfBase64Async(string termsOfServiceUrl) {
try {
var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath);
var termsOfServicePdfPath = Path.Combine(_config.DataFolder, fileName);
foreach (var file in Directory.GetFiles(_config.DataFolder, "*.pdf")) {
if (!string.Equals(Path.GetFileName(file), fileName, StringComparison.OrdinalIgnoreCase)) {
try {
File.Delete(file);
var cachedResult = await _termsOfServiceCache.GetByUrlAsync(termsOfServiceUrl);
if (cachedResult.IsSuccess && cachedResult.Value != null && cachedResult.Value.ExpiresAtUtc > DateTimeOffset.UtcNow)
return Result<string?>.Ok(Convert.ToBase64String(cachedResult.Value.ContentBytes));
using var request = new HttpRequestMessage(HttpMethod.Get, termsOfServiceUrl);
if (cachedResult.IsSuccess && cachedResult.Value != null) {
if (!string.IsNullOrWhiteSpace(cachedResult.Value.ETag))
request.Headers.TryAddWithoutValidation("If-None-Match", cachedResult.Value.ETag);
if (cachedResult.Value.LastModifiedUtc.HasValue)
request.Headers.IfModifiedSince = cachedResult.Value.LastModifiedUtc.Value;
}
catch { /* ignore */ }
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotModified && cachedResult.IsSuccess && cachedResult.Value != null) {
var now = DateTimeOffset.UtcNow;
var notModifiedEntry = cachedResult.Value;
notModifiedEntry.FetchedAtUtc = now;
notModifiedEntry.ExpiresAtUtc = GetExpiry(now, response.Headers.CacheControl, response.Content.Headers.Expires);
var refreshResult = await _termsOfServiceCache.UpsertAsync(notModifiedEntry);
if (!refreshResult.IsSuccess)
return refreshResult.ToResultOfType<string?>(null);
return Result<string?>.Ok(Convert.ToBase64String(notModifiedEntry.ContentBytes));
}
}
byte[] pdfBytes;
if (File.Exists(termsOfServicePdfPath)) {
pdfBytes = File.ReadAllBytes(termsOfServicePdfPath);
}
else {
pdfBytes = _httpClient.GetByteArrayAsync(termsOfServiceUrl).GetAwaiter().GetResult();
File.WriteAllBytes(termsOfServicePdfPath, pdfBytes);
}
var base64 = Convert.ToBase64String(pdfBytes);
return Result<string?>.Ok(base64);
if (!response.IsSuccessStatusCode)
return Result<string?>.InternalServerError(null, $"Failed to download Terms of Service PDF. Status: {(int)response.StatusCode} {response.ReasonPhrase}");
var bytes = await response.Content.ReadAsByteArrayAsync();
if (bytes.Length == 0)
return Result<string?>.InternalServerError(null, "Downloaded Terms of Service PDF is empty.");
var fetchedAt = DateTimeOffset.UtcNow;
var cacheEntry = new TermsOfServiceCacheDto {
Url = termsOfServiceUrl,
UrlHashHex = ComputeSha256Hex(termsOfServiceUrl),
ETag = response.Headers.ETag?.Tag,
LastModifiedUtc = response.Content.Headers.LastModified,
ContentType = response.Content.Headers.ContentType?.MediaType ?? "application/pdf",
ContentBytes = bytes,
FetchedAtUtc = fetchedAt,
ExpiresAtUtc = GetExpiry(fetchedAt, response.Headers.CacheControl, response.Content.Headers.Expires)
};
var upsertResult = await _termsOfServiceCache.UpsertAsync(cacheEntry);
if (!upsertResult.IsSuccess)
return upsertResult.ToResultOfType<string?>(null);
return Result<string?>.Ok(Convert.ToBase64String(bytes));
}
catch (Exception ex) {
_logger.LogError(ex, "Failed to download, cache, or convert Terms of Service PDF");
return Result<string?>.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}");
_logger.LogError(ex, "Failed to fetch or cache Terms of Service PDF");
return Result<string?>.InternalServerError(null, $"Failed to fetch or cache Terms of Service PDF: {ex.Message}");
}
}
private static string ComputeSha256Hex(string text) {
var bytes = Encoding.UTF8.GetBytes(text);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static DateTimeOffset GetExpiry(DateTimeOffset now, CacheControlHeaderValue? cacheControl, DateTimeOffset? expiresHeader) {
if (cacheControl?.MaxAge is TimeSpan maxAge && maxAge > TimeSpan.Zero)
return now.Add(maxAge);
if (expiresHeader.HasValue && expiresHeader.Value > now)
return expiresHeader.Value;
return now.AddHours(24);
}
#endregion
#region Session, orders, and certificates

View File

@ -0,0 +1,13 @@
namespace MaksIT.CertsUI.Engine.Dto.Certs;
/// <summary>PostgreSQL <c>terms_of_service_cache</c> row keyed by Terms of Service URL.</summary>
public class TermsOfServiceCacheDto {
public string Url { get; set; } = "";
public string UrlHashHex { get; set; } = "";
public string? ETag { get; set; }
public DateTimeOffset? LastModifiedUtc { get; set; }
public string ContentType { get; set; } = "application/pdf";
public byte[] ContentBytes { get; set; } = [];
public DateTimeOffset FetchedAtUtc { get; set; }
public DateTimeOffset ExpiresAtUtc { get; set; }
}

View File

@ -1,5 +1,4 @@
using FluentMigrator.Runner;
using FluentMigrator.Runner.Initialization;
using Microsoft.Extensions.DependencyInjection;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.FluentMigrations;
@ -26,21 +25,14 @@ public static class ServiceCollectionExtensions {
if (string.IsNullOrWhiteSpace(certsEngineConfiguration.ConnectionString))
throw new ArgumentException("Certs engine connection string is required for FluentMigrator (empty string uses connectionless/preview mode and will not create tables).", nameof(certsEngineConfiguration));
// FluentMigrator: IRunMigrationsService invoked from Program.cs before RunAsync. Use .For.All() so migration discovery
// matches in-process runner expectations (see FluentMigrator docs / #1062).
// Version table: AddFluentMigratorCore registers a default IVersionTableMetaDataAccessor; replace it so public.version_info
// is always used (ConfigureRunner's WithVersionTable alone can lose to that default depending on DI resolution order).
// FluentMigrator: IRunMigrationsService invoked from Program.cs before RunAsync. Use .For.All() so version metadata
// and migration discovery match in-process runner expectations (see FluentMigrator docs / #1062).
services.AddFluentMigratorCore()
.ConfigureRunner(rb => rb
.AddPostgres()
.WithGlobalConnectionString(certsEngineConfiguration.ConnectionString)
.ScanIn(typeof(BaselineCertsSchema).Assembly).For.All())
.AddLogging(lb => lb.AddFluentMigratorConsole());
foreach (var d in services.Where(x => x.ServiceType == typeof(IVersionTableMetaDataAccessor)).ToList())
services.Remove(d);
services.AddSingleton<IVersionTableMetaDataAccessor>(
new PassThroughVersionTableMetaDataAccessor(new CertsFluentMigratorVersionTableMetaData()));
services.AddScoped<IRunMigrationsService, RunMigrationsService>();
services.AddScoped<ISchemaSyncService, SchemaSyncService>();
@ -61,6 +53,7 @@ public static class ServiceCollectionExtensions {
#region Registration cache
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
#endregion

View File

@ -0,0 +1,27 @@
using FluentMigrator;
namespace MaksIT.CertsUI.Engine.FluentMigrations;
[Migration(20260427123000)]
public class TermsOfServiceCache : Migration {
public override void Up() {
Create.Table("terms_of_service_cache")
.WithColumn("url").AsCustom("text").NotNullable().PrimaryKey()
.WithColumn("url_hash_hex").AsCustom("text").NotNullable()
.WithColumn("etag").AsCustom("text").Nullable()
.WithColumn("last_modified_utc").AsDateTimeOffset().Nullable()
.WithColumn("content_type").AsCustom("text").NotNullable()
.WithColumn("content_bytes").AsCustom("bytea").NotNullable()
.WithColumn("fetched_at_utc").AsDateTimeOffset().NotNullable()
.WithColumn("expires_at_utc").AsDateTimeOffset().NotNullable();
Create.Index("IX_terms_of_service_cache_url_hash_hex").OnTable("terms_of_service_cache").OnColumn("url_hash_hex");
Create.Index("IX_terms_of_service_cache_expires_at_utc").OnTable("terms_of_service_cache").OnColumn("expires_at_utc");
}
public override void Down() {
Delete.Index("IX_terms_of_service_cache_url_hash_hex").OnTable("terms_of_service_cache");
Delete.Index("IX_terms_of_service_cache_expires_at_utc").OnTable("terms_of_service_cache");
Delete.Table("terms_of_service_cache");
}
}

View File

@ -1,33 +0,0 @@
using FluentMigrator.Runner.VersionTableInfo;
namespace MaksIT.CertsUI.Engine.Infrastructure;
/// <summary>FluentMigrator version table: snake_case <c>public.version_info</c> (table and columns) for PostgreSQL consistency.</summary>
public sealed class CertsFluentMigratorVersionTableMetaData : IVersionTableMetaData {
public const string Table = "version_info";
public const string VersionColumn = "version";
public const string AppliedOnColumn = "applied_on";
public const string DescriptionColumn = "description";
public const string UniqueIndex = "uc_version";
public bool OwnsSchema => true;
public string SchemaName => "public";
public string TableName => Table;
public string ColumnName => VersionColumn;
public string DescriptionColumnName => DescriptionColumn;
public string UniqueIndexName => UniqueIndex;
public string AppliedOnColumnName => AppliedOnColumn;
public bool CreateWithPrimaryKey => false;
}

View File

@ -51,7 +51,7 @@ public sealed class RunMigrationsService(
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'app_runtime_leases')
AND (
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users')
OR EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'version_info')
OR EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'VersionInfo')
);
""",
conn);
@ -61,7 +61,7 @@ public sealed class RunMigrationsService(
return;
throw new InvalidOperationException(
"After migrations and coordination DDL, schema \"public\" is missing \"app_runtime_leases\" and/or core tables (\"users\" / \"version_info\"). " +
"After migrations and coordination DDL, schema \"public\" is missing \"app_runtime_leases\" and/or core tables (\"users\" / \"VersionInfo\"). " +
"Confirm Database= in the connection string, role CREATE privileges, and that FluentMigrator committed (non-empty connection string).");
}

View File

@ -0,0 +1,9 @@
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.Persistance.Services;
public interface ITermsOfServiceCachePersistenceService {
Task<Result<TermsOfServiceCacheDto?>> GetByUrlAsync(string url, CancellationToken cancellationToken = default);
Task<Result> UpsertAsync(TermsOfServiceCacheDto cacheEntry, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,66 @@
using LinqToDB;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
public sealed class TermsOfServiceCachePersistenceServiceLinq2Db(
ILogger<TermsOfServiceCachePersistenceServiceLinq2Db> logger,
ICertsDataConnectionFactory connectionFactory
) : ITermsOfServiceCachePersistenceService {
public Task<Result<TermsOfServiceCacheDto?>> GetByUrlAsync(string url, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(url))
return Task.FromResult(Result<TermsOfServiceCacheDto?>.BadRequest(null, "Terms of Service URL is required."));
try {
using var db = connectionFactory.Create();
var row = db.GetTable<TermsOfServiceCacheDto>().FirstOrDefault(x => x.Url == url);
if (row == null)
return Task.FromResult(Result<TermsOfServiceCacheDto?>.NotFound(null, $"Terms of Service cache not found for URL: {url}"));
return Task.FromResult(Result<TermsOfServiceCacheDto?>.Ok(row));
}
catch (Exception ex) {
logger.LogError(ex, "Failed to load Terms of Service cache for {Url}", url);
return Task.FromResult(Result<TermsOfServiceCacheDto?>.InternalServerError(null, ["Failed to load Terms of Service cache.", .. ex.ExtractMessages()]));
}
}
public Task<Result> UpsertAsync(TermsOfServiceCacheDto cacheEntry, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(cacheEntry);
if (string.IsNullOrWhiteSpace(cacheEntry.Url))
return Task.FromResult(Result.BadRequest("Terms of Service URL is required."));
try {
using var db = connectionFactory.Create();
var existing = db.GetTable<TermsOfServiceCacheDto>().FirstOrDefault(x => x.Url == cacheEntry.Url);
if (existing == null) {
db.Insert(cacheEntry);
}
else {
db.GetTable<TermsOfServiceCacheDto>()
.Where(x => x.Url == cacheEntry.Url)
.Set(x => x.UrlHashHex, cacheEntry.UrlHashHex)
.Set(x => x.ETag, cacheEntry.ETag)
.Set(x => x.LastModifiedUtc, cacheEntry.LastModifiedUtc)
.Set(x => x.ContentType, cacheEntry.ContentType)
.Set(x => x.ContentBytes, cacheEntry.ContentBytes)
.Set(x => x.FetchedAtUtc, cacheEntry.FetchedAtUtc)
.Set(x => x.ExpiresAtUtc, cacheEntry.ExpiresAtUtc)
.Update();
}
return Task.FromResult(Result.Ok());
}
catch (Exception ex) {
logger.LogError(ex, "Failed to upsert Terms of Service cache for {Url}", cacheEntry.Url);
return Task.FromResult(Result.InternalServerError(["Failed to upsert Terms of Service cache.", .. ex.ExtractMessages()]));
}
}
}

View File

@ -16,5 +16,6 @@ public class Table(int id, string name) : Enumeration(id, name) {
#region Certs
public static readonly Table RegistrationCaches = new(2, "registration_caches");
public static readonly Table TermsOfServiceCache = new(5, "terms_of_service_cache");
#endregion
}

View File

@ -1,6 +1,7 @@
using System.Net;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
@ -26,6 +27,7 @@ public sealed class CertsFlowServiceTests
Mock<ILetsEncryptService> le,
Mock<IRegistrationCachePersistanceService>? registrationCache = null,
Mock<IAgentDeploymentService>? agent = null,
Mock<ITermsOfServiceCachePersistenceService>? termsOfServiceCache = null,
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
Mock<IRuntimeLeaseService>? runtimeLease = null,
Mock<IRuntimeInstanceId>? runtimeInstance = null,
@ -34,6 +36,14 @@ public sealed class CertsFlowServiceTests
{
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
agent ??= new Mock<IAgentDeploymentService>();
var tosCacheProvided = termsOfServiceCache is not null;
termsOfServiceCache ??= new Mock<ITermsOfServiceCachePersistenceService>();
if (!tosCacheProvided) {
termsOfServiceCache.Setup(c => c.GetByUrlAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<TermsOfServiceCacheDto?>.NotFound(null, "missing"));
termsOfServiceCache.Setup(c => c.UpsertAsync(It.IsAny<TermsOfServiceCacheDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
}
var httpChallengesProvided = httpChallenges is not null;
httpChallenges ??= new Mock<IAcmeHttpChallengePersistenceService>();
if (!httpChallengesProvided) {
@ -68,6 +78,7 @@ public sealed class CertsFlowServiceTests
registrationCache.Object,
agent.Object,
new TestCertsFlowEngineConfiguration(fx),
termsOfServiceCache.Object,
httpChallenges.Object,
runtimeLease.Object,
runtimeInstance.Object,
@ -308,40 +319,52 @@ public sealed class CertsFlowServiceTests
}
[Fact]
public void GetTermsOfService_WhenLetsEncryptFails_Propagates()
public async Task GetTermsOfServiceAsync_WhenLetsEncryptFails_Propagates()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(It.IsAny<Guid>()))
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
.Returns(Result<string?>.InternalServerError(null, "no uri"));
var sut = CreateSut(fx, le);
var result = sut.GetTermsOfService(Guid.NewGuid());
var result = await sut.GetTermsOfServiceAsync(sessionId);
Assert.False(result.IsSuccess);
}
[Fact]
public void GetTermsOfService_WhenPdfAlreadyOnDisk_ReturnsBase64WithoutHttp()
public async Task GetTermsOfServiceAsync_WhenCachedAndNotExpired_ReturnsBase64WithoutHttp()
{
using var fx = new WebApiTestFixture();
var fileName = "cached-tos.pdf";
var dataPath = Path.Combine(fx.AppOptions.Value.CertsUIEngineConfiguration.DataFolder, fileName);
File.WriteAllBytes(dataPath, [7, 7, 7]);
var sessionId = Guid.NewGuid();
var url = "https://acme.test/sub/cached-tos.pdf";
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(It.IsAny<Guid>()))
.Returns(Result<string?>.Ok($"https://acme.test/sub/{fileName}"));
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
.Returns(Result<string?>.Ok(url));
var tosCache = new Mock<ITermsOfServiceCachePersistenceService>();
tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result<TermsOfServiceCacheDto?>.Ok(new TermsOfServiceCacheDto {
Url = url,
UrlHashHex = "abc",
ContentType = "application/pdf",
ContentBytes = [7, 7, 7],
FetchedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-1),
ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1)
}));
tosCache.Setup(c => c.UpsertAsync(It.IsAny<TermsOfServiceCacheDto>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Ok());
var httpHit = false;
var sut = CreateSut(fx, le, httpHandler: new StubHttpMessageHandler(_ =>
var sut = CreateSut(fx, le, termsOfServiceCache: tosCache, httpHandler: new StubHttpMessageHandler(_ =>
{
httpHit = true;
return new HttpResponseMessage(HttpStatusCode.OK);
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([1, 2, 3]) };
}));
var result = sut.GetTermsOfService(Guid.NewGuid());
var result = await sut.GetTermsOfServiceAsync(sessionId);
Assert.True(result.IsSuccess);
Assert.Equal(Convert.ToBase64String([7, 7, 7]), result.Value);

View File

@ -25,8 +25,8 @@ namespace MaksIT.CertsUI.Controllers {
}
[HttpGet("{sessionId}/terms-of-service")]
public IActionResult TermsOfService(Guid sessionId) {
var result = _certsFlowService.GetTermsOfService(sessionId);
public async Task<IActionResult> TermsOfService(Guid sessionId) {
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
return result.ToCertsFlowActionResult();
}

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>3.3.20</Version>
<Version>3.3.22</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -4,7 +4,7 @@ using MaksIT.Results;
namespace MaksIT.CertsUI.Services;
public interface ICertsFlowService {
Result<string?> GetTermsOfService(Guid sessionId);
Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId);
Task<Result> CompleteChallengesAsync(Guid sessionId);
Task<Result<Guid?>> ConfigureClientAsync(bool isStaging);
Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
@ -23,8 +23,8 @@ public sealed class CertsFlowService(
ICertsFlowDomainService domain
) : ICertsFlowService {
public Result<string?> GetTermsOfService(Guid sessionId) =>
domain.GetTermsOfService(sessionId);
public Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) =>
domain.GetTermsOfServiceAsync(sessionId);
public Task<Result> CompleteChallengesAsync(Guid sessionId) =>
domain.CompleteChallengesAsync(sessionId);

View File

@ -1,4 +1,5 @@
VITE_APP_TITLE=MaksIT.CertsUI
VITE_APP_VERSION=0.0.0
VITE_COMPANY=MaksIT
VITE_COMPANY_URL=https://maks-it.com
VITE_API_URL=http://localhost:8080/api

View File

@ -1,4 +1,5 @@
VITE_APP_TITLE=MaksIT.CertsUI
VITE_APP_VERSION=0.0.0
VITE_COMPANY=MaksIT
VITE_COMPANY_URL=https://maks-it.com
VITE_API_URL=http://localhost:8080/api

View File

@ -54,8 +54,7 @@ const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
}
footer={
{
children: <p>
&copy; {new Date().getFullYear()} <a
children: <p>{import.meta.env.VITE_APP_VERSION} - &copy; {new Date().getFullYear()} <a
href={import.meta.env.VITE_COMPANY_URL}
target={'_blank'}
rel={'noopener noreferrer'}>

View File

@ -47,6 +47,27 @@ function Get-RegistryCredentialsFromEnv {
return @{ User = $parts[0]; Password = $parts[1] }
}
function Set-EnvVersionValue {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string]$Version
)
$content = Get-Content -LiteralPath $FilePath -Raw
if ($content -match '(?m)^\s*VITE_APP_VERSION\s*=') {
$content = $content -replace '(?m)^\s*VITE_APP_VERSION\s*=.*$', "VITE_APP_VERSION=$Version"
}
else {
$separator = if ($content -match "(\r?\n)$") { '' } else { [Environment]::NewLine }
$content = "$content${separator}VITE_APP_VERSION=$Version"
}
Set-Content -LiteralPath $FilePath -Value $content -NoNewline
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
@ -137,6 +158,33 @@ function Invoke-Plugin {
$service = [string]$img.service
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
$versionEnvFiles = @()
if ($img.PSObject.Properties.Name -contains 'versionEnvFiles' -and $null -ne $img.versionEnvFiles) {
foreach ($relativeEnvFile in @($img.versionEnvFiles)) {
if ([string]::IsNullOrWhiteSpace([string]$relativeEnvFile)) {
continue
}
$envFilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath ([string]$relativeEnvFile)))
if (-not (Test-Path -LiteralPath $envFilePath -PathType Leaf)) {
throw "Configured versionEnvFiles entry not found: $envFilePath"
}
$backupPath = "$envFilePath.repoutils.bak"
Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force
$versionEnvFiles += [pscustomobject]@{
FilePath = $envFilePath
BackupPath = $backupPath
}
}
}
try {
foreach ($envFile in $versionEnvFiles) {
Write-Log -Level "INFO" -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)"
Set-EnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion
}
$primaryRef = "${baseName}:$($imageTags[0])"
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
docker build -t $primaryRef -f $dockerfilePath $contextPath
@ -163,6 +211,19 @@ function Invoke-Plugin {
}
}
}
finally {
foreach ($envFile in $versionEnvFiles) {
if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) {
Move-Item -LiteralPath $envFile.BackupPath -Destination $envFile.FilePath -Force
}
}
foreach ($envFile in $versionEnvFiles) {
if (Test-Path -LiteralPath $envFile.BackupPath -PathType Leaf) {
Remove-Item -LiteralPath $envFile.BackupPath -Force -ErrorAction SilentlyContinue
}
}
}
}
}
finally {
docker logout $registryUrl 2>&1 | Out-Null

View File

@ -29,7 +29,7 @@ Configure this plugin **immediately before** `DockerPush`, `HelmPush`, `GitHub`,
## Helm charts in git
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the release semver (same as the git tag / `DotNetReleaseVersion`) before packaging, then restores the file. Image tags for `DockerPush` come from the engine context, not from the chart file in the repo.
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the release semver (same as the git tag / `DotNetReleaseVersion`) before packaging, then restores the file. `DockerPush` image tags come from the engine context (not from chart files), and you can optionally set per-image `versionEnvFiles` to temporarily write `VITE_APP_VERSION={shared.version}` into frontend `.env` files during build, with automatic restore after push.
This repository uses `src/helm/`. For a minimal scaffold chart, see **maksit-repoutils** `charts/my-service/`.

View File

@ -58,7 +58,11 @@
},
{
"service": "client",
"dockerfile": "MaksIT.WebUI/Dockerfile.prod"
"dockerfile": "MaksIT.WebUI/Dockerfile.prod",
"versionEnvFiles": [
"MaksIT.WebUI/.env",
"MaksIT.WebUI/.env.prod"
]
},
{
"service": "reverseproxy",
@ -92,6 +96,7 @@
"projectName": "DockerPush: image path segment: registryUrl/projectName/service:tag.",
"contextPath": "DockerPush: directory containing MaksIT.CertsUI and MaksIT.WebUI (repo src/).",
"images": "Dockerfile paths are relative to contextPath.",
"versionEnvFiles": "DockerPush image option: array of files (relative to contextPath) where VITE_APP_VERSION is temporarily set to shared.version before docker build, then restored after build/push.",
"chartPath": "HelmPush: directory containing Chart.yaml. Keep version/appVersion as placeholders in git (e.g. 0.0.0); HelmPush overwrites with bare semver from DotNetReleaseVersion (shared.version, e.g. 3.3.4, no v) before package/push; falls back to stripping v from shared.tag if version is missing.",
"ociRepository": "HelmPush: OCI registry URL for helm push (e.g. oci://cr.maks-it.com/charts).",
"pushLatest": "Docker: also push :latest (images also get bare :3.3.4 and :v3.3.4 from DotNetReleaseVersion). Helm: oras copy chart to :latest (requires oras on PATH).",