mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +02:00
(refactor): reverting VersionInfo to standard behavior
This commit is contained in:
parent
d7721ff80e
commit
1c68cc63b8
24
CHANGELOG.md
24
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).
|
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
|
## [3.3.20] - 2026-04-26
|
||||||
|
|
||||||
### Fixed
|
### 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
|
## [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
|
### 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
|
### 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
|
## [3.3.18] - 2026-04-26
|
||||||
|
|
||||||
|
|||||||
@ -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%">
|
<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.8%</title>
|
<title>Branch Coverage: 7.7%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 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.8%</text>
|
<text x="127.5" y="14" fill="#fff">7.7%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 19.8%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 19.9%">
|
||||||
<title>Method Coverage: 19.8%</title>
|
<title>Method Coverage: 19.9%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 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.8%</text>
|
<text x="128.75" y="14" fill="#fff">19.9%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -73,6 +73,17 @@ public static class CertsLinq2DbMapping {
|
|||||||
.Property(x => x.TokenValue).HasColumnName("token_value")
|
.Property(x => x.TokenValue).HasColumnName("token_value")
|
||||||
.Property(x => x.CreatedAtUtc).HasColumnName("created_at_utc");
|
.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();
|
builder.Build();
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||||
|
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||||
using MaksIT.CertsUI.Engine.Services;
|
using MaksIT.CertsUI.Engine.Services;
|
||||||
using MaksIT.Results;
|
using MaksIT.Results;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace MaksIT.CertsUI.Engine.DomainServices;
|
namespace MaksIT.CertsUI.Engine.DomainServices;
|
||||||
|
|
||||||
@ -14,7 +19,7 @@ namespace MaksIT.CertsUI.Engine.DomainServices;
|
|||||||
public interface ICertsFlowDomainService {
|
public interface ICertsFlowDomainService {
|
||||||
|
|
||||||
#region Terms of service
|
#region Terms of service
|
||||||
Result<string?> GetTermsOfService(Guid sessionId);
|
Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId);
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Session, orders, and certificates
|
#region Session, orders, and certificates
|
||||||
@ -54,6 +59,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
private readonly IRegistrationCachePersistanceService _registrationCache;
|
private readonly IRegistrationCachePersistanceService _registrationCache;
|
||||||
private readonly IAgentDeploymentService _agentDeployment;
|
private readonly IAgentDeploymentService _agentDeployment;
|
||||||
private readonly ICertsFlowEngineConfiguration _config;
|
private readonly ICertsFlowEngineConfiguration _config;
|
||||||
|
private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache;
|
||||||
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
|
private readonly IAcmeHttpChallengePersistenceService _httpChallenges;
|
||||||
private readonly IRuntimeLeaseService _runtimeLease;
|
private readonly IRuntimeLeaseService _runtimeLease;
|
||||||
private readonly IRuntimeInstanceId _runtimeInstance;
|
private readonly IRuntimeInstanceId _runtimeInstance;
|
||||||
@ -67,6 +73,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
IRegistrationCachePersistanceService registrationCache,
|
IRegistrationCachePersistanceService registrationCache,
|
||||||
IAgentDeploymentService agentDeployment,
|
IAgentDeploymentService agentDeployment,
|
||||||
ICertsFlowEngineConfiguration config,
|
ICertsFlowEngineConfiguration config,
|
||||||
|
ITermsOfServiceCachePersistenceService termsOfServiceCache,
|
||||||
IAcmeHttpChallengePersistenceService httpChallenges,
|
IAcmeHttpChallengePersistenceService httpChallenges,
|
||||||
IRuntimeLeaseService runtimeLease,
|
IRuntimeLeaseService runtimeLease,
|
||||||
IRuntimeInstanceId runtimeInstance,
|
IRuntimeInstanceId runtimeInstance,
|
||||||
@ -77,6 +84,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
_registrationCache = registrationCache;
|
_registrationCache = registrationCache;
|
||||||
_agentDeployment = agentDeployment;
|
_agentDeployment = agentDeployment;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_termsOfServiceCache = termsOfServiceCache;
|
||||||
_httpChallenges = httpChallenges;
|
_httpChallenges = httpChallenges;
|
||||||
_runtimeLease = runtimeLease;
|
_runtimeLease = runtimeLease;
|
||||||
_runtimeInstance = runtimeInstance;
|
_runtimeInstance = runtimeInstance;
|
||||||
@ -86,44 +94,85 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
|||||||
|
|
||||||
#region Terms of service
|
#region Terms of service
|
||||||
|
|
||||||
public Result<string?> GetTermsOfService(Guid sessionId) {
|
public async Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) {
|
||||||
if (!_primaryReplica.IsPrimary)
|
var termsUriResult = _letsEncryptService.GetTermsOfServiceUri(sessionId);
|
||||||
return Result<string?>.ServiceUnavailable(null, CertsFlowPrimaryReplica.ServiceUnavailableMessages);
|
if (!termsUriResult.IsSuccess || termsUriResult.Value == null)
|
||||||
|
return termsUriResult;
|
||||||
|
|
||||||
var result = _letsEncryptService.GetTermsOfServiceUri(sessionId);
|
return await GetOrFetchTermsOfServicePdfBase64Async(termsUriResult.Value);
|
||||||
if (!result.IsSuccess || result.Value == null)
|
}
|
||||||
return result;
|
|
||||||
|
|
||||||
var termsOfServiceUrl = result.Value;
|
|
||||||
|
|
||||||
|
private async Task<Result<string?>> GetOrFetchTermsOfServicePdfBase64Async(string termsOfServiceUrl) {
|
||||||
try {
|
try {
|
||||||
var fileName = Path.GetFileName(new Uri(termsOfServiceUrl).LocalPath);
|
var cachedResult = await _termsOfServiceCache.GetByUrlAsync(termsOfServiceUrl);
|
||||||
var termsOfServicePdfPath = Path.Combine(_config.DataFolder, fileName);
|
if (cachedResult.IsSuccess && cachedResult.Value != null && cachedResult.Value.ExpiresAtUtc > DateTimeOffset.UtcNow)
|
||||||
foreach (var file in Directory.GetFiles(_config.DataFolder, "*.pdf")) {
|
return Result<string?>.Ok(Convert.ToBase64String(cachedResult.Value.ContentBytes));
|
||||||
if (!string.Equals(Path.GetFileName(file), fileName, StringComparison.OrdinalIgnoreCase)) {
|
|
||||||
try {
|
using var request = new HttpRequestMessage(HttpMethod.Get, termsOfServiceUrl);
|
||||||
File.Delete(file);
|
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 (!response.IsSuccessStatusCode)
|
||||||
if (File.Exists(termsOfServicePdfPath)) {
|
return Result<string?>.InternalServerError(null, $"Failed to download Terms of Service PDF. Status: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||||
pdfBytes = File.ReadAllBytes(termsOfServicePdfPath);
|
|
||||||
}
|
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
else {
|
if (bytes.Length == 0)
|
||||||
pdfBytes = _httpClient.GetByteArrayAsync(termsOfServiceUrl).GetAwaiter().GetResult();
|
return Result<string?>.InternalServerError(null, "Downloaded Terms of Service PDF is empty.");
|
||||||
File.WriteAllBytes(termsOfServicePdfPath, pdfBytes);
|
|
||||||
}
|
var fetchedAt = DateTimeOffset.UtcNow;
|
||||||
var base64 = Convert.ToBase64String(pdfBytes);
|
var cacheEntry = new TermsOfServiceCacheDto {
|
||||||
return Result<string?>.Ok(base64);
|
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) {
|
catch (Exception ex) {
|
||||||
_logger.LogError(ex, "Failed to download, cache, or convert Terms of Service PDF");
|
_logger.LogError(ex, "Failed to fetch or cache Terms of Service PDF");
|
||||||
return Result<string?>.InternalServerError(null, $"Failed to download, cache, or convert Terms of Service PDF: {ex.Message}");
|
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
|
#endregion
|
||||||
|
|
||||||
#region Session, orders, and certificates
|
#region Session, orders, and certificates
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
using FluentMigrator.Runner;
|
using FluentMigrator.Runner;
|
||||||
using FluentMigrator.Runner.Initialization;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MaksIT.CertsUI.Engine.DomainServices;
|
using MaksIT.CertsUI.Engine.DomainServices;
|
||||||
using MaksIT.CertsUI.Engine.FluentMigrations;
|
using MaksIT.CertsUI.Engine.FluentMigrations;
|
||||||
@ -26,21 +25,14 @@ public static class ServiceCollectionExtensions {
|
|||||||
if (string.IsNullOrWhiteSpace(certsEngineConfiguration.ConnectionString))
|
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));
|
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
|
// FluentMigrator: IRunMigrationsService invoked from Program.cs before RunAsync. Use .For.All() so version metadata
|
||||||
// matches in-process runner expectations (see FluentMigrator docs / #1062).
|
// and migration discovery match 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).
|
|
||||||
services.AddFluentMigratorCore()
|
services.AddFluentMigratorCore()
|
||||||
.ConfigureRunner(rb => rb
|
.ConfigureRunner(rb => rb
|
||||||
.AddPostgres()
|
.AddPostgres()
|
||||||
.WithGlobalConnectionString(certsEngineConfiguration.ConnectionString)
|
.WithGlobalConnectionString(certsEngineConfiguration.ConnectionString)
|
||||||
.ScanIn(typeof(BaselineCertsSchema).Assembly).For.All())
|
.ScanIn(typeof(BaselineCertsSchema).Assembly).For.All())
|
||||||
.AddLogging(lb => lb.AddFluentMigratorConsole());
|
.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<IRunMigrationsService, RunMigrationsService>();
|
||||||
services.AddScoped<ISchemaSyncService, SchemaSyncService>();
|
services.AddScoped<ISchemaSyncService, SchemaSyncService>();
|
||||||
|
|
||||||
@ -61,6 +53,7 @@ public static class ServiceCollectionExtensions {
|
|||||||
#region Registration cache
|
#region Registration cache
|
||||||
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
|
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
|
||||||
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
|
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
|
||||||
|
services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>();
|
||||||
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
|
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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')
|
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'app_runtime_leases')
|
||||||
AND (
|
AND (
|
||||||
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users')
|
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);
|
conn);
|
||||||
@ -61,7 +61,7 @@ public sealed class RunMigrationsService(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
throw new InvalidOperationException(
|
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).");
|
"Confirm Database= in the connection string, role CREATE privileges, and that FluentMigrator committed (non-empty connection string).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,5 +16,6 @@ public class Table(int id, string name) : Enumeration(id, name) {
|
|||||||
|
|
||||||
#region Certs
|
#region Certs
|
||||||
public static readonly Table RegistrationCaches = new(2, "registration_caches");
|
public static readonly Table RegistrationCaches = new(2, "registration_caches");
|
||||||
|
public static readonly Table TermsOfServiceCache = new(5, "terms_of_service_cache");
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||||
using MaksIT.CertsUI.Engine.DomainServices;
|
using MaksIT.CertsUI.Engine.DomainServices;
|
||||||
|
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||||
@ -26,6 +27,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
Mock<ILetsEncryptService> le,
|
Mock<ILetsEncryptService> le,
|
||||||
Mock<IRegistrationCachePersistanceService>? registrationCache = null,
|
Mock<IRegistrationCachePersistanceService>? registrationCache = null,
|
||||||
Mock<IAgentDeploymentService>? agent = null,
|
Mock<IAgentDeploymentService>? agent = null,
|
||||||
|
Mock<ITermsOfServiceCachePersistenceService>? termsOfServiceCache = null,
|
||||||
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
|
Mock<IAcmeHttpChallengePersistenceService>? httpChallenges = null,
|
||||||
Mock<IRuntimeLeaseService>? runtimeLease = null,
|
Mock<IRuntimeLeaseService>? runtimeLease = null,
|
||||||
Mock<IRuntimeInstanceId>? runtimeInstance = null,
|
Mock<IRuntimeInstanceId>? runtimeInstance = null,
|
||||||
@ -34,6 +36,14 @@ public sealed class CertsFlowServiceTests
|
|||||||
{
|
{
|
||||||
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
||||||
agent ??= new Mock<IAgentDeploymentService>();
|
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;
|
var httpChallengesProvided = httpChallenges is not null;
|
||||||
httpChallenges ??= new Mock<IAcmeHttpChallengePersistenceService>();
|
httpChallenges ??= new Mock<IAcmeHttpChallengePersistenceService>();
|
||||||
if (!httpChallengesProvided) {
|
if (!httpChallengesProvided) {
|
||||||
@ -68,6 +78,7 @@ public sealed class CertsFlowServiceTests
|
|||||||
registrationCache.Object,
|
registrationCache.Object,
|
||||||
agent.Object,
|
agent.Object,
|
||||||
new TestCertsFlowEngineConfiguration(fx),
|
new TestCertsFlowEngineConfiguration(fx),
|
||||||
|
termsOfServiceCache.Object,
|
||||||
httpChallenges.Object,
|
httpChallenges.Object,
|
||||||
runtimeLease.Object,
|
runtimeLease.Object,
|
||||||
runtimeInstance.Object,
|
runtimeInstance.Object,
|
||||||
@ -308,40 +319,52 @@ public sealed class CertsFlowServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetTermsOfService_WhenLetsEncryptFails_Propagates()
|
public async Task GetTermsOfServiceAsync_WhenLetsEncryptFails_Propagates()
|
||||||
{
|
{
|
||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
var le = new Mock<ILetsEncryptService>();
|
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"));
|
.Returns(Result<string?>.InternalServerError(null, "no uri"));
|
||||||
|
|
||||||
var sut = CreateSut(fx, le);
|
var sut = CreateSut(fx, le);
|
||||||
|
|
||||||
var result = sut.GetTermsOfService(Guid.NewGuid());
|
var result = await sut.GetTermsOfServiceAsync(sessionId);
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetTermsOfService_WhenPdfAlreadyOnDisk_ReturnsBase64WithoutHttp()
|
public async Task GetTermsOfServiceAsync_WhenCachedAndNotExpired_ReturnsBase64WithoutHttp()
|
||||||
{
|
{
|
||||||
using var fx = new WebApiTestFixture();
|
using var fx = new WebApiTestFixture();
|
||||||
var fileName = "cached-tos.pdf";
|
var sessionId = Guid.NewGuid();
|
||||||
var dataPath = Path.Combine(fx.AppOptions.Value.CertsUIEngineConfiguration.DataFolder, fileName);
|
var url = "https://acme.test/sub/cached-tos.pdf";
|
||||||
File.WriteAllBytes(dataPath, [7, 7, 7]);
|
|
||||||
|
|
||||||
var le = new Mock<ILetsEncryptService>();
|
var le = new Mock<ILetsEncryptService>();
|
||||||
le.Setup(x => x.GetTermsOfServiceUri(It.IsAny<Guid>()))
|
le.Setup(x => x.GetTermsOfServiceUri(sessionId))
|
||||||
.Returns(Result<string?>.Ok($"https://acme.test/sub/{fileName}"));
|
.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 httpHit = false;
|
||||||
var sut = CreateSut(fx, le, httpHandler: new StubHttpMessageHandler(_ =>
|
var sut = CreateSut(fx, le, termsOfServiceCache: tosCache, httpHandler: new StubHttpMessageHandler(_ =>
|
||||||
{
|
{
|
||||||
httpHit = true;
|
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.True(result.IsSuccess);
|
||||||
Assert.Equal(Convert.ToBase64String([7, 7, 7]), result.Value);
|
Assert.Equal(Convert.ToBase64String([7, 7, 7]), result.Value);
|
||||||
|
|||||||
@ -25,8 +25,8 @@ namespace MaksIT.CertsUI.Controllers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{sessionId}/terms-of-service")]
|
[HttpGet("{sessionId}/terms-of-service")]
|
||||||
public IActionResult TermsOfService(Guid sessionId) {
|
public async Task<IActionResult> TermsOfService(Guid sessionId) {
|
||||||
var result = _certsFlowService.GetTermsOfService(sessionId);
|
var result = await _certsFlowService.GetTermsOfServiceAsync(sessionId);
|
||||||
return result.ToCertsFlowActionResult();
|
return result.ToCertsFlowActionResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.3.20</Version>
|
<Version>3.3.22</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ using MaksIT.Results;
|
|||||||
namespace MaksIT.CertsUI.Services;
|
namespace MaksIT.CertsUI.Services;
|
||||||
|
|
||||||
public interface ICertsFlowService {
|
public interface ICertsFlowService {
|
||||||
Result<string?> GetTermsOfService(Guid sessionId);
|
Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId);
|
||||||
Task<Result> CompleteChallengesAsync(Guid sessionId);
|
Task<Result> CompleteChallengesAsync(Guid sessionId);
|
||||||
Task<Result<Guid?>> ConfigureClientAsync(bool isStaging);
|
Task<Result<Guid?>> ConfigureClientAsync(bool isStaging);
|
||||||
Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
|
Task<Result<Guid?>> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
|
||||||
@ -23,8 +23,8 @@ public sealed class CertsFlowService(
|
|||||||
ICertsFlowDomainService domain
|
ICertsFlowDomainService domain
|
||||||
) : ICertsFlowService {
|
) : ICertsFlowService {
|
||||||
|
|
||||||
public Result<string?> GetTermsOfService(Guid sessionId) =>
|
public Task<Result<string?>> GetTermsOfServiceAsync(Guid sessionId) =>
|
||||||
domain.GetTermsOfService(sessionId);
|
domain.GetTermsOfServiceAsync(sessionId);
|
||||||
|
|
||||||
public Task<Result> CompleteChallengesAsync(Guid sessionId) =>
|
public Task<Result> CompleteChallengesAsync(Guid sessionId) =>
|
||||||
domain.CompleteChallengesAsync(sessionId);
|
domain.CompleteChallengesAsync(sessionId);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
VITE_APP_TITLE=MaksIT.CertsUI
|
VITE_APP_TITLE=MaksIT.CertsUI
|
||||||
|
VITE_APP_VERSION=0.0.0
|
||||||
VITE_COMPANY=MaksIT
|
VITE_COMPANY=MaksIT
|
||||||
VITE_COMPANY_URL=https://maks-it.com
|
VITE_COMPANY_URL=https://maks-it.com
|
||||||
VITE_API_URL=http://localhost:8080/api
|
VITE_API_URL=http://localhost:8080/api
|
||||||
@ -1,4 +1,5 @@
|
|||||||
VITE_APP_TITLE=MaksIT.CertsUI
|
VITE_APP_TITLE=MaksIT.CertsUI
|
||||||
|
VITE_APP_VERSION=0.0.0
|
||||||
VITE_COMPANY=MaksIT
|
VITE_COMPANY=MaksIT
|
||||||
VITE_COMPANY_URL=https://maks-it.com
|
VITE_COMPANY_URL=https://maks-it.com
|
||||||
VITE_API_URL=http://localhost:8080/api
|
VITE_API_URL=http://localhost:8080/api
|
||||||
@ -54,8 +54,7 @@ const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
{
|
{
|
||||||
children: <p>
|
children: <p>{import.meta.env.VITE_APP_VERSION} - © {new Date().getFullYear()} <a
|
||||||
© {new Date().getFullYear()} <a
|
|
||||||
href={import.meta.env.VITE_COMPANY_URL}
|
href={import.meta.env.VITE_COMPANY_URL}
|
||||||
target={'_blank'}
|
target={'_blank'}
|
||||||
rel={'noopener noreferrer'}>
|
rel={'noopener noreferrer'}>
|
||||||
|
|||||||
@ -47,6 +47,27 @@ function Get-RegistryCredentialsFromEnv {
|
|||||||
return @{ User = $parts[0]; Password = $parts[1] }
|
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 {
|
function Invoke-Plugin {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@ -137,6 +158,33 @@ function Invoke-Plugin {
|
|||||||
$service = [string]$img.service
|
$service = [string]$img.service
|
||||||
$baseName = "$registryUrl/$($pluginSettings.projectName)/$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])"
|
$primaryRef = "${baseName}:$($imageTags[0])"
|
||||||
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
|
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
|
||||||
docker build -t $primaryRef -f $dockerfilePath $contextPath
|
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 {
|
finally {
|
||||||
docker logout $registryUrl 2>&1 | Out-Null
|
docker logout $registryUrl 2>&1 | Out-Null
|
||||||
|
|||||||
@ -29,7 +29,7 @@ Configure this plugin **immediately before** `DockerPush`, `HelmPush`, `GitHub`,
|
|||||||
|
|
||||||
## Helm charts in git
|
## 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/`.
|
This repository uses `src/helm/`. For a minimal scaffold chart, see **maksit-repoutils** `charts/my-service/`.
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"service": "client",
|
"service": "client",
|
||||||
"dockerfile": "MaksIT.WebUI/Dockerfile.prod"
|
"dockerfile": "MaksIT.WebUI/Dockerfile.prod",
|
||||||
|
"versionEnvFiles": [
|
||||||
|
"MaksIT.WebUI/.env",
|
||||||
|
"MaksIT.WebUI/.env.prod"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"service": "reverseproxy",
|
"service": "reverseproxy",
|
||||||
@ -92,6 +96,7 @@
|
|||||||
"projectName": "DockerPush: image path segment: registryUrl/projectName/service:tag.",
|
"projectName": "DockerPush: image path segment: registryUrl/projectName/service:tag.",
|
||||||
"contextPath": "DockerPush: directory containing MaksIT.CertsUI and MaksIT.WebUI (repo src/).",
|
"contextPath": "DockerPush: directory containing MaksIT.CertsUI and MaksIT.WebUI (repo src/).",
|
||||||
"images": "Dockerfile paths are relative to contextPath.",
|
"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.",
|
"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).",
|
"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).",
|
"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).",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user