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
@@ -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>