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