merge(agent-a): rows 37/86/87/88 — deployment reads consolidated on QueryDeploymentListPageAsync (QueryDeploymentSummariesAsync deleted); SiteScopeService honors the Administrator system-wide rule (claims no longer scope admins — the documented ManagementActor semantic now holds on both surfaces); site scope rides the query join (whole-table instance read gone), PageSize bounded (default 50, cap 1000); unparseable --status fails loudly naming valid values
This commit is contained in:
@@ -94,10 +94,18 @@ public static class DeployCommands
|
||||
private static Command BuildStatus(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var instanceIdOption = new Option<int?>("--instance-id") { Description = "Filter by instance ID" };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
||||
// The valid set is named in the help text rather than enforced with
|
||||
// AcceptOnlyFromAmong: that check is case-SENSITIVE, and the server has
|
||||
// always accepted `--status success`. The server rejects an unrecognised
|
||||
// value with the same list (register row 88) and is the authoritative
|
||||
// check — HTTP API callers never pass through this option at all.
|
||||
var statusOption = new Option<string?>("--status")
|
||||
{
|
||||
Description = "Filter by status (Pending, InProgress, Success, Failed; case-insensitive)",
|
||||
};
|
||||
var pageOption = new Option<int>("--page") { Description = "Page number" };
|
||||
pageOption.DefaultValueFactory = _ => 1;
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size" };
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Page size (1-1000; 0 or unset uses 50)" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 50;
|
||||
|
||||
var cmd = new Command("status") { Description = "Query deployment status" };
|
||||
@@ -113,7 +121,8 @@ public static class DeployCommands
|
||||
var pageSize = result.GetValue(pageSizeOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new QueryDeploymentsCommand(instanceId, status, page, pageSize));
|
||||
new QueryDeploymentsCommand(instanceId, status, page, pageSize),
|
||||
tableProjector: DeploymentStatusTableProjection.ProjectRows);
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Table projection for <c>deploy status</c> (deferred-work register rows 37/87).
|
||||
///
|
||||
/// <para>
|
||||
/// The handler now returns a <c>DeploymentQueryResult</c> envelope —
|
||||
/// <c>{ "deployments": [ … ], "totalCount": N, "page": P, "pageSize": S }</c> — where
|
||||
/// it used to return a bare row array. Handed to the generic renderer unprojected,
|
||||
/// an object takes the Property/Value branch and the whole <c>deployments</c> array
|
||||
/// lands in a single unreadable cell. This unwraps the envelope to the row array the
|
||||
/// renderer expects, exactly as <see cref="AuditConfigTableProjection"/> does for
|
||||
/// <c>audit-config query</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The envelope's counts would be lost by a plain unwrap, and losing THOSE is the one
|
||||
/// thing this projection must not do: the page size is bounded server-side now, so a
|
||||
/// full page is no longer proof that there are no more rows. When the page is a strict
|
||||
/// subset a single trailing row carries a <c>note</c> cell saying how many of how many
|
||||
/// are shown and how to get the rest. When the page IS the whole result set the column
|
||||
/// is not emitted at all.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// JSON output is never touched — this runs only on the table path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class DeploymentStatusTableProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects a <c>DeploymentQueryResult</c> envelope (or a bare row array) to the
|
||||
/// row array the table renderer consumes. Returns the input unchanged when it is
|
||||
/// not JSON or not one of those shapes, so the renderer's own fallbacks apply.
|
||||
/// </summary>
|
||||
/// <param name="json">The raw success JSON body from the management API.</param>
|
||||
/// <returns>JSON suitable for table rendering.</returns>
|
||||
internal static string ProjectRows(string json)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
|
||||
return json;
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var root = doc.RootElement;
|
||||
|
||||
// A bare array is accepted so the command still renders usefully against
|
||||
// an older server that returns the pre-envelope shape.
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
return BuildRows(root, totalCount: null).ToJsonString();
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& TryGetPropertyCI(root, "deployments", out var rows)
|
||||
&& rows.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int? total = TryGetPropertyCI(root, "totalCount", out var tc)
|
||||
&& tc.ValueKind == JsonValueKind.Number
|
||||
&& tc.TryGetInt32(out var n)
|
||||
? n
|
||||
: null;
|
||||
return BuildRows(rows, total).ToJsonString();
|
||||
}
|
||||
|
||||
// Some other object (e.g. an error envelope) — leave it to the renderer.
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonArray BuildRows(JsonElement rows, int? totalCount)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
var shown = 0;
|
||||
foreach (var item in rows.EnumerateArray())
|
||||
{
|
||||
arr.Add(item.ValueKind == JsonValueKind.Object
|
||||
? JsonNode.Parse(item.GetRawText())!
|
||||
: JsonValue.Create(item.ToString())!);
|
||||
shown++;
|
||||
}
|
||||
|
||||
// Only when the page is a strict subset — otherwise the count is just the row
|
||||
// count the operator can already see, and the extra column would be noise.
|
||||
if (totalCount is int total && total > shown)
|
||||
{
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["note"] = JsonValue.Create(
|
||||
$"showing {shown} of {total} — use --page / --page-size for the rest"),
|
||||
});
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1011,9 +1011,23 @@ scadabridge --url <url> deploy status [--instance-id <int>] [--status <string>]
|
||||
| Option | Required | Default | Description |
|
||||
|--------|----------|---------|-------------|
|
||||
| `--instance-id` | no | — | Filter by instance ID |
|
||||
| `--status` | no | — | Filter by deployment status string |
|
||||
| `--status` | no | — | Filter by deployment status: `Pending`, `InProgress`, `Success`, `Failed` (case-insensitive) |
|
||||
| `--page` | no | `1` | Page number |
|
||||
| `--page-size` | no | `50` | Results per page |
|
||||
| `--page-size` | no | `50` | Results per page; capped at `1000`. `0` means "unspecified" and takes the default — it does **not** mean unlimited |
|
||||
|
||||
Output is an envelope: `deployments` (the page of rows, newest first), `totalCount`
|
||||
(rows matching the filter across all pages), plus the `page` and `pageSize` the server
|
||||
actually applied. `--format table` renders the rows and appends a `showing N of M`
|
||||
note when the page is a strict subset.
|
||||
|
||||
An unrecognised `--status` is an **error** naming the valid values, not a silent
|
||||
fallback to "all statuses" — a typo like `--status Sucess` used to return every
|
||||
deployment record, which looks exactly like a complete answer to the question you
|
||||
meant to ask.
|
||||
|
||||
Site scoping: a site-scoped Deployer sees only deployments for instances at their
|
||||
permitted sites. A user holding `Administrator` is system-wide regardless of any site
|
||||
grant, matching the Central UI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,14 +6,26 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the set of sites the current user is permitted to operate on, from
|
||||
/// the <c>SiteId</c> claims attached at login.
|
||||
/// the roles and <c>SiteId</c> claims attached at login.
|
||||
/// <para>
|
||||
/// The design (Component-CentralUI, CLAUDE.md "Security & Auth") makes the
|
||||
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group
|
||||
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
|
||||
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
|
||||
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
|
||||
/// system-wide.
|
||||
/// A user with no <c>SiteId</c> claim is system-wide, and so is any user holding
|
||||
/// the <see cref="Roles.Administrator"/> role — regardless of the <c>SiteId</c>
|
||||
/// claims they carry.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The Administrator bypass is not a convenience, it is the agreed semantic on
|
||||
/// the other side of the same rule.</b> <c>ManagementActor</c> has always exempted
|
||||
/// Administrator from site scoping (<c>EnforceSiteScope</c> and every handler that
|
||||
/// calls it), so a user holding Administrator ALONGSIDE a scoped Deployer grant
|
||||
/// could act fleet-wide through the CLI/HTTP surface while this service hid the
|
||||
/// same sites in the UI — the UI was the side that disagreed with the documented
|
||||
/// rule (deferred-work register row 86). Note that Designer is NOT exempt: the
|
||||
/// enforced rule has only ever bypassed Administrator, and this doc used to claim
|
||||
/// "any Admin/Design user" is system-wide, which was never true of either side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deployment and Monitoring pages must filter every site/instance list through
|
||||
@@ -35,8 +47,9 @@ public sealed class SiteScopeService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
|
||||
/// claims). System-wide users see and act on every site.
|
||||
/// True when the user is not restricted to a site subset — either because they
|
||||
/// hold <see cref="Roles.Administrator"/> or because they carry no
|
||||
/// <c>SiteId</c> claims. System-wide users see and act on every site.
|
||||
/// </summary>
|
||||
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
|
||||
public async Task<bool> IsSystemWideAsync()
|
||||
@@ -83,19 +96,36 @@ public sealed class SiteScopeService
|
||||
return cached;
|
||||
|
||||
var state = await _authStateProvider.GetAuthenticationStateAsync();
|
||||
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
|
||||
|
||||
// Administrator => system-wide, whatever SiteId claims are present. Read
|
||||
// the role CLAIM rather than IsInRole: the principal's role claim type is
|
||||
// only bound to JwtTokenService.RoleClaimType when the identity was built
|
||||
// by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match
|
||||
// ManagementActor's — IsInRole compares claim VALUES ordinally, which would
|
||||
// make the two sides disagree on a differently-cased role name.
|
||||
var isAdministrator = state.User
|
||||
.FindAll(JwtTokenService.RoleClaimType)
|
||||
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var claim in siteClaims)
|
||||
if (!isAdministrator)
|
||||
{
|
||||
if (int.TryParse(claim.Value, out var id))
|
||||
ids.Add(id);
|
||||
// An Administrator's SiteId claims are not merely overridden, they are
|
||||
// not collected at all: the invariant "system-wide => empty set" then
|
||||
// holds for every caller, so a consumer that reads the id set without
|
||||
// first consulting IsSystemWide cannot silently narrow an Administrator
|
||||
// to a subset of sites.
|
||||
foreach (var claim in state.User.FindAll(JwtTokenService.SiteIdClaimType))
|
||||
{
|
||||
if (int.TryParse(claim.Value, out var id))
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// No SiteId claims => system-wide. Absence of scope rules means an
|
||||
// unrestricted deployer (this service is the sole
|
||||
// site-scoping mechanism — there is no separate handler to mirror).
|
||||
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
|
||||
var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
|
||||
_cached = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
+16
-28
@@ -16,8 +16,21 @@ public interface IDeploymentManagerRepository
|
||||
/// <returns>The deployment record, or null if not found.</returns>
|
||||
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Database-side paged, filtered and instance-joined deployment listing —
|
||||
/// the read path behind the Central UI deployment-status page.
|
||||
/// Database-side paged, filtered and instance-joined deployment listing — THE
|
||||
/// paged read of <c>DeploymentRecords</c>, shared by the Central UI
|
||||
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command.
|
||||
///
|
||||
/// <para>
|
||||
/// It was two reads until deferred-work register row 37. The other one
|
||||
/// (<c>QueryDeploymentSummariesAsync</c>) differed in three ways that were all
|
||||
/// defects rather than requirements: it took a raw <c>skip</c> instead of a page
|
||||
/// number, returned no total (so a truncated page was indistinguishable from a
|
||||
/// complete one), skipped the <c>Instance</c> join, and scoped by a caller-resolved
|
||||
/// INSTANCE-ID LIST — which its one caller built by loading every instance in the
|
||||
/// system on every scoped query. Site scoping is expressed here as
|
||||
/// <see cref="Types.Deployment.DeploymentListFilter.SiteIdScope"/> and resolved by
|
||||
/// the join, so no caller needs that read.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces a <c>GetAllDeploymentRecords</c> whole-table read. That method's only
|
||||
@@ -50,7 +63,7 @@ public interface IDeploymentManagerRepository
|
||||
/// </summary>
|
||||
/// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param>
|
||||
/// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param>
|
||||
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentRecordSummary.MaxPageSize]</c>.</param>
|
||||
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentListPage.MaxPageSize]</c>. There is no "unlimited" value.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The requested page of rows plus the total count of the filtered set.</returns>
|
||||
Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync(
|
||||
@@ -80,31 +93,6 @@ public interface IDeploymentManagerRepository
|
||||
Types.Deployment.DeploymentListFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Database-side paged and filtered deployment query returning row summaries.
|
||||
/// Backs the <c>QueryDeployments</c> management command, which previously
|
||||
/// loaded the ENTIRE deployment-record table (insert-only, one row per deploy
|
||||
/// attempt for the life of the system) and ignored the page arguments the CLI
|
||||
/// was already sending.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">Restrict to one instance's history, or <see langword="null"/> for all.</param>
|
||||
/// <param name="status">Restrict to one deployment status, or <see langword="null"/> for all.</param>
|
||||
/// <param name="instanceIdScope">
|
||||
/// When non-null, restrict to these instance ids. Used to apply a site-scoped
|
||||
/// user's permitted-site filter IN THE DATABASE rather than by loading every
|
||||
/// record and every instance and filtering in memory.
|
||||
/// </param>
|
||||
/// <param name="skip">Rows to skip; negative values are floored at 0.</param>
|
||||
/// <param name="take">Page size; non-positive or null returns every remaining row.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>One page of deployment summaries, newest first.</returns>
|
||||
Task<IReadOnlyList<Types.Deployment.DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
|
||||
int? instanceId,
|
||||
DeploymentStatus? status,
|
||||
IReadOnlyCollection<int>? instanceIdScope,
|
||||
int skip,
|
||||
int? take,
|
||||
CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Deletes TERMINAL deployment records (<see cref="DeploymentStatus.Success"/> /
|
||||
/// <see cref="DeploymentStatus.Failed"/>) that completed before
|
||||
/// <paramref name="cutoffUtc"/>, in one bounded batch.
|
||||
|
||||
@@ -23,4 +23,36 @@ public record MgmtDeployArtifactsCommand(int? SiteId = null);
|
||||
/// <param name="SiteId">Database id of the target site. Required — this command is never fleet-wide.</param>
|
||||
public record MgmtDeploySiteCommand(int SiteId);
|
||||
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
|
||||
|
||||
/// <summary>
|
||||
/// Result wrapper for <see cref="QueryDeploymentsCommand"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The handler used to return a bare row array. It cannot any more: the page size
|
||||
/// is now BOUNDED (a non-positive <c>PageSize</c> no longer means "unlimited" —
|
||||
/// deferred-work register row 87), and a bounded page without a total is a silent
|
||||
/// truncation. <see cref="TotalCount"/> is what lets a caller tell "that is all of
|
||||
/// them" from "there is more, ask for page 2". Mirrors
|
||||
/// <c>AuditLogQueryResult</c> / <c>SecuredWriteListResult</c>, the other paged
|
||||
/// management query results.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Page"/> and <see cref="PageSize"/> echo the values the server
|
||||
/// actually applied, which are not necessarily the ones requested: both are
|
||||
/// floored/clamped/defaulted server-side, so the echo is the only way a caller
|
||||
/// learns the effective slice. Additive contract; new fields go on the end with a
|
||||
/// default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Deployments">The requested page of deployment rows, newest first.</param>
|
||||
/// <param name="TotalCount">Total rows matching the filter across all pages.</param>
|
||||
/// <param name="Page">The 1-based page number actually served.</param>
|
||||
/// <param name="PageSize">The page size actually applied.</param>
|
||||
public record DeploymentQueryResult(
|
||||
IReadOnlyList<Types.Deployment.DeploymentListRow> Deployments,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize);
|
||||
|
||||
public record GetDeploymentDiffCommand(int InstanceId);
|
||||
|
||||
@@ -19,6 +19,24 @@ public sealed record DeploymentListPage(
|
||||
IReadOnlyList<DeploymentListRow> Rows,
|
||||
int TotalCount)
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard ceiling applied to a requested page size by every caller of the paged
|
||||
/// deployment read. Lives on the page contract because the page is the single
|
||||
/// shape that read now has: the older <c>DeploymentRecordSummary</c> projection
|
||||
/// (which owned this constant, and whose query accepted an unlimited
|
||||
/// <c>take</c>) was folded into this one by deferred-work register row 37.
|
||||
/// </summary>
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Page size used when a caller supplies none (or a non-positive one). Matches
|
||||
/// the CLI's own <c>--page-size</c> default, so <c>deploy status</c> with no
|
||||
/// paging flags behaves identically whether the value travels on the wire or
|
||||
/// is defaulted here (register row 87 — a non-positive size used to mean
|
||||
/// "unlimited" and materialized the whole retention window).
|
||||
/// </summary>
|
||||
public const int DefaultPageSize = 50;
|
||||
|
||||
/// <summary>An empty page — no rows, no matches.</summary>
|
||||
public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0);
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// One rendered row of the Central UI deployment-status list: a
|
||||
/// <c>DeploymentRecord</c> projection already joined to its <c>Instance</c>.
|
||||
/// One row of a deployment listing: a <c>DeploymentRecord</c> projection already
|
||||
/// joined to its <c>Instance</c>. THE row projection — the Central UI
|
||||
/// deployment-status page and the CLI/HTTP <c>QueryDeployments</c> command share
|
||||
/// it, having previously had a projection each (deferred-work register row 37;
|
||||
/// the CLI-side <c>DeploymentRecordSummary</c> was the one that went).
|
||||
///
|
||||
/// <para>
|
||||
/// The join is what removes the page's SECOND full-table read. Rendering a row
|
||||
@@ -14,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Like <see cref="DeploymentRecordSummary"/>, the <c>RowVersion</c>
|
||||
/// The <c>RowVersion</c>
|
||||
/// optimistic-concurrency token is deliberately absent — this is a READ projection
|
||||
/// and must never be mistaken for something that can be written back. Mutating
|
||||
/// paths keep loading the tracked entity.
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Row-shaped projection of a <c>DeploymentRecord</c> for list surfaces (CLI
|
||||
/// <c>deploy list</c> / <c>instance</c> history, the Central UI deployment-status
|
||||
/// page).
|
||||
///
|
||||
/// <para>
|
||||
/// Exists so a deployment listing can be paged and filtered in the DATABASE.
|
||||
/// Deployment records are insert-only — one row per deploy attempt, retained for
|
||||
/// the configured retention window — so the previous "load the whole table, then
|
||||
/// filter in memory" read path degraded with the age of the system rather than
|
||||
/// with the size of the requested page.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>RowVersion</c> optimistic-concurrency token is deliberately absent: this
|
||||
/// is a READ projection, and a summary must never be mistaken for something that
|
||||
/// can be written back. Mutating paths keep loading the tracked entity.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Message-contract evolution rule: additive-only. New fields go on the end with a
|
||||
/// default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Id">Deployment record row id.</param>
|
||||
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
|
||||
/// <param name="InstanceId">Instance the deployment targeted.</param>
|
||||
/// <param name="Status">Terminal or in-flight deployment status.</param>
|
||||
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
|
||||
/// <param name="DeployedBy">User who initiated the deployment.</param>
|
||||
/// <param name="DeployedAt">When the deployment was initiated.</param>
|
||||
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
|
||||
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
|
||||
public record DeploymentRecordSummary(
|
||||
int Id,
|
||||
string DeploymentId,
|
||||
int InstanceId,
|
||||
DeploymentStatus Status,
|
||||
string? RevisionHash,
|
||||
string DeployedBy,
|
||||
DateTimeOffset DeployedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? ErrorMessage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard ceiling applied to a requested page size, mirroring the in-memory
|
||||
/// <c>Page</c> clamp the management actor already applies elsewhere.
|
||||
/// </summary>
|
||||
public const int MaxPageSize = 1000;
|
||||
}
|
||||
+3
-2
@@ -41,8 +41,9 @@ public class DeploymentRecordConfiguration : IEntityTypeConfiguration<Deployment
|
||||
builder.HasIndex(d => d.InstanceId);
|
||||
|
||||
// Deployment-list sort key. Every list read orders DeployedAt DESC, Id DESC
|
||||
// (DeploymentManagerRepository.QueryDeploymentListPageAsync and
|
||||
// QueryDeploymentSummariesAsync) — Id is the deterministic tie-break, because
|
||||
// (DeploymentManagerRepository.QueryDeploymentListPageAsync, which register
|
||||
// row 37 made the single paged read behind BOTH the Central UI page and the
|
||||
// CLI/HTTP QueryDeployments command) — Id is the deterministic tie-break, because
|
||||
// a bulk site deploy stamps many rows in the same DeployedAt instant and a
|
||||
// non-deterministic order would let a row repeat or vanish across pages.
|
||||
// Declaring both key columns descending lets a forward scan feed the paged
|
||||
|
||||
+1
-53
@@ -115,7 +115,7 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize);
|
||||
var size = Math.Clamp(pageSize, 1, DeploymentListPage.MaxPageSize);
|
||||
|
||||
var query = BuildListQuery(filter, applyStatus: true);
|
||||
|
||||
@@ -396,58 +396,6 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
return expired.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
|
||||
int? instanceId,
|
||||
DeploymentStatus? status,
|
||||
IReadOnlyCollection<int>? instanceIdScope,
|
||||
int skip,
|
||||
int? take,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.DeploymentRecords.AsNoTracking();
|
||||
|
||||
if (instanceId.HasValue)
|
||||
query = query.Where(d => d.InstanceId == instanceId.Value);
|
||||
|
||||
if (status.HasValue)
|
||||
query = query.Where(d => d.Status == status.Value);
|
||||
|
||||
if (instanceIdScope != null)
|
||||
{
|
||||
// Site scoping applied DB-side. An empty scope is a real, meaningful
|
||||
// filter — a user permitted no in-scope instances sees nothing — so it
|
||||
// must not be short-circuited into "no filter".
|
||||
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
|
||||
query = query.Where(d => scope.Contains(d.InstanceId));
|
||||
}
|
||||
|
||||
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
|
||||
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
|
||||
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
|
||||
// pages).
|
||||
var paged = query
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.ThenByDescending(d => d.Id)
|
||||
.Skip(Math.Max(0, skip));
|
||||
|
||||
if (take is > 0)
|
||||
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
|
||||
|
||||
return await paged
|
||||
.Select(d => new DeploymentRecordSummary(
|
||||
d.Id,
|
||||
d.DeploymentId,
|
||||
d.InstanceId,
|
||||
d.Status,
|
||||
d.RevisionHash,
|
||||
d.DeployedBy,
|
||||
d.DeployedAt,
|
||||
d.CompletedAt,
|
||||
d.ErrorMessage))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
|
||||
DateTimeOffset cutoffUtc,
|
||||
|
||||
@@ -25,6 +25,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
@@ -2558,53 +2559,112 @@ public class ManagementActor : ReceiveActor
|
||||
if (sp.GetService<DeploymentService>() is { } deploymentService)
|
||||
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
|
||||
|
||||
// Page and Status are honoured DB-side. Both arrived on the command
|
||||
// contract (and the CLI has been sending --page / --page-size / --status
|
||||
// all along) but the handler previously ignored all three and returned the
|
||||
// whole table.
|
||||
// Page, PageSize and Status are all honoured DB-side, through the SAME
|
||||
// paged read the Central UI deployment page uses (register row 37 — there
|
||||
// used to be a second, subtly different repository query behind this
|
||||
// handler alone).
|
||||
var status = ParseDeploymentStatus(cmd.Status);
|
||||
var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize));
|
||||
var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null;
|
||||
var page = Math.Max(1, cmd.Page);
|
||||
var pageSize = ResolveDeploymentPageSize(cmd.PageSize);
|
||||
|
||||
// Instance-scoped query: enforce against the target instance's site
|
||||
// before reading its deployment history.
|
||||
if (cmd.InstanceId.HasValue)
|
||||
{
|
||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
|
||||
return await repo.QueryDeploymentSummariesAsync(
|
||||
cmd.InstanceId.Value, status, instanceIdScope: null, skip, take);
|
||||
return await QueryDeploymentPage(
|
||||
repo, new DeploymentListFilter(status, InstanceId: cmd.InstanceId.Value), page, pageSize);
|
||||
}
|
||||
|
||||
// Unfiltered query: a site-scoped Deployment user must only see records
|
||||
// for instances at sites within their scope. DeploymentRecord has no
|
||||
// SiteId, so the scope is resolved to the set of in-scope instance ids and
|
||||
// pushed into the query as an id filter — rather than loading every record
|
||||
// AND every instance and intersecting them in memory, which made an
|
||||
// unfiltered listing cost two full-table loads regardless of page size.
|
||||
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take);
|
||||
|
||||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
|
||||
var allInstances = await templateRepo.GetAllInstancesAsync();
|
||||
var scopedInstanceIds = allInstances
|
||||
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
|
||||
.Select(i => i.Id)
|
||||
.ToArray();
|
||||
|
||||
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
|
||||
// SiteId of its own, so the scope rides as a SITE-ID filter resolved by the
|
||||
// query's own join to Instance. It used to be resolved here instead, by
|
||||
// loading EVERY instance in the system on every scoped query to turn the
|
||||
// site grant into an instance-id list (register row 87).
|
||||
var siteScope = ResolveSiteIdScope(user);
|
||||
return await QueryDeploymentPage(
|
||||
repo, new DeploymentListFilter(status, SiteIdScope: siteScope), page, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
|
||||
/// An unparseable value is treated as "no filter" rather than an error, which
|
||||
/// preserves the previous handler's behaviour of ignoring the field entirely.
|
||||
/// Runs the shared paged deployment read and wraps it in the command's result
|
||||
/// envelope, echoing the page coordinates that were actually applied.
|
||||
/// </summary>
|
||||
private static DeploymentStatus? ParseDeploymentStatus(string? status) =>
|
||||
!string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
private static async Task<DeploymentQueryResult> QueryDeploymentPage(
|
||||
IDeploymentManagerRepository repo, DeploymentListFilter filter, int page, int pageSize)
|
||||
{
|
||||
var result = await repo.QueryDeploymentListPageAsync(filter, page, pageSize);
|
||||
return new DeploymentQueryResult(result.Rows, result.TotalCount, page, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller's site-scope grant into the query's
|
||||
/// <see cref="DeploymentListFilter.SiteIdScope"/>: <see langword="null"/> for a
|
||||
/// system-wide caller, otherwise the permitted site ids.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Administrator is system-wide here, matching <see cref="EnforceSiteScope"/>
|
||||
/// and the rest of this actor. A permitted-site id that is not an integer is
|
||||
/// DROPPED rather than ignored: dropping can only narrow the grant, whereas
|
||||
/// treating the whole grant as unparseable would widen it to fleet-wide. A
|
||||
/// grant of nothing but garbage therefore yields an EMPTY scope, which the
|
||||
/// filter treats as a real "sees nothing" constraint.
|
||||
/// </remarks>
|
||||
private static IReadOnlyCollection<int>? ResolveSiteIdScope(AuthenticatedUser user)
|
||||
{
|
||||
if (user.PermittedSiteIds.Length == 0) return null;
|
||||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
|
||||
|
||||
var ids = new List<int>(user.PermittedSiteIds.Length);
|
||||
foreach (var raw in user.PermittedSiteIds)
|
||||
{
|
||||
if (int.TryParse(raw, out var id))
|
||||
ids.Add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds the requested deployment page size: a non-positive value means
|
||||
/// "unspecified" and takes the default, and every value is capped at
|
||||
/// <see cref="DeploymentListPage.MaxPageSize"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 87. A non-positive size used to mean UNLIMITED all the way down
|
||||
/// to the repository, so <c>--page-size 0</c> materialized the entire
|
||||
/// terminal-record retention window into one response. The result envelope
|
||||
/// carries the total count precisely so bounding the page does not hide rows.
|
||||
/// </remarks>
|
||||
private static int ResolveDeploymentPageSize(int requested) =>
|
||||
requested <= 0
|
||||
? DeploymentListPage.DefaultPageSize
|
||||
: Math.Min(requested, DeploymentListPage.MaxPageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
|
||||
/// Absent/blank means "no filter"; an unrecognised value is an ERROR naming the
|
||||
/// valid ones.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Register row 88. An unparseable value used to fall back to "no filter", so
|
||||
/// <c>--status Sucess</c> silently returned every status — the caller saw a
|
||||
/// plausible, complete-looking result that did not answer the question they
|
||||
/// asked. Same loud-failure principle as row 46.
|
||||
/// </remarks>
|
||||
/// <exception cref="ManagementCommandException">The value is not a deployment status.</exception>
|
||||
private static DeploymentStatus? ParseDeploymentStatus(string? status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(status)) return null;
|
||||
if (Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
|
||||
&& Enum.IsDefined(parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ManagementCommandException(
|
||||
$"Unknown deployment status '{status}'. Valid values: {string.Join(", ", Enum.GetNames<DeploymentStatus>())}.");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Audit Log handler
|
||||
|
||||
Reference in New Issue
Block a user