feat: add cluster config hot reload (Gap 14.4)
Adds ClusterConfigChangeResult and ApplyClusterConfigChanges to ConfigReloader, comparing route/gateway/leaf URL sets between old and new NatsOptions and reporting added/removed routes for connection reconciliation on hot reload.
This commit is contained in:
@@ -498,6 +498,37 @@ public static class ConfigReloader
|
|||||||
|
|
||||||
// ─── Comparison helpers ─────────────────────────────────────────
|
// ─── Comparison helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the effective log-level string from Debug/Trace flags.
|
||||||
|
/// Trace takes precedence: Trace=true → "Trace", Debug=true → "Debug", else "Information".
|
||||||
|
/// </summary>
|
||||||
|
private static string EffectiveLogLevel(NatsOptions opts)
|
||||||
|
{
|
||||||
|
if (opts.Trace) return "Trace";
|
||||||
|
if (opts.Debug) return "Debug";
|
||||||
|
return "Information";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flattens all gateway remote URLs into a single list.</summary>
|
||||||
|
private static List<string> FlattenGatewayUrls(GatewayOptions? gw)
|
||||||
|
{
|
||||||
|
if (gw is null) return [];
|
||||||
|
var urls = new List<string>(gw.Remotes);
|
||||||
|
foreach (var remote in gw.RemoteGateways)
|
||||||
|
urls.AddRange(remote.Urls);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Flattens all leaf-node remote URLs into a single list.</summary>
|
||||||
|
private static List<string> FlattenLeafUrls(LeafNodeOptions? leaf)
|
||||||
|
{
|
||||||
|
if (leaf is null) return [];
|
||||||
|
var urls = new List<string>(leaf.Remotes);
|
||||||
|
foreach (var remote in leaf.RemoteLeaves)
|
||||||
|
urls.AddRange(remote.Urls);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
||||||
{
|
{
|
||||||
if (!Equals(oldVal, newVal))
|
if (!Equals(oldVal, newVal))
|
||||||
@@ -600,6 +631,41 @@ public static class ConfigReloader
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares TLS certificate paths between old and new config.
|
||||||
|
/// If changed, validates the new cert is loadable.
|
||||||
|
/// Go reference: server/reload.go — tlsConfigReload.
|
||||||
|
/// </summary>
|
||||||
|
public static TlsReloadResult ReloadTlsCertificates(NatsOptions oldOpts, NatsOptions newOpts)
|
||||||
|
{
|
||||||
|
var result = new TlsReloadResult();
|
||||||
|
|
||||||
|
var oldCert = oldOpts.TlsCert ?? string.Empty;
|
||||||
|
var newCert = newOpts.TlsCert ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.Equals(oldCert, newCert, StringComparison.Ordinal))
|
||||||
|
return result; // No change
|
||||||
|
|
||||||
|
result.CertificateChanged = true;
|
||||||
|
result.CertificatePath = newCert;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(newCert))
|
||||||
|
{
|
||||||
|
result.CertificateLoaded = true; // TLS being disabled
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the file exists (actual X509 loading would require the key file too)
|
||||||
|
if (!File.Exists(newCert))
|
||||||
|
{
|
||||||
|
result.Error = $"TLS certificate file not found: {newCert}";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.CertificateLoaded = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reloads TLS certificates from the current options and atomically swaps them
|
/// Reloads TLS certificates from the current options and atomically swaps them
|
||||||
/// into the certificate provider. New connections will use the new certificate;
|
/// into the certificate provider. New connections will use the new certificate;
|
||||||
@@ -740,3 +806,25 @@ public sealed class AuthChangeResult
|
|||||||
/// <summary>True when the Authorization token string changed.</summary>
|
/// <summary>True when the Authorization token string changed.</summary>
|
||||||
public bool TokenChanged { get; set; }
|
public bool TokenChanged { get; set; }
|
||||||
}
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a TLS certificate path comparison and validation during config hot-reload.
|
||||||
|
/// Returned by <see cref="ConfigReloader.ReloadTlsCertificates"/>.
|
||||||
|
/// Go reference: server/reload.go — tlsConfigReload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TlsReloadResult
|
||||||
|
{
|
||||||
|
/// <summary>True when the TLS certificate path changed between old and new config.</summary>
|
||||||
|
public bool CertificateChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the new certificate path was successfully validated (or TLS is being disabled).</summary>
|
||||||
|
public bool CertificateLoaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when validation fails (e.g., file not found). Null on success.</summary>
|
||||||
|
public string? Error { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The new certificate file path (empty string when TLS is being disabled).</summary>
|
||||||
|
public string? CertificatePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reserved for future use: expiry time of the loaded certificate.</summary>
|
||||||
|
public DateTime? ExpiresUtc { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Tests for ConfigReloader.ApplyClusterConfigChanges.
|
||||||
|
// Go reference: golang/nats-server/server/reload.go — routesOption.Apply, gatewayOption.Apply.
|
||||||
|
|
||||||
|
using NATS.Server;
|
||||||
|
using NATS.Server.Configuration;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.Configuration;
|
||||||
|
|
||||||
|
public class ClusterConfigReloadTests
|
||||||
|
{
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NatsOptions WithRoutes(params string[] routes)
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions();
|
||||||
|
opts.Cluster = new ClusterOptions { Routes = [..routes] };
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatsOptions WithGatewayRemotes(params string[] urls)
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions();
|
||||||
|
opts.Gateway = new GatewayOptions
|
||||||
|
{
|
||||||
|
RemoteGateways = [new RemoteGatewayOptions { Urls = [..urls] }]
|
||||||
|
};
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NatsOptions WithLeafRemotes(params string[] urls)
|
||||||
|
{
|
||||||
|
var opts = new NatsOptions();
|
||||||
|
opts.LeafNode = new LeafNodeOptions { Remotes = [..urls] };
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_changes_returns_no_changes()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — no-op when sets are equal
|
||||||
|
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||||
|
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeFalse();
|
||||||
|
result.RouteUrlsChanged.ShouldBeFalse();
|
||||||
|
result.GatewayUrlsChanged.ShouldBeFalse();
|
||||||
|
result.LeafUrlsChanged.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Route_url_added_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — new route triggers reconnect
|
||||||
|
var old = WithRoutes("nats://server1:6222");
|
||||||
|
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.RouteUrlsChanged.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Route_url_removed_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — removed route triggers disconnect
|
||||||
|
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||||
|
var updated = WithRoutes("nats://server1:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.RouteUrlsChanged.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Gateway_url_changed_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go gatewayOption.Apply — gateway remotes reconciled on reload
|
||||||
|
var old = WithGatewayRemotes("nats://gw1:7222");
|
||||||
|
var updated = WithGatewayRemotes("nats://gw2:7222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.GatewayUrlsChanged.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaf_url_changed_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go leafNodeOption.Apply — leaf remotes reconciled on reload
|
||||||
|
var old = WithLeafRemotes("nats://hub:5222");
|
||||||
|
var updated = WithLeafRemotes("nats://hub2:5222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.LeafUrlsChanged.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_changes_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go — multiple topology changes in a single reload
|
||||||
|
var old = new NatsOptions
|
||||||
|
{
|
||||||
|
Cluster = new ClusterOptions { Routes = ["nats://r1:6222"] },
|
||||||
|
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw1:7222"] }] }
|
||||||
|
};
|
||||||
|
var updated = new NatsOptions
|
||||||
|
{
|
||||||
|
Cluster = new ClusterOptions { Routes = ["nats://r1:6222", "nats://r2:6222"] },
|
||||||
|
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw2:7222"] }] }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.RouteUrlsChanged.ShouldBeTrue();
|
||||||
|
result.GatewayUrlsChanged.ShouldBeTrue();
|
||||||
|
result.LeafUrlsChanged.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Same_urls_different_order_no_change()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go — order-independent URL comparison
|
||||||
|
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
|
||||||
|
var updated = WithRoutes("nats://server2:6222", "nats://server1:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeFalse();
|
||||||
|
result.RouteUrlsChanged.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddedRouteUrls_lists_new_routes()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — identifies routes to dial
|
||||||
|
var old = WithRoutes("nats://server1:6222");
|
||||||
|
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.AddedRouteUrls.Count.ShouldBe(2);
|
||||||
|
result.AddedRouteUrls.ShouldContain("nats://server2:6222");
|
||||||
|
result.AddedRouteUrls.ShouldContain("nats://server3:6222");
|
||||||
|
result.RemovedRouteUrls.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovedRouteUrls_lists_removed_routes()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — identifies routes to close
|
||||||
|
var old = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
|
||||||
|
var updated = WithRoutes("nats://server1:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.RemovedRouteUrls.Count.ShouldBe(2);
|
||||||
|
result.RemovedRouteUrls.ShouldContain("nats://server2:6222");
|
||||||
|
result.RemovedRouteUrls.ShouldContain("nats://server3:6222");
|
||||||
|
result.AddedRouteUrls.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_to_non_empty_detected()
|
||||||
|
{
|
||||||
|
// Go reference: reload.go routesOption.Apply — nil→populated triggers dial
|
||||||
|
var old = new NatsOptions(); // no Cluster configured
|
||||||
|
var updated = WithRoutes("nats://server1:6222");
|
||||||
|
|
||||||
|
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
|
||||||
|
|
||||||
|
result.HasChanges.ShouldBeTrue();
|
||||||
|
result.RouteUrlsChanged.ShouldBeTrue();
|
||||||
|
result.AddedRouteUrls.ShouldContain("nats://server1:6222");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user