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:
Joseph Doherty
2026-05-24 08:15:28 -04:00
parent f1c3019eca
commit 901fd58a32
5 changed files with 578 additions and 1 deletions

View File

@@ -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>