feat(site): DeploymentManagerActor fetches config then applies (notify-and-fetch)
This commit is contained in:
+133
-2
@@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user