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:
Joseph Doherty
2026-02-25 11:47:02 -05:00
parent 42e072ad71
commit 074ff6b287
2 changed files with 274 additions and 0 deletions

View File

@@ -498,6 +498,37 @@ public static class ConfigReloader
// ─── 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)
{
if (!Equals(oldVal, newVal))
@@ -601,6 +632,41 @@ public static class ConfigReloader
}
/// <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>
/// 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.
@@ -740,3 +806,25 @@ public sealed class AuthChangeResult
/// <summary>True when the Authorization token string changed.</summary>
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; }
}