From bdbf5cdab070e98f56af92985d9047705bb737a5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:14:39 -0400 Subject: [PATCH] feat(transport): export site/instance selection end-to-end via CLI + ManagementActor (M8 B4) --- .../Commands/BundleCommands.cs | 36 +++- .../Messages/Management/TransportCommands.cs | 8 +- .../ManagementActor.cs | 38 +++- .../Export/BundleExporter.cs | 11 +- .../Export/DependencyResolver.cs | 17 ++ .../Commands/BundleCommandsStreamingTests.cs | 46 +++++ .../ManagementActorTests.cs | 98 ++++++++++ .../CompositionImportTests.cs | 3 + .../ConflictResolutionTests.cs | 3 + .../Export/BundleExporterTests.cs | 184 +++++++++++++++++- .../Import/BundleImporterApplyTests.cs | 3 + .../Import/BundleImporterPreviewTests.cs | 3 + .../BundleImporterRollbackFailureTests.cs | 3 + .../RoundTripTests.cs | 3 + .../SemanticValidatorImportTests.cs | 3 + .../ValidationFailureTests.cs | 3 + 16 files changed, 450 insertions(+), 12 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs index f3564e86..76d467f2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs @@ -65,6 +65,10 @@ public static class BundleCommands // --api-keys option. Re-create keys and re-grant their method scopes on the // destination via the admin UI/CLI. var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names"); + // M8 (B4): site/instance-scoped export. Sites accept a SiteIdentifier + // (preferred) or friendly Name per token; instances accept a UniqueName. + var sitesOption = NameListOption("--sites", "Comma-separated site identifiers or names"); + var instancesOption = NameListOption("--instances", "Comma-separated instance unique-names"); var includeDepsOption = new Option("--include-dependencies") { Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.", @@ -88,6 +92,8 @@ public static class BundleCommands cmd.Add(notificationListsOption); cmd.Add(smtpConfigsOption); cmd.Add(apiMethodsOption); + cmd.Add(sitesOption); + cmd.Add(instancesOption); cmd.Add(includeDepsOption); cmd.Add(sourceEnvOption); @@ -110,7 +116,9 @@ public static class BundleCommands ApiMethodNames: result.GetValue(apiMethodsOption), IncludeDependencies: includeDeps, Passphrase: passphrase, - SourceEnvironment: sourceEnv); + SourceEnvironment: sourceEnv, + SiteNames: result.GetValue(sitesOption), + InstanceNames: result.GetValue(instancesOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, @@ -359,14 +367,26 @@ public static class BundleCommands { Description = description, CustomParser = arg => - { - var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value; - if (string.IsNullOrWhiteSpace(token)) return null; - return token - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToArray(); - }, + ParseNameList(arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value), }; return opt; } + + /// + /// Splits a comma-separated CLI value into a trimmed, empty-entry-free name + /// list (the shared shape used by every --templates/--sites/… + /// option). Returns null for a null/blank token so the management + /// command sees "not specified" rather than an empty list. Exposed + /// internal so the flag-parse tests can assert the split semantics + /// without reaching into the per-command local options. + /// + /// The raw comma-separated option value, or null. + /// The parsed name array, or null when the token is null/blank. + internal static IReadOnlyList? ParseNameList(string? token) + { + if (string.IsNullOrWhiteSpace(token)) return null; + return token + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs index 1c786a19..70343d2b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs @@ -24,7 +24,13 @@ public sealed record ExportBundleCommand( IReadOnlyList? ApiMethodNames, bool IncludeDependencies, string? Passphrase, - string SourceEnvironment); + string SourceEnvironment, + // Additive (M8 B4): site/instance-scoped selection. Sites resolve by + // SiteIdentifier (preferred) or Name; instances resolve by UniqueName. + // Defaulted null so every existing positional caller keeps compiling; the + // handler normalizes null to "select nothing" (or everything under All=true). + IReadOnlyList? SiteNames = null, + IReadOnlyList? InstanceNames = null); /// /// Bundle body returned as base64-encoded ZIP. is the diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 98ec8964..9e4f24e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -2305,6 +2305,7 @@ public class ManagementActor : ReceiveActor var externalRepo = sp.GetRequiredService(); var notifRepo = sp.GetRequiredService(); var inboundRepo = sp.GetRequiredService(); + var siteRepo = sp.GetRequiredService(); var templates = await templateRepo.GetAllTemplatesAsync(); var sharedScripts = await templateRepo.GetAllSharedScriptsAsync(); @@ -2314,6 +2315,10 @@ public class ManagementActor : ReceiveActor var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync(); // Inbound API keys are not transported between environments (re-arch C4); only methods. var apiMethods = await inboundRepo.GetAllApiMethodsAsync(); + // M8 (B4): site/instance-scoped selection. Sites match on SiteIdentifier + // (preferred) or Name; instances match on UniqueName. + var sites = await siteRepo.GetAllSitesAsync(); + var instances = await templateRepo.GetAllInstancesAsync(); int[] ResolveIds(IReadOnlyList all, IReadOnlyList? names, Func getName, Func getId, string entityType) @@ -2333,6 +2338,33 @@ public class ManagementActor : ReceiveActor return matched; } + // Sites accept EITHER the SiteIdentifier (preferred) or the friendly Name + // per requested token, so this needs a two-key matcher rather than the + // single-key ResolveIds above. Under All=true every site is included. + int[] ResolveSiteIds() + { + if (cmd.All) return sites.Select(s => s.Id).ToArray(); + var names = cmd.SiteNames; + if (names is null || names.Count == 0) return Array.Empty(); + var matchedIds = new List(); + var unmatched = new List(); + foreach (var token in names) + { + var site = sites.FirstOrDefault(s => + string.Equals(s.SiteIdentifier, token, StringComparison.Ordinal)) + ?? sites.FirstOrDefault(s => + string.Equals(s.Name, token, StringComparison.Ordinal)); + if (site is null) unmatched.Add(token); + else matchedIds.Add(site.Id); + } + if (unmatched.Count > 0) + { + throw new ManagementCommandException( + $"Unknown site identifier/name(s): {string.Join(", ", unmatched.OrderBy(n => n, StringComparer.Ordinal))}."); + } + return matchedIds.Distinct().ToArray(); + } + var selection = new ExportSelection( TemplateIds: ResolveIds(templates, cmd.TemplateNames, t => t.Name, t => t.Id, "template"), SharedScriptIds: ResolveIds(sharedScripts, cmd.SharedScriptNames, s => s.Name, s => s.Id, "shared script"), @@ -2343,7 +2375,11 @@ public class ManagementActor : ReceiveActor // preview row shows the Host value, so the CLI uses Host too. SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"), ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"), - IncludeDependencies: cmd.IncludeDependencies); + IncludeDependencies: cmd.IncludeDependencies, + // M8 (B4): site/instance-scoped selection. Under All=true include every + // site + instance, mirroring how All includes every template/etc. + SiteIds: ResolveSiteIds(), + InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance")); var exporter = sp.GetRequiredService(); await using var stream = await exporter.ExportAsync( diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs index 1280e063..692821f0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/BundleExporter.cs @@ -73,6 +73,10 @@ public sealed class BundleExporter : IBundleExporter var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false); // 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name). + // M8 (B4): the resolver's site/dataConnection/instance closure is wired + // through via object-initializer — without this the aggregate would + // carry the empty defaults and the bundle would ship empty site/ + // instance arrays even when those were selected (review item I3). var aggregate = new EntityAggregate( TemplateFolders: resolved.TemplateFolders, Templates: resolved.Templates, @@ -83,7 +87,12 @@ public sealed class BundleExporter : IBundleExporter NotificationLists: resolved.NotificationLists, SmtpConfigurations: resolved.SmtpConfigs, // Inbound API keys are not transported between environments (re-arch C4). - ApiMethods: resolved.ApiMethods); + ApiMethods: resolved.ApiMethods) + { + Sites = resolved.Sites, + DataConnections = resolved.DataConnections, + Instances = resolved.Instances, + }; var contentDto = _entitySerializer.ToBundleContent(aggregate); // 3. Per-category summary counts — used for fast humanly-readable preview at import. diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs index aac641ae..19dd292b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Export/DependencyResolver.cs @@ -196,6 +196,23 @@ public sealed class DependencyResolver // in `sites` after closure expansion, so ordering + dependsOn edges resolve names. var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier); + // I1: when an instance/data-connection is selected directly with + // IncludeDependencies=false, its owning site is not packed (so it never + // lands in `sites`) and the manifest dep-edge would degrade to the raw id + // (Site:) via SiteIdentifierOf's fallback. Resolve the identifier + // for any referenced-but-unpacked owning site so the manifest reads + // Site: regardless of the deps flag — readability only, the + // site row itself stays out of the bundle. + var referencedSiteIds = instances.Values.Select(i => i.SiteId) + .Concat(dataConnections.Values.Select(c => c.SiteId)) + .Distinct() + .Where(id => !siteIdentifierById.ContainsKey(id)); + foreach (var siteId in referencedSiteIds) + { + var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false); + if (site is not null) siteIdentifierById[site.Id] = site.SiteIdentifier; + } + var orderedSites = sites.Values .OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal) .ToList(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs index 7f10efc6..e99ba4d9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs @@ -1,3 +1,4 @@ +using System.CommandLine; using ZB.MOM.WW.ScadaBridge.CLI.Commands; namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands; @@ -91,4 +92,49 @@ public class BundleCommandsStreamingTests : IDisposable { Assert.Throws(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty)); } + + // ---- M8 (B4): --sites / --instances comma-split + flag parsing ---------- + + [Fact] + public void ParseNameList_CommaSeparated_SplitsAndTrims() + { + var result = BundleCommands.ParseNameList(" North-01 , East-02 ,West-03 "); + Assert.NotNull(result); + Assert.Equal(new[] { "North-01", "East-02", "West-03" }, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseNameList_NullOrBlank_ReturnsNull(string? token) + { + Assert.Null(BundleCommands.ParseNameList(token)); + } + + [Fact] + public void ParseNameList_EmptyEntries_AreDropped() + { + var result = BundleCommands.ParseNameList("a,,b, ,c"); + Assert.Equal(new[] { "a", "b", "c" }, result); + } + + [Fact] + public void BundleExport_SitesAndInstancesFlags_ParseWithoutError() + { + var url = new Option("--url") { Recursive = true }; + var format = new Option("--format") { Recursive = true }; + var username = new Option("--username") { Recursive = true }; + var password = new Option("--password") { Recursive = true }; + var bundle = BundleCommands.Build(url, format, username, password); + + var parse = bundle.Parse(new[] + { + "export", "--output", "/tmp/out.scadabundle", + "--sites", "NORTH-01,East Plant", + "--instances", "NORTH-01.Pump1,NORTH-01.Pump2", + }); + + Assert.Empty(parse.Errors); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 8f14aba3..30c4dfdd 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1386,6 +1386,15 @@ public class ManagementActorTests : TestKit, IDisposable .Returns(new List