diff --git a/docs/requirements/Component-DeploymentManager.md b/docs/requirements/Component-DeploymentManager.md index 17173f37..f13de138 100644 --- a/docs/requirements/Component-DeploymentManager.md +++ b/docs/requirements/Component-DeploymentManager.md @@ -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 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 one, so the two entry points cannot drift on deployment identity, idempotency, lock coverage, or optimistic concurrency. diff --git a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs index 936f620f..4b72c434 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/DeploymentService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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; @@ -38,6 +39,7 @@ public class DeploymentService { private readonly IDeploymentManagerRepository _repository; private readonly ISiteRepository _siteRepository; + private readonly ITemplateEngineRepository _templateRepository; private readonly IFlatteningPipeline _flatteningPipeline; private readonly CommunicationService _communicationService; private readonly OperationLockManager _lockManager; @@ -96,6 +98,11 @@ public class DeploymentService /// /// Repository for deployment manager data access. /// Repository for site data access. + /// + /// Repository for area lookups — the area-subtree bulk deploy + /// () and its pre-confirm counts resolve the area + /// tree through it. + /// /// Pipeline for flattening and validating template configurations. /// Service for cross-cluster communication with sites. /// Manager for per-instance operation locks. @@ -119,6 +126,7 @@ public class DeploymentService public DeploymentService( IDeploymentManagerRepository repository, ISiteRepository siteRepository, + ITemplateEngineRepository templateRepository, IFlatteningPipeline flatteningPipeline, CommunicationService communicationService, OperationLockManager lockManager, @@ -132,6 +140,7 @@ public class DeploymentService { _repository = repository; _siteRepository = siteRepository; + _templateRepository = templateRepository; _flatteningPipeline = flatteningPipeline; _communicationService = communicationService; _lockManager = lockManager; @@ -938,7 +947,37 @@ public class DeploymentService if (site == null) return Result.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); + } + + /// + /// The body of every bulk deploy — whole site () + /// or one area subtree (). Loads the site's + /// instances, narrows them to when one is given, + /// then applies the Disabled skip, the up-to-date pre-filter and the three + /// phases exactly as documented on . + /// + /// The resolved site whose instances are deployed. + /// + /// Area ids an instance's must be in to be part of + /// the batch, or null for every instance at the site (area-less ones included). + /// + /// Human-readable batch scope for the log lines (e.g. site, area 12). + /// The username initiating the deployment. + /// Suppresses the up-to-date filter; never the Disabled one. + /// Cancellation token for the operation. + /// The per-instance result matrix. + private async Task> DeployInstancesOfSiteAsync( + Site site, + IReadOnlySet? areaFilter, + string scope, + string user, + bool force, + CancellationToken cancellationToken) + { + IReadOnlyList 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) return Result.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 // makes a bulk deploy usable while individual instances are busy. _logger.LogWarning(ex, - "Preparing instance {Instance} for bulk deployment of site {SiteId} failed", - instance.UniqueName, site.SiteIdentifier); + "Preparing instance {Instance} for bulk deployment of site {SiteId} ({Scope}) failed", + instance.UniqueName, site.SiteIdentifier, scope); results.Add(new InstanceDeploymentResult( instance.Id, instance.UniqueName, null, false, ex.Message)); continue; @@ -1101,12 +1140,12 @@ public class DeploymentService } _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}) " + "({ChainLoads} template-chain load(s) across {InstanceCount} deployed instance(s); " + "up-to-date pre-filter probed {ProbedCount} instance(s) in {PreFilterMs} ms with " + "{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, preFilterProbed, preFilterTimer.ElapsedMilliseconds, preFilterChainLoads); @@ -1124,12 +1163,25 @@ public class DeploymentService /// Deploy up-to-date instances too; never re-enables Disabled ones. /// Cancellation token for the operation. /// The same per-instance result matrix as a site deploy, or a failure when the area cannot be resolved. - public Task> DeployAreaAsync( + public async Task> DeployAreaAsync( int areaId, string user, bool force = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + var area = await _templateRepository.GetAreaByIdAsync(areaId, cancellationToken); + if (area == null) + return Result.Failure($"Area with ID {areaId} not found."); + + var site = await _siteRepository.GetSiteByIdAsync(area.SiteId, cancellationToken); + if (site == null) + return Result.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); + } /// /// Cheap pre-confirm counts for a site or area subtree deploy (Gitea #40): how @@ -1140,11 +1192,71 @@ public class DeploymentService /// The subtree's root area, or null for the whole site. /// Cancellation token for the operation. /// The counts, or a failure when the site or area cannot be resolved. - public Task> PreviewSubtreeDeployAsync( + public async Task> PreviewSubtreeDeployAsync( int siteId, int? areaId, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + { + var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken); + if (site == null) + return Result.Failure($"Site with ID {siteId} not found."); + + IReadOnlySet? subtree = null; + if (areaId is { } id) + { + var area = await _templateRepository.GetAreaByIdAsync(id, cancellationToken); + if (area == null) + return Result.Failure($"Area with ID {id} not found."); + if (area.SiteId != siteId) + return Result.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.Success(new SubtreeDeployPreview(total, disabled)); + } + + /// + /// The ids of and every area nested beneath it at any + /// depth, from ONE read of the site's areas walked in memory over + /// . Shared by and + /// 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. + /// + private async Task> 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 { root.Id }; + var pending = new Queue(); + pending.Enqueue(root.Id); + while (pending.Count > 0) + { + foreach (var childId in childrenByParent[pending.Dequeue()]) + { + if (subtree.Add(childId)) + pending.Enqueue(childId); + } + } + + return subtree; + } /// /// True when a bulk site deploy should leave this instance alone because the diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs new file mode 100644 index 00000000..f082b7d5 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeployAreaAsyncTests.cs @@ -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; + +/// +/// Area-subtree bulk deployment (Gitea #40). DeployAreaAsync is +/// DeploySiteAsync 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 PreviewSubtreeDeployAsync returns. +/// +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(); + private readonly IFlatteningPipeline _pipeline = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); + private readonly IAuditService _audit = Substitute.For(); + + private readonly Dictionary _deployedHashes = new(); + private readonly Dictionary _currentRecords = new(); + private readonly OperationLockManager _lockManager = new(); + private List _siteAreas = new(); + + /// Initializes the test class with the area tree above and substituted repositories. + public DeployAreaAsyncTests() + { + _siteRepo.GetSiteByIdAsync(Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0) is SiteId or OtherSiteId + ? new Site($"Site {ci.ArgAt(0)}", $"site-{ci.ArgAt(0)}") { Id = ci.ArgAt(0) } + : null); + + _pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark())); + + _repo.GetDeployedRevisionHashesAsync(Arg.Any()) + .Returns(_ => (IReadOnlyDictionary)new Dictionary(_deployedHashes)); + _repo.GetCurrentDeploymentStatusesAsync(Arg.Any>(), Arg.Any()) + .Returns(ci => (IReadOnlyDictionary)ci.ArgAt>(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()) + .Returns(Area(RemoteArea, OtherSiteId, null)); + } + + private static Area Area(int id, int siteId, int? parentId) => + new($"Area-{id}") { Id = id, SiteId = siteId, ParentAreaId = parentId }; + + /// Replaces site 1's area set (by-id and by-site lookups both answer from it). + private void ArrangeAreas(params Area[] areas) + { + _siteAreas = areas.ToList(); + foreach (var area in areas) + _templateRepo.GetAreaByIdAsync(area.Id, Arg.Any()).Returns(area); + _templateRepo.GetAreasBySiteIdAsync(SiteId, Arg.Any()) + .Returns(_ => (IReadOnlyList)_siteAreas); + } + + /// + /// Seeds one deployable instance per entry, in the given area (null = no area). + /// Instance ids are 1-based in argument order and named Inst-NN. + /// + private List ArrangeInstances(params int?[] areaIds) + { + var instances = new List(); + 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()).Returns(instance); + _repo.GetCurrentDeploymentStatusAsync(i, Arg.Any()).Returns((DeploymentRecord?)null); + + var config = new FlattenedConfiguration { InstanceUniqueName = instance.UniqueName }; + _pipeline.FlattenAndValidateAsync( + i, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success( + new FlatteningPipelineResult(config, $"sha256:{i}", ValidationResult.Success()))); + } + + _siteRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any()).Returns(instances); + return instances; + } + + /// Makes the instance Enabled with a deployed snapshot whose hash the probe recomputes. + 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()).Returns(prior); + _pipeline.ComputeRevisionHashAsync(instance.Id, Arg.Any(), Arg.Any()) + .Returns(Result.Success(hash)); + } + + private (DeploymentService Service, ConcurrentBag Applied) CreateService() + { + var applied = new ConcurrentBag(); + var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied))); + + var comms = new CommunicationService( + Options.Create(new CommunicationOptions { DeploymentTimeout = TimeSpan.FromSeconds(30) }), + NullLogger.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.Instance), + options, + Options.Create(new CommunicationOptions + { + CentralFetchBaseUrl = "https://central.test:9000", + PendingDeploymentTtl = TimeSpan.FromMinutes(5) + }), + NullLogger.Instance); + return (service, applied); + } + + /// + /// 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. + /// + /// A task that represents the asynchronous operation. + [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(), Arg.Any(), Arg.Any()); + } + + /// A root area's subtree spans every nested level beneath it. + /// A task that represents the asynchronous operation. + [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()); + } + + /// Disabled instances inside the subtree are skipped, forced or not. + /// Whether the deploy is forced. + /// A task that represents the asynchronous operation. + [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); + } + + /// Up-to-date instances inside the subtree are skipped unless the deploy is forced. + /// Whether the deploy is forced. + /// A task that represents the asynchronous operation. + [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); + } + } + + /// An unknown area is a failure result, not an empty success. + /// A task that represents the asynchronous operation. + [Fact] + public async Task DeployAreaAsync_UnknownArea_ReturnsFailure() + { + ArrangeInstances(Line1Area); + _templateRepo.GetAreaByIdAsync(4242, Arg.Any()).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); + } + + /// An area with no instances anywhere in its subtree is an empty success. + /// A task that represents the asynchronous operation. + [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); + } + + /// + /// A corrupt parent chain (a cycle) must not hang the walk: the subtree still + /// resolves and the deploy completes. + /// + /// A task that represents the asynchronous operation. + [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()); + } + + /// The whole-site deploy is unaffected by areas: area-less and every-area instances all go. + /// A task that represents the asynchronous operation. + [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()); + } + + /// The whole-site preview counts every instance and the Disabled ones among them. + /// A task that represents the asynchronous operation. + [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(); + } + + /// The area preview counts exactly the subtree's instances. + /// A task that represents the asynchronous operation. + [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); + } + + /// An area belonging to a different site, an unknown area, or an unknown site is a failure. + /// A task that represents the asynchronous operation. + [Fact] + public async Task PreviewSubtreeDeployAsync_RejectsForeignOrUnknownAreaAndUnknownSite() + { + ArrangeInstances(Line1Area); + _templateRepo.GetAreaByIdAsync(4242, Arg.Any()).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); + } + + /// Answers every deploy with Success, recording which instances the site was asked to apply. + private sealed class RecordingSiteActor : ReceiveActor + { + /// Initializes the actor with the collection that records applied instance names. + /// The collection that receives each applied instance's unique name. + public RecordingSiteActor(ConcurrentBag applied) + { + Receive(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)); + }); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs index 22d2d51e..d4fb73b7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploySiteAsyncTests.cs @@ -107,7 +107,7 @@ public class DeploySiteAsyncTests : TestKit }); return new DeploymentService( - _repo, _siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, _siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs index 8d710680..45c59a63 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceStalenessTests.cs @@ -61,6 +61,7 @@ public class DeploymentServiceStalenessTests _service = new DeploymentService( _repo, siteRepo, + Substitute.For(), _pipeline, new CommunicationService( Options.Create(new CommunicationOptions()), diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs index 9d7fbfe7..6a75c534 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentServiceTests.cs @@ -51,7 +51,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); _service = new DeploymentService( - _repo, siteRepo, _pipeline, _comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -196,7 +196,7 @@ public class DeploymentServiceTests : TestKit siteRepo.GetSiteByIdAsync(42, Arg.Any()).Returns((Site?)null); var service = new DeploymentService( - _repo, siteRepo, _pipeline, _comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -428,7 +428,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); var service = new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -1243,7 +1243,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); return new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -1862,7 +1862,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); var service = new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -1922,7 +1922,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); var deadline = TimeSpan.FromMilliseconds(300); var service = new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -1973,7 +1973,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); var service = new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), @@ -2021,7 +2021,7 @@ public class DeploymentServiceTests : TestKit var siteRepo = CreateSiteRepoStub(); var service = new DeploymentService( - _repo, siteRepo, _pipeline, comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), new DeploymentStatusNotifier(NullLogger.Instance), diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs index f45e3620..89b66629 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/DeploymentStatusNotifierTests.cs @@ -60,7 +60,7 @@ public class DeploymentStatusNotifierTests : TestKit }); _service = new DeploymentService( - _repo, siteRepo, _pipeline, _comms, _lockManager, _audit, + _repo, siteRepo, Substitute.For(), _pipeline, _comms, _lockManager, _audit, new DiffService(), new RevisionHashService(), _notifier, options, // Notify-and-fetch: a non-empty CentralFetchBaseUrl lets the diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs index a000aec7..575d1201 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/PendingUpdatesEvaluatorTests.cs @@ -73,6 +73,7 @@ public class PendingUpdatesEvaluatorTests _deploymentService = new DeploymentService( _deploymentRepo, _siteRepo, + Substitute.For(), _pipeline, comms, new OperationLockManager(), diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs index 3bf83e76..ad364c2c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/BulkDeployPoisonedContextTests.cs @@ -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.Sites; 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.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types; @@ -206,7 +207,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable .Returns(Result.Failure("indeterminate")); return new DeploymentService( - repository, siteRepository, pipeline, comms, new OperationLockManager(), + repository, siteRepository, Substitute.For(), pipeline, comms, new OperationLockManager(), Substitute.For(), new DiffService(), new RevisionHashService(),