From 52149324473ce90481029904c19795c62c553cae Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 14 Sep 2026 13:42:51 -0400 Subject: [PATCH] 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 --- .../ArtifactDeploymentService.cs | 120 ++++++- .../ArtifactRevisionHashService.cs | 295 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 5 + .../Admin/SitesPageTests.cs | 3 +- .../ArtifactDeploymentServiceTests.cs | 173 +++++++++- .../ArtifactRevisionHashServiceTests.cs | 255 +++++++++++++++ .../ManagementActorTests.cs | 3 +- 7 files changed, 849 insertions(+), 5 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs index b6915ef1..9f312dc0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs @@ -39,6 +39,7 @@ public class ArtifactDeploymentService private readonly IAuditService _auditService; private readonly DeploymentManagerOptions _options; private readonly ILogger _logger; + private readonly ArtifactRevisionHashService _hashService; /// /// Initializes a new instance of the ArtifactDeploymentService. @@ -57,6 +58,11 @@ public class ArtifactDeploymentService /// Service for audit logging. /// Deployment manager options. /// Logger instance. + /// + /// Computes the deterministic revision hash of a per-site artifact set, stamped on the + /// row after a successful deploy. Last on the signature so the + /// addition stays positionally additive for existing call sites. + /// public ArtifactDeploymentService( ISiteRepository siteRepo, IDeploymentManagerRepository deploymentRepo, @@ -66,7 +72,8 @@ public class ArtifactDeploymentService CommunicationService communicationService, IAuditService auditService, IOptions options, - ILogger logger) + ILogger logger, + ArtifactRevisionHashService hashService) { _siteRepo = siteRepo; _deploymentRepo = deploymentRepo; @@ -79,6 +86,7 @@ public class ArtifactDeploymentService _auditService = auditService; _options = options.Value; _logger = logger; + _hashService = hashService; } /// @@ -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.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}"); } } + + /// + /// 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 is how the pending-updates + /// aggregate decides whether a site's system-wide artifacts are out of date. + /// + /// The sites to compute current hashes for. + /// Cancellation token. + /// + /// A map of site database id to the hash of the artifact set that a deploy would send + /// to that site right now. + /// + /// + /// The global artifact queries run ONCE for the whole sweep (the same hoist + /// performs); only the per-site data-connection query + /// runs per site. The commands are built with the constant deployment id "current", + /// which is harmless because 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. + /// + public async Task> ComputeCurrentArtifactHashesAsync( + IReadOnlyList sites, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sites); + + var hashes = new Dictionary(); + 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; + } + + /// + /// Stamps and + /// 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. + /// + 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))); + } + } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs new file mode 100644 index 00000000..be55a8be --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactRevisionHashService.cs @@ -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; + +/// +/// Produces a deterministic SHA-256 hash of the per-site system-wide artifact set +/// carried by a (shared scripts, external +/// systems, database connections, and the site's data connections). The hash is +/// stamped on Site.ArtifactRevisionHash 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. +/// +/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR declaration +/// order and does NOT sort them. Stable output therefore relies on the private +/// Hashable* 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 +/// ArtifactRevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically, +/// mirroring RevisionHashService. Every artifact list is explicitly sorted by +/// Name (ordinal) before hashing, so repository row order never leaks into the +/// hash, and an external system's MethodDefinitionsJson — itself a serialized +/// collection — is re-canonicalised with its methods sorted by name. +/// +/// +/// EXCLUDED FROM THE HASH: the command's DeploymentId and Timestamp +/// (both volatile per deploy — two deploys of identical definitions must hash the +/// same), and NotificationLists/SmtpConfigurations (notification +/// delivery is central-only; central never populates those fields, so folding them +/// in would only add a permanently-null branch). +/// +/// +/// 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 +/// 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. +/// +/// +public sealed class ArtifactRevisionHashService +{ + private static readonly JsonSerializerOptions CanonicalJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + /// + /// 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. + /// + /// The per-site artifact deployment command to hash. + /// + /// The canonical revision hash, encoded as sha256: followed by the lower-case + /// hex digest (the same encoding as RevisionHashService). + /// + 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)}"; + } + + /// + /// 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. + /// + 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 + { + /// + /// The site's data connections, sorted by name; null when the site has none. + /// + public List? DataConnections { get; init; } + /// + /// The system-wide database connections, sorted by name; null when there are none. + /// + public List? DatabaseConnections { get; init; } + /// + /// The system-wide external systems, sorted by name; null when there are none. + /// + public List? ExternalSystems { get; init; } + /// + /// The system-wide shared scripts, sorted by name; null when there are none. + /// + public List? SharedScripts { get; init; } + } + + private sealed record HashableDataConnection + { + /// + /// Backup connection configuration JSON (plaintext in memory), if any. + /// + public string? BackupConfigurationJson { get; init; } + /// + /// Number of failover retries before giving up. + /// + public int FailoverRetryCount { get; init; } + /// + /// The connection name — the key scripts and bindings address it by. + /// + public string Name { get; init; } = string.Empty; + /// + /// Primary connection configuration JSON (plaintext in memory), if any. + /// + public string? PrimaryConfigurationJson { get; init; } + /// + /// Protocol name (e.g. "OpcUa"). + /// + public string Protocol { get; init; } = string.Empty; + } + + private sealed record HashableDatabaseConnection + { + /// + /// The database connection string shipped to the site. + /// + public string ConnectionString { get; init; } = string.Empty; + /// + /// Store-and-Forward retry budget for this connection's cached writes (0 = unset). + /// + public int MaxRetries { get; init; } + /// + /// The database connection name. + /// + public string Name { get; init; } = string.Empty; + /// + /// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather + /// than a so the serialized form cannot vary by format. + /// + public long RetryDelayTicks { get; init; } + } + + private sealed record HashableExternalSystem + { + /// + /// JSON-serialized authentication configuration for . + /// + public string? AuthConfiguration { get; init; } + /// + /// Authentication type identifier (e.g. ApiKey, BasicAuth). + /// + public string AuthType { get; init; } = string.Empty; + /// + /// Base URL of the system's HTTP endpoint. + /// + public string EndpointUrl { get; init; } = string.Empty; + /// + /// Store-and-Forward retry budget for this system's cached calls (0 = unset). + /// + public int MaxRetries { get; init; } + /// + /// The system's method list, re-canonicalised with the methods sorted by name; + /// null when the system defines none. + /// + public string? MethodDefinitionsJson { get; init; } + /// + /// Unique system name — the key scripts call it by. + /// + public string Name { get; init; } = string.Empty; + /// + /// Store-and-Forward retry interval in ticks (0 = unset). Carried as ticks rather + /// than a so the serialized form cannot vary by format. + /// + public long RetryDelayTicks { get; init; } + /// + /// Per-system HTTP round-trip timeout in seconds (0 = unset). + /// + public int TimeoutSeconds { get; init; } + } + + private sealed record HashableSharedScript + { + /// + /// The shared script's source code. + /// + public string Code { get; init; } = string.Empty; + /// + /// The shared script name — the key other scripts call it by. + /// + public string Name { get; init; } = string.Empty; + /// + /// JSON representation of the script's parameter definitions. + /// + public string? ParameterDefinitions { get; init; } + /// + /// JSON representation of the script's return type definition. + /// + public string? ReturnDefinition { get; init; } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs index 1078563e..7e3405d9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ServiceCollectionExtensions.cs @@ -58,6 +58,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // 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(); + // 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs index 3862dc42..a171122c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs @@ -44,7 +44,8 @@ public class SitesPageTests : BunitContext comms, Substitute.For(), Options.Create(new DeploymentManagerOptions()), - NullLogger.Instance); + NullLogger.Instance, + new ArtifactRevisionHashService()); Services.AddSingleton(artifactSvc); Services.AddSingleton(Substitute.For()); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs index ae0b4e7c..e08b7d46 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs @@ -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()).Returns(new List { 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()); + await _siteRepo.Received(1).SaveChangesAsync(Arg.Any()); + } + + [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()).Returns(new List { 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()); + await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any()); + } + + [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()).Returns(new List { site }); + _siteRepo.SaveChangesAsync(Arg.Any()) + .Returns(_ => 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()).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()); + await _siteRepo.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite() + { + var site = new Site("Retry Site", "fail-site") { Id = 7 }; + _siteRepo.GetSiteByIdAsync(7, Arg.Any()).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(), Arg.Any()); + } + + [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 + { + new("Site One", "site-1") { Id = 1 }, + new("Site Two", "site-2") { Id = 2 } + }; + _siteRepo.GetAllSitesAsync(Arg.Any()).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 + { + 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()); + await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); + await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); + await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any()); + await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any()); + } + + [Fact] + public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying() + { + var service = CreateService(); + + var current = await service.ComputeCurrentArtifactHashesAsync([]); + + Assert.Empty(current); + await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any()); + } + 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.Instance); + NullLogger.Instance, + _hashService); } private ArtifactDeploymentService CreateServiceWithCommActor(IActorRef commActor) @@ -331,7 +499,8 @@ public class ArtifactDeploymentServiceTests : TestKit { ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5) }), - NullLogger.Instance); + NullLogger.Instance, + _hashService); } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs new file mode 100644 index 00000000..aa9463a8 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactRevisionHashServiceTests.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; + +namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests; + +/// +/// Tests for , 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. +/// +public class ArtifactRevisionHashServiceTests +{ + private readonly ArtifactRevisionHashService _sut = new(); + + private static DeployArtifactsCommand Command( + string deploymentId = "dep-1", + DateTimeOffset? timestamp = null, + IReadOnlyList? sharedScripts = null, + IReadOnlyList? externalSystems = null, + IReadOnlyList? databaseConnections = null, + IReadOnlyList? 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 ChangedFieldCases() + { + var data = new TheoryData(); + + 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(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)}]"); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 779a5cce..f64e099d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -84,7 +84,8 @@ public class ManagementActorTests : TestKit, IDisposable comms, Substitute.For(), Options.Create(new DeploymentManagerOptions()), - NullLogger.Instance); + NullLogger.Instance, + new ArtifactRevisionHashService()); _services.AddScoped(_ => _artifactService); }