fix(site-secrets): mirror secrets before pushing artifact data connections to the DCL

Final review M2: HandleDeployArtifacts now sends ApplyArtifactDataConnectionsToDcl after the
shipped secrets are written and mirrored (from a finally, so the push still goes out if the
secrets step fails), so a connection created by the same deploy resolves a newly shipped
secret on first connect. Definitions are still stored at the same point; response semantics
unchanged.
This commit is contained in:
Joseph Doherty
2026-09-28 21:08:34 -04:00
parent 6f1787abe1
commit b1b49bb36c
2 changed files with 77 additions and 4 deletions
@@ -1,4 +1,5 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -47,7 +48,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
TestLocalDb.DeleteFiles(path);
}
private IActorRef CreateDeploymentManager(DeployedSecretsMirror? secretsMirror = null)
private IActorRef CreateDeploymentManager(DeployedSecretsMirror? secretsMirror = null, IActorRef? dclManager = null)
{
var options = new SiteRuntimeOptions();
// Positional, not named, past the constructor's first six parameters: a named
@@ -62,7 +63,7 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
null, // streamManager: no stream manager in tests
options,
NullLogger<DeploymentManagerActor>.Instance,
null, // dclManager
dclManager,
null, // healthCollector
null, // serviceProvider
null, // loggerFactory
@@ -245,6 +246,61 @@ public class DeploymentManagerActorArtifactSecretsTests : TestKit, IDisposable
Assert.Null(r2.ErrorMessage);
}
[Fact]
public void DataConnections_PushedToDcl_OnlyAfterShippedSecretsAreMirrored()
{
// M2 (final review): a connection whose config references a secret shipped by THIS
// deploy must find it on its first connect, so the DCL create must not reach the
// DCL manager before the secret is in this node's store.
var store = new FakeSecretStore();
var resolver = new FakeSecretResolver();
var mirror = CreateMirror(store, resolver);
var dcl = CreateTestProbe();
bool? secretPresentWhenDclSawCreate = null;
dcl.SetAutoPilot(new DelegateAutoPilot((_, msg) =>
{
if (msg is ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand)
{
secretPresentWhenDclSawCreate ??= store.Contains("gw-key");
}
return AutoPilot.KeepRunning;
}));
var manager = CreateDeploymentManager(mirror, dcl.Ref);
var command = ArtifactsCommand("dep-order", secrets: [Art("gw-key", 1)]) with
{
DataConnections = [new DataConnectionArtifact("Gw", "OpcUa", "{}", null, 3)],
};
manager.Tell(command);
var response = ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10));
Assert.True(response.Success);
Assert.Equal(1, response.SecretsApplied);
AwaitCondition(() => secretPresentWhenDclSawCreate.HasValue, TimeSpan.FromSeconds(10));
Assert.True(secretPresentWhenDclSawCreate);
}
[Fact]
public async Task DataConnections_StillStoredAndPushed_WhenSecretsStepFails()
{
// The reorder must not lose the connection push when the secrets step fails.
var dcl = CreateTestProbe();
var manager = CreateDeploymentManager(secretsMirror: null, dclManager: dcl.Ref);
var command = ArtifactsCommand("dep-order-fail", secrets: [Art("gw-key", 1)]) with
{
DataConnections = [new DataConnectionArtifact("Gw", "OpcUa", "{}", null, 3)],
};
manager.Tell(command);
var response = ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10));
Assert.False(response.Success);
dcl.ExpectMsg<ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection.CreateConnectionCommand>(
TimeSpan.FromSeconds(10));
Assert.Contains(await _storage.GetAllDataConnectionDefinitionsAsync(), d => d.Name == "Gw");
}
// ── Fakes (mirrors DeployedSecretsMirrorTests' fakes; kept local/minimal per-task) ──
private sealed class FakeSecretStore : ISecretStore