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:
@@ -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
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository for deployment manager 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="communicationService">Service for cross-cluster communication with sites.</param>
|
||||
/// <param name="lockManager">Manager for per-instance operation locks.</param>
|
||||
@@ -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<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)
|
||||
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
|
||||
// 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
|
||||
/// <param name="force">Deploy up-to-date instances too; never re-enables Disabled ones.</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>
|
||||
public Task<Result<SiteDeploymentSummary>> DeployAreaAsync(
|
||||
public async Task<Result<SiteDeploymentSummary>> 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<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>
|
||||
/// 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="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <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? areaId,
|
||||
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>
|
||||
/// True when a bulk site deploy should leave this instance alone because the
|
||||
|
||||
Reference in New Issue
Block a user