e2f433e849
Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.
The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.
docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
347 lines
17 KiB
C#
347 lines
17 KiB
C#
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;
|
|
|
|
internal static class CommandHelpers
|
|
{
|
|
/// <summary>
|
|
/// Resolves the management URL, credentials, and output format, then sends <paramref name="command"/>
|
|
/// to the management API and returns the process exit code.
|
|
/// </summary>
|
|
/// <param name="result">Parsed command-line result from which option values are read.</param>
|
|
/// <param name="urlOption">Option that supplies the management URL override.</param>
|
|
/// <param name="formatOption">Option that supplies the output format override.</param>
|
|
/// <param name="usernameOption">Option that supplies the username override.</param>
|
|
/// <param name="passwordOption">Option that supplies the password override.</param>
|
|
/// <param name="command">The management command object to send.</param>
|
|
/// <param name="timeout">
|
|
/// Optional per-command HTTP timeout. Deploy operations defer to the server-owned
|
|
/// deadline; ordinary commands default to 30 seconds. An explicit timeout
|
|
/// overrides this policy (for example, bundle and Flow Debugger requests).
|
|
/// </param>
|
|
/// <param name="onSuccess">
|
|
/// Optional success handler. When supplied, the helper invokes it with the success
|
|
/// body instead of running the default <see cref="HandleResponse"/> rendering path —
|
|
/// useful when the caller needs to capture the response (e.g. write a file) rather
|
|
/// than print it. The authorization-failure exit-code contract
|
|
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way.
|
|
/// </param>
|
|
/// <param name="tableProjector">
|
|
/// Optional transform applied to the success JSON body <em>only</em> when the resolved
|
|
/// format is <c>table</c>. Lets a command render a compact table projection (e.g.
|
|
/// <c>template list</c> dropping per-template attribute dumps) while
|
|
/// leaving JSON output untouched for machine consumers. Ignored when
|
|
/// <paramref name="onSuccess"/> is supplied.
|
|
/// </param>
|
|
/// <returns>A task that resolves to the process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
|
|
internal static async Task<int> ExecuteCommandAsync(
|
|
ParseResult result,
|
|
Option<string> urlOption,
|
|
Option<string> formatOption,
|
|
Option<string> usernameOption,
|
|
Option<string> passwordOption,
|
|
object command,
|
|
TimeSpan? timeout = null,
|
|
Func<string, int>? onSuccess = null,
|
|
Func<string, string>? tableProjector = null)
|
|
{
|
|
var config = CliConfig.Load();
|
|
var format = ResolveFormat(result, formatOption, config);
|
|
|
|
// Resolve management URL
|
|
var url = result.GetValue(urlOption);
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
url = config.ManagementUrl;
|
|
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
|
|
"NO_URL");
|
|
return 1;
|
|
}
|
|
|
|
if (!IsValidManagementUrl(url))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
|
"INVALID_URL");
|
|
return 1;
|
|
}
|
|
|
|
// Resolve credentials: command-line options take precedence, then the
|
|
// SCADABRIDGE_USERNAME / SCADABRIDGE_PASSWORD environment variables.
|
|
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
|
|
var password = ResolvePassword(result, passwordOption, config);
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
"Credentials required. Use --username with --password or --password-file, or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
|
|
"NO_CREDENTIALS");
|
|
return 1;
|
|
}
|
|
|
|
// Derive command name from type
|
|
var commandName = ManagementCommandRegistry.GetCommandName(command.GetType());
|
|
|
|
// Send via HTTP
|
|
using var client = new ManagementHttpClient(url, username, password);
|
|
// Every alias of a deploy contract shares the same policy. The server bounds
|
|
// the operation; a shorter client deadline can report failure while it applies.
|
|
var requestTimeout = timeout ?? (command is MgmtDeployInstanceCommand or MgmtDeployArtifactsCommand or MgmtDeploySiteCommand
|
|
or MgmtDeployAreaCommand
|
|
? Timeout.InfiniteTimeSpan
|
|
: TimeSpan.FromSeconds(30));
|
|
var response = await client.SendCommandAsync(commandName, command, requestTimeout);
|
|
|
|
// Caller-supplied success handler short-circuits the default rendering — but
|
|
// the error path still routes through IsAuthorizationFailure so the documented
|
|
// exit code 2 contract holds whether or not a custom handler is provided.
|
|
if (onSuccess is not null)
|
|
{
|
|
if (response.JsonData is not null)
|
|
return onSuccess(response.JsonData);
|
|
|
|
OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR");
|
|
return IsAuthorizationFailure(response) ? 2 : 1;
|
|
}
|
|
|
|
return HandleResponse(response, format, tableProjector);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the output format using the documented precedence chain:
|
|
/// an explicitly supplied <c>--format</c> option wins, otherwise the
|
|
/// config-file / environment-variable default (<see cref="CliConfig.DefaultFormat"/>)
|
|
/// is used, otherwise <c>json</c>. The <c>--format</c> option must not declare a
|
|
/// <c>DefaultValueFactory</c> — that would mask whether the flag was supplied.
|
|
/// </summary>
|
|
/// <param name="result">Parsed command-line result.</param>
|
|
/// <param name="formatOption">The <c>--format</c> option definition.</param>
|
|
/// <param name="config">Loaded CLI configuration providing the default format fallback.</param>
|
|
/// <returns>The resolved format string (e.g. <c>"json"</c> or <c>"table"</c>).</returns>
|
|
internal static string ResolveFormat(ParseResult result, Option<string> formatOption, CliConfig config)
|
|
{
|
|
// GetResult returns non-null only when the option was actually present on the
|
|
// command line, letting an explicit --format override the config default.
|
|
if (result.GetResult(formatOption) != null)
|
|
{
|
|
var explicitValue = result.GetValue(formatOption);
|
|
if (!string.IsNullOrWhiteSpace(explicitValue))
|
|
return explicitValue;
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(config.DefaultFormat) ? "json" : config.DefaultFormat;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a single credential: an explicit command-line value wins, otherwise the
|
|
/// environment-variable fallback (from <see cref="CliConfig"/>) is used.
|
|
/// </summary>
|
|
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
|
|
/// <param name="envValue">Fallback value from the config file or environment variable.</param>
|
|
/// <returns>The command-line value when non-empty; otherwise the environment fallback (may be null).</returns>
|
|
/// <summary>
|
|
/// Resolves the LDAP password: <c>--password-file</c> (file or stdin), then
|
|
/// <c>--password</c>, then <c>SCADABRIDGE_PASSWORD</c>.
|
|
/// </summary>
|
|
/// <param name="result">Parsed command-line result.</param>
|
|
/// <param name="passwordOption">The inline <c>--password</c> option.</param>
|
|
/// <param name="config">Loaded CLI configuration providing the environment fallback.</param>
|
|
/// <returns>The resolved password, or null when none was supplied.</returns>
|
|
internal static string? ResolvePassword(ParseResult result, Option<string> passwordOption, CliConfig config)
|
|
=> result.GetResult(CliOptions.PasswordFileOption) is { Implicit: false }
|
|
? result.GetValue(CliOptions.PasswordFileOption)
|
|
: ResolveCredential(result.GetValue(passwordOption), config.Password);
|
|
|
|
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
|
|
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
|
|
|
|
/// <summary>
|
|
/// Validates that a management URL is an absolute http/https URL. A malformed URL
|
|
/// (missing scheme, empty, or a non-http scheme) would otherwise reach
|
|
/// <c>new Uri(...)</c> in the <see cref="ManagementHttpClient"/> constructor and throw
|
|
/// an unhandled <see cref="UriFormatException"/>.
|
|
/// </summary>
|
|
/// <param name="url">URL string to validate.</param>
|
|
/// <returns><c>true</c> when the URL is an absolute http or https URL; otherwise <c>false</c>.</returns>
|
|
internal static bool IsValidManagementUrl(string? url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
return false;
|
|
|
|
return Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the management response to stdout and returns the appropriate process exit code.
|
|
/// </summary>
|
|
/// <param name="response">Response received from the management API.</param>
|
|
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
|
|
/// <param name="tableProjector">
|
|
/// Optional transform applied to the JSON body before table rendering only — JSON
|
|
/// output is never altered. See <see cref="ExecuteCommandAsync"/>.
|
|
/// </param>
|
|
/// <returns>The process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
|
|
internal static int HandleResponse(ManagementResponse response, string format, Func<string, string>? tableProjector = null)
|
|
{
|
|
if (response.JsonData != null)
|
|
{
|
|
// A success status with an empty/whitespace body (e.g. a 204 from a delete)
|
|
// is a "command succeeded, no output" case — do not attempt to parse it.
|
|
if (string.IsNullOrWhiteSpace(response.JsonData))
|
|
{
|
|
Console.WriteLine("(ok)");
|
|
return 0;
|
|
}
|
|
|
|
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// A table projector compacts the body for terminal display (e.g. dropping
|
|
// per-template attribute dumps). JSON output stays full/untouched so
|
|
// machine consumers keep the complete payload.
|
|
var body = tableProjector != null ? tableProjector(response.JsonData) : response.JsonData;
|
|
WriteAsTable(body);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(response.JsonData);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
var errorCode = response.ErrorCode ?? "ERROR";
|
|
var error = response.Error ?? "Unknown error";
|
|
|
|
OutputFormatter.WriteError(error, errorCode);
|
|
return IsAuthorizationFailure(response) ? 2 : 1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether an error response represents an authorization failure
|
|
/// (insufficient role), which the documented exit-code table maps to exit code 2.
|
|
/// An HTTP 403 status is the primary signal; the server may also signal it via an
|
|
/// <c>UNAUTHORIZED</c> / <c>FORBIDDEN</c> error code on a different HTTP status, so
|
|
/// both channels are honoured. (Authentication failure — HTTP 401 / bad credentials
|
|
/// — is deliberately <em>not</em> treated as authorization failure; it is exit 1.)
|
|
/// </summary>
|
|
/// <param name="response">The management response to inspect for authorization failure signals.</param>
|
|
/// <returns><c>true</c> when the response signals an authorization failure (HTTP 403 or FORBIDDEN/UNAUTHORIZED code).</returns>
|
|
internal static bool IsAuthorizationFailure(ManagementResponse response)
|
|
{
|
|
if (response.StatusCode == 403)
|
|
return true;
|
|
|
|
return string.Equals(response.ErrorCode, "FORBIDDEN", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(response.ErrorCode, "UNAUTHORIZED", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static void WriteAsTable(string json)
|
|
{
|
|
JsonDocument doc;
|
|
try
|
|
{
|
|
doc = JsonDocument.Parse(json);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// The server returned a success status but a non-JSON body (e.g. a proxy
|
|
// HTML error page, or a plain-text message). Print it verbatim rather than
|
|
// crashing — mirrors the raw-body fallback on the JSON path.
|
|
Console.WriteLine(json);
|
|
return;
|
|
}
|
|
|
|
using (doc)
|
|
{
|
|
var root = doc.RootElement;
|
|
|
|
if (root.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var items = root.EnumerateArray().ToList();
|
|
if (items.Count == 0)
|
|
{
|
|
Console.WriteLine("(no results)");
|
|
return;
|
|
}
|
|
|
|
// Derive the header set as the union of property names across *every*
|
|
// element, in first-seen order. Using only items[0] would silently drop
|
|
// columns for any later element with a different shape.
|
|
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
|
|
|
|
// A "note" property that appears ONLY on single-property rows (a trailing
|
|
// run-level note, or a step's folded log/exception
|
|
// line) is not a real column: every other row's cell there would render blank,
|
|
// and column padding would swallow the raw line's own leading indentation. Such
|
|
// rows print as a raw unpadded line instead, at their position in the sequence,
|
|
// and "note" is excluded from the header union so nothing renders a blank column
|
|
// for it.
|
|
var noteOnlyRows = objectItems
|
|
.Where(i => i.EnumerateObject().Any(p => p.Name == "note"))
|
|
.ToList();
|
|
var noteIsRawLine = noteOnlyRows.Count > 0
|
|
&& noteOnlyRows.All(i => i.EnumerateObject().Count() == 1);
|
|
|
|
string[] headers;
|
|
if (objectItems.Count > 0)
|
|
{
|
|
var seen = new List<string>();
|
|
var known = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var item in objectItems)
|
|
foreach (var prop in item.EnumerateObject())
|
|
{
|
|
if (noteIsRawLine && prop.Name == "note")
|
|
continue;
|
|
if (known.Add(prop.Name))
|
|
seen.Add(prop.Name);
|
|
}
|
|
headers = seen.ToArray();
|
|
}
|
|
else
|
|
{
|
|
headers = new[] { "Value" };
|
|
}
|
|
|
|
var rows = items.Select(item =>
|
|
{
|
|
if (item.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (noteIsRawLine
|
|
&& item.EnumerateObject().Count() == 1
|
|
&& item.TryGetProperty("note", out var noteValue))
|
|
{
|
|
return OutputFormatter.TableRow.Raw(
|
|
noteValue.ValueKind == JsonValueKind.Null ? "" : noteValue.ToString());
|
|
}
|
|
|
|
return OutputFormatter.TableRow.Columns(headers.Select(h =>
|
|
item.TryGetProperty(h, out var val)
|
|
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
|
|
: "").ToArray());
|
|
}
|
|
return OutputFormatter.TableRow.Columns(new[] { item.ToString() });
|
|
});
|
|
|
|
OutputFormatter.WriteTable(rows, headers);
|
|
}
|
|
else if (root.ValueKind == JsonValueKind.Object)
|
|
{
|
|
var headers = new[] { "Property", "Value" };
|
|
var rows = root.EnumerateObject().Select(p =>
|
|
new[] { p.Name, p.Value.ValueKind == JsonValueKind.Null ? "" : p.Value.ToString() });
|
|
OutputFormatter.WriteTable(rows, headers);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(root.ToString());
|
|
}
|
|
}
|
|
}
|
|
}
|