feat(m9): CLI cached-call retry/discard command group

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).
This commit is contained in:
Joseph Doherty
2026-06-18 10:13:56 -04:00
parent 0f04afbdf1
commit efcdd18794
6 changed files with 293 additions and 3 deletions
+15
View File
@@ -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. 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. 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 <identifier> --tracked-operation-id <id>
scadabridge cached-call discard --site-id <identifier> --tracked-operation-id <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. The `--format json|table` option is recursive and accepted on every command above.
## Configuration ## Configuration
@@ -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;
/// <summary>
/// Builds the <c>cached-call</c> command group with sub-commands for retrying and
/// discarding parked cached-call operations. Both sub-commands relay to the existing
/// Deployer-gated <see cref="RetryParkedMessageCommand"/> /
/// <see cref="DiscardParkedMessageCommand"/> management commands via the central
/// <c>SiteCallAuditActor</c> → site relay.
/// </summary>
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<string> RetrySiteIdOption =
new("--site-id") { Description = "Site identifier of the parked operation", Required = true };
private static readonly Option<string> RetryMessageIdOption =
new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true };
private static readonly Option<string> DiscardSiteIdOption =
new("--site-id") { Description = "Site identifier of the parked operation", Required = true };
private static readonly Option<string> DiscardMessageIdOption =
new("--tracked-operation-id") { Description = "Tracked operation ID (MessageId) of the parked cached call", Required = true };
/// <summary>
/// Builds the <c>cached-call</c> command group.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>cached-call</c> command with <c>retry</c> and <c>discard</c> sub-commands.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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;
}
/// <summary>
/// Builds a <see cref="RetryParkedMessageCommand"/> from a parsed <c>cached-call retry</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping
/// without triggering the HTTP call.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>cached-call retry</c> invocation.</param>
/// <returns>A <see cref="RetryParkedMessageCommand"/> populated from the parsed result.</returns>
internal static RetryParkedMessageCommand BuildRetryCommand(ParseResult result)
{
var siteId = result.GetValue(RetrySiteIdOption)!;
var messageId = result.GetValue(RetryMessageIdOption)!;
return new RetryParkedMessageCommand(siteId, messageId);
}
/// <summary>
/// Builds a <see cref="DiscardParkedMessageCommand"/> from a parsed <c>cached-call discard</c>
/// invocation. Exposed internally so command-construction tests can assert field mapping
/// without triggering the HTTP call.
/// </summary>
/// <param name="result">The parsed command-line result from the <c>cached-call discard</c> invocation.</param>
/// <returns>A <see cref="DiscardParkedMessageCommand"/> populated from the parsed result.</returns>
internal static DiscardParkedMessageCommand BuildDiscardCommand(ParseResult result)
{
var siteId = result.GetValue(DiscardSiteIdOption)!;
var messageId = result.GetValue(DiscardMessageIdOption)!;
return new DiscardParkedMessageCommand(siteId, messageId);
}
}
+1
View File
@@ -34,6 +34,7 @@ rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOpti
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(CachedCallCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.SetAction(_ => rootCommand.SetAction(_ =>
{ {
+34
View File
@@ -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 <url> cached-call retry --site-id <string> --tracked-operation-id <string>
```
| 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 <url> cached-call discard --site-id <string> --tracked-operation-id <string>
```
| 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 ## 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`. 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`.
@@ -40,6 +40,7 @@ public class CommandTreeTests
DbConnectionCommands.Build(Url, Format, Username, Password), DbConnectionCommands.Build(Url, Format, Username, Password),
ApiMethodCommands.Build(Url, Format, Username, Password), ApiMethodCommands.Build(Url, Format, Username, Password),
BundleCommands.Build(Url, Format, Username, Password), BundleCommands.Build(Url, Format, Username, Password),
CachedCallCommands.Build(Url, Format, Username, Password),
}; };
private static IEnumerable<Command> LeafCommands(Command command) private static IEnumerable<Command> LeafCommands(Command command)
@@ -60,11 +61,11 @@ public class CommandTreeTests
{ {
var groups = AllCommandGroups().ToList(); var groups = AllCommandGroups().ToList();
// CLI-022: bump this count whenever a new top-level command group is // 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, // template, instance, site, deploy, data-connection, external-system,
// notification, security, audit-config, audit, health, debug, // notification, security, audit-config, audit, health, debug,
// shared-script, db-connection, api-method, bundle. // shared-script, db-connection, api-method, bundle, cached-call.
Assert.Equal(16, groups.Count); Assert.Equal(17, groups.Count);
Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name))); Assert.All(groups, g => Assert.False(string.IsNullOrWhiteSpace(g.Name)));
} }
@@ -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;
/// <summary>
/// Tests for <see cref="CachedCallCommands"/> — pins that option parsing maps to the
/// correct management command objects with the correct field values.
/// </summary>
public class CachedCallCommandsTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> 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));
}
}