From 901fd58a32a26ea922f0591c7871bf8f6372fdc1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 08:15:28 -0400 Subject: [PATCH] feat(cli): bundle export / preview / import for Transport (#24) Three new CLI commands automate the Transport feature end-to-end: scadalink bundle export --output FILE --passphrase X [--all | --templates A,B ...] [--include-dependencies] [--source-environment NAME] scadalink bundle preview --input FILE --passphrase X scadalink bundle import --input FILE --passphrase X [--on-conflict skip|overwrite|rename] Wire format: bundle bytes travel as base64 inside the existing /management JSON envelope -- no new endpoints, no streaming plumbing. The 100 MB raw cap inflates to ~140 MB base64; per-request body size on the management endpoint is raised to 200 MB via the IHttpMaxRequestBodySizeFeature. Server side: three new command records in ScadaLink.Commons.Messages.Management (auto-discovered by the existing ManagementCommandRegistry), ManagementActor dispatch and role rules (Export=Design, Preview/Import=Admin), and three handlers that delegate to the existing IBundleExporter / IBundleImporter services with name-keyed selection resolution. Per-bundle CLI timeout bumped to 5 min for large exports. Conflict policy on import is a single global flag for all Modified rows; Identical rows always Skip, New rows always Add, Blocker rows abort. Rename mints a per-bundle timestamp suffix. --- src/ScadaLink.CLI/Commands/BundleCommands.cs | 301 ++++++++++++++++++ src/ScadaLink.CLI/Program.cs | 1 + .../Messages/Management/TransportCommands.cs | 62 ++++ .../ManagementActor.cs | 199 +++++++++++- .../ManagementEndpoints.cs | 16 + 5 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 src/ScadaLink.CLI/Commands/BundleCommands.cs create mode 100644 src/ScadaLink.Commons/Messages/Management/TransportCommands.cs diff --git a/src/ScadaLink.CLI/Commands/BundleCommands.cs b/src/ScadaLink.CLI/Commands/BundleCommands.cs new file mode 100644 index 0000000..0eb595a --- /dev/null +++ b/src/ScadaLink.CLI/Commands/BundleCommands.cs @@ -0,0 +1,301 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json; +using ScadaLink.Commons.Messages.Management; + +namespace ScadaLink.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); + + 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"); + var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names"); + var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method 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(apiKeysOption); + cmd.Add(apiMethodsOption); + 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), + ApiKeyNames: result.GetValue(apiKeysOption), + ApiMethodNames: result.GetValue(apiMethodsOption), + IncludeDependencies: includeDeps, + Passphrase: passphrase, + SourceEnvironment: sourceEnv); + + return await RunBundleCommandAsync( + result, urlOption, usernameOption, passwordOption, + payload, jsonOk => + { + using var doc = JsonDocument.Parse(jsonOk); + var base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!; + var byteCount = doc.RootElement.GetProperty("byteCount").GetInt32(); + var bytes = Convert.FromBase64String(base64); + File.WriteAllBytes(output, bytes); + Console.WriteLine($"Wrote {bytes.Length: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 RunBundleCommandAsync( + result, urlOption, usernameOption, passwordOption, + payload, jsonOk => + { + Console.WriteLine(jsonOk); + return 0; + }); + }); + return cmd; + } + + // ==================================================================== + // 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", + }; + + 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.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 ImportBundleCommand( + Base64Bundle: Convert.ToBase64String(bytes), + Passphrase: result.GetValue(passphraseOption), + DefaultConflictPolicy: result.GetValue(onConflictOption)!); + + return await RunBundleCommandAsync( + result, urlOption, usernameOption, passwordOption, + payload, jsonOk => + { + Console.WriteLine(jsonOk); + return 0; + }); + }); + return cmd; + } + + // ==================================================================== + // Shared HTTP plumbing + // ==================================================================== + + /// + /// Same shape as but with + /// a longer per-command timeout (bundles are big) and a caller-supplied + /// success handler so export can capture the base64 payload into a file + /// rather than print the whole envelope to stdout. + /// + private static async Task RunBundleCommandAsync( + ParseResult result, + Option urlOption, + Option usernameOption, + Option passwordOption, + object payload, + Func onSuccess) + { + var config = CliConfig.Load(); + var url = result.GetValue(urlOption); + if (string.IsNullOrWhiteSpace(url)) url = config.ManagementUrl; + if (string.IsNullOrWhiteSpace(url)) + { + OutputFormatter.WriteError( + "No management URL specified. Use --url or set 'managementUrl' in ~/.scadalink/config.json.", + "NO_URL"); + return 1; + } + if (!CommandHelpers.IsValidManagementUrl(url)) + { + OutputFormatter.WriteError( + $"Invalid management URL '{url}'.", "INVALID_URL"); + return 1; + } + var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username); + var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + OutputFormatter.WriteError( + "Credentials required. Use --username/--password or SCADALINK_USERNAME/SCADALINK_PASSWORD.", + "NO_CREDENTIALS"); + return 1; + } + + var commandName = ManagementCommandRegistry.GetCommandName(payload.GetType()); + using var client = new ManagementHttpClient(url, username, password); + var response = await client.SendCommandAsync(commandName, payload, BundleCommandTimeout); + + if (response.JsonData is not null) + { + return onSuccess(response.JsonData); + } + OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR"); + if (response.StatusCode == 403) return 2; + return 1; + } + + private static Option?> NameListOption(string name, string description) + { + var opt = new Option?>(name) + { + 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(); + }, + }; + return opt; + } +} diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 240b410..d474833 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -33,6 +33,7 @@ rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, pas rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); 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.SetAction(_ => { diff --git a/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs b/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs new file mode 100644 index 0000000..355277f --- /dev/null +++ b/src/ScadaLink.Commons/Messages/Management/TransportCommands.cs @@ -0,0 +1,62 @@ +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Commons.Messages.Management; + +/// +/// Exports a bundle. Names rather than IDs in the selection so test scripts can +/// be written without an ID lookup step. All=true overrides the per-type +/// name lists and exports every entity of every supported type. +/// +public sealed record ExportBundleCommand( + bool All, + IReadOnlyList? TemplateNames, + IReadOnlyList? SharedScriptNames, + IReadOnlyList? ExternalSystemNames, + IReadOnlyList? DatabaseConnectionNames, + IReadOnlyList? NotificationListNames, + IReadOnlyList? SmtpConfigurationNames, + IReadOnlyList? ApiKeyNames, + IReadOnlyList? ApiMethodNames, + bool IncludeDependencies, + string? Passphrase, + string SourceEnvironment); + +/// +/// Bundle body returned as base64-encoded ZIP. is the +/// pre-encoded size for sanity checks against the configured bundle cap. +/// +public sealed record ExportBundleResult( + string Base64Bundle, + int ByteCount); + +/// +/// Loads a bundle and returns its preview without applying anything. Useful +/// for tests that want to assert on the diff shape before committing. +/// +public sealed record PreviewBundleCommand( + string Base64Bundle, + string? Passphrase); + +public sealed record PreviewBundleResult( + IReadOnlyList Items, + int AddCount, + int ModifiedCount, + int IdenticalCount, + int BlockerCount); + +/// +/// Loads, previews, and applies a bundle in a single call. The diff is built +/// internally; every row is resolved with +/// , every +/// row gets , every +/// row gets , +/// and any row fails the call. +/// +/// Valid values: "skip", +/// "overwrite", "rename". Rename mints a unique suffix per row. +/// +/// +public sealed record ImportBundleCommand( + string Base64Bundle, + string? Passphrase, + string DefaultConflictPolicy); diff --git a/src/ScadaLink.ManagementService/ManagementActor.cs b/src/ScadaLink.ManagementService/ManagementActor.cs index 10e79d7..8b69783 100644 --- a/src/ScadaLink.ManagementService/ManagementActor.cs +++ b/src/ScadaLink.ManagementService/ManagementActor.cs @@ -16,9 +16,11 @@ using ScadaLink.Commons.Entities.Sites; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.DebugView; +using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Messages.Management; using ScadaLink.Commons.Messages.RemoteQuery; using ScadaLink.Commons.Types.InboundApi; +using ScadaLink.Commons.Types.Transport; using ScadaLink.DeploymentManager; using ScadaLink.HealthMonitoring; using ScadaLink.Communication; @@ -176,7 +178,13 @@ public class ManagementActor : ReceiveActor or UpdateAreaCommand or CreateTemplateFolderCommand or RenameTemplateFolderCommand or MoveTemplateFolderCommand or DeleteTemplateFolderCommand - or MoveTemplateToFolderCommand => "Design", + or MoveTemplateToFolderCommand + or ExportBundleCommand => "Design", + + // Transport import operations (mirror the Central UI gating: Admin + // for inbound bundle handling because they mutate cross-cutting + // configuration; Export stays Design because it only reads). + PreviewBundleCommand or ImportBundleCommand => "Admin", // Deployment operations CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand @@ -333,6 +341,11 @@ public class ManagementActor : ReceiveActor DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user), DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user), + // Transport (#24) bundle operations + ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username), + PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd), + ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username), + // NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step // "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP // auth and role resolution itself before sending a single envelope. Leaving a @@ -1686,6 +1699,190 @@ public class ManagementActor : ReceiveActor var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N")); return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request); } + + // ======================================================================== + // Transport (#24) bundle operations + // ======================================================================== + + /// + /// Resolves the per-type name lists in against the + /// repositories, builds an , exports the bundle, + /// and returns the encrypted ZIP as base64. + /// + private static async Task HandleExportBundle( + IServiceProvider sp, ExportBundleCommand cmd, string username) + { + var templateRepo = sp.GetRequiredService(); + var externalRepo = sp.GetRequiredService(); + var notifRepo = sp.GetRequiredService(); + var inboundRepo = sp.GetRequiredService(); + + var templates = await templateRepo.GetAllTemplatesAsync(); + var sharedScripts = await templateRepo.GetAllSharedScriptsAsync(); + var externalSystems = await externalRepo.GetAllExternalSystemsAsync(); + var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync(); + var notificationLists = await notifRepo.GetAllNotificationListsAsync(); + var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync(); + var apiKeys = await inboundRepo.GetAllApiKeysAsync(); + var apiMethods = await inboundRepo.GetAllApiMethodsAsync(); + + int[] ResolveIds(IReadOnlyList all, IReadOnlyList? names, + Func getName, Func getId, string entityType) + { + if (cmd.All) return all.Select(getId).ToArray(); + if (names is null || names.Count == 0) return Array.Empty(); + var nameSet = new HashSet(names, StringComparer.Ordinal); + var matched = all.Where(e => nameSet.Contains(getName(e))).Select(getId).ToArray(); + var missing = nameSet + .Except(all.Select(getName), StringComparer.Ordinal) + .ToArray(); + if (missing.Length > 0) + { + throw new ManagementCommandException( + $"Unknown {entityType} name(s): {string.Join(", ", missing.OrderBy(n => n, StringComparer.Ordinal))}."); + } + return matched; + } + + 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"), + ExternalSystemIds: ResolveIds(externalSystems, cmd.ExternalSystemNames, e => e.Name, e => e.Id, "external system"), + DatabaseConnectionIds: ResolveIds(dbConnections, cmd.DatabaseConnectionNames, d => d.Name, d => d.Id, "database connection"), + NotificationListIds: ResolveIds(notificationLists, cmd.NotificationListNames, n => n.Name, n => n.Id, "notification list"), + // SmtpConfiguration is keyed by Host (no Name column); the bundle + // 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"), + ApiKeyIds: ResolveIds(apiKeys, cmd.ApiKeyNames, k => k.Name, k => k.Id, "API key"), + ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"), + IncludeDependencies: cmd.IncludeDependencies); + + var exporter = sp.GetRequiredService(); + await using var stream = await exporter.ExportAsync( + selection, user: username, sourceEnvironment: cmd.SourceEnvironment, + passphrase: cmd.Passphrase); + + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var bytes = ms.ToArray(); + return new ExportBundleResult(Convert.ToBase64String(bytes), bytes.Length); + } + + /// + /// Loads + diffs a base64-encoded bundle and returns the preview rows. Does + /// not apply anything; callers can use this to gate Apply on the diff shape. + /// + private static async Task HandlePreviewBundle( + IServiceProvider sp, PreviewBundleCommand cmd) + { + var importer = sp.GetRequiredService(); + var bytes = DecodeBundle(cmd.Base64Bundle); + BundleSession session; + try + { + await using var stream = new MemoryStream(bytes); + session = await importer.LoadAsync(stream, cmd.Passphrase); + } + catch (ArgumentException ex) + { + throw new ManagementCommandException(ex.Message); + } + catch (System.Security.Cryptography.CryptographicException) + { + throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); + } + + var preview = await importer.PreviewAsync(session.SessionId); + var adds = preview.Items.Count(i => i.Kind == ConflictKind.New); + var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified); + var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical); + var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker); + return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks); + } + + /// + /// One-shot import: load + preview + apply with a single global conflict + /// policy applied to every row. Any + /// rows fail the call before Apply. + /// + private static async Task HandleImportBundle( + IServiceProvider sp, ImportBundleCommand cmd, string username) + { + var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy); + var importer = sp.GetRequiredService(); + var bytes = DecodeBundle(cmd.Base64Bundle); + + BundleSession session; + try + { + await using var stream = new MemoryStream(bytes); + session = await importer.LoadAsync(stream, cmd.Passphrase); + } + catch (ArgumentException ex) + { + throw new ManagementCommandException(ex.Message); + } + catch (System.Security.Cryptography.CryptographicException) + { + throw new ManagementCommandException("Wrong passphrase or bundle was tampered."); + } + + var preview = await importer.PreviewAsync(session.SessionId); + + var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList(); + if (blockers.Count > 0) + { + var details = string.Join("; ", + blockers.Select(b => $"{b.Name}: {b.BlockerReason}")); + throw new ManagementCommandException( + $"Bundle has {blockers.Count} blocker(s); import aborted. {details}"); + } + + var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var resolutions = preview.Items.Select(item => new ImportResolution( + item.EntityType, + item.Name, + item.Kind switch + { + ConflictKind.New => ResolutionAction.Add, + ConflictKind.Identical => ResolutionAction.Skip, + ConflictKind.Modified => policy, + _ => ResolutionAction.Skip, + }, + (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename) + ? $"{item.Name}-imported-{renameStamp}" + : null)).ToList(); + + return await importer.ApplyAsync(session.SessionId, resolutions, username); + } + + private static byte[] DecodeBundle(string base64) + { + if (string.IsNullOrEmpty(base64)) + { + throw new ManagementCommandException("Bundle payload is empty."); + } + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new ManagementCommandException("Bundle payload is not valid base64."); + } + } + + private static ResolutionAction ParseConflictPolicy(string? raw) + { + return (raw ?? string.Empty).Trim().ToLowerInvariant() switch + { + "skip" => ResolutionAction.Skip, + "overwrite" => ResolutionAction.Overwrite, + "rename" => ResolutionAction.Rename, + _ => throw new ManagementCommandException( + $"Invalid DefaultConflictPolicy '{raw}'. Use one of: skip, overwrite, rename."), + }; + } } /// diff --git a/src/ScadaLink.ManagementService/ManagementEndpoints.cs b/src/ScadaLink.ManagementService/ManagementEndpoints.cs index a554c5b..5bcad0d 100644 --- a/src/ScadaLink.ManagementService/ManagementEndpoints.cs +++ b/src/ScadaLink.ManagementService/ManagementEndpoints.cs @@ -35,10 +35,26 @@ public static class ManagementEndpoints return endpoints; } + /// + /// Per-request body-size ceiling for the management endpoint. ASP.NET Core's + /// default cap is ~30 MB and would reject Transport (#24) Import calls -- a + /// 100 MB raw bundle base64-inflates to ~140 MB plus envelope. 200 MB is + /// comfortable without going unbounded. + /// + private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024; + private static async Task HandleRequest(HttpContext context) { var logger = context.RequestServices.GetRequiredService>(); + // 0. Raise the per-request body-size cap before any body is read. + // The feature is only writable before the request body has been touched. + var maxBodyFeature = context.Features.Get(); + if (maxBodyFeature is { IsReadOnly: false }) + { + maxBodyFeature.MaxRequestBodySize = MaxManagementRequestBodyBytes; + } + // 1. Decode Basic Auth var authHeader = context.Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))