feat: add auth change propagation to existing connections (Gap 14.2)

Add PropagateAuthChanges to ConfigReloader that compares Users, Accounts,
and Authorization token between old and new NatsOptions, returning an
AuthChangeResult describing which auth fields changed for connection re-evaluation.
This commit is contained in:
Joseph Doherty
2026-02-25 11:46:28 -05:00
parent 5fea08dda0
commit 42e072ad71
2 changed files with 383 additions and 0 deletions

View File

@@ -422,6 +422,80 @@ public static class ConfigReloader
RejectedChanges: rejectedChanges));
}
/// <summary>
/// Compares cluster-topology settings between two <see cref="NatsOptions"/> instances
/// and returns a <see cref="ClusterConfigChangeResult"/> describing what changed.
/// Callers use this to reconcile route/gateway/leaf connections after a hot reload.
/// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply.
/// </summary>
public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts)
{
var result = new ClusterConfigChangeResult();
// Compare cluster route URLs (ClusterOptions.Routes)
var oldRoutes = oldOpts.Cluster?.Routes ?? [];
var newRoutes = newOpts.Cluster?.Routes ?? [];
var oldSet = new HashSet<string>(oldRoutes, StringComparer.OrdinalIgnoreCase);
var newSet = new HashSet<string>(newRoutes, StringComparer.OrdinalIgnoreCase);
if (!oldSet.SetEquals(newSet))
{
result.RouteUrlsChanged = true;
result.AddedRouteUrls.AddRange(newSet.Except(oldSet, StringComparer.OrdinalIgnoreCase));
result.RemovedRouteUrls.AddRange(oldSet.Except(newSet, StringComparer.OrdinalIgnoreCase));
}
// Compare gateway remote URLs — flatten all URLs across all RemoteGateway entries
var oldGwUrls = FlattenGatewayUrls(oldOpts.Gateway);
var newGwUrls = FlattenGatewayUrls(newOpts.Gateway);
if (!new HashSet<string>(oldGwUrls, StringComparer.OrdinalIgnoreCase)
.SetEquals(new HashSet<string>(newGwUrls, StringComparer.OrdinalIgnoreCase)))
result.GatewayUrlsChanged = true;
// Compare leaf-node remote URLs — flatten Remotes + all RemoteLeaves entries
var oldLeafUrls = FlattenLeafUrls(oldOpts.LeafNode);
var newLeafUrls = FlattenLeafUrls(newOpts.LeafNode);
if (!new HashSet<string>(oldLeafUrls, StringComparer.OrdinalIgnoreCase)
.SetEquals(new HashSet<string>(newLeafUrls, StringComparer.OrdinalIgnoreCase)))
result.LeafUrlsChanged = true;
result.HasChanges = result.RouteUrlsChanged || result.GatewayUrlsChanged || result.LeafUrlsChanged;
return result;
}
/// <summary>
/// Compares logging settings between two <see cref="NatsOptions"/> instances and
/// returns a <see cref="LoggingChangeResult"/> describing what changed.
/// The effective log level is derived from the Debug/Trace flags: Trace → "Trace",
/// Debug → "Debug", otherwise "Information" — matching Go's precedence.
/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply.
/// </summary>
public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts)
{
var result = new LoggingChangeResult();
// Derive effective level from Debug/Trace flags (Trace takes precedence over Debug)
var oldLevel = EffectiveLogLevel(oldOpts);
var newLevel = EffectiveLogLevel(newOpts);
if (!string.Equals(oldLevel, newLevel, StringComparison.OrdinalIgnoreCase))
{
result.LevelChanged = true;
result.OldLevel = oldLevel;
result.NewLevel = newLevel;
}
// Check individual trace/debug flags
if (oldOpts.Trace != newOpts.Trace)
result.TraceChanged = true;
if (oldOpts.Debug != newOpts.Debug)
result.DebugChanged = true;
result.HasChanges = result.LevelChanged || result.TraceChanged || result.DebugChanged;
return result;
}
// ─── Comparison helpers ─────────────────────────────────────────
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
@@ -487,6 +561,45 @@ public static class ConfigReloader
return !string.Equals(oldJetStream.StoreDir, newJetStream.StoreDir, StringComparison.Ordinal);
}
/// <summary>
/// Compares auth-related fields between two <see cref="NatsOptions"/> instances and
/// returns an <see cref="AuthChangeResult"/> describing what changed. This drives
/// re-evaluation of existing connections after a config reload.
/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply.
/// </summary>
public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts)
{
var result = new AuthChangeResult();
// Check if users changed
var oldUsers = oldOpts.Users;
var newUsers = newOpts.Users;
if ((oldUsers?.Count ?? 0) != (newUsers?.Count ?? 0))
{
result.UsersChanged = true;
}
else if (oldUsers is not null && newUsers is not null)
{
var oldUserSet = new HashSet<string>(oldUsers.Select(u => u.Username), StringComparer.Ordinal);
var newUserSet = new HashSet<string>(newUsers.Select(u => u.Username), StringComparer.Ordinal);
if (!oldUserSet.SetEquals(newUserSet))
result.UsersChanged = true;
}
// Check if accounts changed
var oldAccountCount = oldOpts.Accounts?.Count ?? 0;
var newAccountCount = newOpts.Accounts?.Count ?? 0;
if (oldAccountCount != newAccountCount)
result.AccountsChanged = true;
// Check auth token (Authorization is the token string directly on NatsOptions)
if (!string.Equals(oldOpts.Authorization, newOpts.Authorization, StringComparison.Ordinal))
result.TokenChanged = true;
result.HasChanges = result.UsersChanged || result.AccountsChanged || result.TokenChanged;
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;
@@ -553,3 +666,77 @@ public sealed class ConfigReloadResult
/// Result of an in-memory options comparison for reload validation.
/// </summary>
public sealed record ReloadFromOptionsResult(bool Success, List<string> RejectedChanges);
/// <summary>
/// Describes what cluster-topology configuration changed between two <see cref="NatsOptions"/>
/// instances. Returned by <see cref="ConfigReloader.ApplyClusterConfigChanges"/> and used to
/// determine which routes, gateways, or leaf connections need to be reconciled after a reload.
/// Reference: golang/nats-server/server/reload.go — clusterOption.Apply / routesOption.Apply.
/// </summary>
public sealed class ClusterConfigChangeResult
{
/// <summary>True when any cluster-topology field changed.</summary>
public bool HasChanges { get; set; }
/// <summary>True when the set of cluster route URLs changed.</summary>
public bool RouteUrlsChanged { get; set; }
/// <summary>True when any gateway remote URL set changed.</summary>
public bool GatewayUrlsChanged { get; set; }
/// <summary>True when the leaf-node remote URL set changed.</summary>
public bool LeafUrlsChanged { get; set; }
/// <summary>Route URLs present in new config but absent in old config.</summary>
public List<string> AddedRouteUrls { get; init; } = [];
/// <summary>Route URLs present in old config but absent in new config.</summary>
public List<string> RemovedRouteUrls { get; init; } = [];
}
/// <summary>
/// Describes what logging configuration changed between two <see cref="NatsOptions"/>
/// instances. Returned by <see cref="ConfigReloader.ApplyLoggingChanges"/>.
/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply.
/// </summary>
public sealed class LoggingChangeResult
{
/// <summary>True when any logging field changed.</summary>
public bool HasChanges { get; set; }
/// <summary>True when the effective log level string changed.</summary>
public bool LevelChanged { get; set; }
/// <summary>The effective log level before the reload.</summary>
public string? OldLevel { get; set; }
/// <summary>The effective log level after the reload.</summary>
public string? NewLevel { get; set; }
/// <summary>True when the Trace flag changed.</summary>
public bool TraceChanged { get; set; }
/// <summary>True when the Debug flag changed.</summary>
public bool DebugChanged { get; set; }
}
/// <summary>
/// Describes what auth-related configuration changed between two <see cref="NatsOptions"/>
/// instances. Returned by <see cref="ConfigReloader.PropagateAuthChanges"/> and used to
/// determine which existing connections need to be re-evaluated after a reload.
/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply.
/// </summary>
public sealed class AuthChangeResult
{
/// <summary>True when any auth field changed.</summary>
public bool HasChanges { get; set; }
/// <summary>True when the set of configured users changed.</summary>
public bool UsersChanged { get; set; }
/// <summary>True when the set of configured accounts changed.</summary>
public bool AccountsChanged { get; set; }
/// <summary>True when the Authorization token string changed.</summary>
public bool TokenChanged { get; set; }
}