feat(deploymentmanager): DeployAreaAsync bulk-deploys an area subtree through the shared DeploySiteAsync core; PreviewSubtreeDeployAsync returns confirm counts (Gitea #40)

DeploySiteAsync's body moves into a private core taking the site plus an
optional area-id filter; site deploy behaviour is unchanged. The subtree is
one in-memory ParentAreaId walk (cycle-safe) shared by the deploy and the
preview. DeploymentService gains an ITemplateEngineRepository dependency for
the area lookups.
This commit is contained in:
Joseph Doherty
2026-10-22 14:07:33 -04:00
parent b9751473a6
commit eb1c9ea7f9
9 changed files with 568 additions and 20 deletions
@@ -219,6 +219,30 @@ runs for it. An escape from phase 1 or phase 3 unwinds every not-yet-finalised
entry the same way: `Failed` status plus lock release, so no instance touched by entry the same way: `Failed` status plus lock release, so no instance touched by
a cancelled bulk deploy can be left holding its lock. a cancelled bulk deploy can be left holding its lock.
### Area-subtree deployment (`DeployAreaAsync`, Gitea #40)
`DeployAreaAsync(areaId, user, force)` is the same bulk deploy narrowed to one
area **subtree** — the area itself plus every area nested beneath it at any
depth — surfaced as the `MgmtDeployArea` management command, the CLI
`deploy area --id`, and the Central UI Topology page's **Deploy all…** context
item on area nodes (the site node's item calls `DeploySiteAsync`). Both entry
points run one private core (`DeployInstancesOfSiteAsync`) that loads the site's
instances, keeps only those whose `AreaId` is in the subtree (area-less instances
belong to the whole-site scope only), and then applies everything above
unchanged: the Disabled skip (even under `force`), the up-to-date skip unless
`force`, the three phases, not-all-or-nothing, and the same
`SiteDeploymentSummary` with `SkippedInstances`. An unknown area is a failure
result, not an empty success.
The subtree is resolved from ONE read of the site's areas
(`GetAreasBySiteIdAsync`) walked in memory over `ParentAreaId`; each area is
visited at most once, so a corrupt parent chain that loops cannot hang the walk.
`PreviewSubtreeDeployAsync(siteId, areaId?)` shares that walk to return the
confirm dialog's `SubtreeDeployPreview(Total, Disabled)` counts (`areaId` null =
the whole site; an area of another site is a failure) — a plain DB read with no
staleness probe, so the up-to-date count is only known once the deploy reports
its skipped rows.
`DeployInstanceAsync` is composed from the same three phase helpers with a batch of `DeployInstanceAsync` is composed from the same three phase helpers with a batch of
one, so the two entry points cannot drift on deployment identity, idempotency, lock one, so the two entry points cannot drift on deployment identity, idempotency, lock
coverage, or optimistic concurrency. coverage, or optimistic concurrency.
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; 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.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
@@ -38,6 +39,7 @@ public class DeploymentService
{ {
private readonly IDeploymentManagerRepository _repository; private readonly IDeploymentManagerRepository _repository;
private readonly ISiteRepository _siteRepository; private readonly ISiteRepository _siteRepository;
private readonly ITemplateEngineRepository _templateRepository;
private readonly IFlatteningPipeline _flatteningPipeline; private readonly IFlatteningPipeline _flatteningPipeline;
private readonly CommunicationService _communicationService; private readonly CommunicationService _communicationService;
private readonly OperationLockManager _lockManager; private readonly OperationLockManager _lockManager;
@@ -96,6 +98,11 @@ public class DeploymentService
/// </summary> /// </summary>
/// <param name="repository">Repository for deployment manager data access.</param> /// <param name="repository">Repository for deployment manager data access.</param>
/// <param name="siteRepository">Repository for site data access.</param> /// <param name="siteRepository">Repository for site data access.</param>
/// <param name="templateRepository">
/// Repository for area lookups — the area-subtree bulk deploy
/// (<see cref="DeployAreaAsync"/>) and its pre-confirm counts resolve the area
/// tree through it.
/// </param>
/// <param name="flatteningPipeline">Pipeline for flattening and validating template configurations.</param> /// <param name="flatteningPipeline">Pipeline for flattening and validating template configurations.</param>
/// <param name="communicationService">Service for cross-cluster communication with sites.</param> /// <param name="communicationService">Service for cross-cluster communication with sites.</param>
/// <param name="lockManager">Manager for per-instance operation locks.</param> /// <param name="lockManager">Manager for per-instance operation locks.</param>
@@ -119,6 +126,7 @@ public class DeploymentService
public DeploymentService( public DeploymentService(
IDeploymentManagerRepository repository, IDeploymentManagerRepository repository,
ISiteRepository siteRepository, ISiteRepository siteRepository,
ITemplateEngineRepository templateRepository,
IFlatteningPipeline flatteningPipeline, IFlatteningPipeline flatteningPipeline,
CommunicationService communicationService, CommunicationService communicationService,
OperationLockManager lockManager, OperationLockManager lockManager,
@@ -132,6 +140,7 @@ public class DeploymentService
{ {
_repository = repository; _repository = repository;
_siteRepository = siteRepository; _siteRepository = siteRepository;
_templateRepository = templateRepository;
_flatteningPipeline = flatteningPipeline; _flatteningPipeline = flatteningPipeline;
_communicationService = communicationService; _communicationService = communicationService;
_lockManager = lockManager; _lockManager = lockManager;
@@ -938,7 +947,37 @@ public class DeploymentService
if (site == null) if (site == null)
return Result<SiteDeploymentSummary>.Failure($"Site with ID {siteId} not found."); return Result<SiteDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken); return await DeployInstancesOfSiteAsync(site, areaFilter: null, scope: "site", user, force, cancellationToken);
}
/// <summary>
/// The body of every bulk deploy — whole site (<see cref="DeploySiteAsync"/>)
/// or one area subtree (<see cref="DeployAreaAsync"/>). Loads the site's
/// instances, narrows them to <paramref name="areaFilter"/> when one is given,
/// then applies the Disabled skip, the up-to-date pre-filter and the three
/// phases exactly as documented on <see cref="DeploySiteAsync"/>.
/// </summary>
/// <param name="site">The resolved site whose instances are deployed.</param>
/// <param name="areaFilter">
/// Area ids an instance's <see cref="Instance.AreaId"/> must be in to be part of
/// the batch, or null for every instance at the site (area-less ones included).
/// </param>
/// <param name="scope">Human-readable batch scope for the log lines (e.g. <c>site</c>, <c>area 12</c>).</param>
/// <param name="user">The username initiating the deployment.</param>
/// <param name="force">Suppresses the up-to-date filter; never the Disabled one.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The per-instance result matrix.</returns>
private async Task<Result<SiteDeploymentSummary>> DeployInstancesOfSiteAsync(
Site site,
IReadOnlySet<int>? areaFilter,
string scope,
string user,
bool force,
CancellationToken cancellationToken)
{
IReadOnlyList<Instance> instances = await _siteRepository.GetInstancesBySiteIdAsync(site.Id, cancellationToken);
if (areaFilter != null)
instances = instances.Where(i => i.AreaId is { } areaId && areaFilter.Contains(areaId)).ToList();
if (instances.Count == 0) if (instances.Count == 0)
return Result<SiteDeploymentSummary>.Success(new SiteDeploymentSummary(site.SiteIdentifier, [], 0, 0, [])); return Result<SiteDeploymentSummary>.Success(new SiteDeploymentSummary(site.SiteIdentifier, [], 0, 0, []));
@@ -1017,8 +1056,8 @@ public class DeploymentService
// fails only this instance. Recording it and moving on is what // fails only this instance. Recording it and moving on is what
// makes a bulk deploy usable while individual instances are busy. // makes a bulk deploy usable while individual instances are busy.
_logger.LogWarning(ex, _logger.LogWarning(ex,
"Preparing instance {Instance} for bulk deployment of site {SiteId} failed", "Preparing instance {Instance} for bulk deployment of site {SiteId} ({Scope}) failed",
instance.UniqueName, site.SiteIdentifier); instance.UniqueName, site.SiteIdentifier, scope);
results.Add(new InstanceDeploymentResult( results.Add(new InstanceDeploymentResult(
instance.Id, instance.UniqueName, null, false, ex.Message)); instance.Id, instance.UniqueName, null, false, ex.Message));
continue; continue;
@@ -1101,12 +1140,12 @@ public class DeploymentService
} }
_logger.LogInformation( _logger.LogInformation(
"Bulk deployment of site {SiteId} complete: {SuccessCount} succeeded, {FailureCount} failed, " + "Bulk deployment of site {SiteId} ({Scope}) complete: {SuccessCount} succeeded, {FailureCount} failed, " +
"{DisabledCount} skipped as Disabled, {UpToDateCount} skipped as up-to-date (force={Force}) " + "{DisabledCount} skipped as Disabled, {UpToDateCount} skipped as up-to-date (force={Force}) " +
"({ChainLoads} template-chain load(s) across {InstanceCount} deployed instance(s); " + "({ChainLoads} template-chain load(s) across {InstanceCount} deployed instance(s); " +
"up-to-date pre-filter probed {ProbedCount} instance(s) in {PreFilterMs} ms with " + "up-to-date pre-filter probed {ProbedCount} instance(s) in {PreFilterMs} ms with " +
"{PreFilterChainLoads} of those chain load(s))", "{PreFilterChainLoads} of those chain load(s))",
site.SiteIdentifier, summary.SuccessCount, summary.FailureCount, disabledCount, upToDateCount, site.SiteIdentifier, scope, summary.SuccessCount, summary.FailureCount, disabledCount, upToDateCount,
force, session.ChainLoads, deployable.Count, force, session.ChainLoads, deployable.Count,
preFilterProbed, preFilterTimer.ElapsedMilliseconds, preFilterChainLoads); preFilterProbed, preFilterTimer.ElapsedMilliseconds, preFilterChainLoads);
@@ -1124,12 +1163,25 @@ public class DeploymentService
/// <param name="force">Deploy up-to-date instances too; never re-enables Disabled ones.</param> /// <param name="force">Deploy up-to-date instances too; never re-enables Disabled ones.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param> /// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The same per-instance result matrix as a site deploy, or a failure when the area cannot be resolved.</returns> /// <returns>The same per-instance result matrix as a site deploy, or a failure when the area cannot be resolved.</returns>
public Task<Result<SiteDeploymentSummary>> DeployAreaAsync( public async Task<Result<SiteDeploymentSummary>> DeployAreaAsync(
int areaId, int areaId,
string user, string user,
bool force = false, bool force = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> throw new NotImplementedException(); {
var area = await _templateRepository.GetAreaByIdAsync(areaId, cancellationToken);
if (area == null)
return Result<SiteDeploymentSummary>.Failure($"Area with ID {areaId} not found.");
var site = await _siteRepository.GetSiteByIdAsync(area.SiteId, cancellationToken);
if (site == null)
return Result<SiteDeploymentSummary>.Failure(
$"Site with ID {area.SiteId} (owner of area {areaId}) not found.");
var subtree = await GetAreaSubtreeIdsAsync(area, cancellationToken);
return await DeployInstancesOfSiteAsync(
site, subtree, scope: $"area {area.Id} '{area.Name}'", user, force, cancellationToken);
}
/// <summary> /// <summary>
/// Cheap pre-confirm counts for a site or area subtree deploy (Gitea #40): how /// Cheap pre-confirm counts for a site or area subtree deploy (Gitea #40): how
@@ -1140,11 +1192,71 @@ public class DeploymentService
/// <param name="areaId">The subtree's root area, or null for the whole site.</param> /// <param name="areaId">The subtree's root area, or null for the whole site.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param> /// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The counts, or a failure when the site or area cannot be resolved.</returns> /// <returns>The counts, or a failure when the site or area cannot be resolved.</returns>
public Task<Result<SubtreeDeployPreview>> PreviewSubtreeDeployAsync( public async Task<Result<SubtreeDeployPreview>> PreviewSubtreeDeployAsync(
int siteId, int siteId,
int? areaId, int? areaId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
=> throw new NotImplementedException(); {
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
if (site == null)
return Result<SubtreeDeployPreview>.Failure($"Site with ID {siteId} not found.");
IReadOnlySet<int>? subtree = null;
if (areaId is { } id)
{
var area = await _templateRepository.GetAreaByIdAsync(id, cancellationToken);
if (area == null)
return Result<SubtreeDeployPreview>.Failure($"Area with ID {id} not found.");
if (area.SiteId != siteId)
return Result<SubtreeDeployPreview>.Failure(
$"Area with ID {id} does not belong to site with ID {siteId}.");
subtree = await GetAreaSubtreeIdsAsync(area, cancellationToken);
}
var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
var total = 0;
var disabled = 0;
foreach (var instance in instances)
{
if (subtree != null && !(instance.AreaId is { } a && subtree.Contains(a)))
continue;
total++;
if (instance.State == InstanceState.Disabled)
disabled++;
}
return Result<SubtreeDeployPreview>.Success(new SubtreeDeployPreview(total, disabled));
}
/// <summary>
/// The ids of <paramref name="root"/> and every area nested beneath it at any
/// depth, from ONE read of the site's areas walked in memory over
/// <see cref="Area.ParentAreaId"/>. Shared by <see cref="DeployAreaAsync"/> and
/// <see cref="PreviewSubtreeDeployAsync"/> so the confirm dialog's counts and the
/// deploy that follows always agree on the subtree. A corrupt parent chain that
/// loops is tolerated: each area is visited at most once.
/// </summary>
private async Task<IReadOnlySet<int>> GetAreaSubtreeIdsAsync(Area root, CancellationToken cancellationToken)
{
var areas = await _templateRepository.GetAreasBySiteIdAsync(root.SiteId, cancellationToken);
var childrenByParent = areas
.Where(a => a.ParentAreaId.HasValue)
.ToLookup(a => a.ParentAreaId!.Value, a => a.Id);
var subtree = new HashSet<int> { root.Id };
var pending = new Queue<int>();
pending.Enqueue(root.Id);
while (pending.Count > 0)
{
foreach (var childId in childrenByParent[pending.Dequeue()])
{
if (subtree.Add(childId))
pending.Enqueue(childId);
}
}
return subtree;
}
/// <summary> /// <summary>
/// True when a bulk site deploy should leave this instance alone because the /// True when a bulk site deploy should leave this instance alone because the
@@ -0,0 +1,409 @@
using System.Collections.Concurrent;
using Akka.Actor;
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.Messages.Deployment;
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.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// Area-subtree bulk deployment (Gitea #40). <c>DeployAreaAsync</c> is
/// <c>DeploySiteAsync</c> narrowed to the instances under one area and all of its
/// descendants, so these tests pin the narrowing — descendants in, siblings and
/// ancestors out — plus the bulk rules it must inherit unchanged (Disabled always
/// skipped, up-to-date skipped unless forced). They also cover the cheap
/// pre-confirm counts <c>PreviewSubtreeDeployAsync</c> returns.
/// </summary>
public class DeployAreaAsyncTests : TestKit
{
private const int SiteId = 1;
private const int OtherSiteId = 2;
// Site 1: Plant(100) ─┬─ Line1(101) ── Cell(102)
// └─ Line2(103)
// Warehouse(200)
// Site 2: Remote(900)
private const int PlantArea = 100;
private const int Line1Area = 101;
private const int CellArea = 102;
private const int Line2Area = 103;
private const int WarehouseArea = 200;
private const int RemoteArea = 900;
private readonly IDeploymentManagerRepository _repo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly Dictionary<int, string> _deployedHashes = new();
private readonly Dictionary<int, DeploymentRecord> _currentRecords = new();
private readonly OperationLockManager _lockManager = new();
private List<Area> _siteAreas = new();
/// <summary>Initializes the test class with the area tree above and substituted repositories.</summary>
public DeployAreaAsyncTests()
{
_siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ci.ArgAt<int>(0) is SiteId or OtherSiteId
? new Site($"Site {ci.ArgAt<int>(0)}", $"site-{ci.ArgAt<int>(0)}") { Id = ci.ArgAt<int>(0) }
: null);
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
_repo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
.Returns(_ => (IReadOnlyDictionary<int, string>)new Dictionary<int, string>(_deployedHashes));
_repo.GetCurrentDeploymentStatusesAsync(Arg.Any<IReadOnlyCollection<int>>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyDictionary<int, DeploymentRecord>)ci.ArgAt<IReadOnlyCollection<int>>(0)
.Where(_currentRecords.ContainsKey)
.ToDictionary(id => id, id => _currentRecords[id]));
ArrangeAreas(
Area(PlantArea, SiteId, null),
Area(Line1Area, SiteId, PlantArea),
Area(CellArea, SiteId, Line1Area),
Area(Line2Area, SiteId, PlantArea),
Area(WarehouseArea, SiteId, null));
_templateRepo.GetAreaByIdAsync(RemoteArea, Arg.Any<CancellationToken>())
.Returns(Area(RemoteArea, OtherSiteId, null));
}
private static Area Area(int id, int siteId, int? parentId) =>
new($"Area-{id}") { Id = id, SiteId = siteId, ParentAreaId = parentId };
/// <summary>Replaces site 1's area set (by-id and by-site lookups both answer from it).</summary>
private void ArrangeAreas(params Area[] areas)
{
_siteAreas = areas.ToList();
foreach (var area in areas)
_templateRepo.GetAreaByIdAsync(area.Id, Arg.Any<CancellationToken>()).Returns(area);
_templateRepo.GetAreasBySiteIdAsync(SiteId, Arg.Any<CancellationToken>())
.Returns(_ => (IReadOnlyList<Area>)_siteAreas);
}
/// <summary>
/// Seeds one deployable instance per entry, in the given area (null = no area).
/// Instance ids are 1-based in argument order and named <c>Inst-NN</c>.
/// </summary>
private List<Instance> ArrangeInstances(params int?[] areaIds)
{
var instances = new List<Instance>();
for (var i = 1; i <= areaIds.Length; i++)
{
var instance = new Instance($"Inst-{i:00}")
{
Id = i,
SiteId = SiteId,
TemplateId = 10,
AreaId = areaIds[i - 1],
State = InstanceState.NotDeployed
};
instances.Add(instance);
_repo.GetInstanceByIdAsync(i, Arg.Any<CancellationToken>()).Returns(instance);
_repo.GetCurrentDeploymentStatusAsync(i, Arg.Any<CancellationToken>()).Returns((DeploymentRecord?)null);
var config = new FlattenedConfiguration { InstanceUniqueName = instance.UniqueName };
_pipeline.FlattenAndValidateAsync(
i, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, $"sha256:{i}", ValidationResult.Success())));
}
_siteRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns(instances);
return instances;
}
/// <summary>Makes the instance <c>Enabled</c> with a deployed snapshot whose hash the probe recomputes.</summary>
private void ArrangeUpToDate(Instance instance)
{
var hash = $"sha256:{instance.Id}";
instance.State = InstanceState.Enabled;
_deployedHashes[instance.Id] = hash;
var prior = new DeploymentRecord($"prior-{instance.Id}", "admin")
{
InstanceId = instance.Id,
Status = DeploymentStatus.Success,
RevisionHash = hash,
};
_currentRecords[instance.Id] = prior;
_repo.GetCurrentDeploymentStatusAsync(instance.Id, Arg.Any<CancellationToken>()).Returns(prior);
_pipeline.ComputeRevisionHashAsync(instance.Id, Arg.Any<FlattenSession?>(), Arg.Any<CancellationToken>())
.Returns(Result<string>.Success(hash));
}
private (DeploymentService Service, ConcurrentBag<string> Applied) CreateService()
{
var applied = new ConcurrentBag<string>();
var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied)));
var comms = new CommunicationService(
Options.Create(new CommunicationOptions { DeploymentTimeout = TimeSpan.FromSeconds(30) }),
NullLogger<CommunicationService>.Instance);
comms.SetCommunicationActor(commActor);
var options = Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
SiteDeploymentMaxParallelism = 3,
SiteDeploymentTimeoutPerInstance = TimeSpan.FromSeconds(30),
SiteDeploymentReconcileTimeout = TimeSpan.FromSeconds(5)
});
var service = new DeploymentService(
_repo, _siteRepo, _templateRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
options,
Options.Create(new CommunicationOptions
{
CentralFetchBaseUrl = "https://central.test:9000",
PendingDeploymentTtl = TimeSpan.FromMinutes(5)
}),
NullLogger<DeploymentService>.Instance);
return (service, applied);
}
/// <summary>
/// The subtree is the area plus every descendant at any depth; an instance
/// sitting directly in the area counts, and siblings, ancestors, other root
/// areas and area-less instances do not.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployAreaAsync_DeploysAreaAndDescendants_Only()
{
// 1:Plant 2:Line1 3:Cell 4:Line2 5:Warehouse 6:no area
ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea, null);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(Line1Area, "admin");
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
Assert.Equal("site-1", result.Value.SiteIdentifier);
Assert.Equal(new[] { "Inst-02", "Inst-03" }, applied.Order().ToArray());
Assert.Equal(2, result.Value.SuccessCount);
Assert.Equal(0, result.Value.FailureCount);
Assert.Empty(result.Value.SkippedInstances!);
// Nothing outside the subtree was even flattened.
foreach (var id in new[] { 1, 4, 5, 6 })
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
id, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
}
/// <summary>A root area's subtree spans every nested level beneath it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployAreaAsync_RootArea_IncludesEveryNestedLevel()
{
ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea, null);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(PlantArea, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03", "Inst-04" }, applied.Order().ToArray());
}
/// <summary>Disabled instances inside the subtree are skipped, forced or not.</summary>
/// <param name="force">Whether the deploy is forced.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DeployAreaAsync_DisabledInstancesAreSkipped_RegardlessOfForce(bool force)
{
var instances = ArrangeInstances(Line1Area, CellArea);
instances[1].State = InstanceState.Disabled;
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(Line1Area, "admin", force);
Assert.True(result.IsSuccess);
Assert.Equal(new[] { "Inst-01" }, applied.ToArray());
var skipped = Assert.Single(result.Value.SkippedInstances!);
Assert.Equal("Inst-02", skipped.UniqueName);
Assert.Contains("deploy individually to re-enable", skipped.Reason);
Assert.Equal(InstanceState.Disabled, instances[1].State);
}
/// <summary>Up-to-date instances inside the subtree are skipped unless the deploy is forced.</summary>
/// <param name="force">Whether the deploy is forced.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DeployAreaAsync_UpToDateInstancesAreSkipped_UnlessForced(bool force)
{
var instances = ArrangeInstances(Line1Area, CellArea);
ArrangeUpToDate(instances[1]);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(Line1Area, "admin", force);
Assert.True(result.IsSuccess);
if (force)
{
Assert.Equal(new[] { "Inst-01", "Inst-02" }, applied.Order().ToArray());
Assert.Empty(result.Value.SkippedInstances!);
}
else
{
Assert.Equal(new[] { "Inst-01" }, applied.ToArray());
var skipped = Assert.Single(result.Value.SkippedInstances!);
Assert.Equal("Inst-02", skipped.UniqueName);
Assert.Contains("up-to-date", skipped.Reason);
}
}
/// <summary>An unknown area is a failure result, not an empty success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployAreaAsync_UnknownArea_ReturnsFailure()
{
ArrangeInstances(Line1Area);
_templateRepo.GetAreaByIdAsync(4242, Arg.Any<CancellationToken>()).Returns((Area?)null);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(4242, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
Assert.Empty(applied);
}
/// <summary>An area with no instances anywhere in its subtree is an empty success.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployAreaAsync_EmptySubtree_SucceedsWithEmptySummary()
{
ArrangeInstances(WarehouseArea);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(Line2Area, "admin");
Assert.True(result.IsSuccess);
Assert.Empty(result.Value.InstanceResults);
Assert.Empty(applied);
}
/// <summary>
/// A corrupt parent chain (a cycle) must not hang the walk: the subtree still
/// resolves and the deploy completes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployAreaAsync_ParentCycle_TerminatesAndDeploysTheReachableSet()
{
ArrangeAreas(
Area(PlantArea, SiteId, CellArea), // Plant -> Cell -> Line1 -> Plant
Area(Line1Area, SiteId, PlantArea),
Area(CellArea, SiteId, Line1Area),
Area(WarehouseArea, SiteId, null));
ArrangeInstances(PlantArea, Line1Area, CellArea, WarehouseArea);
var (service, applied) = CreateService();
var result = await service.DeployAreaAsync(Line1Area, "admin")
.WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(result.IsSuccess);
Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03" }, applied.Order().ToArray());
}
/// <summary>The whole-site deploy is unaffected by areas: area-less and every-area instances all go.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeploySiteAsync_StillDeploysEveryInstance_RegardlessOfArea()
{
ArrangeInstances(PlantArea, CellArea, WarehouseArea, null);
var (service, applied) = CreateService();
var result = await service.DeploySiteAsync(SiteId, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(new[] { "Inst-01", "Inst-02", "Inst-03", "Inst-04" }, applied.Order().ToArray());
}
/// <summary>The whole-site preview counts every instance and the Disabled ones among them.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewSubtreeDeployAsync_WholeSite_CountsAllAndDisabled()
{
var instances = ArrangeInstances(PlantArea, CellArea, WarehouseArea, null);
instances[2].State = InstanceState.Disabled;
var (service, _) = CreateService();
var result = await service.PreviewSubtreeDeployAsync(SiteId, null);
Assert.True(result.IsSuccess);
Assert.Equal(new SubtreeDeployPreview(4, 1), result.Value);
// Cheap: no staleness probe, no flatten.
_pipeline.DidNotReceive().CreateSession();
}
/// <summary>The area preview counts exactly the subtree's instances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewSubtreeDeployAsync_Area_CountsSubtreeOnly()
{
var instances = ArrangeInstances(PlantArea, Line1Area, CellArea, Line2Area, WarehouseArea);
instances[2].State = InstanceState.Disabled;
var (service, _) = CreateService();
var result = await service.PreviewSubtreeDeployAsync(SiteId, Line1Area);
Assert.True(result.IsSuccess);
Assert.Equal(new SubtreeDeployPreview(2, 1), result.Value);
}
/// <summary>An area belonging to a different site, an unknown area, or an unknown site is a failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreviewSubtreeDeployAsync_RejectsForeignOrUnknownAreaAndUnknownSite()
{
ArrangeInstances(Line1Area);
_templateRepo.GetAreaByIdAsync(4242, Arg.Any<CancellationToken>()).Returns((Area?)null);
var (service, _) = CreateService();
Assert.True((await service.PreviewSubtreeDeployAsync(SiteId, RemoteArea)).IsFailure);
Assert.True((await service.PreviewSubtreeDeployAsync(SiteId, 4242)).IsFailure);
Assert.True((await service.PreviewSubtreeDeployAsync(77, null)).IsFailure);
}
/// <summary>Answers every deploy with Success, recording which instances the site was asked to apply.</summary>
private sealed class RecordingSiteActor : ReceiveActor
{
/// <summary>Initializes the actor with the collection that records applied instance names.</summary>
/// <param name="applied">The collection that receives each applied instance's unique name.</param>
public RecordingSiteActor(ConcurrentBag<string> applied)
{
Receive<SiteEnvelope>(env =>
{
if (env.Message is not RefreshDeploymentCommand cmd)
return;
applied.Add(cmd.InstanceUniqueName);
Sender.Tell(new DeploymentStatusResponse(
cmd.DeploymentId, cmd.InstanceUniqueName,
DeploymentStatus.Success, null, DateTimeOffset.UtcNow));
});
}
}
}
@@ -107,7 +107,7 @@ public class DeploySiteAsyncTests : TestKit
}); });
return new DeploymentService( return new DeploymentService(
_repo, _siteRepo, _pipeline, comms, _lockManager, _audit, _repo, _siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -61,6 +61,7 @@ public class DeploymentServiceStalenessTests
_service = new DeploymentService( _service = new DeploymentService(
_repo, _repo,
siteRepo, siteRepo,
Substitute.For<ITemplateEngineRepository>(),
_pipeline, _pipeline,
new CommunicationService( new CommunicationService(
Options.Create(new CommunicationOptions()), Options.Create(new CommunicationOptions()),
@@ -51,7 +51,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
_service = new DeploymentService( _service = new DeploymentService(
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, _comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -196,7 +196,7 @@ public class DeploymentServiceTests : TestKit
siteRepo.GetSiteByIdAsync(42, Arg.Any<CancellationToken>()).Returns((Site?)null); siteRepo.GetSiteByIdAsync(42, Arg.Any<CancellationToken>()).Returns((Site?)null);
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, _comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -428,7 +428,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -1243,7 +1243,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
return new DeploymentService( return new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -1862,7 +1862,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -1922,7 +1922,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
var deadline = TimeSpan.FromMilliseconds(300); var deadline = TimeSpan.FromMilliseconds(300);
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -1973,7 +1973,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -2021,7 +2021,7 @@ public class DeploymentServiceTests : TestKit
var siteRepo = CreateSiteRepoStub(); var siteRepo = CreateSiteRepoStub();
var service = new DeploymentService( var service = new DeploymentService(
_repo, siteRepo, _pipeline, comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, comms, _lockManager, _audit,
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance), new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
@@ -60,7 +60,7 @@ public class DeploymentStatusNotifierTests : TestKit
}); });
_service = new DeploymentService( _service = new DeploymentService(
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit, _repo, siteRepo, Substitute.For<ITemplateEngineRepository>(), _pipeline, _comms, _lockManager, _audit,
new DiffService(), new RevisionHashService(), _notifier, new DiffService(), new RevisionHashService(), _notifier,
options, options,
// Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the // Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the
@@ -73,6 +73,7 @@ public class PendingUpdatesEvaluatorTests
_deploymentService = new DeploymentService( _deploymentService = new DeploymentService(
_deploymentRepo, _deploymentRepo,
_siteRepo, _siteRepo,
Substitute.For<ITemplateEngineRepository>(),
_pipeline, _pipeline,
comms, comms,
new OperationLockManager(), new OperationLockManager(),
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -206,7 +207,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
.Returns(Result<string>.Failure("indeterminate")); .Returns(Result<string>.Failure("indeterminate"));
return new DeploymentService( return new DeploymentService(
repository, siteRepository, pipeline, comms, new OperationLockManager(), repository, siteRepository, Substitute.For<ITemplateEngineRepository>(), pipeline, comms, new OperationLockManager(),
Substitute.For<IAuditService>(), Substitute.For<IAuditService>(),
new DiffService(), new DiffService(),
new RevisionHashService(), new RevisionHashService(),