From efcdd1879429847c5ba391167d658610452a1425 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 10:13:56 -0400 Subject: [PATCH] feat(m9): CLI cached-call retry/discard command group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `cached-call retry` and `cached-call discard` subcommands that relay to the existing Deployer-gated RetryParkedMessageCommand / DiscardParkedMessageCommand via the central SiteCallAuditActor → site relay. ManagementCommandRegistry already covered both types via reflection auto-discovery. CommandTreeTests updated to include cached-call (group count 16 → 17). --- docs/requirements/Component-CLI.md | 15 ++ .../Commands/CachedCallCommands.cs | 97 ++++++++++++ src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs | 1 + src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 34 +++++ .../CommandTreeTests.cs | 7 +- .../Commands/CachedCallCommandsTests.cs | 142 ++++++++++++++++++ 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CachedCallCommands.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index fee3fd15..688db71e 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -339,6 +339,21 @@ On import, the mapping flags reconcile environment-specific identifiers. `--map- Inbound API keys are not transported between environments — re-create them on the destination via CLI or UI. Bundle commands use a 5-minute timeout. +### Cached-Call Commands (Site Call Audit #22) + +Retry or discard parked `ExternalSystem.CachedCall` / `Database.CachedWrite` operations +at a site. Both commands relay via the central `SiteCallAuditActor` → site relay and +require the **Deployer** role (same gate as the Central UI Site Calls page). + +``` +scadabridge cached-call retry --site-id --tracked-operation-id +scadabridge cached-call discard --site-id --tracked-operation-id +``` + +The `--tracked-operation-id` value corresponds to the `MessageId` field of the parked +`SiteCall` row (visible on the Central UI Site Calls page and in the `SiteCalls` audit +table). + The `--format json|table` option is recursive and accepted on every command above. ## Configuration diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CachedCallCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CachedCallCommands.cs new file mode 100644 index 00000000..3d7cc993 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/CachedCallCommands.cs @@ -0,0 +1,97 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; + +/// +/// Builds the cached-call command group with sub-commands for retrying and +/// discarding parked cached-call operations. Both sub-commands relay to the existing +/// Deployer-gated / +/// management commands via the central +/// SiteCallAuditActor → site relay. +/// +public static class CachedCallCommands +{ + // Options are static so the parsed values can be read back from both SetAction + // and the internal BuildRetryCommand / BuildDiscardCommand helpers (used by tests). + private static readonly Option RetrySiteIdOption = + new("--site-id") { Description = "Site identifier of the parked operation", Required = true }; + private static readonly Option RetryMessageIdOption = + new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true }; + + private static readonly Option DiscardSiteIdOption = + new("--site-id") { Description = "Site identifier of the parked operation", Required = true }; + private static readonly Option DiscardMessageIdOption = + new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true }; + + /// + /// Builds the cached-call command group. + /// + /// Global --url option for the management API endpoint. + /// Global --format option for output format. + /// Global --username option for authentication. + /// Global --password option for authentication. + /// The configured cached-call command with retry and discard sub-commands. + public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var command = new Command("cached-call") { Description = "Manage parked cached-call operations (ExternalSystem.CachedCall / Database.CachedWrite)" }; + + command.Add(BuildRetry(urlOption, formatOption, usernameOption, passwordOption)); + command.Add(BuildDiscard(urlOption, formatOption, usernameOption, passwordOption)); + + return command; + } + + private static Command BuildRetry(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("retry") { Description = "Retry a parked cached-call operation" }; + cmd.Add(RetrySiteIdOption); + cmd.Add(RetryMessageIdOption); + cmd.SetAction(async (ParseResult result) => + await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + BuildRetryCommand(result))); + return cmd; + } + + private static Command BuildDiscard(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var cmd = new Command("discard") { Description = "Discard a parked cached-call operation" }; + cmd.Add(DiscardSiteIdOption); + cmd.Add(DiscardMessageIdOption); + cmd.SetAction(async (ParseResult result) => + await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + BuildDiscardCommand(result))); + return cmd; + } + + /// + /// Builds a from a parsed cached-call retry + /// invocation. Exposed internally so command-construction tests can assert field mapping + /// without triggering the HTTP call. + /// + /// The parsed command-line result from the cached-call retry invocation. + /// A populated from the parsed result. + internal static RetryParkedMessageCommand BuildRetryCommand(ParseResult result) + { + var siteId = result.GetValue(RetrySiteIdOption)!; + var messageId = result.GetValue(RetryMessageIdOption)!; + return new RetryParkedMessageCommand(siteId, messageId); + } + + /// + /// Builds a from a parsed cached-call discard + /// invocation. Exposed internally so command-construction tests can assert field mapping + /// without triggering the HTTP call. + /// + /// The parsed command-line result from the cached-call discard invocation. + /// A populated from the parsed result. + internal static DiscardParkedMessageCommand BuildDiscardCommand(ParseResult result) + { + var siteId = result.GetValue(DiscardSiteIdOption)!; + var messageId = result.GetValue(DiscardMessageIdOption)!; + return new DiscardParkedMessageCommand(siteId, messageId); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs index 6983a271..d2b44425 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Program.cs @@ -34,6 +34,7 @@ rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOpti rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.SetAction(_ => { diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 6a2fa7ee..87a527c0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -1743,6 +1743,40 @@ Site identifiers and connection names are environment-specific; the importer fir --- +### `cached-call` — Retry or discard parked cached-call operations + +Manage parked `ExternalSystem.CachedCall` and `Database.CachedWrite` operations at a +site. Both sub-commands relay via the central `SiteCallAuditActor` → site relay and +require the **Deployer** role. + +#### `cached-call retry` + +Retry a parked cached-call operation at the specified site. + +```sh +scadabridge --url cached-call retry --site-id --tracked-operation-id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | yes | Site identifier where the operation is parked | +| `--tracked-operation-id` | yes | Tracked operation ID (MessageId) of the parked cached call | + +#### `cached-call discard` + +Discard a parked cached-call operation at the specified site. + +```sh +scadabridge --url cached-call discard --site-id --tracked-operation-id +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `--site-id` | yes | Site identifier where the operation is parked | +| `--tracked-operation-id` | yes | Tracked operation ID (MessageId) of the parked cached call | + +--- + ## Architecture Notes The CLI connects to the Central cluster using Akka.NET's `ClusterClient`. It does not join the cluster — it contacts the `ClusterClientReceptionist` on one of the configured Central nodes and sends commands to the `ManagementActor` at path `/user/management`. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs index d6823ce9..5ea591cf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs @@ -40,6 +40,7 @@ public class CommandTreeTests DbConnectionCommands.Build(Url, Format, Username, Password), ApiMethodCommands.Build(Url, Format, Username, Password), BundleCommands.Build(Url, Format, Username, Password), + CachedCallCommands.Build(Url, Format, Username, Password), }; private static IEnumerable LeafCommands(Command command) @@ -60,11 +61,11 @@ public class CommandTreeTests { var groups = AllCommandGroups().ToList(); // CLI-022: bump this count whenever a new top-level command group is - // registered in Program.cs. Current registered groups (16): + // registered in Program.cs. Current registered groups (17): // template, instance, site, deploy, data-connection, external-system, // notification, security, audit-config, audit, health, debug, - // shared-script, db-connection, api-method, bundle. - Assert.Equal(16, groups.Count); + // shared-script, db-connection, api-method, bundle, cached-call. + Assert.Equal(17, groups.Count); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs new file mode 100644 index 00000000..3c55ecfc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/CachedCallCommandsTests.cs @@ -0,0 +1,142 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using ZB.MOM.WW.ScadaBridge.CLI.Commands; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; + +/// +/// Tests for — pins that option parsing maps to the +/// correct management command objects with the correct field values. +/// +public class CachedCallCommandsTests +{ + private static readonly Option Url = new("--url") { Recursive = true }; + private static readonly Option Username = new("--username") { Recursive = true }; + private static readonly Option Password = new("--password") { Recursive = true }; + private static readonly Option Format = CliOptions.CreateFormatOption(); + + private static Command BuildCachedCall() + => CachedCallCommands.Build(Url, Format, Username, Password); + + // ── retry ────────────────────────────────────────────────────────────── + + [Fact] + public void Retry_WithSiteIdAndMessageId_ProducesCorrectCommand() + { + var group = BuildCachedCall(); + var retry = group.Subcommands.Single(c => c.Name == "retry"); + + var result = retry.Parse( + ["--site-id", "site-a", "--tracked-operation-id", "11111111-2222-3333-4444-555555555555"]); + + Assert.Empty(result.Errors); + var cmd = CachedCallCommands.BuildRetryCommand(result); + + Assert.Equal("site-a", cmd.SiteIdentifier); + Assert.Equal("11111111-2222-3333-4444-555555555555", cmd.MessageId); + } + + [Fact] + public void Retry_SiteIdAndTrackedOperationId_AreRequired() + { + var group = BuildCachedCall(); + var retry = group.Subcommands.Single(c => c.Name == "retry"); + + var siteId = retry.Options.Single(o => o.Name == "--site-id"); + var opId = retry.Options.Single(o => o.Name == "--tracked-operation-id"); + + Assert.True(siteId.Required, "--site-id must be required."); + Assert.True(opId.Required, "--tracked-operation-id must be required."); + } + + [Fact] + public void Retry_MissingOptions_ProducesParseErrors() + { + var group = BuildCachedCall(); + var retry = group.Subcommands.Single(c => c.Name == "retry"); + + var result = retry.Parse([]); + + Assert.NotEmpty(result.Errors); + } + + // ── discard ──────────────────────────────────────────────────────────── + + [Fact] + public void Discard_WithSiteIdAndMessageId_ProducesCorrectCommand() + { + var group = BuildCachedCall(); + var discard = group.Subcommands.Single(c => c.Name == "discard"); + + var result = discard.Parse( + ["--site-id", "site-b", "--tracked-operation-id", "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]); + + Assert.Empty(result.Errors); + var cmd = CachedCallCommands.BuildDiscardCommand(result); + + Assert.Equal("site-b", cmd.SiteIdentifier); + Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", cmd.MessageId); + } + + [Fact] + public void Discard_SiteIdAndTrackedOperationId_AreRequired() + { + var group = BuildCachedCall(); + var discard = group.Subcommands.Single(c => c.Name == "discard"); + + var siteId = discard.Options.Single(o => o.Name == "--site-id"); + var opId = discard.Options.Single(o => o.Name == "--tracked-operation-id"); + + Assert.True(siteId.Required, "--site-id must be required."); + Assert.True(opId.Required, "--tracked-operation-id must be required."); + } + + [Fact] + public void Discard_MissingOptions_ProducesParseErrors() + { + var group = BuildCachedCall(); + var discard = group.Subcommands.Single(c => c.Name == "discard"); + + var result = discard.Parse([]); + + Assert.NotEmpty(result.Errors); + } + + // ── group shape ──────────────────────────────────────────────────────── + + [Fact] + public void CachedCallGroup_HasRetryAndDiscard() + { + var group = BuildCachedCall(); + var subNames = group.Subcommands.Select(c => c.Name).ToHashSet(); + + Assert.Contains("retry", subNames); + Assert.Contains("discard", subNames); + } + + [Fact] + public void CachedCallGroup_Name_IsCachedCall() + { + var group = BuildCachedCall(); + Assert.Equal("cached-call", group.Name); + } + + // ── registry round-trip ──────────────────────────────────────────────── + + [Fact] + public void RetryParkedMessageCommand_ResolvesViaRegistry() + { + var name = ManagementCommandRegistry.GetCommandName(typeof(RetryParkedMessageCommand)); + Assert.False(string.IsNullOrWhiteSpace(name)); + Assert.Equal(typeof(RetryParkedMessageCommand), ManagementCommandRegistry.Resolve(name)); + } + + [Fact] + public void DiscardParkedMessageCommand_ResolvesViaRegistry() + { + var name = ManagementCommandRegistry.GetCommandName(typeof(DiscardParkedMessageCommand)); + Assert.False(string.IsNullOrWhiteSpace(name)); + Assert.Equal(typeof(DiscardParkedMessageCommand), ManagementCommandRegistry.Resolve(name)); + } +}