Refactor SurfaceCacheValidator to simplify oldest entry calculation
Add global using for Xunit in test project Enhance ImportValidatorTests with async validation and quarantine checks Implement FileSystemQuarantineServiceTests for quarantine functionality Add integration tests for ImportValidator to check monotonicity Create BundleVersionTests to validate version parsing and comparison logic Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
144
src/AirGap/StellaOps.AirGap.Importer/Versioning/BundleVersion.cs
Normal file
144
src/AirGap/StellaOps.AirGap.Importer/Versioning/BundleVersion.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bundle version with semantic versioning and timestamp.
|
||||
/// Monotonicity is enforced by comparing (Major, Minor, Patch, Prerelease, CreatedAt).
|
||||
/// </summary>
|
||||
public sealed record BundleVersion(
|
||||
int Major,
|
||||
int Minor,
|
||||
int Patch,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? Prerelease = null)
|
||||
{
|
||||
public static BundleVersion Parse(string version, DateTimeOffset createdAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var dashIndex = version.IndexOf('-', StringComparison.Ordinal);
|
||||
var core = dashIndex < 0 ? version : version[..dashIndex];
|
||||
var prerelease = dashIndex < 0 ? null : version[(dashIndex + 1)..];
|
||||
|
||||
var parts = core.Split('.', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
throw new FormatException($"Invalid version core '{core}'. Expected '<major>.<minor>.<patch>'.");
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) ||
|
||||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor) ||
|
||||
!int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
|
||||
{
|
||||
throw new FormatException($"Invalid version numbers in '{core}'.");
|
||||
}
|
||||
|
||||
if (major < 0 || minor < 0 || patch < 0)
|
||||
{
|
||||
throw new FormatException($"Invalid version numbers in '{core}'.");
|
||||
}
|
||||
|
||||
prerelease = string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim();
|
||||
|
||||
return new BundleVersion(major, minor, patch, createdAt, prerelease);
|
||||
}
|
||||
|
||||
public string SemVer =>
|
||||
string.IsNullOrWhiteSpace(Prerelease)
|
||||
? $"{Major}.{Minor}.{Patch}"
|
||||
: $"{Major}.{Minor}.{Patch}-{Prerelease}";
|
||||
|
||||
public bool IsNewerThan(BundleVersion other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
|
||||
if (Major != other.Major)
|
||||
{
|
||||
return Major > other.Major;
|
||||
}
|
||||
|
||||
if (Minor != other.Minor)
|
||||
{
|
||||
return Minor > other.Minor;
|
||||
}
|
||||
|
||||
if (Patch != other.Patch)
|
||||
{
|
||||
return Patch > other.Patch;
|
||||
}
|
||||
|
||||
var prereleaseComparison = ComparePrerelease(Prerelease, other.Prerelease);
|
||||
if (prereleaseComparison != 0)
|
||||
{
|
||||
return prereleaseComparison > 0;
|
||||
}
|
||||
|
||||
return CreatedAt > other.CreatedAt;
|
||||
}
|
||||
|
||||
private static int ComparePrerelease(string? left, string? right)
|
||||
{
|
||||
var leftEmpty = string.IsNullOrWhiteSpace(left);
|
||||
var rightEmpty = string.IsNullOrWhiteSpace(right);
|
||||
|
||||
// Per SemVer: absence of prerelease indicates higher precedence than any prerelease.
|
||||
if (leftEmpty && rightEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftEmpty)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightEmpty)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var leftIds = left!.Split('.', StringSplitOptions.None);
|
||||
var rightIds = right!.Split('.', StringSplitOptions.None);
|
||||
|
||||
var min = Math.Min(leftIds.Length, rightIds.Length);
|
||||
for (var i = 0; i < min; i++)
|
||||
{
|
||||
var a = leftIds[i];
|
||||
var b = rightIds[i];
|
||||
|
||||
var aIsNum = int.TryParse(a, NumberStyles.None, CultureInfo.InvariantCulture, out var aNum);
|
||||
var bIsNum = int.TryParse(b, NumberStyles.None, CultureInfo.InvariantCulture, out var bNum);
|
||||
|
||||
if (aIsNum && bIsNum)
|
||||
{
|
||||
var cmp = aNum.CompareTo(bNum);
|
||||
if (cmp != 0)
|
||||
{
|
||||
return cmp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (aIsNum && !bIsNum)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!aIsNum && bIsNum)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var s = string.Compare(a, b, StringComparison.Ordinal);
|
||||
if (s != 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
return leftIds.Length.CompareTo(rightIds.Length);
|
||||
}
|
||||
|
||||
public override string ToString() => $"{SemVer} ({CreatedAt:O})";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
public interface IBundleVersionStore
|
||||
{
|
||||
Task<BundleVersionRecord?> GetCurrentAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task UpsertAsync(
|
||||
BundleVersionRecord record,
|
||||
CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
int limit = 10,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record BundleVersionRecord(
|
||||
string TenantId,
|
||||
string BundleType,
|
||||
string VersionString,
|
||||
int Major,
|
||||
int Minor,
|
||||
int Patch,
|
||||
string? Prerelease,
|
||||
DateTimeOffset BundleCreatedAt,
|
||||
string BundleDigest,
|
||||
DateTimeOffset ActivatedAt,
|
||||
bool WasForceActivated,
|
||||
string? ForceActivateReason);
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
public interface IVersionMonotonicityChecker
|
||||
{
|
||||
Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record MonotonicityCheckResult(
|
||||
bool IsMonotonic,
|
||||
BundleVersion? CurrentVersion,
|
||||
string? CurrentBundleDigest,
|
||||
DateTimeOffset? CurrentActivatedAt,
|
||||
string ReasonCode); // "MONOTONIC_OK" | "VERSION_NON_MONOTONIC" | "FIRST_ACTIVATION"
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
public sealed class VersionMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
private readonly IBundleVersionStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VersionMonotonicityChecker(IBundleVersionStore store, TimeProvider timeProvider)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
|
||||
ArgumentNullException.ThrowIfNull(incomingVersion);
|
||||
|
||||
var current = await _store.GetCurrentAsync(tenantId, bundleType, cancellationToken).ConfigureAwait(false);
|
||||
if (current is null)
|
||||
{
|
||||
return new MonotonicityCheckResult(
|
||||
IsMonotonic: true,
|
||||
CurrentVersion: null,
|
||||
CurrentBundleDigest: null,
|
||||
CurrentActivatedAt: null,
|
||||
ReasonCode: "FIRST_ACTIVATION");
|
||||
}
|
||||
|
||||
var currentVersion = new BundleVersion(
|
||||
current.Major,
|
||||
current.Minor,
|
||||
current.Patch,
|
||||
current.BundleCreatedAt,
|
||||
current.Prerelease);
|
||||
|
||||
var isMonotonic = incomingVersion.IsNewerThan(currentVersion);
|
||||
return new MonotonicityCheckResult(
|
||||
IsMonotonic: isMonotonic,
|
||||
CurrentVersion: currentVersion,
|
||||
CurrentBundleDigest: current.BundleDigest,
|
||||
CurrentActivatedAt: current.ActivatedAt,
|
||||
ReasonCode: isMonotonic ? "MONOTONIC_OK" : "VERSION_NON_MONOTONIC");
|
||||
}
|
||||
|
||||
public async Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
|
||||
ArgumentNullException.ThrowIfNull(version);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDigest);
|
||||
|
||||
if (wasForceActivated && string.IsNullOrWhiteSpace(forceActivateReason))
|
||||
{
|
||||
throw new ArgumentException("Force-activate requires a non-empty reason.", nameof(forceActivateReason));
|
||||
}
|
||||
|
||||
var check = await CheckAsync(tenantId, bundleType, version, cancellationToken).ConfigureAwait(false);
|
||||
if (!check.IsMonotonic && !wasForceActivated)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Incoming version '{version.SemVer}' is not monotonic vs current '{check.CurrentVersion?.SemVer}'.");
|
||||
}
|
||||
|
||||
var activatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var record = new BundleVersionRecord(
|
||||
TenantId: tenantId,
|
||||
BundleType: bundleType,
|
||||
VersionString: version.SemVer,
|
||||
Major: version.Major,
|
||||
Minor: version.Minor,
|
||||
Patch: version.Patch,
|
||||
Prerelease: version.Prerelease,
|
||||
BundleCreatedAt: version.CreatedAt,
|
||||
BundleDigest: bundleDigest,
|
||||
ActivatedAt: activatedAt,
|
||||
WasForceActivated: wasForceActivated,
|
||||
ForceActivateReason: wasForceActivated ? forceActivateReason : null);
|
||||
|
||||
await _store.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user