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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user