feat(site): DeploymentManagerActor fetches config then applies (notify-and-fetch)

This commit is contained in:
Joseph Doherty
2026-06-26 13:47:28 -04:00
parent 3955cb4f28
commit 631ce5bfce
3 changed files with 257 additions and 9 deletions
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
@@ -48,7 +49,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
}
private IActorRef CreateDeploymentManager(
SiteRuntimeOptions? options = null, IServiceProvider? serviceProvider = null)
SiteRuntimeOptions? options = null, IServiceProvider? serviceProvider = null,
IDeploymentConfigFetcher? configFetcher = null)
{
options ??= new SiteRuntimeOptions();
return ActorOf(Props.Create(() => new DeploymentManagerActor(
@@ -62,7 +64,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
null,
null,
serviceProvider,
null)));
null,
configFetcher)));
}
private static string MakeConfigJson(string instanceName)
@@ -675,4 +678,132 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.False(snapshot.InstanceNotFound,
"A deployed (but empty) instance must NOT set InstanceNotFound.");
}
// ── Task 10: notify-and-fetch — RefreshDeploymentCommand → fetch → apply ──
[Fact]
public async Task RefreshDeployment_FetchSucceeds_AppliesConfigAndRepliesSuccess()
{
// The active singleton receives a small RefreshDeploymentCommand, fetches the
// flattened config over HTTP via IDeploymentConfigFetcher, then runs the existing
// apply path: the config is persisted to SQLite and a Success status is replied.
var fetcher = new FakeConfigFetcher(MakeConfigJson("FetchedPump"));
var actor = CreateDeploymentManager(configFetcher: fetcher);
await Task.Delay(500); // empty startup
actor.Tell(new RefreshDeploymentCommand(
"dep-fetch-1", "FetchedPump", "sha256:fetch", "admin", DateTimeOffset.UtcNow,
"http://central:9000", "tok-123"));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
Assert.Equal("FetchedPump", response.InstanceUniqueName);
Assert.Equal("dep-fetch-1", response.DeploymentId);
// The fetcher was called with the command's fetch coordinates.
Assert.Equal("http://central:9000", fetcher.LastBaseUrl);
Assert.Equal("dep-fetch-1", fetcher.LastDeploymentId);
Assert.Equal("tok-123", fetcher.LastToken);
// The existing apply path ran end-to-end: the fetched config is persisted.
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.Contains(configs, c => c.InstanceUniqueName == "FetchedPump");
}
[Fact]
public async Task RefreshDeployment_FetchFails_RepliesFailedAndDoesNotApply()
{
// A config fetch failure (DeploymentConfigFetchException surfaces as a faulted
// Task) must reply Failed with a "Communication failure:" message and must NOT
// apply anything — no config persisted, no Instance Actor created.
var fetcher = new FakeConfigFetcher(
new DeploymentConfigFetchException("central unreachable", isSuperseded: false));
var actor = CreateDeploymentManager(configFetcher: fetcher);
await Task.Delay(500);
actor.Tell(new RefreshDeploymentCommand(
"dep-fetch-fail", "UnfetchedPump", "sha256:x", "admin", DateTimeOffset.UtcNow,
"http://central:9000", "tok-x"));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Failed, response.Status);
Assert.Equal("UnfetchedPump", response.InstanceUniqueName);
Assert.NotNull(response.ErrorMessage);
Assert.StartsWith("Communication failure:", response.ErrorMessage!);
// No apply occurred: nothing was persisted for the instance.
await Task.Delay(500);
var configs = await _storage.GetAllDeployedConfigsAsync();
Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "UnfetchedPump");
}
[Fact]
public void RefreshDeployment_NullFetcher_RepliesFailed()
{
// A node with no configured fetcher (the `_configFetcher is null` guard) must reply
// Failed rather than NRE-crashing or silently dropping the central Ask.
var actor = CreateDeploymentManager(); // no configFetcher
actor.Tell(new RefreshDeploymentCommand(
"dep-null", "Pump", "sha256:x", "admin", DateTimeOffset.UtcNow,
"http://central:9000", "tok"));
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(2));
Assert.Equal(DeploymentStatus.Failed, response.Status);
Assert.Equal("Pump", response.InstanceUniqueName);
}
[Fact]
public async Task RefreshDeployment_ReplyGoesToOriginalSender_AcrossAsyncFetch()
{
// The reply must reach the ORIGINAL sender (central's Ask temp actor), proving
// the captured replyTo survives the async fetch + PipeTo continuation — where
// Akka's Sender is no longer valid. Send with an explicit probe as the sender
// and assert the probe (not the default test actor) receives the response.
var fetcher = new FakeConfigFetcher(MakeConfigJson("SenderPump"));
var probe = CreateTestProbe();
var actor = CreateDeploymentManager(configFetcher: fetcher);
await Task.Delay(500);
actor.Tell(new RefreshDeploymentCommand(
"dep-sender", "SenderPump", "sha256:s", "admin", DateTimeOffset.UtcNow,
"http://central:9000", "tok-s"), probe.Ref);
var response = probe.ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
Assert.Equal("SenderPump", response.InstanceUniqueName);
}
/// <summary>
/// In-test fake <see cref="IDeploymentConfigFetcher"/>: returns a canned config JSON
/// (notify-and-fetch success) or throws a canned exception (fetch failure), and records
/// the fetch coordinates it was called with. It is async so a throw surfaces as a faulted
/// <see cref="Task"/> — mirroring the real HttpDeploymentConfigFetcher; a synchronous
/// throw would instead crash the actor before the ContinueWith/PipeTo could produce a
/// RefreshFetchFailed.
/// </summary>
private sealed class FakeConfigFetcher : IDeploymentConfigFetcher
{
private readonly string? _result;
private readonly Exception? _error;
public FakeConfigFetcher(string result) => _result = result;
public FakeConfigFetcher(Exception error) => _error = error;
public string? LastBaseUrl { get; private set; }
public string? LastDeploymentId { get; private set; }
public string? LastToken { get; private set; }
public async Task<string> FetchAsync(
string centralFetchBaseUrl, string deploymentId, string token, CancellationToken ct)
{
LastBaseUrl = centralFetchBaseUrl;
LastDeploymentId = deploymentId;
LastToken = token;
await Task.Yield();
if (_error != null)
throw _error;
return _result!;
}
}
}