901fd58a32
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.
302 lines
12 KiB
C#
302 lines
12 KiB
C#
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;
|
|
}
|
|
}
|