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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user