diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index 9a6591f9..56950f98 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -124,6 +124,7 @@ The whole folder surface is reachable from the CLI as `template folder list|crea - **CreateInstance**: Create a new instance from a template. - **UpdateInstanceOverrides** (`SetInstanceOverrides`): Set attribute overrides on an instance. Every requested attribute is validated against the instance's template up front (unknown or locked ⇒ the whole batch is rejected before any write), then the batch is applied as **one bulk read of the existing override rows plus one commit** (WP2.5). It previously ran K independent read-modify-commit cycles — each re-reading the instance, the template's attributes and the existing overrides, then committing and writing its own audit row — so the all-or-nothing promise was only as good as the pre-validation. It is now a real single-transaction apply, with one audit row summarising the batch. - **MgmtDeploySite** (`SiteId`, WP2.5): Bulk-deploy every deployable instance at one site. Site scope is enforced on the SITE (the command's target); every instance the batch touches belongs to that site by construction. Same `Deployer` authority as `MgmtDeployInstance` — it is N of those, not a new class of privilege — and registered as a long-running command so the caller does not time out mid-batch. Semantics, phasing and failure policy: see `Component-DeploymentManager.md` → Bulk site deployment. +- **MgmtDeployArea** (`AreaId`, `Force`, Gitea #40): Bulk-deploy every deployable instance in one area **subtree** (the area plus all descendant areas) with the site deploy's rules — Disabled always skipped, up-to-date skipped unless `Force`, not all-or-nothing — answering with the same `SiteDeploymentSummary`. Site scope is enforced on the area's owning site; an area that does not resolve is denied for a site-scoped caller exactly like an out-of-scope one (no existence oracle) and is a plain "not found" error for a system-wide caller. `Deployer`-only, long-running with the same overall deadline, `BULK_DEPLOY_TIMEOUT` mapping and +1 min Ask grace as `MgmtDeploySite`. See `Component-DeploymentManager.md` → Area-subtree deployment. - **SetInstanceAlarmOverride** / **DeleteInstanceAlarmOverride** / **ListInstanceAlarmOverrides**: Manage per-instance computed-alarm overrides. - **SetInstanceNativeAlarmSourceOverride** / **DeleteInstanceNativeAlarmSourceOverride**: Retarget or clear a per-instance native alarm source binding, keyed by `SourceCanonicalName` — `ConnectionNameOverride` / `SourceReferenceOverride` / `ConditionFilterOverride` each apply only when non-null. Gated to the **Deployment** role. - **ListInstanceNativeAlarmSourceOverrides** (`InstanceId`): List an instance's native alarm source overrides (read-only). @@ -151,6 +152,7 @@ The whole folder surface is reachable from the CLI as `template folder list|crea - **DeployInstance**: Deploy configuration to a specific instance (includes pre-deployment validation). - **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException` → `ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator. - **DeploySite** (`MgmtDeploySite`, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and `Component-DeploymentManager.md`. +- **DeployArea** (`MgmtDeployArea`, Gitea #40): Bulk-deploy every deployable instance in one area subtree — see the Instances section above. - **GetDeploymentStatus**: Query deployment status. - **QueryDeployments** (`InstanceId?`, `Status?`, `Page`, `PageSize`): List deployment records. **All four arguments are honoured DB-side (WP2.5)** and the result is a `DeploymentQueryResult` envelope — `{ deployments, totalCount, page, pageSize }` over `DeploymentListRow`. The handler previously ignored `Status`, `Page` and `PageSize` entirely (the CLI had been sending them all along) and loaded the whole insert-only `DeploymentRecords` table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in `Component-DeploymentManager.md`. @@ -303,7 +305,7 @@ The ManagementActor receives the following services and repositories via DI (inj | Section | Options Class | Contents | |---------|--------------|----------| | `ScadaBridge:ManagementService` | `ManagementServiceOptions` | `CommandTimeout` (`TimeSpan`, default 30 s) — Ask timeout the HTTP endpoint applies when forwarding to the `ManagementActor`. A non-positive configured value falls back to the 30 s default. | -| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`, `MgmtDeploySite`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. | +| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`, `MgmtDeploySite`, `MgmtDeployArea`; the two bulk deploys get one extra minute of Ask grace beyond it); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. | | | | `SecuredWritePendingTtl` (`TimeSpan`, default 24 h) — age after which a `Pending` secured write is transitioned to `Expired` and can no longer be approved/executed; enforced at approve/reject and swept opportunistically on list. A non-positive value disables expiry (arch-review S2). | | | | `SecuredWriteTerminalRetention` (`TimeSpan`, default 90 days) — age (from the row's last lifecycle instant) after which an `Approved`/`Rejected`/`Expired`/`Executed`/`Failed` secured-write row is purged on the list sweep. A non-positive value disables the purge (review 2026-10-21 08/G3). | | | | `SecuredWriteTerminalPurgeInterval` (`TimeSpan`, default 1 h, must be ≥ 0) — minimum interval between purge passes on one node; zero purges on every list. | diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DeploymentCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DeploymentCommands.cs index a4d53efb..f4a96f00 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DeploymentCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/DeploymentCommands.cs @@ -31,6 +31,21 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null); /// and needs the push anyway. Never overrides the Disabled skip. /// public record MgmtDeploySiteCommand(int SiteId, bool Force = false); + +/// +/// Bulk-deploys every instance in one area SUBTREE — the area plus every area +/// nested beneath it at any depth — with exactly the rules of +/// : Disabled instances are always skipped, +/// up-to-date ones are skipped unless is set, and the batch is +/// not all-or-nothing. Answers with the same site deployment summary. Deployer-only, +/// site-scoped by the area's owning site (Gitea #40). +/// +/// Database id of the subtree's root area. +/// +/// Redeploy instances whose config already matches the deployed revision. Never +/// overrides the Disabled skip. Same meaning as . +/// +public record MgmtDeployAreaCommand(int AreaId, bool Force = false); public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50); /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 81f133db..7ab95ce3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -343,7 +343,8 @@ public class ManagementActor : ReceiveActor PreviewBundleCommand or ImportBundleCommand => AdminOnly, // Deployer operations - CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand + CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtDeployAreaCommand + or MgmtEnableInstanceCommand or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand @@ -431,6 +432,7 @@ public class ManagementActor : ReceiveActor CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user), MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user), MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user), + MgmtDeployAreaCommand cmd => await HandleDeployArea(sp, cmd, user), MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user), MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user), MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user), @@ -1051,6 +1053,43 @@ public class ManagementActor : ReceiveActor } } + /// + /// Bulk-deploys every deployable instance in an area subtree (Gitea #40) — + /// narrowed to one area and its descendants. + /// + /// + /// Site scope is enforced on the area's OWNING site, the only site the batch + /// can touch. An area that does not resolve fails CLOSED for a site-scoped + /// caller (the same denial as an out-of-scope one, so the gate is not an + /// existence oracle — register row 98); a system-wide caller gets a plain + /// "not found" error. Same overall deadline and BULK_DEPLOY_TIMEOUT + /// mapping as the site deploy. + /// + /// + private static async Task HandleDeployArea(IServiceProvider sp, MgmtDeployAreaCommand cmd, AuthenticatedUser user) + { + var area = await sp.GetRequiredService().GetAreaByIdAsync(cmd.AreaId); + if (area is null) + { + DenyUnresolvedTargetIfScoped(user, $"area {cmd.AreaId}"); + throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found."); + } + + EnforceSiteScope(user, area.SiteId); + var svc = sp.GetRequiredService(); + var timeout = sp.GetService>()?.Value.LongRunningCommandTimeout ?? TimeSpan.FromMinutes(5); + using var deadline = new CancellationTokenSource(timeout); + try + { + var result = await svc.DeployAreaAsync(cmd.AreaId, user.Username, cmd.Force, deadline.Token); + return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + throw new ManagementCommandException("Overall bulk deployment deadline exceeded before further apply could be scheduled.", "BULK_DEPLOY_TIMEOUT"); + } + } + private static async Task HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user) { await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs index fe8f3860..bd605b77 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs @@ -50,10 +50,10 @@ public static class ManagementEndpoints [ typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand), typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand), - // A bulk site deploy is by construction the longest command in the set — + // A bulk site (or area-subtree) deploy is by construction the longest command in the set — // it is N instance deploys, so it must never inherit the ordinary Ask // timeout or the caller would 504 while the batch is still applying. - typeof(MgmtDeploySiteCommand), + typeof(MgmtDeploySiteCommand), typeof(MgmtDeployAreaCommand), // Flow Debugger (design 2026-09-05 §7.1): a Live run lasts up to the method timeout // (+ grace) or the value-change wait window — never the ordinary Ask timeout. typeof(FlowDebugRunMethodCommand), typeof(FlowDebugRunValueChangeCommand), @@ -73,7 +73,8 @@ public static class ManagementEndpoints public static TimeSpan ResolveAskTimeout(ManagementServiceOptions? options, Type commandType) => LongRunningCommands.Contains(commandType) ? (options is { LongRunningCommandTimeout.Ticks: > 0 } o ? o.LongRunningCommandTimeout : DefaultLongRunningTimeout) - + (commandType == typeof(MgmtDeploySiteCommand) ? TimeSpan.FromMinutes(1) : TimeSpan.Zero) + + (commandType == typeof(MgmtDeploySiteCommand) || commandType == typeof(MgmtDeployAreaCommand) + ? TimeSpan.FromMinutes(1) : TimeSpan.Zero) : ResolveAskTimeout(options); /// Registers the POST /management endpoint on the given route builder. diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 88a2c181..67348661 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1391,6 +1391,64 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + // ======================================================================== + // Gitea #40: MgmtDeployArea is scoped by the area's OWNING site, and an + // unresolvable area fails closed for a scoped caller (register row 98). + // ======================================================================== + + /// Verifies that deploy area, when the area is out of scope for a site scoped user, returns unauthorized. + [Fact] + public void DeployArea_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() + { + // Area 12 belongs to site 2; user is scoped to site 1. + _templateRepo.GetAreaByIdAsync(12, Arg.Any()) + .Returns(new Area("Line") { Id = 12, SiteId = 2 }); + + var actor = CreateActor(); + var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(12), new[] { "1" }, "Deployer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + } + + /// Verifies that deploy area, when the area does not exist and the caller is site scoped, returns unauthorized without an existence oracle. + [Fact] + public void DeployArea_NonexistentAreaForSiteScopedUser_ReturnsUnauthorized() + { + var actor = CreateActor(); + var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(999), new[] { "1" }, "Deployer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase); + } + + /// Verifies that deploy area, when the area does not exist and the caller is system wide, returns a clean not-found error. + [Fact] + public void DeployArea_NonexistentAreaForSystemWideUser_ReturnsNotFoundError() + { + var actor = CreateActor(); + var envelope = Envelope(new MgmtDeployAreaCommand(999), "Deployer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("not found", response.Error, StringComparison.OrdinalIgnoreCase); + } + + /// Verifies that deploy area is refused for a Designer. + [Fact] + public void DeployArea_WithDesignerRole_ReturnsUnauthorized() + { + var actor = CreateActor(); + actor.Tell(Envelope(new MgmtDeployAreaCommand(12), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + } + /// Verifies that get instance, when in scope for site scoped user, returns success. [Fact] public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs index ac73a5ec..d933b244 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementEndpointsTests.cs @@ -48,6 +48,25 @@ public class ManagementEndpointsTests Assert.Equal(expectedForce, command.Force); } + /// + /// MgmtDeployArea (Gitea #40) parses with the same safe Force + /// default as the site deploy: absent means skip unchanged instances. + /// + /// The management request body to parse. + /// The expected value of the command's Force flag. + [Theory] + [InlineData("""{ "command": "MgmtDeployArea", "payload": { "areaId": 12 } }""", false)] + [InlineData("""{ "command": "MgmtDeployArea", "payload": { "areaId": 12, "force": true } }""", true)] + public void ParseCommand_DeployArea_ForceDefaultsToTheNonDestructiveBehaviour(string json, bool expectedForce) + { + var result = ManagementEndpoints.ParseCommand(json); + + Assert.True(result.Success); + var command = Assert.IsType(result.Command); + Assert.Equal(12, command.AreaId); + Assert.Equal(expectedForce, command.Force); + } + /// Verifies that parsing a request with no payload deserializes a parameterless command. [Fact] public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand() @@ -168,4 +187,16 @@ public class ManagementEndpointsTests var options = new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(2) }; Assert.Equal(TimeSpan.FromMinutes(3), ManagementEndpoints.ResolveAskTimeout(options, typeof(MgmtDeploySiteCommand))); } + + /// + /// The area-subtree bulk deploy (Gitea #40) is a bulk deploy too: same + /// long-running deadline plus the same cleanup grace as the site deploy. + /// + [Fact] + public void BulkAsk_AreaDeploy_GetsTheSameGraceAsSiteDeploy() + { + var options = new ManagementServiceOptions { LongRunningCommandTimeout = TimeSpan.FromMinutes(2) }; + Assert.Equal(TimeSpan.FromMinutes(3), ManagementEndpoints.ResolveAskTimeout(options, typeof(MgmtDeployAreaCommand))); + Assert.Equal(TimeSpan.FromMinutes(6), ManagementEndpoints.ResolveAskTimeout(null, typeof(MgmtDeployAreaCommand))); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index afad37df..02051cc1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -133,6 +133,9 @@ public class RequiredRoleMatrixTests // Bulk site deploy. Same authority as the single-instance deploy // it batches — it is N of those, not a new class of privilege. ["MgmtDeploySite"] = [Roles.Deployer], + // Area-subtree bulk deploy (Gitea #40): the site deploy narrowed to one + // area subtree — same authority, never a wider one. + ["MgmtDeployArea"] = [Roles.Deployer], ["MgmtEnableInstance"] = [Roles.Deployer], ["MgmtDisableInstance"] = [Roles.Deployer], ["MgmtDeleteInstance"] = [Roles.Deployer],