feat: add TLS certificate hot-reload for new connections (Gap 14.3)
Add ReloadTlsCertificates(oldOpts, newOpts) returning TlsReloadResult for path-based cert comparison and validation during config hot-reload. Add 10 targeted tests covering no-change, path detection, missing file, null transitions, and success cases.
This commit is contained in:
@@ -666,7 +666,7 @@ public static class ConfigReloader
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares JetStream configuration between two <see cref="NatsOptions"/> instances and
|
||||
/// returns a <see cref="JetStreamConfigChangeResult"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -806,6 +854,46 @@ public sealed class AuthChangeResult
|
||||
/// <summary>True when the Authorization token string changed.</summary>
|
||||
public bool TokenChanged { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes what JetStream configuration changed between two <see cref="NatsOptions"/>
|
||||
/// instances. Returned by <see cref="ConfigReloader.ApplyJetStreamConfigChanges"/> and used to
|
||||
/// drive live reconfiguration of JetStream limits and domain after a hot reload.
|
||||
/// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
|
||||
/// </summary>
|
||||
public sealed class JetStreamConfigChangeResult
|
||||
{
|
||||
/// <summary>True when any JetStream field changed.</summary>
|
||||
public bool HasChanges { get; set; }
|
||||
|
||||
/// <summary>True when the maximum memory store limit changed.</summary>
|
||||
public bool MaxMemoryChanged { get; set; }
|
||||
|
||||
/// <summary>True when the maximum file store limit changed.</summary>
|
||||
public bool MaxStoreChanged { get; set; }
|
||||
|
||||
/// <summary>True when the JetStream domain name changed.</summary>
|
||||
public bool DomainChanged { get; set; }
|
||||
|
||||
/// <summary>Previous MaxMemoryStore value in bytes (0 = unlimited).</summary>
|
||||
public long OldMaxMemory { get; set; }
|
||||
|
||||
/// <summary>New MaxMemoryStore value in bytes (0 = unlimited).</summary>
|
||||
public long NewMaxMemory { get; set; }
|
||||
|
||||
/// <summary>Previous MaxFileStore value in bytes (0 = unlimited).</summary>
|
||||
public long OldMaxStore { get; set; }
|
||||
|
||||
/// <summary>New MaxFileStore value in bytes (0 = unlimited).</summary>
|
||||
public long NewMaxStore { get; set; }
|
||||
|
||||
/// <summary>Previous domain name (empty string when not configured).</summary>
|
||||
public string? OldDomain { get; set; }
|
||||
|
||||
/// <summary>New domain name (empty string when not configured).</summary>
|
||||
public string? NewDomain { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a TLS certificate path comparison and validation during config hot-reload.
|
||||
/// Returned by <see cref="ConfigReloader.ReloadTlsCertificates"/>.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to write a self-signed certificate to PEM files.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user