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.
This commit is contained in:
301
src/ScadaLink.CLI/Commands/BundleCommands.cs
Normal file
301
src/ScadaLink.CLI/Commands/BundleCommands.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Messages.Management;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class BundleCommands
|
||||
{
|
||||
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static Command Build(
|
||||
Option<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> 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<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var outputOption = new Option<string>("--output")
|
||||
{
|
||||
Description = "Output file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "Encryption passphrase. Omit to produce an unencrypted bundle.",
|
||||
};
|
||||
var allOption = new Option<bool>("--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<bool>("--include-dependencies")
|
||||
{
|
||||
Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.",
|
||||
};
|
||||
var sourceEnvOption = new Option<string?>("--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<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var inputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Bundle file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--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<string> urlOption, Option<string> formatOption,
|
||||
Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var inputOption = new Option<string>("--input")
|
||||
{
|
||||
Description = "Bundle file path (.scadabundle)",
|
||||
Required = true,
|
||||
};
|
||||
var passphraseOption = new Option<string?>("--passphrase")
|
||||
{
|
||||
Description = "Passphrase for encrypted bundles.",
|
||||
};
|
||||
var onConflictOption = new Option<string>("--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
|
||||
// ====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Same shape as <see cref="CommandHelpers.ExecuteCommandAsync"/> 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.
|
||||
/// </summary>
|
||||
private static async Task<int> RunBundleCommandAsync(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> passwordOption,
|
||||
object payload,
|
||||
Func<string, int> 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<IReadOnlyList<string>?> NameListOption(string name, string description)
|
||||
{
|
||||
var opt = new Option<IReadOnlyList<string>?>(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;
|
||||
}
|
||||
}
|
||||
@@ -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(_ =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using ScadaLink.Commons.Types.Transport;
|
||||
|
||||
namespace ScadaLink.Commons.Messages.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Exports a bundle. Names rather than IDs in the selection so test scripts can
|
||||
/// be written without an ID lookup step. <c>All=true</c> overrides the per-type
|
||||
/// name lists and exports every entity of every supported type.
|
||||
/// </summary>
|
||||
public sealed record ExportBundleCommand(
|
||||
bool All,
|
||||
IReadOnlyList<string>? TemplateNames,
|
||||
IReadOnlyList<string>? SharedScriptNames,
|
||||
IReadOnlyList<string>? ExternalSystemNames,
|
||||
IReadOnlyList<string>? DatabaseConnectionNames,
|
||||
IReadOnlyList<string>? NotificationListNames,
|
||||
IReadOnlyList<string>? SmtpConfigurationNames,
|
||||
IReadOnlyList<string>? ApiKeyNames,
|
||||
IReadOnlyList<string>? ApiMethodNames,
|
||||
bool IncludeDependencies,
|
||||
string? Passphrase,
|
||||
string SourceEnvironment);
|
||||
|
||||
/// <summary>
|
||||
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
|
||||
/// pre-encoded size for sanity checks against the configured bundle cap.
|
||||
/// </summary>
|
||||
public sealed record ExportBundleResult(
|
||||
string Base64Bundle,
|
||||
int ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Loads a bundle and returns its preview without applying anything. Useful
|
||||
/// for tests that want to assert on the diff shape before committing.
|
||||
/// </summary>
|
||||
public sealed record PreviewBundleCommand(
|
||||
string Base64Bundle,
|
||||
string? Passphrase);
|
||||
|
||||
public sealed record PreviewBundleResult(
|
||||
IReadOnlyList<ImportPreviewItem> Items,
|
||||
int AddCount,
|
||||
int ModifiedCount,
|
||||
int IdenticalCount,
|
||||
int BlockerCount);
|
||||
|
||||
/// <summary>
|
||||
/// Loads, previews, and applies a bundle in a single call. The diff is built
|
||||
/// internally; every <see cref="ConflictKind.Modified"/> row is resolved with
|
||||
/// <see cref="DefaultConflictPolicy"/>, every <see cref="ConflictKind.New"/>
|
||||
/// row gets <see cref="ResolutionAction.Add"/>, every
|
||||
/// <see cref="ConflictKind.Identical"/> row gets <see cref="ResolutionAction.Skip"/>,
|
||||
/// and any <see cref="ConflictKind.Blocker"/> row fails the call.
|
||||
/// <para>
|
||||
/// Valid <see cref="DefaultConflictPolicy"/> values: <c>"skip"</c>,
|
||||
/// <c>"overwrite"</c>, <c>"rename"</c>. Rename mints a unique suffix per row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ImportBundleCommand(
|
||||
string Base64Bundle,
|
||||
string? Passphrase,
|
||||
string DefaultConflictPolicy);
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
|
||||
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
|
||||
/// and returns the encrypted ZIP as base64.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleExportBundle(
|
||||
IServiceProvider sp, ExportBundleCommand cmd, string username)
|
||||
{
|
||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
||||
var notifRepo = sp.GetRequiredService<INotificationRepository>();
|
||||
var inboundRepo = sp.GetRequiredService<IInboundApiRepository>();
|
||||
|
||||
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<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names,
|
||||
Func<T, string> getName, Func<T, int> getId, string entityType)
|
||||
{
|
||||
if (cmd.All) return all.Select(getId).ToArray();
|
||||
if (names is null || names.Count == 0) return Array.Empty<int>();
|
||||
var nameSet = new HashSet<string>(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<IBundleExporter>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandlePreviewBundle(
|
||||
IServiceProvider sp, PreviewBundleCommand cmd)
|
||||
{
|
||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-shot import: load + preview + apply with a single global conflict
|
||||
/// policy applied to every <see cref="ConflictKind.Modified"/> row. Any
|
||||
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleImportBundle(
|
||||
IServiceProvider sp, ImportBundleCommand cmd, string username)
|
||||
{
|
||||
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
|
||||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||||
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."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -35,10 +35,26 @@ public static class ManagementEndpoints
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const long MaxManagementRequestBodyBytes = 200L * 1024 * 1024;
|
||||
|
||||
private static async Task<IResult> HandleRequest(HttpContext context)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<ManagementActorHolder>>();
|
||||
|
||||
// 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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user