refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,365 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.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);
/// <summary>Builds the <c>bundle</c> command group with export, preview, and import sub-commands.</summary>
/// <param name="urlOption">Shared management URL option.</param>
/// <param name="formatOption">Shared output format option.</param>
/// <param name="usernameOption">Shared username option.</param>
/// <param name="passwordOption">Shared password option.</param>
/// <returns>The configured <see cref="Command"/> for the bundle group.</returns>
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 CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
// CLI-020: previously the JSON envelope parse + property extraction +
// base64 decode all ran unguarded — a server-side bug that omits one of
// the two expected properties, returns a null base64 value, sends invalid
// base64, or returns a malformed JSON envelope would surface as one of
// KeyNotFoundException / InvalidOperationException / FormatException /
// JsonException, i.e. an unhandled stack trace rather than the
// documented "exit 1 with a clean INVALID_RESPONSE error". Wrap the
// envelope parse and the streamed write in a single try/catch matching
// the graceful-degradation theme established by CLI-002 / CLI-003 / CLI-005.
string base64;
int byteCount;
try
{
using var doc = JsonDocument.Parse(jsonOk);
base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
}
catch (Exception ex) when (ex is JsonException
or KeyNotFoundException
or InvalidOperationException)
{
OutputFormatter.WriteError(
$"Server returned a malformed bundle-export response: {ex.Message}",
"INVALID_RESPONSE");
return 1;
}
// CLI-019: stream the base64 → file write so a 100 MB bundle
// doesn't double-buffer through Convert.FromBase64String's
// ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes.
// The management envelope's body is still buffered into the
// jsonOk string (wire-format limit), but the decode + write
// are now chunked, so peak working-set drops from
// ~base64+byte[]+envelope to ~base64+small-chunk.
long written;
try
{
written = StreamBase64ToFile(base64, output);
}
catch (FormatException ex)
{
OutputFormatter.WriteError(
$"Server returned invalid base64 in the bundle response: {ex.Message}",
"INVALID_RESPONSE");
return 1;
}
Console.WriteLine($"Wrote {written: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 CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: 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 CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
payload,
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
Console.WriteLine(jsonOk);
return 0;
});
});
return cmd;
}
// ====================================================================
// Shared HTTP plumbing
// ====================================================================
//
// CLI-017: bundle commands previously routed through a private
// RunBundleCommandAsync that re-implemented URL/credential resolution and
// skipped the IsAuthorizationFailure(...) check that ExecuteCommandAsync
// enforces — a server that signalled FORBIDDEN/UNAUTHORIZED via the error
// code on a non-403 status would exit 1 instead of the documented exit 2.
// The bundle path now delegates to CommandHelpers.ExecuteCommandAsync with
// the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group.
// CLI-019: chunked base64 → file streaming. The management envelope's
// success body is a single buffered JSON string (the wire format does not
// currently support response-body streaming), so we cannot remove the
// ~base64-string + ~envelope-string allocation. What we CAN — and do —
// remove is the intermediate ~bytecount-sized byte[] that
// Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes:
// we slice the base64 string into 4-byte-multiple chunks (4 base64 chars
// decode into exactly 3 bytes, so any multiple of 4 is a clean boundary)
// and decode each chunk into a small rented buffer that we copy into the
// output FileStream. The chunk size is a tradeoff — large enough that the
// per-chunk loop overhead is negligible, small enough that we never put
// anything on the LOH (1 MB is below the 85 KB LOH threshold's larger
// cousin for buffers we don't keep). Returns the total decoded byte count
// for the post-write summary line.
internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded
internal static long StreamBase64ToFile(string base64, string outputPath)
{
if (base64 is null) throw new ArgumentNullException(nameof(base64));
if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath));
// Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars
// tolerates internal whitespace, but slicing on arbitrary positions would split a
// run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice
// is independently decodable.
var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4);
var totalChars = base64.Length;
var totalWritten = 0L;
using var fileStream = new FileStream(
outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: false);
// 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly.
var byteBuffer = new byte[(chunkChars / 4) * 3];
for (var offset = 0; offset < totalChars; offset += chunkChars)
{
var take = Math.Min(chunkChars, totalChars - offset);
var slice = base64.AsSpan(offset, take);
// The final slice may be shorter than chunkChars and may carry
// trailing '=' padding; TryFromBase64Chars handles that.
if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written))
{
throw new FormatException(
$"Bundle response contained invalid base64 at character offset {offset}.");
}
fileStream.Write(byteBuffer, 0, written);
totalWritten += written;
}
return totalWritten;
}
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;
}
}