refactor(deployments): one paged read of DeploymentRecords + close its three drifts
Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the three behavioral divergences it had accumulated, fixed together. Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync` is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same insert-only table with different contracts — raw offset vs page number, no total vs total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync` with a `DeploymentListFilter`, exactly like the Central UI page. The `DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains `DefaultPageSize`. Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and only their granted sites on the Deployments page. One user, one dataset, two answers. Administrator = system-wide is the intended semantic on both sides; the UI was the defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the identity carries the matching roleType — the two sides must not disagree on a differently-cased role name. An Administrator's site claims are not collected at all, so the invariant "system-wide => empty permitted-id set" holds for every caller. Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide, which was true of neither side, so the doc was corrected rather than the bypass widened. Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id list by loading EVERY instance in the system, non-transactionally, on every scoped query; the grant now rides as `SiteIdScope` and is resolved by the query's own join. An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer means UNLIMITED down at the repository (`--page-size 0` materialized the whole retention window): it means "unspecified" and takes the CLI's own default of 50, capped at 1000. Bounding without telling would be silent truncation, so the handler now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page coordinates actually applied — with a CLI table projection (mirroring row 57's `AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note. Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like `--status Sucess` returned every status — a complete-looking answer to a question the caller never asked. It is now a `ManagementError` naming the valid values. Parsing stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse` accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud failure over silent full results, per row 46. Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified failing 6/7 against the pre-fix service, the Designer pin passing both ways); the ManagementActor deployment suite migrated onto the consolidated query with new pins for the status error, case-insensitive parse, numeric rejection, page-size default/ cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository test for site-scope+status composing over a bounded page; and 6 CLI tests for the new table projection.
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user