using System.CommandLine; using System.CommandLine.Parsing; using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// Transport (#24) bundle export / preview / import. The bundle bytes travel /// through the management endpoint as base64 inside the standard JSON envelope /// so no transport plumbing diverges from the other commands; the CLI handles /// file I/O at the edges. /// public static class BundleCommands { private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5); /// Builds the bundle command group with export, preview, and import sub-commands. /// Shared management URL option. /// Shared output format option. /// Shared username option. /// Shared password option. /// The configured for the bundle group. public static Command Build( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("bundle") { Description = "Export, preview, and import Transport bundles", }; command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption)); return command; } // ==================================================================== // bundle export // ==================================================================== private static Command BuildExport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var outputOption = new Option("--output") { Description = "Output file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Encryption passphrase. Omit to produce an unencrypted bundle.", }; var allOption = new Option("--all") { Description = "Export every entity of every supported type (ignores per-type name flags).", }; var templatesOption = NameListOption("--templates", "Comma-separated template names"); var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names"); var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names"); var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names"); var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names"); var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names"); // Inbound API keys are not transported between environments (re-arch C4) — no // --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.", }; var sourceEnvOption = new Option("--source-environment") { Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.", }; var cmd = new Command("export") { Description = "Export a bundle to a file", }; cmd.Add(outputOption); cmd.Add(passphraseOption); cmd.Add(allOption); cmd.Add(templatesOption); cmd.Add(sharedScriptsOption); cmd.Add(externalSystemsOption); cmd.Add(dbConnectionsOption); cmd.Add(notificationListsOption); cmd.Add(smtpConfigsOption); cmd.Add(apiMethodsOption); cmd.Add(sitesOption); cmd.Add(instancesOption); cmd.Add(includeDepsOption); cmd.Add(sourceEnvOption); cmd.SetAction(async (ParseResult result) => { var output = result.GetValue(outputOption)!; var passphrase = result.GetValue(passphraseOption); var all = result.GetValue(allOption); var includeDeps = result.GetValue(includeDepsOption); var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli"; var payload = new ExportBundleCommand( All: all, TemplateNames: result.GetValue(templatesOption), SharedScriptNames: result.GetValue(sharedScriptsOption), ExternalSystemNames: result.GetValue(externalSystemsOption), DatabaseConnectionNames: result.GetValue(dbConnectionsOption), NotificationListNames: result.GetValue(notificationListsOption), SmtpConfigurationNames: result.GetValue(smtpConfigsOption), ApiMethodNames: result.GetValue(apiMethodsOption), IncludeDependencies: includeDeps, Passphrase: passphrase, SourceEnvironment: sourceEnv, SiteNames: result.GetValue(sitesOption), InstanceNames: result.GetValue(instancesOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { // CLI-020: previously the JSON envelope parse + property extraction + // base64 decode all ran unguarded — a server-side bug that omits one of // the two expected properties, returns a null base64 value, sends invalid // base64, or returns a malformed JSON envelope would surface as one of // KeyNotFoundException / InvalidOperationException / FormatException / // JsonException, i.e. an unhandled stack trace rather than the // documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the // envelope parse and the streamed write in a single try/catch matching // the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005. string base64; int byteCount; try { using var doc = JsonDocument.Parse(jsonOk); base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!; byteCount = doc.RootElement.GetProperty("byteCount").GetInt32(); } catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException) { OutputFormatter.WriteError( $"Server returned a malformed bundle-export response: {ex.Message}", "INVALID_RESPONSE"); return 1; } // CLI-019: stream the base64 → file write so a 100 MB bundle // doesn't double-buffer through Convert.FromBase64String's // ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes. // The management envelope's body is still buffered into the // jsonOk string (wire-format limit), but the decode + write // are now chunked, so peak working-set drops from // ~base64+byte[]+envelope to ~base64+small-chunk. long written; try { written = StreamBase64ToFile(base64, output); } catch (FormatException ex) { OutputFormatter.WriteError( $"Server returned invalid base64 in the bundle response: {ex.Message}", "INVALID_RESPONSE"); return 1; } Console.WriteLine($"Wrote {written:N0} bytes to {output} (server reported {byteCount:N0})."); return 0; }); }); return cmd; } // ==================================================================== // bundle preview // ==================================================================== private static Command BuildPreview( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var cmd = new Command("preview") { Description = "Load a bundle and print the diff preview without applying", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new PreviewBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { // Surface the required site/connection mappings so an operator // sees which references need resolving on `bundle import`. The // full PreviewBundleResult JSON (including the new fields) is // still printed verbatim afterwards for tooling/jq. PrintRequiredMappingSummary(jsonOk); Console.WriteLine(jsonOk); return 0; }); }); return cmd; } /// /// Parses the JSON envelope and writes a /// human-readable summary of the requiredSiteMappings / /// requiredConnectionMappings arrays to stdout. Best-effort: any parse /// hiccup is swallowed so the raw JSON (printed by the caller) remains the /// authoritative output. /// /// The success-body JSON returned by the management endpoint. private static void PrintRequiredMappingSummary(string jsonOk) { try { using var doc = JsonDocument.Parse(jsonOk); var root = doc.RootElement; var sites = root.TryGetProperty("requiredSiteMappings", out var s) && s.ValueKind == JsonValueKind.Array ? s : default; var conns = root.TryGetProperty("requiredConnectionMappings", out var c) && c.ValueKind == JsonValueKind.Array ? c : default; var siteCount = sites.ValueKind == JsonValueKind.Array ? sites.GetArrayLength() : 0; var connCount = conns.ValueKind == JsonValueKind.Array ? conns.GetArrayLength() : 0; if (siteCount == 0 && connCount == 0) return; Console.WriteLine("Mappings required before import:"); foreach (var site in sites.EnumerateArray()) { var src = site.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null; var name = site.TryGetProperty("sourceSiteName", out var n) ? n.GetString() : null; var auto = site.TryGetProperty("autoMatchTargetIdentifier", out var a) && a.ValueKind == JsonValueKind.String ? a.GetString() : null; var hint = auto is null ? "no auto-match (use --map-site or --create-missing-sites)" : $"auto-matches '{auto}'"; Console.WriteLine($" site: {src} ({name}) — {hint}"); } foreach (var conn in conns.EnumerateArray()) { var src = conn.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null; var cname = conn.TryGetProperty("sourceConnectionName", out var n) ? n.GetString() : null; var auto = conn.TryGetProperty("autoMatchTargetName", out var a) && a.ValueKind == JsonValueKind.String ? a.GetString() : null; var hint = auto is null ? "no auto-match (use --map-connection or --create-missing-connections)" : $"auto-matches '{auto}'"; Console.WriteLine($" connection: {src}/{cname} — {hint}"); } Console.WriteLine(); } catch (JsonException) { // Best-effort: the raw JSON is still printed by the caller. } } // ==================================================================== // bundle import // ==================================================================== private static Command BuildImport( Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var inputOption = new Option("--input") { Description = "Bundle file path (.scadabundle)", Required = true, }; var passphraseOption = new Option("--passphrase") { Description = "Passphrase for encrypted bundles.", }; var onConflictOption = new Option("--on-conflict") { Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.", DefaultValueFactory = _ => "overwrite", }; // M8 (D3): site/connection name mapping. Repeatable. A token with no // "=dst" part, or "=" with an empty right-hand side, means CreateNew (the // destination is created from the bundle payload); otherwise the source // is mapped to the named existing destination. var mapSiteOption = new Option("--map-site") { Description = "Map a source site to a destination: 'srcIdentifier=dstIdentifier'. " + "'srcIdentifier' or 'srcIdentifier=' (no/empty dst) means create-new. Repeatable.", AllowMultipleArgumentsPerToken = true, }; var mapConnectionOption = new Option("--map-connection") { Description = "Map a source connection to a destination: 'srcSiteIdentifier/srcName=dstName'. " + "'srcSiteIdentifier/srcName' or '…=' (no/empty dst) means create-new. Repeatable.", AllowMultipleArgumentsPerToken = true, }; var createMissingSitesOption = new Option("--create-missing-sites") { Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.", }; var createMissingConnectionsOption = new Option("--create-missing-connections") { Description = "Create any unmapped/unmatched source connection from the bundle payload instead of failing.", }; var cmd = new Command("import") { Description = "Load + apply a bundle with a single global conflict policy", }; cmd.Add(inputOption); cmd.Add(passphraseOption); cmd.Add(onConflictOption); cmd.Add(mapSiteOption); cmd.Add(mapConnectionOption); cmd.Add(createMissingSitesOption); cmd.Add(createMissingConnectionsOption); cmd.SetAction(async (ParseResult result) => { var input = result.GetValue(inputOption)!; if (!File.Exists(input)) { OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND"); return 1; } List siteMappings; List connectionMappings; try { siteMappings = ParseSiteMappings(result.GetValue(mapSiteOption)); connectionMappings = ParseConnectionMappings(result.GetValue(mapConnectionOption)); } catch (FormatException ex) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } var bytes = await File.ReadAllBytesAsync(input); var payload = new ImportBundleCommand( Base64Bundle: Convert.ToBase64String(bytes), Passphrase: result.GetValue(passphraseOption), DefaultConflictPolicy: result.GetValue(onConflictOption)!, SiteMappings: siteMappings.Count == 0 ? null : siteMappings, ConnectionMappings: connectionMappings.Count == 0 ? null : connectionMappings, CreateMissingSites: result.GetValue(createMissingSitesOption), CreateMissingConnections: result.GetValue(createMissingConnectionsOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, payload, timeout: BundleCommandTimeout, onSuccess: jsonOk => { Console.WriteLine(jsonOk); return 0; }); }); return cmd; } // ==================================================================== // Shared HTTP plumbing // ==================================================================== // // CLI-017: bundle commands previously routed through a private // RunBundleCommandAsync that re-implemented URL/credential resolution and // skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync // enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error // code on a non-403 status would exit 1 instead of the documented exit 2. // The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with // the longer BundleCommandTimeout and a per-command success handler, so the // exit-code contract is unified across every command group. // CLI-019: chunked base64 → file streaming. The management envelope's // success body is a single buffered JSON string (the wire format does not // currently support response-body streaming), so we cannot remove the // ~base64-string + ~envelope-string allocation. What we CAN — and do — // remove is the intermediate ~bytecount-sized byte[] that // Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes: // we slice the base64 string into 4-byte-multiple chunks (4 base64 chars // decode into exactly 3 bytes, so any multiple of 4 is a clean boundary) // and decode each chunk into a small rented buffer that we copy into the // output FileStream. The chunk size is a tradeoff — large enough that the // per-chunk loop overhead is negligible, small enough that we never put // anything on the LOH (1 MB is below the 85 KB LOH threshold's larger // cousin for buffers we don't keep). Returns the total decoded byte count // for the post-write summary line. internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded /// /// Decodes a base64 string into in chunked fashion to avoid /// large intermediate allocations. Returns the total number of decoded bytes written. /// /// The base64-encoded content to decode and write. /// Destination file path; created or overwritten. /// Total number of bytes written to the output file. internal static long StreamBase64ToFile(string base64, string outputPath) { if (base64 is null) throw new ArgumentNullException(nameof(base64)); if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath)); // Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars // tolerates internal whitespace, but slicing on arbitrary positions would split a // run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice // is independently decodable. var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4); var totalChars = base64.Length; var totalWritten = 0L; using var fileStream = new FileStream( outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: false); // 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly. var byteBuffer = new byte[(chunkChars / 4) * 3]; for (var offset = 0; offset < totalChars; offset += chunkChars) { var take = Math.Min(chunkChars, totalChars - offset); var slice = base64.AsSpan(offset, take); // The final slice may be shorter than chunkChars and may carry // trailing '=' padding; TryFromBase64Chars handles that. if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written)) { throw new FormatException( $"Bundle response contained invalid base64 at character offset {offset}."); } fileStream.Write(byteBuffer, 0, written); totalWritten += written; } return totalWritten; } private static Option?> NameListOption(string name, string description) { var opt = new Option?>(name) { Description = description, CustomParser = arg => 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(); } /// /// Parses the repeatable --map-site tokens into /// s. Each token is /// srcIdentifier=dstIdentifier (map to an existing destination) or /// srcIdentifier / srcIdentifier= (no/empty right-hand side → /// CreateNew, surfaced as a null target). Exposed internal so the /// flag-parse tests can assert the split + CreateNew convention. /// /// Raw --map-site token values, or null. /// The parsed spec list (possibly empty). /// A token has an empty source identifier. internal static List ParseSiteMappings(string[]? tokens) { var result = new List(); if (tokens is null) return result; foreach (var token in tokens) { if (string.IsNullOrWhiteSpace(token)) continue; var (left, right) = SplitOnFirst(token, '='); var src = left.Trim(); if (src.Length == 0) { throw new FormatException( $"Invalid --map-site '{token}': source identifier is required (use 'src=dst' or 'src' for create-new)."); } // null target == CreateNew, per the documented convention. result.Add(new SiteMappingSpec(src, string.IsNullOrWhiteSpace(right) ? null : right!.Trim())); } return result; } /// /// Parses the repeatable --map-connection tokens into /// s. Each token is /// srcSiteIdentifier/srcName=dstName (map to an existing destination) /// or srcSiteIdentifier/srcName / …= (no/empty right-hand side → /// CreateNew, surfaced as a null target). Exposed internal for the /// flag-parse tests. /// /// Raw --map-connection token values, or null. /// The parsed spec list (possibly empty). /// A token is missing the site/name source shape. internal static List ParseConnectionMappings(string[]? tokens) { var result = new List(); if (tokens is null) return result; foreach (var token in tokens) { if (string.IsNullOrWhiteSpace(token)) continue; var (lhs, right) = SplitOnFirst(token, '='); var (site, name) = SplitOnFirst(lhs, '/'); var srcSite = site.Trim(); var srcName = (name ?? string.Empty).Trim(); if (srcSite.Length == 0 || name is null || srcName.Length == 0) { throw new FormatException( $"Invalid --map-connection '{token}': expected 'srcSiteIdentifier/srcName[=dstName]'."); } // null target == CreateNew, per the documented convention. result.Add(new ConnectionMappingSpec(srcSite, srcName, string.IsNullOrWhiteSpace(right) ? null : right!.Trim())); } return result; } /// /// Splits on the first occurrence of /// . When the separator is absent the right side /// is null (distinguishing "src" from "src=" — both mean CreateNew here, /// but callers that need to detect the separator's presence can). /// private static (string Left, string? Right) SplitOnFirst(string value, char separator) { var idx = value.IndexOf(separator); return idx < 0 ? (value, null) : (value[..idx], value[(idx + 1)..]); } }