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:
Joseph Doherty
2026-09-14 13:42:51 -04:00
parent dc60ba1212
commit 5214932447
7 changed files with 849 additions and 5 deletions
@@ -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>());
@@ -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>
@@ -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);
}