diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs index 3379997..7e826f2 100644 --- a/src/NATS.Server/Configuration/ConfigReloader.cs +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -666,7 +666,7 @@ public static class ConfigReloader return result; } - /// + /// /// Reloads TLS certificates from the current options and atomically swaps them /// into the certificate provider. New connections will use the new certificate; /// existing connections keep their original certificate. @@ -688,6 +688,54 @@ public static class ConfigReloader return true; } + + /// + /// Compares JetStream configuration between two instances and + /// returns a describing what changed. This drives + /// live reconfiguration of JetStream memory limits, file store limits, and domain after a + /// hot reload without requiring a server restart. + /// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply. + /// + public static JetStreamConfigChangeResult ApplyJetStreamConfigChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new JetStreamConfigChangeResult(); + + var oldJs = oldOpts.JetStream; + var newJs = newOpts.JetStream; + + // Compare JetStream MaxMemoryStore (bytes) + var oldMem = oldJs?.MaxMemoryStore ?? 0L; + var newMem = newJs?.MaxMemoryStore ?? 0L; + if (oldMem != newMem) + { + result.MaxMemoryChanged = true; + result.OldMaxMemory = oldMem; + result.NewMaxMemory = newMem; + } + + // Compare JetStream MaxFileStore (bytes) + var oldStore = oldJs?.MaxFileStore ?? 0L; + var newStore = newJs?.MaxFileStore ?? 0L; + if (oldStore != newStore) + { + result.MaxStoreChanged = true; + result.OldMaxStore = oldStore; + result.NewMaxStore = newStore; + } + + // Compare JetStream Domain + var oldDomain = oldJs?.Domain ?? string.Empty; + var newDomain = newJs?.Domain ?? string.Empty; + if (!string.Equals(oldDomain, newDomain, StringComparison.Ordinal)) + { + result.DomainChanged = true; + result.OldDomain = oldDomain; + result.NewDomain = newDomain; + } + + result.HasChanges = result.MaxMemoryChanged || result.MaxStoreChanged || result.DomainChanged; + return result; + } } /// @@ -806,6 +854,46 @@ public sealed class AuthChangeResult /// True when the Authorization token string changed. public bool TokenChanged { get; set; } } + +/// +/// Describes what JetStream configuration changed between two +/// instances. Returned by and used to +/// drive live reconfiguration of JetStream limits and domain after a hot reload. +/// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply. +/// +public sealed class JetStreamConfigChangeResult +{ + /// True when any JetStream field changed. + public bool HasChanges { get; set; } + + /// True when the maximum memory store limit changed. + public bool MaxMemoryChanged { get; set; } + + /// True when the maximum file store limit changed. + public bool MaxStoreChanged { get; set; } + + /// True when the JetStream domain name changed. + public bool DomainChanged { get; set; } + + /// Previous MaxMemoryStore value in bytes (0 = unlimited). + public long OldMaxMemory { get; set; } + + /// New MaxMemoryStore value in bytes (0 = unlimited). + public long NewMaxMemory { get; set; } + + /// Previous MaxFileStore value in bytes (0 = unlimited). + public long OldMaxStore { get; set; } + + /// New MaxFileStore value in bytes (0 = unlimited). + public long NewMaxStore { get; set; } + + /// Previous domain name (empty string when not configured). + public string? OldDomain { get; set; } + + /// New domain name (empty string when not configured). + public string? NewDomain { get; set; } +} + /// /// Result of a TLS certificate path comparison and validation during config hot-reload. /// Returned by . diff --git a/tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs b/tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs index 0cd0d10..920b32a 100644 --- a/tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs +++ b/tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs @@ -224,6 +224,153 @@ public class TlsReloadTests result.ChangeCount.ShouldBe(2); } + + // ─── ReloadTlsCertificates (TlsReloadResult) tests ───────────────────────── + + [Fact] + public void No_cert_change_returns_no_change() + { + // Go parity: tlsConfigReload — identical cert path means no reload needed + var oldOpts = new NatsOptions { TlsCert = "/same/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = "/same/cert.pem" }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeFalse(); + result.CertificateLoaded.ShouldBeFalse(); + result.Error.ShouldBeNull(); + } + + [Fact] + public void Cert_path_changed_detected() + { + // Go parity: tlsConfigReload — different cert path triggers reload + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeTrue(); + } + + [Fact] + public void Cert_path_set_returns_path() + { + // Verify CertificatePath reflects the new cert path when changed + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificatePath.ShouldBe("/new/cert.pem"); + } + + [Fact] + public void Missing_cert_file_returns_error() + { + // Go parity: tlsConfigReload — non-existent cert path returns an error + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = "/nonexistent/path/cert.pem" }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.Error.ShouldNotBeNullOrWhiteSpace(); + result.Error!.ShouldContain("/nonexistent/path/cert.pem"); + result.CertificateLoaded.ShouldBeFalse(); + } + + [Fact] + public void Null_to_null_no_change() + { + // Both TlsCert null — no TLS configured on either side, no change + var oldOpts = new NatsOptions { TlsCert = null }; + var newOpts = new NatsOptions { TlsCert = null }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeFalse(); + } + + [Fact] + public void Null_to_value_detected() + { + // Go parity: TestConfigReloadEnableTLS — enabling TLS is detected as a change + var oldOpts = new NatsOptions { TlsCert = null }; + var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeTrue(); + } + + [Fact] + public void Value_to_null_detected() + { + // Go parity: TestConfigReloadDisableTLS — disabling TLS is a change, loads successfully + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = null }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeTrue(); + result.CertificateLoaded.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + [Fact] + public void Valid_cert_path_loaded_true() + { + // Go parity: tlsConfigReload — file exists, so CertificateLoaded is true + var tempFile = Path.GetTempFileName(); + try + { + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = tempFile }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeTrue(); + result.CertificateLoaded.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void Error_null_on_success() + { + // Successful reload (file exists) must have Error as null + var tempFile = Path.GetTempFileName(); + try + { + var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" }; + var newOpts = new NatsOptions { TlsCert = tempFile }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.Error.ShouldBeNull(); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void Same_empty_strings_no_change() + { + // Both TlsCert are empty string — treat as equal, no change + var oldOpts = new NatsOptions { TlsCert = string.Empty }; + var newOpts = new NatsOptions { TlsCert = string.Empty }; + + var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts); + + result.CertificateChanged.ShouldBeFalse(); + } + /// /// Helper to write a self-signed certificate to PEM files. ///