diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs new file mode 100644 index 0000000..3813323 --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -0,0 +1,341 @@ +// 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; + +/// +/// Provides static methods for comparing two instances, +/// validating that detected changes are reloadable, and merging CLI overrides +/// so that command-line flags always take precedence over config file values. +/// +public static class ConfigReloader +{ + // Non-reloadable options (match Go server — Host, Port, ServerName require restart) + private static readonly HashSet NonReloadable = ["Host", "Port", "ServerName"]; + + // Logging-related options + private static readonly HashSet LoggingOptions = + ["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile", + "LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"]; + + // Auth-related options + private static readonly HashSet AuthOptions = + ["Username", "Password", "Authorization", "Users", "NKeys", + "NoAuthUser", "AuthTimeout"]; + + // TLS-related options + private static readonly HashSet TlsOptions = + ["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap", + "TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback", + "AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"]; + + /// + /// Compares two instances property by property and returns + /// a list of for every property that differs. Each change + /// is tagged with the appropriate category flags. + /// + public static List Diff(NatsOptions oldOpts, NatsOptions newOpts) + { + var changes = new List(); + + // 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; + } + + /// + /// Validates a list of config changes and returns error messages for any + /// non-reloadable changes (properties that require a server restart). + /// + public static List Validate(List changes) + { + var errors = new List(); + foreach (var change in changes) + { + if (change.IsNonReloadable) + { + errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)"); + } + } + + return errors; + } + + /// + /// Merges CLI overrides into a freshly-parsed config so that command-line flags + /// always take precedence. Only properties whose names appear in + /// are copied from to . + /// + public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet 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(List 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(List 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))); + } + } +} diff --git a/src/NATS.Server/Configuration/IConfigChange.cs b/src/NATS.Server/Configuration/IConfigChange.cs new file mode 100644 index 0000000..538de12 --- /dev/null +++ b/src/NATS.Server/Configuration/IConfigChange.cs @@ -0,0 +1,54 @@ +// Port of Go server/reload.go option interface — represents a single detected +// configuration change with category flags for reload handling. +// Reference: golang/nats-server/server/reload.go lines 42-74. + +namespace NATS.Server.Configuration; + +/// +/// Represents a single detected configuration change during a hot reload. +/// Category flags indicate what kind of reload action is needed. +/// +public interface IConfigChange +{ + /// + /// The property name that changed (matches NatsOptions property name). + /// + string Name { get; } + + /// + /// Whether this change requires reloading the logger. + /// + bool IsLoggingChange { get; } + + /// + /// Whether this change requires reloading authorization. + /// + bool IsAuthChange { get; } + + /// + /// Whether this change requires reloading TLS configuration. + /// + bool IsTlsChange { get; } + + /// + /// Whether this option cannot be changed at runtime (requires restart). + /// + bool IsNonReloadable { get; } +} + +/// +/// Default implementation of using a primary constructor. +/// +public sealed class ConfigChange( + string name, + bool isLoggingChange = false, + bool isAuthChange = false, + bool isTlsChange = false, + bool isNonReloadable = false) : IConfigChange +{ + public string Name => name; + public bool IsLoggingChange => isLoggingChange; + public bool IsAuthChange => isAuthChange; + public bool IsTlsChange => isTlsChange; + public bool IsNonReloadable => isNonReloadable; +} diff --git a/tests/NATS.Server.Tests/ConfigReloadTests.cs b/tests/NATS.Server.Tests/ConfigReloadTests.cs new file mode 100644 index 0000000..a1d7fcd --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigReloadTests.cs @@ -0,0 +1,89 @@ +using NATS.Server; +using NATS.Server.Auth; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigReloadTests +{ + [Fact] + public void Diff_NoChanges_ReturnsEmpty() + { + var old = new NatsOptions { Port = 4222, Debug = true }; + var @new = new NatsOptions { Port = 4222, Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.ShouldBeEmpty(); + } + + [Fact] + public void Diff_ReloadableChange_ReturnsChange() + { + var old = new NatsOptions { Debug = false }; + var @new = new NatsOptions { Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].Name.ShouldBe("Debug"); + changes[0].IsLoggingChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_NonReloadableChange_ReturnsNonReloadableChange() + { + var old = new NatsOptions { Port = 4222 }; + var @new = new NatsOptions { Port = 5222 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].IsNonReloadable.ShouldBeTrue(); + } + + [Fact] + public void Diff_MultipleChanges_ReturnsAll() + { + var old = new NatsOptions { Debug = false, MaxPayload = 1024 }; + var @new = new NatsOptions { Debug = true, MaxPayload = 2048 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(2); + } + + [Fact] + public void Diff_AuthChange_MarkedCorrectly() + { + var old = new NatsOptions { Username = "alice" }; + var @new = new NatsOptions { Username = "bob" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsAuthChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_TlsChange_MarkedCorrectly() + { + var old = new NatsOptions { TlsCert = "/old/cert.pem" }; + var @new = new NatsOptions { TlsCert = "/new/cert.pem" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsTlsChange.ShouldBeTrue(); + } + + [Fact] + public void Validate_NonReloadableChanges_ReturnsErrors() + { + var changes = new List + { + new ConfigChange("Port", isNonReloadable: true), + }; + var errors = ConfigReloader.Validate(changes); + errors.Count.ShouldBe(1); + errors[0].ShouldContain("Port"); + } + + [Fact] + public void MergeWithCli_CliOverridesConfig() + { + var fromConfig = new NatsOptions { Port = 5222, Debug = true }; + var cliFlags = new HashSet { "Port" }; + var cliValues = new NatsOptions { Port = 4222 }; + + ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags); + fromConfig.Port.ShouldBe(4222); // CLI wins + fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI) + } +}