From 1c68cc63b850e27d367d9ee70992bf6349eab8ab Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sun, 26 Apr 2026 23:31:47 +0200 Subject: [PATCH] (refactor): reverting VersionInfo to standard behavior --- CHANGELOG.md | 24 ++-- assets/badges/coverage-branches.svg | 8 +- assets/badges/coverage-methods.svg | 8 +- .../Data/CertsLinq2DbMapping.cs | 11 ++ .../DomainServices/CertsFlowDomainService.cs | 107 +++++++++++++----- .../Dto/Certs/TermsOfServiceCacheDto.cs | 13 +++ .../Extensions/ServiceCollectionExtensions.cs | 13 +-- .../20260427123000_TermsOfServiceCache.cs | 27 +++++ ...CertsFluentMigratorVersionTableMetaData.cs | 33 ------ .../Infrastructure/RunMigrationsService.cs | 4 +- .../ITermsOfServiceCachePersistenceService.cs | 9 ++ ...OfServiceCachePersistenceServiceLinq2Db.cs | 66 +++++++++++ src/MaksIT.CertsUI.Engine/Table.cs | 1 + .../Services/CertsFlowServiceTests.cs | 49 +++++--- .../Controllers/CertsFlowController.cs | 4 +- src/MaksIT.CertsUI/MaksIT.CertsUI.csproj | 2 +- .../Services/CertsFlowService.cs | 6 +- src/MaksIT.WebUI/.env | 1 + src/MaksIT.WebUI/.env.prod | 1 + src/MaksIT.WebUI/src/AppMap.tsx | 3 +- .../CorePlugins/DockerPush.psm1 | 99 ++++++++++++---- utils/Release-Package/README.md | 2 +- utils/Release-Package/scriptsettings.json | 7 +- 23 files changed, 367 insertions(+), 131 deletions(-) create mode 100644 src/MaksIT.CertsUI.Engine/Dto/Certs/TermsOfServiceCacheDto.cs create mode 100644 src/MaksIT.CertsUI.Engine/FluentMigrations/20260427123000_TermsOfServiceCache.cs delete mode 100644 src/MaksIT.CertsUI.Engine/Infrastructure/CertsFluentMigratorVersionTableMetaData.cs create mode 100644 src/MaksIT.CertsUI.Engine/Persistance/Services/ITermsOfServiceCachePersistenceService.cs create mode 100644 src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e00c4..6ca3baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` (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 pre–FluentMigrator-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 diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index 8304471..95b9f86 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 7.8% + + Branch Coverage: 7.7% @@ -15,7 +15,7 @@ Branch Coverage - - 7.8% + + 7.7% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 8f88cfa..c3f859c 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 19.8% + + Method Coverage: 19.9% @@ -15,7 +15,7 @@ Method Coverage - - 19.8% + + 19.9% diff --git a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs index f9c969c..cafe341 100644 --- a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs +++ b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs @@ -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() + .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; } diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs index f902273..03d9f1d 100644 --- a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs +++ b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs @@ -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 GetTermsOfService(Guid sessionId); + Task> 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 GetTermsOfService(Guid sessionId) { - if (!_primaryReplica.IsPrimary) - return Result.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages); + public async Task> 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> 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); - } - catch { /* ignore */ } - } + var cachedResult = await _termsOfServiceCache.GetByUrlAsync(termsOfServiceUrl); + if (cachedResult.IsSuccess && cachedResult.Value != null && cachedResult.Value.ExpiresAtUtc > DateTimeOffset.UtcNow) + return Result.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; } - byte[] pdfBytes; - if (File.Exists(termsOfServicePdfPath)) { - pdfBytes = File.ReadAllBytes(termsOfServicePdfPath); + + 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(null); + return Result.Ok(Convert.ToBase64String(notModifiedEntry.ContentBytes)); } - else { - pdfBytes = _httpClient.GetByteArrayAsync(termsOfServiceUrl).GetAwaiter().GetResult(); - File.WriteAllBytes(termsOfServicePdfPath, pdfBytes); - } - var base64 = Convert.ToBase64String(pdfBytes); - return Result.Ok(base64); + + if (!response.IsSuccessStatusCode) + return Result.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.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(null); + + return Result.Ok(Convert.ToBase64String(bytes)); } catch (Exception ex) { - _logger.LogError(ex, "Failed to download, cache, or convert Terms of Service PDF"); - return Result.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.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 diff --git a/src/MaksIT.CertsUI.Engine/Dto/Certs/TermsOfServiceCacheDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Certs/TermsOfServiceCacheDto.cs new file mode 100644 index 0000000..d226c00 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Dto/Certs/TermsOfServiceCacheDto.cs @@ -0,0 +1,13 @@ +namespace MaksIT.CertsUI.Engine.Dto.Certs; + +/// PostgreSQL terms_of_service_cache row keyed by Terms of Service URL. +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; } +} diff --git a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs index 372e72c..cfbcd6a 100644 --- a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs @@ -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( - new PassThroughVersionTableMetaDataAccessor(new CertsFluentMigratorVersionTableMetaData())); services.AddScoped(); services.AddScoped(); @@ -61,6 +53,7 @@ public static class ServiceCollectionExtensions { #region Registration cache services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); #endregion diff --git a/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427123000_TermsOfServiceCache.cs b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427123000_TermsOfServiceCache.cs new file mode 100644 index 0000000..e39a5c8 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/FluentMigrations/20260427123000_TermsOfServiceCache.cs @@ -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"); + } +} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/CertsFluentMigratorVersionTableMetaData.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/CertsFluentMigratorVersionTableMetaData.cs deleted file mode 100644 index 46b6c22..0000000 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/CertsFluentMigratorVersionTableMetaData.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentMigrator.Runner.VersionTableInfo; - -namespace MaksIT.CertsUI.Engine.Infrastructure; - -/// FluentMigrator version table: snake_case public.version_info (table and columns) for PostgreSQL consistency. -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; -} diff --git a/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs b/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs index 6b12efc..5d32c85 100644 --- a/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs +++ b/src/MaksIT.CertsUI.Engine/Infrastructure/RunMigrationsService.cs @@ -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)."); } diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/ITermsOfServiceCachePersistenceService.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/ITermsOfServiceCachePersistenceService.cs new file mode 100644 index 0000000..7faf877 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/ITermsOfServiceCachePersistenceService.cs @@ -0,0 +1,9 @@ +using MaksIT.CertsUI.Engine.Dto.Certs; +using MaksIT.Results; + +namespace MaksIT.CertsUI.Engine.Persistance.Services; + +public interface ITermsOfServiceCachePersistenceService { + Task> GetByUrlAsync(string url, CancellationToken cancellationToken = default); + Task UpsertAsync(TermsOfServiceCacheDto cacheEntry, CancellationToken cancellationToken = default); +} diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs new file mode 100644 index 0000000..2fc1e08 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/TermsOfServiceCachePersistenceServiceLinq2Db.cs @@ -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 logger, + ICertsDataConnectionFactory connectionFactory +) : ITermsOfServiceCachePersistenceService { + + public Task> GetByUrlAsync(string url, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(url)) + return Task.FromResult(Result.BadRequest(null, "Terms of Service URL is required.")); + + try { + using var db = connectionFactory.Create(); + var row = db.GetTable().FirstOrDefault(x => x.Url == url); + if (row == null) + return Task.FromResult(Result.NotFound(null, $"Terms of Service cache not found for URL: {url}")); + + return Task.FromResult(Result.Ok(row)); + } + catch (Exception ex) { + logger.LogError(ex, "Failed to load Terms of Service cache for {Url}", url); + return Task.FromResult(Result.InternalServerError(null, ["Failed to load Terms of Service cache.", .. ex.ExtractMessages()])); + } + } + + public Task 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().FirstOrDefault(x => x.Url == cacheEntry.Url); + if (existing == null) { + db.Insert(cacheEntry); + } + else { + db.GetTable() + .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()])); + } + } +} diff --git a/src/MaksIT.CertsUI.Engine/Table.cs b/src/MaksIT.CertsUI.Engine/Table.cs index 6365991..6cecccc 100644 --- a/src/MaksIT.CertsUI.Engine/Table.cs +++ b/src/MaksIT.CertsUI.Engine/Table.cs @@ -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 } diff --git a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs index 21d0447..066f0a2 100644 --- a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs @@ -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 le, Mock? registrationCache = null, Mock? agent = null, + Mock? termsOfServiceCache = null, Mock? httpChallenges = null, Mock? runtimeLease = null, Mock? runtimeInstance = null, @@ -34,6 +36,14 @@ public sealed class CertsFlowServiceTests { registrationCache ??= new Mock(); agent ??= new Mock(); + var tosCacheProvided = termsOfServiceCache is not null; + termsOfServiceCache ??= new Mock(); + if (!tosCacheProvided) { + termsOfServiceCache.Setup(c => c.GetByUrlAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.NotFound(null, "missing")); + termsOfServiceCache.Setup(c => c.UpsertAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok()); + } var httpChallengesProvided = httpChallenges is not null; httpChallenges ??= new Mock(); 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(); - le.Setup(x => x.GetTermsOfServiceUri(It.IsAny())) + le.Setup(x => x.GetTermsOfServiceUri(sessionId)) .Returns(Result.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(); - le.Setup(x => x.GetTermsOfServiceUri(It.IsAny())) - .Returns(Result.Ok($"https://acme.test/sub/{fileName}")); + le.Setup(x => x.GetTermsOfServiceUri(sessionId)) + .Returns(Result.Ok(url)); + + var tosCache = new Mock(); + tosCache.Setup(c => c.GetByUrlAsync(url, It.IsAny())) + .ReturnsAsync(Result.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(), It.IsAny())) + .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); diff --git a/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs b/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs index 2f63816..030c9d0 100644 --- a/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs +++ b/src/MaksIT.CertsUI/Controllers/CertsFlowController.cs @@ -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 TermsOfService(Guid sessionId) { + var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId); return result.ToCertsFlowActionResult(); } diff --git a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj index 46e4d2d..1a90ea4 100644 --- a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj +++ b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj @@ -1,7 +1,7 @@ - 3.3.20 + 3.3.22 net10.0 enable enable diff --git a/src/MaksIT.CertsUI/Services/CertsFlowService.cs b/src/MaksIT.CertsUI/Services/CertsFlowService.cs index f5482d7..8d336ea 100644 --- a/src/MaksIT.CertsUI/Services/CertsFlowService.cs +++ b/src/MaksIT.CertsUI/Services/CertsFlowService.cs @@ -4,7 +4,7 @@ using MaksIT.Results; namespace MaksIT.CertsUI.Services; public interface ICertsFlowService { - Result GetTermsOfService(Guid sessionId); + Task> GetTermsOfServiceAsync(Guid sessionId); Task CompleteChallengesAsync(Guid sessionId); Task> ConfigureClientAsync(bool isStaging); Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts); @@ -23,8 +23,8 @@ public sealed class CertsFlowService( ICertsFlowDomainService domain ) : ICertsFlowService { - public Result GetTermsOfService(Guid sessionId) => - domain.GetTermsOfService(sessionId); + public Task> GetTermsOfServiceAsync(Guid sessionId) => + domain.GetTermsOfServiceAsync(sessionId); public Task CompleteChallengesAsync(Guid sessionId) => domain.CompleteChallengesAsync(sessionId); diff --git a/src/MaksIT.WebUI/.env b/src/MaksIT.WebUI/.env index bc8d02d..f314907 100644 --- a/src/MaksIT.WebUI/.env +++ b/src/MaksIT.WebUI/.env @@ -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 \ No newline at end of file diff --git a/src/MaksIT.WebUI/.env.prod b/src/MaksIT.WebUI/.env.prod index bc8d02d..f314907 100644 --- a/src/MaksIT.WebUI/.env.prod +++ b/src/MaksIT.WebUI/.env.prod @@ -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 \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx index 4b77703..64bd676 100644 --- a/src/MaksIT.WebUI/src/AppMap.tsx +++ b/src/MaksIT.WebUI/src/AppMap.tsx @@ -54,8 +54,7 @@ const LayoutWrapper: FC = (props) => { } footer={ { - children:

- © {new Date().getFullYear()} {import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} diff --git a/utils/Release-Package/CorePlugins/DockerPush.psm1 b/utils/Release-Package/CorePlugins/DockerPush.psm1 index 70ca092..2ed4593 100644 --- a/utils/Release-Package/CorePlugins/DockerPush.psm1 +++ b/utils/Release-Package/CorePlugins/DockerPush.psm1 @@ -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,29 +158,69 @@ function Invoke-Plugin { $service = [string]$img.service $baseName = "$registryUrl/$($pluginSettings.projectName)/$service" - $primaryRef = "${baseName}:$($imageTags[0])" - Write-Log -Level "STEP" -Message "Building $primaryRef ..." - docker build -t $primaryRef -f $dockerfilePath $contextPath - if ($LASTEXITCODE -ne 0) { - throw "Docker build failed for $primaryRef" - } + $versionEnvFiles = @() + if ($img.PSObject.Properties.Name -contains 'versionEnvFiles' -and $null -ne $img.versionEnvFiles) { + foreach ($relativeEnvFile in @($img.versionEnvFiles)) { + if ([string]::IsNullOrWhiteSpace([string]$relativeEnvFile)) { + continue + } - Write-Log -Level "STEP" -Message "Pushing $primaryRef ..." - docker push $primaryRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $primaryRef" - } + $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" + } - for ($ti = 1; $ti -lt $imageTags.Count; $ti++) { - $aliasRef = "${baseName}:$($imageTags[$ti])" - Write-Log -Level "STEP" -Message "Tagging and pushing $aliasRef ..." - docker tag $primaryRef $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker tag failed: $primaryRef -> $aliasRef" + $backupPath = "$envFilePath.repoutils.bak" + Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force + $versionEnvFiles += [pscustomobject]@{ + FilePath = $envFilePath + BackupPath = $backupPath + } } - docker push $aliasRef + } + + 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 if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $aliasRef" + throw "Docker build failed for $primaryRef" + } + + Write-Log -Level "STEP" -Message "Pushing $primaryRef ..." + docker push $primaryRef + if ($LASTEXITCODE -ne 0) { + throw "Docker push failed for $primaryRef" + } + + for ($ti = 1; $ti -lt $imageTags.Count; $ti++) { + $aliasRef = "${baseName}:$($imageTags[$ti])" + Write-Log -Level "STEP" -Message "Tagging and pushing $aliasRef ..." + docker tag $primaryRef $aliasRef + if ($LASTEXITCODE -ne 0) { + throw "Docker tag failed: $primaryRef -> $aliasRef" + } + docker push $aliasRef + if ($LASTEXITCODE -ne 0) { + throw "Docker push failed for $aliasRef" + } + } + } + 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 + } } } } diff --git a/utils/Release-Package/README.md b/utils/Release-Package/README.md index 9714559..ab9b4db 100644 --- a/utils/Release-Package/README.md +++ b/utils/Release-Package/README.md @@ -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/`. diff --git a/utils/Release-Package/scriptsettings.json b/utils/Release-Package/scriptsettings.json index 101e423..24d5c82 100644 --- a/utils/Release-Package/scriptsettings.json +++ b/utils/Release-Package/scriptsettings.json @@ -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).",