b34ec10f8b
P7-6 (behaviour fix, plan decision 1): DeploySiteAsync redeployed EVERY deployable instance unconditionally. A redeploy is not idempotent at the site — it resets static attribute overrides and clears native alarm state by design — so one site-wide push destroyed live operational state on every unchanged instance in the batch, which is normally most of them. Bulk deploy now pre-filters with IStaleInstanceProbe (the same seam the bundle importer and Deployments page use; its memo timing was fixed by the Medium wave's P7-4): an instance whose freshly computed revision hash still matches its DeployedConfigSnapshot is reported in the existing additive SkippedInstances list with reason "up-to-date" and never flattened, staged or sent. The filter is deliberately conservative — every uncertainty deploys: only an Enabled instance qualifies, a missing snapshot disqualifies, a prior record that ShouldQuerySiteBeforeRedeploy flags goes through prepare so the reconcile that HEALS it still runs, and a null/faulting probe means "indeterminate". New additive MgmtDeploySiteCommand.Force (default false = the new filtered behaviour) plus CLI `deploy site --force` restores the unconditional push for the case central cannot see (restored volume, rebuilt node). Composes with the Medium wave's P7-2: Disabled wins — force suppresses the up-to-date filter only, never the Disabled skip. P7-5 (verified, then fixed): the cascade REPRODUCES. All three bulk phases share one scoped DbContext, and a failed SaveChanges does not untrack the offending entity — EF re-attempts it on the next save, including MarkDeploymentFailedAsync's cleanup write. Measured on a real SQLite-backed ScadaBridgeDbContext: a permanent save failure on instance 1's post-success side effects failed instances 2, 3 and 4 and left all three deployment records stranded InProgress. MarkDeploymentFailedAsync now detaches the pending changes (new IDeploymentManagerRepository.DetachPendingChanges) when the cleanup save fails and retries the Failed write once on the clean tracker, so later instances save cleanly and the record reaches a terminal status. The DeployFailed audit row moved outside the status-write try (best-effort, must not be lost because the status write failed). Tests: 5 new DeploySiteAsyncTests (skip + reason + no site round-trip; --force deploys; Disabled skipped under both force values; the three not-enough-to-skip guards), 2 new IntegrationTests over a real EF context (the poisoned-context cascade, and the narrow EF tracking fact the fix rests on), CLI --force flag parse test, and a MgmtDeploySite wire-default test. Proof of failure: both P7-6 behaviour tests and both P7-5 assertions fail against the unfixed code.
138 lines
6.3 KiB
C#
138 lines
6.3 KiB
C#
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
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.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// CentralUI-006: regression tests proving <see cref="DeploymentService"/>
|
|
/// raises <see cref="IDeploymentStatusNotifier.StatusChanged"/> whenever it
|
|
/// writes a deployment record's status. This is the event source the Central
|
|
/// UI deployment-status page subscribes to instead of polling.
|
|
/// </summary>
|
|
public class DeploymentStatusNotifierTests : TestKit
|
|
{
|
|
private readonly IDeploymentManagerRepository _repo;
|
|
private readonly IFlatteningPipeline _pipeline;
|
|
private readonly CommunicationService _comms;
|
|
private readonly OperationLockManager _lockManager;
|
|
private readonly IAuditService _audit;
|
|
private readonly DeploymentStatusNotifier _notifier;
|
|
private readonly DeploymentService _service;
|
|
|
|
public DeploymentStatusNotifierTests()
|
|
{
|
|
_repo = Substitute.For<IDeploymentManagerRepository>();
|
|
_pipeline = Substitute.For<IFlatteningPipeline>();
|
|
_comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance);
|
|
_lockManager = new OperationLockManager();
|
|
_audit = Substitute.For<IAuditService>();
|
|
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
|
|
|
var options = Options.Create(new DeploymentManagerOptions
|
|
{
|
|
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
|
});
|
|
|
|
// DeploymentManager-021: the resolver now throws when the site row
|
|
// is missing, so seed the substitute to return a real-shaped Site for
|
|
// any id these tests touch.
|
|
var siteRepo = Substitute.For<ISiteRepository>();
|
|
siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
|
.Returns(callInfo =>
|
|
{
|
|
var id = callInfo.ArgAt<int>(0);
|
|
return new Site($"Test Site {id}", $"site-{id}") { Id = id };
|
|
});
|
|
|
|
_service = new DeploymentService(
|
|
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit,
|
|
new DiffService(), new RevisionHashService(), _notifier,
|
|
Substitute.For<IStaleInstanceProbe>(), options,
|
|
// Notify-and-fetch (Task 6): a non-empty CentralFetchBaseUrl lets the
|
|
// deploy path reach the send (and, here, the catch block) rather than
|
|
// tripping the fail-fast configuration guard.
|
|
Options.Create(new CommunicationOptions { CentralFetchBaseUrl = "https://central.test:9000" }),
|
|
NullLogger<DeploymentService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeployInstanceAsync_RaisesStatusChange_ForEveryRecordStatusWrite()
|
|
{
|
|
var instance = new Instance("TestInst") { Id = 7, SiteId = 1, State = InstanceState.NotDeployed };
|
|
_repo.GetInstanceByIdAsync(7).Returns(instance);
|
|
|
|
var config = new FlattenedConfiguration { InstanceUniqueName = "TestInst" };
|
|
_pipeline.FlattenAndValidateAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(Result<FlatteningPipelineResult>.Success(
|
|
new FlatteningPipelineResult(config, "sha256:abc", ValidationResult.Success())));
|
|
|
|
var changes = new List<DeploymentStatusChange>();
|
|
_notifier.StatusChanged += c => changes.Add(c);
|
|
|
|
// _comms has no actor set, so the deploy reaches the catch block and
|
|
// the record ends Failed. The notifier must fire for the InProgress
|
|
// and Failed writes — not be silent (the pre-fix behaviour).
|
|
//
|
|
// DeploymentManager-022: the transient Pending write was dropped from
|
|
// the deploy path (the record is now created directly in InProgress),
|
|
// so there is no Pending notification any more. The remaining two
|
|
// writes — the initial InProgress insert and the catch-block Failed
|
|
// update — must each raise a status-change.
|
|
var result = await _service.DeployInstanceAsync(7, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.NotEmpty(changes);
|
|
Assert.All(changes, c => Assert.Equal(7, c.InstanceId));
|
|
Assert.DoesNotContain(changes, c => c.Status == DeploymentStatus.Pending);
|
|
Assert.Contains(changes, c => c.Status == DeploymentStatus.InProgress);
|
|
Assert.Contains(changes, c => c.Status == DeploymentStatus.Failed);
|
|
|
|
// All notifications carry the same deployment id (the one created here).
|
|
var deploymentId = changes[0].DeploymentId;
|
|
Assert.False(string.IsNullOrEmpty(deploymentId));
|
|
Assert.All(changes, c => Assert.Equal(deploymentId, c.DeploymentId));
|
|
}
|
|
|
|
[Fact]
|
|
public void NotifyStatusChanged_WithNoSubscribers_DoesNotThrow()
|
|
{
|
|
var notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
|
|
|
var ex = Record.Exception(() =>
|
|
notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)));
|
|
|
|
Assert.Null(ex);
|
|
}
|
|
|
|
[Fact]
|
|
public void NotifyStatusChanged_ThrowingSubscriber_DoesNotBreakOtherSubscribers()
|
|
{
|
|
var notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
|
var reached = false;
|
|
|
|
notifier.StatusChanged += _ => throw new InvalidOperationException("boom");
|
|
notifier.StatusChanged += _ => reached = true;
|
|
|
|
var ex = Record.Exception(() =>
|
|
notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-2", 2, DeploymentStatus.InProgress)));
|
|
|
|
Assert.Null(ex);
|
|
Assert.True(reached, "a faulting subscriber must not stop later subscribers from being notified");
|
|
}
|
|
}
|