feat(deployment-manager): resolve DeploymentManager-006 — query site deployment state before redeploy and reconcile

Adds DeploymentStateQuery request/response contracts (Commons), a site-side
handler (SiteRuntime), a CommunicationService query method (Communication), and
reconciliation in DeploymentService: when a prior record is InProgress or
Failed-on-timeout, query the site; if it already holds the target revision hash
mark the record Success without re-sending; on query failure fall through to a
normal deploy (site-side stale-rejection is the safety net).
This commit is contained in:
Joseph Doherty
2026-05-16 20:12:24 -04:00
parent cac8aebe9f
commit bc548e1447
13 changed files with 662 additions and 19 deletions

View File

@@ -1,12 +1,15 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Messages.Deployment;
namespace ScadaLink.Communication.Tests;
/// <summary>
/// WP-2: Tests for CommunicationService initialization and state.
/// </summary>
public class CommunicationServiceTests
public class CommunicationServiceTests : TestKit
{
[Fact]
public async Task BeforeInitialization_ThrowsOnUsage()
@@ -18,7 +21,7 @@ public class CommunicationServiceTests
// CommunicationService requires SetCommunicationActor before use
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.DeployInstanceAsync("site1",
new Commons.Messages.Deployment.DeployInstanceCommand(
new DeployInstanceCommand(
"dep1", "inst1", "hash1", "{}", "admin", DateTimeOffset.UtcNow)));
}
@@ -30,4 +33,63 @@ public class CommunicationServiceTests
Assert.NotNull(method);
Assert.Equal(typeof(void), method!.ReturnType);
}
// ── DeploymentManager-006: query-the-site-before-redeploy ──
[Fact]
public async Task QueryDeploymentStateAsync_BeforeInitialization_Throws()
{
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.QueryDeploymentStateAsync("site1",
new DeploymentStateQueryRequest("corr-1", "inst1", DateTimeOffset.UtcNow)));
}
[Fact]
public async Task QueryDeploymentStateAsync_SendsEnvelopeAndReturnsResponse()
{
// The query must be dispatched as a SiteEnvelope over the existing
// command/control transport, exactly like other site-directed commands,
// and the typed response returned to the caller.
var service = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
// A probe stands in for CentralCommunicationActor: it asserts the
// envelope shape and replies with a typed response.
var commActor = Sys.ActorOf(Props.Create(() => new EchoStateQueryActor()));
service.SetCommunicationActor(commActor);
var request = new DeploymentStateQueryRequest("corr-9", "QueriedInst", DateTimeOffset.UtcNow);
var response = await service.QueryDeploymentStateAsync("site-a", request);
Assert.Equal("corr-9", response.CorrelationId);
Assert.Equal("QueriedInst", response.InstanceUniqueName);
Assert.True(response.IsDeployed);
Assert.Equal("sha256:applied", response.AppliedRevisionHash);
}
/// <summary>
/// Stand-in for CentralCommunicationActor: verifies the message is wrapped
/// in a SiteEnvelope targeting the requested site and replies with a typed
/// DeploymentStateQueryResponse.
/// </summary>
private class EchoStateQueryActor : ReceiveActor
{
public EchoStateQueryActor()
{
Receive<SiteEnvelope>(env =>
{
if (env is { SiteId: "site-a", Message: DeploymentStateQueryRequest req })
{
Sender.Tell(new DeploymentStateQueryResponse(
req.CorrelationId, req.InstanceUniqueName, true,
"dep-applied", "sha256:applied", DateTimeOffset.UtcNow));
}
});
}
}
}