feat(deploy): hash the per-site artifact set and stamp it on a successful artifact deploy
ArtifactRevisionHashService (deterministic SHA-256 of the per-site DeployArtifactsCommand, DeploymentId/timestamp excluded) stamps Site.ArtifactRevisionHash/ArtifactsDeployedAt for every site whose deploy succeeded; failed sites keep their previous stamp. ComputeCurrentArtifactHashesAsync answers "what would it be now". Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -39,6 +39,7 @@ public class ArtifactDeploymentService
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly DeploymentManagerOptions _options;
|
||||
private readonly ILogger<ArtifactDeploymentService> _logger;
|
||||
private readonly ArtifactRevisionHashService _hashService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ArtifactDeploymentService.
|
||||
@@ -57,6 +58,11 @@ public class ArtifactDeploymentService
|
||||
/// <param name="auditService">Service for audit logging.</param>
|
||||
/// <param name="options">Deployment manager options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="hashService">
|
||||
/// Computes the deterministic revision hash of a per-site artifact set, stamped on the
|
||||
/// <see cref="Site"/> row after a successful deploy. Last on the signature so the
|
||||
/// addition stays positionally additive for existing call sites.
|
||||
/// </param>
|
||||
public ArtifactDeploymentService(
|
||||
ISiteRepository siteRepo,
|
||||
IDeploymentManagerRepository deploymentRepo,
|
||||
@@ -66,7 +72,8 @@ public class ArtifactDeploymentService
|
||||
CommunicationService communicationService,
|
||||
IAuditService auditService,
|
||||
IOptions<DeploymentManagerOptions> options,
|
||||
ILogger<ArtifactDeploymentService> logger)
|
||||
ILogger<ArtifactDeploymentService> logger,
|
||||
ArtifactRevisionHashService hashService)
|
||||
{
|
||||
_siteRepo = siteRepo;
|
||||
_deploymentRepo = deploymentRepo;
|
||||
@@ -79,6 +86,7 @@ public class ArtifactDeploymentService
|
||||
_auditService = auditService;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_hashService = hashService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -353,6 +361,31 @@ public class ArtifactDeploymentService
|
||||
perSiteResults[result.SiteId] = result;
|
||||
}
|
||||
|
||||
// Stamp the deployed artifact revision on every site whose deploy SUCCEEDED.
|
||||
// A failed site is deliberately left untouched so it keeps its previous stamp
|
||||
// (or none at all) and keeps reading "out of date" on the Sites page.
|
||||
//
|
||||
// The stamp is flushed here, BEFORE the deployment record is added, through
|
||||
// ISiteRepository's own SaveChangesAsync. ISiteRepository and
|
||||
// IDeploymentManagerRepository are both scoped over the same DbContext, so a
|
||||
// single save would in fact commit both — but keeping the stamp save separate
|
||||
// and ahead of the record is what lets a stamp failure be swallowed (logged at
|
||||
// Warning) without taking the deployment record's save down with it. Audit
|
||||
// trail beats staleness bookkeeping: a stamp failure must never turn a
|
||||
// successful deploy into a reported failure. (A failed SaveChanges does leave
|
||||
// the site entities tracked as modified on the shared context, so the record's
|
||||
// save may re-attempt them — it cannot make the stamp any more wrong than the
|
||||
// failure already did.)
|
||||
var successfulSites = sites
|
||||
.Where(site => perSiteResults.TryGetValue(site.SiteIdentifier, out var r) && r.Success)
|
||||
.ToList();
|
||||
if (successfulSites.Count > 0)
|
||||
{
|
||||
await StampArtifactRevisionAsync(
|
||||
successfulSites.Select(site => (site, siteCommands[site.Id])).ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Persist the system artifact deployment record.
|
||||
// SystemArtifactDeploymentRecord has no dedicated
|
||||
// DeploymentId column (adding one is a Commons/ConfigurationDatabase
|
||||
@@ -409,6 +442,18 @@ public class ArtifactDeploymentService
|
||||
|
||||
var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage);
|
||||
|
||||
// Same stamp-on-success rule as the sweep path: a retry that lands is a
|
||||
// successful artifact deploy and must move the site's revision stamp; a
|
||||
// retry that fails leaves the previous stamp in place.
|
||||
if (response.Success)
|
||||
{
|
||||
var site = await _siteRepo.GetSiteByIdAsync(siteDbId, cancellationToken);
|
||||
if (site is not null)
|
||||
{
|
||||
await StampArtifactRevisionAsync([(site, command)], cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact",
|
||||
command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken);
|
||||
|
||||
@@ -421,6 +466,79 @@ public class ArtifactDeploymentService
|
||||
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes what each given site's artifact revision hash WOULD be if the current
|
||||
/// artifact definitions were deployed right now. Comparing the result against the
|
||||
/// site's stored <see cref="Site.ArtifactRevisionHash"/> is how the pending-updates
|
||||
/// aggregate decides whether a site's system-wide artifacts are out of date.
|
||||
/// </summary>
|
||||
/// <param name="sites">The sites to compute current hashes for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A map of site database id to the hash of the artifact set that a deploy would send
|
||||
/// to that site right now.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The global artifact queries run ONCE for the whole sweep (the same hoist
|
||||
/// <see cref="DeployToAllSitesAsync"/> performs); only the per-site data-connection query
|
||||
/// runs per site. The commands are built with the constant deployment id <c>"current"</c>,
|
||||
/// which is harmless because <see cref="ArtifactRevisionHashService"/> excludes both the
|
||||
/// deployment id and the command timestamp from the hash — that exclusion is what makes a
|
||||
/// computed-now hash comparable with one stamped by a past deploy.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyDictionary<int, string>> ComputeCurrentArtifactHashesAsync(
|
||||
IReadOnlyList<Site> sites,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sites);
|
||||
|
||||
var hashes = new Dictionary<int, string>();
|
||||
if (sites.Count == 0)
|
||||
return hashes;
|
||||
|
||||
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
|
||||
foreach (var site in sites)
|
||||
{
|
||||
var command = await BuildDeployArtifactsCommandAsync(
|
||||
site.Id, globals, cancellationToken, deploymentId: "current");
|
||||
hashes[site.Id] = _hashService.ComputeHash(command);
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <see cref="Site.ArtifactRevisionHash"/> and <see cref="Site.ArtifactsDeployedAt"/>
|
||||
/// for every (site, deployed command) pair and flushes them in one save. Best-effort: a
|
||||
/// failure is logged at Warning and swallowed, because the artifacts DID reach the sites —
|
||||
/// losing the stamp only means the Sites page reports those sites out of date until the
|
||||
/// next deploy, which is strictly better than reporting a successful deploy as failed.
|
||||
/// </summary>
|
||||
private async Task StampArtifactRevisionAsync(
|
||||
IReadOnlyList<(Site Site, DeployArtifactsCommand Command)> deployed,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stampedAt = DateTimeOffset.UtcNow;
|
||||
foreach (var (site, command) in deployed)
|
||||
{
|
||||
site.ArtifactRevisionHash = _hashService.ComputeHash(command);
|
||||
site.ArtifactsDeployedAt = stampedAt;
|
||||
await _siteRepo.UpdateSiteAsync(site, cancellationToken);
|
||||
}
|
||||
|
||||
await _siteRepo.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Artifact deployment succeeded but stamping the artifact revision failed for site(s) {Sites}; " +
|
||||
"they will report out-of-date artifacts until the next deploy",
|
||||
string.Join(", ", deployed.Select(d => d.Site.SiteIdentifier)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set
|
||||
/// carried by a <see cref="DeployArtifactsCommand"/> (shared scripts, external
|
||||
/// systems, database connections, and the site's data connections). The hash is
|
||||
/// stamped on <c>Site.ArtifactRevisionHash</c> after a successful artifact deploy
|
||||
/// and compared against a freshly-computed hash of the current definitions to tell
|
||||
/// whether a site's deployed artifacts are out of date.
|
||||
/// <para>
|
||||
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration
|
||||
/// order and does NOT sort them. Stable output therefore relies on the private
|
||||
/// <c>Hashable*</c> records below declaring their properties in alphabetical
|
||||
/// (ordinal) order — a contributor adding a property out of order would silently
|
||||
/// change every site's artifact hash and flag the whole fleet out of date. The
|
||||
/// ordering is guarded by
|
||||
/// <c>ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>,
|
||||
/// mirroring <c>RevisionHashService</c>. Every artifact list is explicitly sorted by
|
||||
/// <c>Name</c> (ordinal) before hashing, so repository row order never leaks into the
|
||||
/// hash, and an external system's <c>MethodDefinitionsJson</c> — itself a serialized
|
||||
/// collection — is re-canonicalised with its methods sorted by name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// EXCLUDED FROM THE HASH: the command's <c>DeploymentId</c> and <c>Timestamp</c>
|
||||
/// (both volatile per deploy — two deploys of identical definitions must hash the
|
||||
/// same), and <c>NotificationLists</c>/<c>SmtpConfigurations</c> (notification
|
||||
/// delivery is central-only; central never populates those fields, so folding them
|
||||
/// in would only add a permanently-null branch).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A no-op re-save of a data connection does NOT change the hash. Connection
|
||||
/// configuration is encrypted at rest through an EF value converter, so the
|
||||
/// ciphertext changes on every save, but the entity — and hence
|
||||
/// <see cref="DataConnectionArtifact"/> and this hash — only ever sees the
|
||||
/// plaintext. Re-encryption at rest is therefore invisible here, which is exactly
|
||||
/// what keeps the "out of date" indicator quiet for a save that changed nothing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ArtifactRevisionHashService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the deterministic SHA-256 hash of the artifact set carried by the given
|
||||
/// per-site deploy command. Identical artifact definitions always produce an identical
|
||||
/// hash, regardless of deployment id, timestamp, or the order the artifacts were
|
||||
/// returned by their repositories.
|
||||
/// </summary>
|
||||
/// <param name="command">The per-site artifact deployment command to hash.</param>
|
||||
/// <returns>
|
||||
/// The canonical revision hash, encoded as <c>sha256:</c> followed by the lower-case
|
||||
/// hex digest (the same encoding as <c>RevisionHashService</c>).
|
||||
/// </returns>
|
||||
public string ComputeHash(DeployArtifactsCommand command)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
// Each list is projected null-when-empty and the serializer omits null
|
||||
// properties, so an absent artifact set and an empty one hash identically.
|
||||
var hashInput = new HashableArtifactSet
|
||||
{
|
||||
DataConnections = command.DataConnections is { Count: > 0 }
|
||||
? command.DataConnections
|
||||
.OrderBy(c => c.Name, StringComparer.Ordinal)
|
||||
.Select(c => new HashableDataConnection
|
||||
{
|
||||
BackupConfigurationJson = c.BackupConfigurationJson,
|
||||
FailoverRetryCount = c.FailoverRetryCount,
|
||||
Name = c.Name,
|
||||
PrimaryConfigurationJson = c.PrimaryConfigurationJson,
|
||||
Protocol = c.Protocol
|
||||
})
|
||||
.ToList()
|
||||
: null,
|
||||
DatabaseConnections = command.DatabaseConnections is { Count: > 0 }
|
||||
? command.DatabaseConnections
|
||||
.OrderBy(d => d.Name, StringComparer.Ordinal)
|
||||
.Select(d => new HashableDatabaseConnection
|
||||
{
|
||||
ConnectionString = d.ConnectionString,
|
||||
MaxRetries = d.MaxRetries,
|
||||
Name = d.Name,
|
||||
RetryDelayTicks = d.RetryDelay.Ticks
|
||||
})
|
||||
.ToList()
|
||||
: null,
|
||||
ExternalSystems = command.ExternalSystems is { Count: > 0 }
|
||||
? command.ExternalSystems
|
||||
.OrderBy(e => e.Name, StringComparer.Ordinal)
|
||||
.Select(e => new HashableExternalSystem
|
||||
{
|
||||
AuthConfiguration = e.AuthConfiguration,
|
||||
AuthType = e.AuthType,
|
||||
EndpointUrl = e.EndpointUrl,
|
||||
MaxRetries = e.MaxRetries,
|
||||
MethodDefinitionsJson = CanonicalizeMethodDefinitions(e.MethodDefinitionsJson),
|
||||
Name = e.Name,
|
||||
RetryDelayTicks = e.RetryDelay.Ticks,
|
||||
TimeoutSeconds = e.TimeoutSeconds
|
||||
})
|
||||
.ToList()
|
||||
: null,
|
||||
SharedScripts = command.SharedScripts is { Count: > 0 }
|
||||
? command.SharedScripts
|
||||
.OrderBy(s => s.Name, StringComparer.Ordinal)
|
||||
.Select(s => new HashableSharedScript
|
||||
{
|
||||
Code = s.Code,
|
||||
Name = s.Name,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition
|
||||
})
|
||||
.ToList()
|
||||
: null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-serializes an external system's JSON-encoded method list with its methods sorted
|
||||
/// by name (ordinal), so the repository's row order cannot leak into the hash. Anything
|
||||
/// that is not a parseable JSON array is returned verbatim — the hash still changes when
|
||||
/// the payload does, which is the only guarantee this method owes.
|
||||
/// </summary>
|
||||
private static string? CanonicalizeMethodDefinitions(string? methodDefinitionsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(methodDefinitionsJson))
|
||||
return methodDefinitionsJson;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(methodDefinitionsJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
return methodDefinitionsJson;
|
||||
|
||||
var ordered = document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(element => element.Clone())
|
||||
.OrderBy(element =>
|
||||
element.ValueKind == JsonValueKind.Object
|
||||
&& element.TryGetProperty("Name", out var name)
|
||||
&& name.ValueKind == JsonValueKind.String
|
||||
? name.GetString() ?? string.Empty
|
||||
: string.Empty,
|
||||
StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return JsonSerializer.Serialize(ordered, CanonicalJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return methodDefinitionsJson;
|
||||
}
|
||||
}
|
||||
|
||||
// Internal types for deterministic serialization. Properties MUST be declared in
|
||||
// alphabetical (ordinal) order — System.Text.Json emits them in declaration order
|
||||
// and does not sort. See the DETERMINISM CONTRACT note on the class summary.
|
||||
private sealed record HashableArtifactSet
|
||||
{
|
||||
/// <summary>
|
||||
/// The site's data connections, sorted by name; null when the site has none.
|
||||
/// </summary>
|
||||
public List<HashableDataConnection>? DataConnections { get; init; }
|
||||
/// <summary>
|
||||
/// The system-wide database connections, sorted by name; null when there are none.
|
||||
/// </summary>
|
||||
public List<HashableDatabaseConnection>? DatabaseConnections { get; init; }
|
||||
/// <summary>
|
||||
/// The system-wide external systems, sorted by name; null when there are none.
|
||||
/// </summary>
|
||||
public List<HashableExternalSystem>? ExternalSystems { get; init; }
|
||||
/// <summary>
|
||||
/// The system-wide shared scripts, sorted by name; null when there are none.
|
||||
/// </summary>
|
||||
public List<HashableSharedScript>? SharedScripts { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableDataConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Backup connection configuration JSON (plaintext in memory), if any.
|
||||
/// </summary>
|
||||
public string? BackupConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Number of failover retries before giving up.
|
||||
/// </summary>
|
||||
public int FailoverRetryCount { get; init; }
|
||||
/// <summary>
|
||||
/// The connection name — the key scripts and bindings address it by.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Primary connection configuration JSON (plaintext in memory), if any.
|
||||
/// </summary>
|
||||
public string? PrimaryConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Protocol name (e.g. "OpcUa").
|
||||
/// </summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableDatabaseConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection string shipped to the site.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Store-and-Forward retry budget for this connection's cached writes (0 = unset).
|
||||
/// </summary>
|
||||
public int MaxRetries { get; init; }
|
||||
/// <summary>
|
||||
/// The database connection name.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
|
||||
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
|
||||
/// </summary>
|
||||
public long RetryDelayTicks { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableExternalSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON-serialized authentication configuration for <see cref="AuthType"/>.
|
||||
/// </summary>
|
||||
public string? AuthConfiguration { get; init; }
|
||||
/// <summary>
|
||||
/// Authentication type identifier (e.g. <c>ApiKey</c>, <c>BasicAuth</c>).
|
||||
/// </summary>
|
||||
public string AuthType { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Base URL of the system's HTTP endpoint.
|
||||
/// </summary>
|
||||
public string EndpointUrl { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Store-and-Forward retry budget for this system's cached calls (0 = unset).
|
||||
/// </summary>
|
||||
public int MaxRetries { get; init; }
|
||||
/// <summary>
|
||||
/// The system's method list, re-canonicalised with the methods sorted by name;
|
||||
/// null when the system defines none.
|
||||
/// </summary>
|
||||
public string? MethodDefinitionsJson { get; init; }
|
||||
/// <summary>
|
||||
/// Unique system name — the key scripts call it by.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather
|
||||
/// than a <see cref="TimeSpan"/> so the serialized form cannot vary by format.
|
||||
/// </summary>
|
||||
public long RetryDelayTicks { get; init; }
|
||||
/// <summary>
|
||||
/// Per-system HTTP round-trip timeout in seconds (0 = unset).
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableSharedScript
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared script's source code.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The shared script name — the key other scripts call it by.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// JSON representation of the script's parameter definitions.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of the script's return type definition.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<DeploymentService>();
|
||||
services.AddScoped<ArtifactDeploymentService>();
|
||||
|
||||
// Deterministic hash of a per-site artifact set, stamped on the Site row after a
|
||||
// successful artifact deploy and recomputed to answer "are this site's artifacts
|
||||
// current?". Singleton — it holds no state and touches no DbContext.
|
||||
services.AddSingleton<ArtifactRevisionHashService>();
|
||||
|
||||
// Expose the flattening pipeline's revision-hash computation
|
||||
// to the Transport bundle importer through the Commons IStaleInstanceProbe
|
||||
// seam, so a template overwrite can enumerate the deployed instances whose
|
||||
|
||||
@@ -44,7 +44,8 @@ public class SitesPageTests : BunitContext
|
||||
comms,
|
||||
Substitute.For<IAuditService>(),
|
||||
Options.Create(new DeploymentManagerOptions()),
|
||||
NullLogger<ArtifactDeploymentService>.Instance);
|
||||
NullLogger<ArtifactDeploymentService>.Instance,
|
||||
new ArtifactRevisionHashService());
|
||||
Services.AddSingleton(artifactSvc);
|
||||
|
||||
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
|
||||
|
||||
+171
-2
@@ -26,6 +26,7 @@ public class ArtifactDeploymentServiceTests : TestKit
|
||||
private readonly IExternalSystemRepository _externalSystemRepo;
|
||||
private readonly INotificationRepository _notificationRepo;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly ArtifactRevisionHashService _hashService = new();
|
||||
|
||||
public ArtifactDeploymentServiceTests()
|
||||
{
|
||||
@@ -301,6 +302,172 @@ public class ArtifactDeploymentServiceTests : TestKit
|
||||
Assert.Contains("999", result.Error);
|
||||
}
|
||||
|
||||
// ── Site artifact revision stamp (pending-updates §3.1) ──
|
||||
|
||||
[Fact]
|
||||
public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves()
|
||||
{
|
||||
var site = new Site("Site One", "site-1") { Id = 1 };
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
|
||||
var result = await service.DeployToAllSitesAsync("admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
var expected = _hashService.ComputeHash(Assert.Single(recorder.Received));
|
||||
Assert.Equal(expected, site.ArtifactRevisionHash);
|
||||
Assert.NotNull(site.ArtifactsDeployedAt);
|
||||
Assert.InRange(site.ArtifactsDeployedAt!.Value, before, DateTimeOffset.UtcNow);
|
||||
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
||||
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployToAllSitesAsync_FailedSite_KeepsItsPreviousStamp()
|
||||
{
|
||||
// A failed site must keep reading "out of date": it keeps the stamp of whatever
|
||||
// artifact set it last received, never the one it just refused.
|
||||
var previousStamp = DateTimeOffset.UtcNow.AddDays(-2);
|
||||
var ok = new Site("Site One", "ok-site") { Id = 1 };
|
||||
var failed = new Site("Site Two", "fail-site")
|
||||
{
|
||||
Id = 2,
|
||||
ArtifactRevisionHash = "sha256:previous",
|
||||
ArtifactsDeployedAt = previousStamp
|
||||
};
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { ok, failed });
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var result = await service.DeployToAllSitesAsync("admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("sha256:previous", failed.ArtifactRevisionHash);
|
||||
Assert.Equal(previousStamp, failed.ArtifactsDeployedAt);
|
||||
Assert.NotNull(ok.ArtifactRevisionHash);
|
||||
await _siteRepo.DidNotReceive().UpdateSiteAsync(failed, Arg.Any<CancellationToken>());
|
||||
await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeployToAllSitesAsync_StampSaveThrows_DeployStillReportsSuccess()
|
||||
{
|
||||
// Audit trail beats staleness bookkeeping: the artifacts DID reach the site, so a
|
||||
// failure to record the stamp is logged and swallowed, never reported as a failure.
|
||||
var site = new Site("Site One", "site-1") { Id = 1 };
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
||||
_siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns<int>(_ => throw new InvalidOperationException("db down"));
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var result = await service.DeployToAllSitesAsync("admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value.SuccessCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryForSiteAsync_Success_StampsTheSite()
|
||||
{
|
||||
var site = new Site("Retry Site", "retry-site") { Id = 7 };
|
||||
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(
|
||||
_hashService.ComputeHash(Assert.Single(recorder.Received)),
|
||||
site.ArtifactRevisionHash);
|
||||
Assert.NotNull(site.ArtifactsDeployedAt);
|
||||
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
||||
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite()
|
||||
{
|
||||
var site = new Site("Retry Site", "fail-site") { Id = 7 };
|
||||
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var result = await service.RetryForSiteAsync(7, "fail-site", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Null(site.ArtifactRevisionHash);
|
||||
Assert.Null(site.ArtifactsDeployedAt);
|
||||
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps()
|
||||
{
|
||||
// The whole comparison rests on this equality: a "what would it be now" hash of
|
||||
// unchanged definitions must equal the hash the last successful deploy stamped.
|
||||
var sites = new List<Site>
|
||||
{
|
||||
new("Site One", "site-1") { Id = 1 },
|
||||
new("Site Two", "site-2") { Id = 2 }
|
||||
};
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
||||
var recorder = new ArtifactProbeRecorder();
|
||||
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
||||
var service = CreateServiceWithCommActor(probe);
|
||||
|
||||
var deployResult = await service.DeployToAllSitesAsync("admin");
|
||||
Assert.True(deployResult.IsSuccess);
|
||||
|
||||
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
||||
|
||||
Assert.Equal(2, current.Count);
|
||||
foreach (var site in sites)
|
||||
{
|
||||
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce()
|
||||
{
|
||||
var sites = new List<Site>
|
||||
{
|
||||
new("Site One", "site-1") { Id = 1 },
|
||||
new("Site Two", "site-2") { Id = 2 },
|
||||
new("Site Three", "site-3") { Id = 3 }
|
||||
};
|
||||
var service = CreateService();
|
||||
|
||||
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
||||
|
||||
Assert.Equal(3, current.Count);
|
||||
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
||||
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
|
||||
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
|
||||
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
|
||||
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var current = await service.ComputeCurrentArtifactHashesAsync([]);
|
||||
|
||||
Assert.Empty(current);
|
||||
await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private ArtifactDeploymentService CreateService()
|
||||
{
|
||||
var comms = new CommunicationService(
|
||||
@@ -311,7 +478,8 @@ public class ArtifactDeploymentServiceTests : TestKit
|
||||
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
|
||||
comms, _audit,
|
||||
Options.Create(new DeploymentManagerOptions()),
|
||||
NullLogger<ArtifactDeploymentService>.Instance);
|
||||
NullLogger<ArtifactDeploymentService>.Instance,
|
||||
_hashService);
|
||||
}
|
||||
|
||||
private ArtifactDeploymentService CreateServiceWithCommActor(IActorRef commActor)
|
||||
@@ -331,7 +499,8 @@ public class ArtifactDeploymentServiceTests : TestKit
|
||||
{
|
||||
ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5)
|
||||
}),
|
||||
NullLogger<ArtifactDeploymentService>.Instance);
|
||||
NullLogger<ArtifactDeploymentService>.Instance,
|
||||
_hashService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ArtifactRevisionHashService"/>, the deterministic hash behind the
|
||||
/// Sites page's "artifacts out of date" indicator. The hash must depend on the artifact
|
||||
/// CONTENT only — never on deployment id, timestamp, or repository row order — or the
|
||||
/// indicator either never clears or never fires.
|
||||
/// </summary>
|
||||
public class ArtifactRevisionHashServiceTests
|
||||
{
|
||||
private readonly ArtifactRevisionHashService _sut = new();
|
||||
|
||||
private static DeployArtifactsCommand Command(
|
||||
string deploymentId = "dep-1",
|
||||
DateTimeOffset? timestamp = null,
|
||||
IReadOnlyList<SharedScriptArtifact>? sharedScripts = null,
|
||||
IReadOnlyList<ExternalSystemArtifact>? externalSystems = null,
|
||||
IReadOnlyList<DatabaseConnectionArtifact>? databaseConnections = null,
|
||||
IReadOnlyList<DataConnectionArtifact>? dataConnections = null) =>
|
||||
new(deploymentId,
|
||||
sharedScripts ??
|
||||
[
|
||||
new SharedScriptArtifact("Alpha", "return 1;", "{\"p\":1}", "{\"r\":1}"),
|
||||
new SharedScriptArtifact("Beta", "return 2;", null, null)
|
||||
],
|
||||
externalSystems ??
|
||||
[
|
||||
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", "{\"key\":\"k\"}",
|
||||
"[{\"Name\":\"MoveOut\"},{\"Name\":\"MoveIn\"}]", 30, 6, TimeSpan.FromSeconds(120)),
|
||||
new ExternalSystemArtifact("Erp", "https://erp", "BasicAuth", null, null)
|
||||
],
|
||||
databaseConnections ??
|
||||
[
|
||||
new DatabaseConnectionArtifact("Historian", "Server=h;", 4, TimeSpan.FromSeconds(30)),
|
||||
new DatabaseConnectionArtifact("Mes", "Server=m;", 0, TimeSpan.Zero)
|
||||
],
|
||||
NotificationLists: null,
|
||||
dataConnections ??
|
||||
[
|
||||
new DataConnectionArtifact("Opc", "OpcUa", "{\"url\":\"opc.tcp://x\"}", null, 3),
|
||||
new DataConnectionArtifact("Mx", "MxGateway", "{\"host\":\"gw\"}", "{\"host\":\"gw2\"}", 5)
|
||||
],
|
||||
SmtpConfigurations: null,
|
||||
timestamp ?? DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_SameInputTwice_ProducesSameHash()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
Assert.Equal(
|
||||
_sut.ComputeHash(Command(timestamp: timestamp)),
|
||||
_sut.ComputeHash(Command(timestamp: timestamp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_IsSha256HexEncoded()
|
||||
{
|
||||
var hash = _sut.ComputeHash(Command());
|
||||
|
||||
// Same encoding as RevisionHashService: "sha256:" + 64 lower-case hex chars.
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
var digest = hash["sha256:".Length..];
|
||||
Assert.Equal(64, digest.Length);
|
||||
Assert.All(digest, c => Assert.Contains(c, "0123456789abcdef"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_ReorderedArtifactLists_ProducesSameHash()
|
||||
{
|
||||
// Repository row order must never leak into the hash — otherwise an unrelated
|
||||
// insert would flag every site out of date.
|
||||
var baseline = Command();
|
||||
var reordered = baseline with
|
||||
{
|
||||
SharedScripts = baseline.SharedScripts!.Reverse().ToList(),
|
||||
ExternalSystems = baseline.ExternalSystems!.Reverse().ToList(),
|
||||
DatabaseConnections = baseline.DatabaseConnections!.Reverse().ToList(),
|
||||
DataConnections = baseline.DataConnections!.Reverse().ToList()
|
||||
};
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(reordered));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_ReorderedExternalSystemMethods_ProducesSameHash()
|
||||
{
|
||||
// MethodDefinitionsJson is itself a serialized collection; it is re-canonicalised
|
||||
// with the methods sorted by name so method row order cannot move the hash either.
|
||||
var a = Command(externalSystems:
|
||||
[
|
||||
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
|
||||
"[{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"}]")
|
||||
]);
|
||||
var b = Command(externalSystems:
|
||||
[
|
||||
new ExternalSystemArtifact("Mes", "https://mes", "ApiKey", null,
|
||||
"[{\"Name\":\"MoveIn\",\"HttpMethod\":\"POST\"},{\"Name\":\"MoveOut\",\"HttpMethod\":\"POST\"}]")
|
||||
]);
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_DifferentDeploymentIdAndTimestamp_ProducesSameHash()
|
||||
{
|
||||
// The whole point of the stamp: two deploys of identical definitions must hash the
|
||||
// same, or every redeploy would look like a change.
|
||||
var a = Command("dep-1", DateTimeOffset.UtcNow);
|
||||
var b = Command("dep-2", DateTimeOffset.UtcNow.AddDays(3));
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(a), _sut.ComputeHash(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_EmptyAndNullArtifactLists_HashIdentically()
|
||||
{
|
||||
var withNulls = new DeployArtifactsCommand(
|
||||
"dep", null, null, null, null, null, null, DateTimeOffset.UtcNow);
|
||||
var withEmpties = new DeployArtifactsCommand(
|
||||
"dep", [], [], [], [], [], [], DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(withNulls), _sut.ComputeHash(withEmpties));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHash_NotificationAndSmtpArtifacts_DoNotAffectTheHash()
|
||||
{
|
||||
// Central never populates these (notification delivery is central-only), so they
|
||||
// are excluded from the projection entirely.
|
||||
var baseline = Command();
|
||||
var withCentralOnlyPayload = baseline with
|
||||
{
|
||||
NotificationLists = [new NotificationListArtifact("Ops", ["a@b.c"])],
|
||||
SmtpConfigurations =
|
||||
[new SmtpConfigurationArtifact("Main", "smtp", 25, "Basic", "a@b.c", null, null, null)]
|
||||
};
|
||||
|
||||
Assert.Equal(_sut.ComputeHash(baseline), _sut.ComputeHash(withCentralOnlyPayload));
|
||||
}
|
||||
|
||||
public static TheoryData<string, DeployArtifactsCommand> ChangedFieldCases()
|
||||
{
|
||||
var data = new TheoryData<string, DeployArtifactsCommand>();
|
||||
|
||||
void Add(string field, DeployArtifactsCommand command) => data.Add(field, command);
|
||||
|
||||
// SharedScriptArtifact — every field.
|
||||
Add("script.Name", Command(sharedScripts: [new SharedScriptArtifact("Renamed", "c", "p", "r")]));
|
||||
Add("script.Code", Command(sharedScripts: [new SharedScriptArtifact("S", "changed", "p", "r")]));
|
||||
Add("script.ParameterDefinitions", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "changed", "r")]));
|
||||
Add("script.ReturnDefinition", Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "changed")]));
|
||||
|
||||
// ExternalSystemArtifact — every field.
|
||||
Add("externalSystem.Name",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("Renamed", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.EndpointUrl",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "changed", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.AuthType",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "BasicAuth", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.AuthConfiguration",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "changed", "[]", 1, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.MethodDefinitionsJson",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[{\"Name\":\"M\"}]", 1, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.TimeoutSeconds",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 99, 2, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.MaxRetries",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 99, TimeSpan.FromSeconds(3))]));
|
||||
Add("externalSystem.RetryDelay",
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(99))]));
|
||||
|
||||
// DatabaseConnectionArtifact — every field.
|
||||
Add("databaseConnection.Name",
|
||||
Command(databaseConnections: [new DatabaseConnectionArtifact("Renamed", "cs", 1, TimeSpan.FromSeconds(2))]));
|
||||
Add("databaseConnection.ConnectionString",
|
||||
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "changed", 1, TimeSpan.FromSeconds(2))]));
|
||||
Add("databaseConnection.MaxRetries",
|
||||
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 99, TimeSpan.FromSeconds(2))]));
|
||||
Add("databaseConnection.RetryDelay",
|
||||
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(99))]));
|
||||
|
||||
// DataConnectionArtifact — every field.
|
||||
Add("dataConnection.Name",
|
||||
Command(dataConnections: [new DataConnectionArtifact("Renamed", "OpcUa", "p", "b", 1)]));
|
||||
Add("dataConnection.Protocol",
|
||||
Command(dataConnections: [new DataConnectionArtifact("C", "MxGateway", "p", "b", 1)]));
|
||||
Add("dataConnection.PrimaryConfigurationJson",
|
||||
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "changed", "b", 1)]));
|
||||
Add("dataConnection.BackupConfigurationJson",
|
||||
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "changed", 1)]));
|
||||
Add("dataConnection.FailoverRetryCount",
|
||||
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 99)]));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ChangedFieldCases))]
|
||||
public void ComputeHash_EveryArtifactField_ParticipatesInTheHash(
|
||||
string changedField, DeployArtifactsCommand changed)
|
||||
{
|
||||
// Each case is the same command with ONE artifact field differing from the
|
||||
// per-class baseline below; a field silently dropped from the projection would
|
||||
// make the two hashes equal and leave that edit invisible to the indicator.
|
||||
var baselines = new Dictionary<string, DeployArtifactsCommand>(StringComparer.Ordinal)
|
||||
{
|
||||
["script"] = Command(sharedScripts: [new SharedScriptArtifact("S", "c", "p", "r")]),
|
||||
["externalSystem"] =
|
||||
Command(externalSystems: [new ExternalSystemArtifact("E", "u", "ApiKey", "a", "[]", 1, 2, TimeSpan.FromSeconds(3))]),
|
||||
["databaseConnection"] =
|
||||
Command(databaseConnections: [new DatabaseConnectionArtifact("D", "cs", 1, TimeSpan.FromSeconds(2))]),
|
||||
["dataConnection"] =
|
||||
Command(dataConnections: [new DataConnectionArtifact("C", "OpcUa", "p", "b", 1)]),
|
||||
};
|
||||
|
||||
var baseline = baselines[changedField.Split('.')[0]];
|
||||
|
||||
Assert.NotEqual(_sut.ComputeHash(baseline), _sut.ComputeHash(changed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashableRecords_PropertiesDeclaredAlphabetically()
|
||||
{
|
||||
// Artifact-hash determinism depends entirely on the private Hashable* records
|
||||
// declaring their properties in alphabetical order (System.Text.Json emits
|
||||
// properties in CLR declaration order and does not sort). Without this guard a
|
||||
// contributor could silently change every site's artifact hash — flagging the
|
||||
// whole fleet out of date — by adding a property out of order. Mirrors
|
||||
// RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically.
|
||||
var nested = typeof(ArtifactRevisionHashService)
|
||||
.GetNestedTypes(BindingFlags.NonPublic)
|
||||
.Where(t => t.Name.StartsWith("Hashable", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(nested);
|
||||
|
||||
foreach (var type in nested)
|
||||
{
|
||||
var propNames = type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.Name != "EqualityContract")
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(
|
||||
propNames.SequenceEqual(sorted),
|
||||
$"{type.Name} properties must be declared alphabetically. " +
|
||||
$"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,8 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
comms,
|
||||
Substitute.For<IAuditService>(),
|
||||
Options.Create(new DeploymentManagerOptions()),
|
||||
NullLogger<ArtifactDeploymentService>.Instance);
|
||||
NullLogger<ArtifactDeploymentService>.Instance,
|
||||
new ArtifactRevisionHashService());
|
||||
_services.AddScoped(_ => _artifactService);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user