Files
natsdotnet/src/NATS.Server/Configuration/ConfigReloader.cs
Joseph Doherty d21243bc8a feat: add config reloader with diff, validate, and CLI merge
Port of Go server/reload.go option interface and diffing logic. Compares
NatsOptions property-by-property to detect changes, tags each with category
flags (logging, auth, TLS, non-reloadable), validates that non-reloadable
options (Host, Port, ServerName) are not changed at runtime, and provides
MergeCliOverrides to ensure CLI flags always take precedence over config
file values during hot reload.
2026-02-23 04:53:25 -05:00

342 lines
16 KiB
C#

// Port of Go server/reload.go — config diffing, validation, and CLI override merging
// for hot reload support. Reference: golang/nats-server/server/reload.go.
namespace NATS.Server.Configuration;
/// <summary>
/// Provides static methods for comparing two <see cref="NatsOptions"/> instances,
/// validating that detected changes are reloadable, and merging CLI overrides
/// so that command-line flags always take precedence over config file values.
/// </summary>
public static class ConfigReloader
{
// Non-reloadable options (match Go server — Host, Port, ServerName require restart)
private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];
// Logging-related options
private static readonly HashSet<string> LoggingOptions =
["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
"LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];
// Auth-related options
private static readonly HashSet<string> AuthOptions =
["Username", "Password", "Authorization", "Users", "NKeys",
"NoAuthUser", "AuthTimeout"];
// TLS-related options
private static readonly HashSet<string> TlsOptions =
["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
"TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
"AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];
/// <summary>
/// Compares two <see cref="NatsOptions"/> instances property by property and returns
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
/// is tagged with the appropriate category flags.
/// </summary>
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
{
var changes = new List<IConfigChange>();
// Non-reloadable
CompareAndAdd(changes, "Host", oldOpts.Host, newOpts.Host);
CompareAndAdd(changes, "Port", oldOpts.Port, newOpts.Port);
CompareAndAdd(changes, "ServerName", oldOpts.ServerName, newOpts.ServerName);
// Logging
CompareAndAdd(changes, "Debug", oldOpts.Debug, newOpts.Debug);
CompareAndAdd(changes, "Trace", oldOpts.Trace, newOpts.Trace);
CompareAndAdd(changes, "TraceVerbose", oldOpts.TraceVerbose, newOpts.TraceVerbose);
CompareAndAdd(changes, "Logtime", oldOpts.Logtime, newOpts.Logtime);
CompareAndAdd(changes, "LogtimeUTC", oldOpts.LogtimeUTC, newOpts.LogtimeUTC);
CompareAndAdd(changes, "LogFile", oldOpts.LogFile, newOpts.LogFile);
CompareAndAdd(changes, "LogSizeLimit", oldOpts.LogSizeLimit, newOpts.LogSizeLimit);
CompareAndAdd(changes, "LogMaxFiles", oldOpts.LogMaxFiles, newOpts.LogMaxFiles);
CompareAndAdd(changes, "Syslog", oldOpts.Syslog, newOpts.Syslog);
CompareAndAdd(changes, "RemoteSyslog", oldOpts.RemoteSyslog, newOpts.RemoteSyslog);
// Auth
CompareAndAdd(changes, "Username", oldOpts.Username, newOpts.Username);
CompareAndAdd(changes, "Password", oldOpts.Password, newOpts.Password);
CompareAndAdd(changes, "Authorization", oldOpts.Authorization, newOpts.Authorization);
CompareCollectionAndAdd(changes, "Users", oldOpts.Users, newOpts.Users);
CompareCollectionAndAdd(changes, "NKeys", oldOpts.NKeys, newOpts.NKeys);
CompareAndAdd(changes, "NoAuthUser", oldOpts.NoAuthUser, newOpts.NoAuthUser);
CompareAndAdd(changes, "AuthTimeout", oldOpts.AuthTimeout, newOpts.AuthTimeout);
// TLS
CompareAndAdd(changes, "TlsCert", oldOpts.TlsCert, newOpts.TlsCert);
CompareAndAdd(changes, "TlsKey", oldOpts.TlsKey, newOpts.TlsKey);
CompareAndAdd(changes, "TlsCaCert", oldOpts.TlsCaCert, newOpts.TlsCaCert);
CompareAndAdd(changes, "TlsVerify", oldOpts.TlsVerify, newOpts.TlsVerify);
CompareAndAdd(changes, "TlsMap", oldOpts.TlsMap, newOpts.TlsMap);
CompareAndAdd(changes, "TlsTimeout", oldOpts.TlsTimeout, newOpts.TlsTimeout);
CompareAndAdd(changes, "TlsHandshakeFirst", oldOpts.TlsHandshakeFirst, newOpts.TlsHandshakeFirst);
CompareAndAdd(changes, "TlsHandshakeFirstFallback", oldOpts.TlsHandshakeFirstFallback, newOpts.TlsHandshakeFirstFallback);
CompareAndAdd(changes, "AllowNonTls", oldOpts.AllowNonTls, newOpts.AllowNonTls);
CompareAndAdd(changes, "TlsRateLimit", oldOpts.TlsRateLimit, newOpts.TlsRateLimit);
CompareCollectionAndAdd(changes, "TlsPinnedCerts", oldOpts.TlsPinnedCerts, newOpts.TlsPinnedCerts);
// Limits
CompareAndAdd(changes, "MaxConnections", oldOpts.MaxConnections, newOpts.MaxConnections);
CompareAndAdd(changes, "MaxPayload", oldOpts.MaxPayload, newOpts.MaxPayload);
CompareAndAdd(changes, "MaxPending", oldOpts.MaxPending, newOpts.MaxPending);
CompareAndAdd(changes, "WriteDeadline", oldOpts.WriteDeadline, newOpts.WriteDeadline);
CompareAndAdd(changes, "PingInterval", oldOpts.PingInterval, newOpts.PingInterval);
CompareAndAdd(changes, "MaxPingsOut", oldOpts.MaxPingsOut, newOpts.MaxPingsOut);
CompareAndAdd(changes, "MaxControlLine", oldOpts.MaxControlLine, newOpts.MaxControlLine);
CompareAndAdd(changes, "MaxSubs", oldOpts.MaxSubs, newOpts.MaxSubs);
CompareAndAdd(changes, "MaxSubTokens", oldOpts.MaxSubTokens, newOpts.MaxSubTokens);
CompareAndAdd(changes, "MaxTracedMsgLen", oldOpts.MaxTracedMsgLen, newOpts.MaxTracedMsgLen);
CompareAndAdd(changes, "MaxClosedClients", oldOpts.MaxClosedClients, newOpts.MaxClosedClients);
// Misc
CompareCollectionAndAdd(changes, "Tags", oldOpts.Tags, newOpts.Tags);
CompareAndAdd(changes, "LameDuckDuration", oldOpts.LameDuckDuration, newOpts.LameDuckDuration);
CompareAndAdd(changes, "LameDuckGracePeriod", oldOpts.LameDuckGracePeriod, newOpts.LameDuckGracePeriod);
CompareAndAdd(changes, "ClientAdvertise", oldOpts.ClientAdvertise, newOpts.ClientAdvertise);
CompareAndAdd(changes, "DisableSublistCache", oldOpts.DisableSublistCache, newOpts.DisableSublistCache);
CompareAndAdd(changes, "ConnectErrorReports", oldOpts.ConnectErrorReports, newOpts.ConnectErrorReports);
CompareAndAdd(changes, "ReconnectErrorReports", oldOpts.ReconnectErrorReports, newOpts.ReconnectErrorReports);
CompareAndAdd(changes, "NoHeaderSupport", oldOpts.NoHeaderSupport, newOpts.NoHeaderSupport);
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
return changes;
}
/// <summary>
/// Validates a list of config changes and returns error messages for any
/// non-reloadable changes (properties that require a server restart).
/// </summary>
public static List<string> Validate(List<IConfigChange> changes)
{
var errors = new List<string>();
foreach (var change in changes)
{
if (change.IsNonReloadable)
{
errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)");
}
}
return errors;
}
/// <summary>
/// Merges CLI overrides into a freshly-parsed config so that command-line flags
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
/// </summary>
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
{
foreach (var flag in cliFlags)
{
switch (flag)
{
// Non-reloadable
case "Host":
fromConfig.Host = cliValues.Host;
break;
case "Port":
fromConfig.Port = cliValues.Port;
break;
case "ServerName":
fromConfig.ServerName = cliValues.ServerName;
break;
// Logging
case "Debug":
fromConfig.Debug = cliValues.Debug;
break;
case "Trace":
fromConfig.Trace = cliValues.Trace;
break;
case "TraceVerbose":
fromConfig.TraceVerbose = cliValues.TraceVerbose;
break;
case "Logtime":
fromConfig.Logtime = cliValues.Logtime;
break;
case "LogtimeUTC":
fromConfig.LogtimeUTC = cliValues.LogtimeUTC;
break;
case "LogFile":
fromConfig.LogFile = cliValues.LogFile;
break;
case "LogSizeLimit":
fromConfig.LogSizeLimit = cliValues.LogSizeLimit;
break;
case "LogMaxFiles":
fromConfig.LogMaxFiles = cliValues.LogMaxFiles;
break;
case "Syslog":
fromConfig.Syslog = cliValues.Syslog;
break;
case "RemoteSyslog":
fromConfig.RemoteSyslog = cliValues.RemoteSyslog;
break;
// Auth
case "Username":
fromConfig.Username = cliValues.Username;
break;
case "Password":
fromConfig.Password = cliValues.Password;
break;
case "Authorization":
fromConfig.Authorization = cliValues.Authorization;
break;
case "Users":
fromConfig.Users = cliValues.Users;
break;
case "NKeys":
fromConfig.NKeys = cliValues.NKeys;
break;
case "NoAuthUser":
fromConfig.NoAuthUser = cliValues.NoAuthUser;
break;
case "AuthTimeout":
fromConfig.AuthTimeout = cliValues.AuthTimeout;
break;
// TLS
case "TlsCert":
fromConfig.TlsCert = cliValues.TlsCert;
break;
case "TlsKey":
fromConfig.TlsKey = cliValues.TlsKey;
break;
case "TlsCaCert":
fromConfig.TlsCaCert = cliValues.TlsCaCert;
break;
case "TlsVerify":
fromConfig.TlsVerify = cliValues.TlsVerify;
break;
case "TlsMap":
fromConfig.TlsMap = cliValues.TlsMap;
break;
case "TlsTimeout":
fromConfig.TlsTimeout = cliValues.TlsTimeout;
break;
case "TlsHandshakeFirst":
fromConfig.TlsHandshakeFirst = cliValues.TlsHandshakeFirst;
break;
case "TlsHandshakeFirstFallback":
fromConfig.TlsHandshakeFirstFallback = cliValues.TlsHandshakeFirstFallback;
break;
case "AllowNonTls":
fromConfig.AllowNonTls = cliValues.AllowNonTls;
break;
case "TlsRateLimit":
fromConfig.TlsRateLimit = cliValues.TlsRateLimit;
break;
case "TlsPinnedCerts":
fromConfig.TlsPinnedCerts = cliValues.TlsPinnedCerts;
break;
// Limits
case "MaxConnections":
fromConfig.MaxConnections = cliValues.MaxConnections;
break;
case "MaxPayload":
fromConfig.MaxPayload = cliValues.MaxPayload;
break;
case "MaxPending":
fromConfig.MaxPending = cliValues.MaxPending;
break;
case "WriteDeadline":
fromConfig.WriteDeadline = cliValues.WriteDeadline;
break;
case "PingInterval":
fromConfig.PingInterval = cliValues.PingInterval;
break;
case "MaxPingsOut":
fromConfig.MaxPingsOut = cliValues.MaxPingsOut;
break;
case "MaxControlLine":
fromConfig.MaxControlLine = cliValues.MaxControlLine;
break;
case "MaxSubs":
fromConfig.MaxSubs = cliValues.MaxSubs;
break;
case "MaxSubTokens":
fromConfig.MaxSubTokens = cliValues.MaxSubTokens;
break;
case "MaxTracedMsgLen":
fromConfig.MaxTracedMsgLen = cliValues.MaxTracedMsgLen;
break;
case "MaxClosedClients":
fromConfig.MaxClosedClients = cliValues.MaxClosedClients;
break;
// Misc
case "Tags":
fromConfig.Tags = cliValues.Tags;
break;
case "LameDuckDuration":
fromConfig.LameDuckDuration = cliValues.LameDuckDuration;
break;
case "LameDuckGracePeriod":
fromConfig.LameDuckGracePeriod = cliValues.LameDuckGracePeriod;
break;
case "ClientAdvertise":
fromConfig.ClientAdvertise = cliValues.ClientAdvertise;
break;
case "DisableSublistCache":
fromConfig.DisableSublistCache = cliValues.DisableSublistCache;
break;
case "ConnectErrorReports":
fromConfig.ConnectErrorReports = cliValues.ConnectErrorReports;
break;
case "ReconnectErrorReports":
fromConfig.ReconnectErrorReports = cliValues.ReconnectErrorReports;
break;
case "NoHeaderSupport":
fromConfig.NoHeaderSupport = cliValues.NoHeaderSupport;
break;
case "NoSystemAccount":
fromConfig.NoSystemAccount = cliValues.NoSystemAccount;
break;
case "SystemAccount":
fromConfig.SystemAccount = cliValues.SystemAccount;
break;
}
}
}
// ─── Comparison helpers ─────────────────────────────────────────
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
{
if (!Equals(oldVal, newVal))
{
changes.Add(new ConfigChange(
name,
isLoggingChange: LoggingOptions.Contains(name),
isAuthChange: AuthOptions.Contains(name),
isTlsChange: TlsOptions.Contains(name),
isNonReloadable: NonReloadable.Contains(name)));
}
}
private static void CompareCollectionAndAdd<T>(List<IConfigChange> changes, string name, T? oldVal, T? newVal)
where T : class
{
// For collections we compare by reference and null state.
// A change from null to non-null (or vice versa), or a different reference, counts as changed.
if (ReferenceEquals(oldVal, newVal))
return;
if (oldVal is null || newVal is null || !ReferenceEquals(oldVal, newVal))
{
changes.Add(new ConfigChange(
name,
isLoggingChange: LoggingOptions.Contains(name),
isAuthChange: AuthOptions.Contains(name),
isTlsChange: TlsOptions.Contains(name),
isNonReloadable: NonReloadable.Contains(name)));
}
}
}