feat(managementservice): MgmtDeployArea — Deployer-only, scoped by the area's owning site (fail-closed on an unknown area), long-running like MgmtDeploySite (Gitea #40)

This commit is contained in:
Joseph Doherty
2026-10-22 14:10:53 -04:00
parent eb1c9ea7f9
commit f72c72c52f
7 changed files with 154 additions and 5 deletions
@@ -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. |
@@ -31,6 +31,21 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null);
/// and needs the push anyway. Never overrides the Disabled skip.
/// </param>
public record MgmtDeploySiteCommand(int SiteId, bool Force = false);
/// <summary>
/// Bulk-deploys every instance in one area SUBTREE — the area plus every area
/// nested beneath it at any depth — with exactly the rules of
/// <see cref="MgmtDeploySiteCommand"/>: Disabled instances are always skipped,
/// up-to-date ones are skipped unless <see cref="Force"/> 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).
/// </summary>
/// <param name="AreaId">Database id of the subtree's root area.</param>
/// <param name="Force">
/// Redeploy instances whose config already matches the deployed revision. Never
/// overrides the Disabled skip. Same meaning as <see cref="MgmtDeploySiteCommand.Force"/>.
/// </param>
public record MgmtDeployAreaCommand(int AreaId, bool Force = false);
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
/// <summary>
@@ -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
}
}
/// <summary>
/// Bulk-deploys every deployable instance in an area subtree (Gitea #40) —
/// <see cref="HandleDeploySite"/> narrowed to one area and its descendants.
///
/// <para>
/// 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 <c>BULK_DEPLOY_TIMEOUT</c>
/// mapping as the site deploy.
/// </para>
/// </summary>
private static async Task<object?> HandleDeployArea(IServiceProvider sp, MgmtDeployAreaCommand cmd, AuthenticatedUser user)
{
var area = await sp.GetRequiredService<ITemplateEngineRepository>().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<DeploymentService>();
var timeout = sp.GetService<Microsoft.Extensions.Options.IOptions<ManagementServiceOptions>>()?.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<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
@@ -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);
/// <summary>Registers the <c>POST /management</c> endpoint on the given route builder.</summary>
@@ -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).
// ========================================================================
/// <summary>Verifies that deploy area, when the area is out of scope for a site scoped user, returns unauthorized.</summary>
[Fact]
public void DeployArea_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
{
// Area 12 belongs to site 2; user is scoped to site 1.
_templateRepo.GetAreaByIdAsync(12, Arg.Any<CancellationToken>())
.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<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
/// <summary>Verifies that deploy area, when the area does not exist and the caller is site scoped, returns unauthorized without an existence oracle.</summary>
[Fact]
public void DeployArea_NonexistentAreaForSiteScopedUser_ReturnsUnauthorized()
{
var actor = CreateActor();
var envelope = ScopedEnvelope(new MgmtDeployAreaCommand(999), new[] { "1" }, "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.DoesNotContain("not found", response.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("exist", response.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that deploy area, when the area does not exist and the caller is system wide, returns a clean not-found error.</summary>
[Fact]
public void DeployArea_NonexistentAreaForSystemWideUser_ReturnsNotFoundError()
{
var actor = CreateActor();
var envelope = Envelope(new MgmtDeployAreaCommand(999), "Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Contains("not found", response.Error, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that deploy area is refused for a Designer.</summary>
[Fact]
public void DeployArea_WithDesignerRole_ReturnsUnauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new MgmtDeployAreaCommand(12), "Designer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that get instance, when in scope for site scoped user, returns success.</summary>
[Fact]
public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
@@ -48,6 +48,25 @@ public class ManagementEndpointsTests
Assert.Equal(expectedForce, command.Force);
}
/// <summary>
/// <c>MgmtDeployArea</c> (Gitea #40) parses with the same safe <c>Force</c>
/// default as the site deploy: absent means skip unchanged instances.
/// </summary>
/// <param name="json">The management request body to parse.</param>
/// <param name="expectedForce">The expected value of the command's <c>Force</c> flag.</param>
[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<MgmtDeployAreaCommand>(result.Command);
Assert.Equal(12, command.AreaId);
Assert.Equal(expectedForce, command.Force);
}
/// <summary>Verifies that parsing a request with no payload deserializes a parameterless command.</summary>
[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)));
}
/// <summary>
/// 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.
/// </summary>
[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)));
}
}
@@ -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],