feat(batch6-task2): implement F1 opts parsing and verify features

This commit is contained in:
Joseph Doherty
2026-02-28 09:29:50 -05:00
parent edc2afbb2f
commit 4c8fb4e344
5 changed files with 1140 additions and 800 deletions

View File

@@ -14,7 +14,9 @@
// Adapted from server/opts.go in the NATS server Go source.
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using ZB.MOM.NatsNet.Server.Config;
namespace ZB.MOM.NatsNet.Server;
@@ -36,6 +38,12 @@ internal static class ConfigFlags
public sealed partial class ServerOptions
{
/// <summary>
/// Toggles unknown top-level field handling for config parsing.
/// Mirrors <c>NoErrOnUnknownFields</c> in opts.go.
/// </summary>
public static void NoErrOnUnknownFields(bool noError) => ConfigFlags.NoErrOnUnknownFields(noError);
/// <summary>
/// Snapshot of command-line flags, populated during <see cref="ConfigureOptions"/>.
/// Mirrors <c>FlagSnapshot</c> in opts.go.
@@ -399,10 +407,804 @@ public sealed partial class ServerOptions
return null;
}
// -------------------------------------------------------------------------
// Batch 6: opts.go package-level parse/config helpers (F1)
// -------------------------------------------------------------------------
/// <summary>
/// Deep copies route/gateway URL lists.
/// Mirrors <c>deepCopyURLs</c> in opts.go.
/// </summary>
public static List<Uri>? DeepCopyURLs(IReadOnlyList<Uri>? urls)
{
if (urls == null)
return null;
var copied = new List<Uri>(urls.Count);
foreach (var u in urls)
copied.Add(new Uri(u.ToString(), UriKind.Absolute));
return copied;
}
/// <summary>
/// Loads server options from a config file.
/// Mirrors package-level <c>ProcessConfigFile</c> in opts.go.
/// </summary>
public static ServerOptions ProcessConfigFile(string configFile) =>
ServerOptionsConfiguration.ProcessConfigFile(configFile);
/// <summary>
/// Normalizes token-like values to plain CLR values.
/// Mirrors <c>unwrapValue</c> intent from opts.go.
/// </summary>
public static object? UnwrapValue(object? value) => NormalizeConfigValue(value);
/// <summary>
/// Converts a recovered panic/exception to an error list entry.
/// Mirrors <c>convertPanicToErrorList</c> in opts.go.
/// </summary>
public static void ConvertPanicToErrorList(Exception? panic, ICollection<Exception>? errors, string? context = null)
{
if (panic == null || errors == null)
return;
var message = string.IsNullOrWhiteSpace(context)
? "encountered panic while processing config"
: $"encountered panic while processing {context}";
errors.Add(new InvalidOperationException(message, panic));
}
/// <summary>
/// Converts a recovered panic/exception to a single error output.
/// Mirrors <c>convertPanicToError</c> in opts.go.
/// </summary>
public static void ConvertPanicToError(Exception? panic, ref Exception? error, string? context = null)
{
if (panic == null || error != null)
return;
var message = string.IsNullOrWhiteSpace(context)
? "encountered panic while processing config"
: $"encountered panic while processing {context}";
error = new InvalidOperationException(message, panic);
}
/// <summary>
/// Applies <c>system_account</c>/<c>system</c> config values.
/// Mirrors <c>configureSystemAccount</c> in opts.go.
/// </summary>
public static Exception? ConfigureSystemAccount(ServerOptions options, IReadOnlyDictionary<string, object?> config)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(config);
if (!TryGetFirst(config, ["system_account", "system"], out var value))
return null;
if (value is not string systemAccount)
return new InvalidOperationException("system account name must be a string");
options.SystemAccount = systemAccount;
return null;
}
/// <summary>
/// Builds a username/nkey identity map for duplicate detection.
/// Mirrors <c>setupUsersAndNKeysDuplicateCheckMap</c> in opts.go.
/// </summary>
public static HashSet<string> SetupUsersAndNKeysDuplicateCheckMap(ServerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var identities = new HashSet<string>(StringComparer.Ordinal);
if (options.Users != null)
{
foreach (var user in options.Users)
{
if (!string.IsNullOrWhiteSpace(user.Username))
identities.Add(user.Username);
}
}
if (options.Nkeys != null)
{
foreach (var user in options.Nkeys)
{
if (!string.IsNullOrWhiteSpace(user.Nkey))
identities.Add(user.Nkey);
}
}
return identities;
}
/// <summary>
/// Parses a duration from config value.
/// Mirrors <c>parseDuration</c> in opts.go.
/// </summary>
public static TimeSpan ParseDuration(
string field,
object? value,
ICollection<Exception>? errors = null,
ICollection<Exception>? warnings = null)
{
if (value is string s)
{
try
{
return NatsDurationJsonConverter.Parse(s);
}
catch (Exception ex)
{
errors?.Add(new InvalidOperationException($"error parsing {field}: {ex.Message}", ex));
return TimeSpan.Zero;
}
}
if (TryConvertToLong(value, out var legacySeconds))
{
warnings?.Add(new InvalidOperationException($"{field} should be converted to a duration"));
return TimeSpan.FromSeconds(legacySeconds);
}
errors?.Add(new InvalidOperationException($"{field} should be a duration string or number of seconds"));
return TimeSpan.Zero;
}
/// <summary>
/// Parses write timeout policy value.
/// Mirrors <c>parseWriteDeadlinePolicy</c> in opts.go.
/// </summary>
public static WriteTimeoutPolicy ParseWriteDeadlinePolicy(string value, ICollection<Exception>? errors = null) =>
value.ToLowerInvariant() switch
{
"default" => WriteTimeoutPolicy.Default,
"close" => WriteTimeoutPolicy.Close,
"retry" => WriteTimeoutPolicy.Retry,
_ => ParseWriteDeadlinePolicyFallback(value, errors),
};
/// <summary>
/// Parses <c>listen</c> values (<c>port</c> or <c>host:port</c>).
/// Mirrors <c>parseListen</c> in opts.go.
/// </summary>
public static (string Host, int Port) ParseListen(object? value)
{
if (TryConvertToLong(value, out var portOnly))
return (string.Empty, checked((int)portOnly));
if (value is not string address)
throw new InvalidOperationException($"expected port or host:port, got {value?.GetType().Name ?? "null"}");
if (!TrySplitHostPort(address, out var host, out var port))
throw new InvalidOperationException($"could not parse address string \"{address}\"");
return (host, port);
}
/// <summary>
/// Parses cluster block config.
/// Mirrors <c>parseCluster</c> in opts.go.
/// </summary>
public static Exception? ParseCluster(
object? value,
ServerOptions options,
ICollection<Exception>? errors = null,
ICollection<Exception>? warnings = null)
{
ArgumentNullException.ThrowIfNull(options);
if (!TryGetMap(value, out var clusterMap))
return new InvalidOperationException($"Expected map to define cluster, got {value?.GetType().Name ?? "null"}");
foreach (var (rawKey, rawValue) in clusterMap)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "name":
{
var name = entry as string ?? string.Empty;
if (name.Contains(' '))
{
errors?.Add(new InvalidOperationException(ServerErrors.ErrClusterNameHasSpaces.Message));
break;
}
options.Cluster.Name = name;
break;
}
case "listen":
{
try
{
var (host, port) = ParseListen(entry);
options.Cluster.Host = host;
options.Cluster.Port = port;
}
catch (Exception ex)
{
errors?.Add(ex);
}
break;
}
case "port":
if (TryConvertToLong(entry, out var clusterPort))
options.Cluster.Port = checked((int)clusterPort);
break;
case "host":
case "net":
options.Cluster.Host = entry as string ?? string.Empty;
break;
case "authorization":
{
var auth = ParseSimpleAuthorization(entry, errors, warnings);
if (auth == null)
break;
if (auth.HasUsers)
{
errors?.Add(new InvalidOperationException("Cluster authorization does not allow multiple users"));
break;
}
if (!string.IsNullOrEmpty(auth.Token))
{
errors?.Add(new InvalidOperationException("Cluster authorization does not support tokens"));
break;
}
if (auth.HasCallout)
{
errors?.Add(new InvalidOperationException("Cluster authorization does not support callouts"));
break;
}
options.Cluster.Username = auth.Username;
options.Cluster.Password = auth.Password;
if (auth.TimeoutSeconds > 0)
options.Cluster.AuthTimeout = auth.TimeoutSeconds;
break;
}
case "routes":
if (TryGetArray(entry, out var routes))
options.Routes = ParseURLs(routes, "route", warnings, errors);
break;
case "cluster_advertise":
case "advertise":
options.Cluster.Advertise = entry as string ?? string.Empty;
break;
case "no_advertise":
if (TryConvertToBool(entry, out var noAdvertise))
{
options.Cluster.NoAdvertise = noAdvertise;
TrackExplicitVal(options.InConfig, "Cluster.NoAdvertise", noAdvertise);
}
break;
case "connect_retries":
if (TryConvertToLong(entry, out var retries))
options.Cluster.ConnectRetries = checked((int)retries);
break;
case "connect_backoff":
if (TryConvertToBool(entry, out var connectBackoff))
options.Cluster.ConnectBackoff = connectBackoff;
break;
case "compression":
{
var parseError = ParseCompression(
options.Cluster.Compression,
CompressionModes.S2Fast,
"compression",
entry);
if (parseError != null)
errors?.Add(parseError);
break;
}
case "ping_interval":
options.Cluster.PingInterval = ParseDuration("ping_interval", entry, errors, warnings);
break;
case "ping_max":
if (TryConvertToLong(entry, out var pingMax))
options.Cluster.MaxPingsOut = checked((int)pingMax);
break;
case "write_deadline":
options.Cluster.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
break;
case "write_timeout":
options.Cluster.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
break;
default:
if (!ConfigFlags.AllowUnknownTopLevelField)
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
break;
}
}
return null;
}
/// <summary>
/// Parses compression option values from bool/string/map forms.
/// Mirrors <c>parseCompression</c> in opts.go.
/// </summary>
public static Exception? ParseCompression(
CompressionOpts compression,
string chosenModeForOn,
string fieldName,
object? value)
{
ArgumentNullException.ThrowIfNull(compression);
switch (NormalizeConfigValue(value))
{
case string mode:
compression.Mode = mode;
return null;
case bool enabled:
compression.Mode = enabled ? chosenModeForOn : CompressionModes.Off;
return null;
default:
if (!TryGetMap(value, out var map))
return new InvalidOperationException(
$"field \"{fieldName}\" should be a boolean or a structure, got {value?.GetType().Name ?? "null"}");
foreach (var (rawKey, rawValue) in map)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "mode":
compression.Mode = entry as string ?? string.Empty;
break;
case "rtt_thresholds":
case "thresholds":
case "rtts":
case "rtt":
if (!TryGetArray(entry, out var thresholds))
return new InvalidOperationException("rtt_thresholds should be an array");
foreach (var threshold in thresholds)
{
if (threshold is not string thresholdValue)
return new InvalidOperationException("rtt_thresholds entries should be duration strings");
compression.RttThresholds.Add(NatsDurationJsonConverter.Parse(thresholdValue));
}
break;
default:
return new InvalidOperationException($"unknown field \"{rawKey}\"");
}
}
return null;
}
}
/// <summary>
/// Parses URL arrays with duplicate detection.
/// Mirrors <c>parseURLs</c> in opts.go.
/// </summary>
public static List<Uri> ParseURLs(
IEnumerable<object?> values,
string type,
ICollection<Exception>? warnings = null,
ICollection<Exception>? errors = null)
{
var urls = new List<Uri>();
var dedupe = new HashSet<string>(StringComparer.Ordinal);
foreach (var rawValue in values)
{
if (NormalizeConfigValue(rawValue) is not string urlValue)
{
errors?.Add(new InvalidOperationException($"{type} url must be a string"));
continue;
}
if (!dedupe.Add(urlValue))
{
warnings?.Add(new InvalidOperationException($"Duplicate {type} entry detected: {urlValue}"));
continue;
}
try
{
urls.Add(ParseURL(urlValue, type));
}
catch (Exception ex)
{
errors?.Add(ex);
}
}
return urls;
}
/// <summary>
/// Parses a single URL entry.
/// Mirrors <c>parseURL</c> in opts.go.
/// </summary>
public static Uri ParseURL(string value, string type)
{
var trimmed = value.Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
throw new InvalidOperationException($"error parsing {type} url [\"{trimmed}\"]");
return parsed;
}
/// <summary>
/// Parses gateway block config.
/// Mirrors <c>parseGateway</c> in opts.go.
/// </summary>
public static Exception? ParseGateway(
object? value,
ServerOptions options,
ICollection<Exception>? errors = null,
ICollection<Exception>? warnings = null)
{
ArgumentNullException.ThrowIfNull(options);
if (!TryGetMap(value, out var gatewayMap))
return new InvalidOperationException($"Expected gateway to be a map, got {value?.GetType().Name ?? "null"}");
foreach (var (rawKey, rawValue) in gatewayMap)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "name":
{
var name = entry as string ?? string.Empty;
if (name.Contains(' '))
{
errors?.Add(new InvalidOperationException(ServerErrors.ErrGatewayNameHasSpaces.Message));
break;
}
options.Gateway.Name = name;
break;
}
case "listen":
{
try
{
var (host, port) = ParseListen(entry);
options.Gateway.Host = host;
options.Gateway.Port = port;
}
catch (Exception ex)
{
errors?.Add(ex);
}
break;
}
case "port":
if (TryConvertToLong(entry, out var gatewayPort))
options.Gateway.Port = checked((int)gatewayPort);
break;
case "host":
case "net":
options.Gateway.Host = entry as string ?? string.Empty;
break;
case "authorization":
{
var auth = ParseSimpleAuthorization(entry, errors, warnings);
if (auth == null)
break;
if (auth.HasUsers)
{
errors?.Add(new InvalidOperationException("Gateway authorization does not allow multiple users"));
break;
}
if (!string.IsNullOrEmpty(auth.Token))
{
errors?.Add(new InvalidOperationException("Gateway authorization does not support tokens"));
break;
}
if (auth.HasCallout)
{
errors?.Add(new InvalidOperationException("Gateway authorization does not support callouts"));
break;
}
options.Gateway.Username = auth.Username;
options.Gateway.Password = auth.Password;
if (auth.TimeoutSeconds > 0)
options.Gateway.AuthTimeout = auth.TimeoutSeconds;
break;
}
case "advertise":
options.Gateway.Advertise = entry as string ?? string.Empty;
break;
case "connect_retries":
if (TryConvertToLong(entry, out var retries))
options.Gateway.ConnectRetries = checked((int)retries);
break;
case "connect_backoff":
if (TryConvertToBool(entry, out var connectBackoff))
options.Gateway.ConnectBackoff = connectBackoff;
break;
case "reject_unknown":
case "reject_unknown_cluster":
if (TryConvertToBool(entry, out var rejectUnknown))
options.Gateway.RejectUnknown = rejectUnknown;
break;
case "write_deadline":
options.Gateway.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
break;
case "write_timeout":
options.Gateway.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
break;
default:
if (!ConfigFlags.AllowUnknownTopLevelField)
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
break;
}
}
return null;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection<Exception>? errors)
{
errors?.Add(new InvalidOperationException(
$"write_timeout must be 'default', 'close' or 'retry' (received '{value}')"));
return WriteTimeoutPolicy.Default;
}
private sealed record ParsedAuthorization(
string Username,
string Password,
string Token,
double TimeoutSeconds,
bool HasUsers,
bool HasCallout);
private static ParsedAuthorization? ParseSimpleAuthorization(
object? value,
ICollection<Exception>? errors,
ICollection<Exception>? warnings)
{
if (!TryGetMap(value, out var map))
{
errors?.Add(new InvalidOperationException("authorization should be a map"));
return null;
}
string user = string.Empty;
string pass = string.Empty;
string token = string.Empty;
double timeout = 0;
var hasUsers = false;
var hasCallout = false;
foreach (var (rawKey, rawValue) in map)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "user":
case "username":
user = entry as string ?? string.Empty;
break;
case "pass":
case "password":
pass = entry as string ?? string.Empty;
break;
case "token":
token = entry as string ?? string.Empty;
break;
case "timeout":
case "auth_timeout":
if (entry is string timeoutAsString)
{
timeout = ParseDuration("auth_timeout", timeoutAsString, errors, warnings).TotalSeconds;
}
else if (TryConvertToDouble(entry, out var timeoutSeconds))
{
timeout = timeoutSeconds;
}
break;
case "users":
case "nkeys":
hasUsers = true;
break;
case "callout":
case "auth_callout":
hasCallout = true;
break;
}
}
return new ParsedAuthorization(user, pass, token, timeout, hasUsers, hasCallout);
}
private static bool TrySplitHostPort(string value, out string host, out int port)
{
host = string.Empty;
port = 0;
if (Uri.TryCreate($"tcp://{value}", UriKind.Absolute, out var uri))
{
host = uri.Host;
if (uri.Port >= 0)
{
port = uri.Port;
return true;
}
}
var idx = value.LastIndexOf(':');
if (idx <= 0 || idx >= value.Length - 1)
return false;
host = value[..idx];
return int.TryParse(value[(idx + 1)..], out port);
}
private static bool TryGetFirst(
IReadOnlyDictionary<string, object?> map,
IEnumerable<string> keys,
out object? value)
{
foreach (var key in keys)
{
if (map.TryGetValue(key, out value))
{
value = NormalizeConfigValue(value);
return true;
}
}
value = null;
return false;
}
private static bool TryGetMap(object? value, out IReadOnlyDictionary<string, object?> map)
{
var normalized = NormalizeConfigValue(value);
if (normalized is IReadOnlyDictionary<string, object?> readonlyMap)
{
map = readonlyMap;
return true;
}
if (normalized is Dictionary<string, object?> dict)
{
map = dict;
return true;
}
map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
return false;
}
private static bool TryGetArray(object? value, out IReadOnlyList<object?> values)
{
var normalized = NormalizeConfigValue(value);
if (normalized is IReadOnlyList<object?> readonlyValues)
{
values = readonlyValues;
return true;
}
if (normalized is List<object?> listValues)
{
values = listValues;
return true;
}
values = [];
return false;
}
private static object? NormalizeConfigValue(object? value)
{
if (value is not JsonElement element)
return value;
return element.ValueKind switch
{
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => NormalizeConfigValue(p.Value), StringComparer.OrdinalIgnoreCase),
JsonValueKind.Array => element.EnumerateArray()
.Select(item => NormalizeConfigValue(item))
.ToList(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.GetRawText(),
};
}
private static bool TryConvertToLong(object? value, out long converted)
{
switch (NormalizeConfigValue(value))
{
case long longValue:
converted = longValue;
return true;
case int intValue:
converted = intValue;
return true;
case short shortValue:
converted = shortValue;
return true;
case byte byteValue:
converted = byteValue;
return true;
case double doubleValue when doubleValue >= long.MinValue && doubleValue <= long.MaxValue:
converted = checked((long)doubleValue);
return true;
case float floatValue when floatValue >= long.MinValue && floatValue <= long.MaxValue:
converted = checked((long)floatValue);
return true;
case string s when long.TryParse(s, out var parsed):
converted = parsed;
return true;
default:
converted = 0;
return false;
}
}
private static bool TryConvertToDouble(object? value, out double converted)
{
switch (NormalizeConfigValue(value))
{
case double d:
converted = d;
return true;
case float f:
converted = f;
return true;
case long l:
converted = l;
return true;
case int i:
converted = i;
return true;
case string s when double.TryParse(s, out var parsed):
converted = parsed;
return true;
default:
converted = 0;
return false;
}
}
private static bool TryConvertToBool(object? value, out bool converted)
{
switch (NormalizeConfigValue(value))
{
case bool b:
converted = b;
return true;
case string s when bool.TryParse(s, out var parsed):
converted = parsed;
return true;
default:
converted = false;
return false;
}
}
private static void MergeRoutes(ServerOptions opts, ServerOptions flagOpts)
{
var routeUrls = RoutesFromStr(flagOpts.RoutesStr);