feat: integrate config file loading and SIGHUP hot reload
Wire up the config parsing infrastructure into the server: - NatsServer: add ReloadConfig() with digest-based change detection, diff/validate, CLI override preservation, and side-effect triggers - Program.cs: two-pass CLI parsing — load config file first, then apply CLI args on top with InCmdLine tracking for reload precedence - SIGHUP handler upgraded from stub warning to actual reload - Remove config file "not yet supported" warning from StartAsync - Add integration tests for config loading, CLI overrides, and reload validation
This commit is contained in:
@@ -1,85 +1,124 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
|
||||
var options = new NatsOptions();
|
||||
// First pass: scan args for -c flag to get config file path
|
||||
string? configFile = null;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "-c" && i + 1 < args.Length)
|
||||
{
|
||||
configFile = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ALL CLI flags into NatsOptions first
|
||||
// If config file specified, load it as the base options
|
||||
var options = configFile != null
|
||||
? ConfigProcessor.ProcessConfigFile(configFile)
|
||||
: new NatsOptions();
|
||||
|
||||
// Second pass: apply CLI args on top of config-loaded options, tracking InCmdLine
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "-p" or "--port" when i + 1 < args.Length:
|
||||
options.Port = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("Port");
|
||||
break;
|
||||
case "-a" or "--addr" when i + 1 < args.Length:
|
||||
options.Host = args[++i];
|
||||
options.InCmdLine.Add("Host");
|
||||
break;
|
||||
case "-n" or "--name" when i + 1 < args.Length:
|
||||
options.ServerName = args[++i];
|
||||
options.InCmdLine.Add("ServerName");
|
||||
break;
|
||||
case "-m" or "--http_port" when i + 1 < args.Length:
|
||||
options.MonitorPort = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("MonitorPort");
|
||||
break;
|
||||
case "--http_base_path" when i + 1 < args.Length:
|
||||
options.MonitorBasePath = args[++i];
|
||||
options.InCmdLine.Add("MonitorBasePath");
|
||||
break;
|
||||
case "--https_port" when i + 1 < args.Length:
|
||||
options.MonitorHttpsPort = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("MonitorHttpsPort");
|
||||
break;
|
||||
case "-c" when i + 1 < args.Length:
|
||||
options.ConfigFile = args[++i];
|
||||
// Already handled in first pass; skip the value
|
||||
i++;
|
||||
break;
|
||||
case "--pid" when i + 1 < args.Length:
|
||||
options.PidFile = args[++i];
|
||||
options.InCmdLine.Add("PidFile");
|
||||
break;
|
||||
case "--ports_file_dir" when i + 1 < args.Length:
|
||||
options.PortsFileDir = args[++i];
|
||||
options.InCmdLine.Add("PortsFileDir");
|
||||
break;
|
||||
case "--tls":
|
||||
break;
|
||||
case "--tlscert" when i + 1 < args.Length:
|
||||
options.TlsCert = args[++i];
|
||||
options.InCmdLine.Add("TlsCert");
|
||||
break;
|
||||
case "--tlskey" when i + 1 < args.Length:
|
||||
options.TlsKey = args[++i];
|
||||
options.InCmdLine.Add("TlsKey");
|
||||
break;
|
||||
case "--tlscacert" when i + 1 < args.Length:
|
||||
options.TlsCaCert = args[++i];
|
||||
options.InCmdLine.Add("TlsCaCert");
|
||||
break;
|
||||
case "--tlsverify":
|
||||
options.TlsVerify = true;
|
||||
options.InCmdLine.Add("TlsVerify");
|
||||
break;
|
||||
case "-D" or "--debug":
|
||||
options.Debug = true;
|
||||
options.InCmdLine.Add("Debug");
|
||||
break;
|
||||
case "-V" or "-T" or "--trace":
|
||||
options.Trace = true;
|
||||
options.InCmdLine.Add("Trace");
|
||||
break;
|
||||
case "-DV":
|
||||
options.Debug = true;
|
||||
options.Trace = true;
|
||||
options.InCmdLine.Add("Debug");
|
||||
options.InCmdLine.Add("Trace");
|
||||
break;
|
||||
case "-l" or "--log" or "--log_file" when i + 1 < args.Length:
|
||||
options.LogFile = args[++i];
|
||||
options.InCmdLine.Add("LogFile");
|
||||
break;
|
||||
case "--log_size_limit" when i + 1 < args.Length:
|
||||
options.LogSizeLimit = long.Parse(args[++i]);
|
||||
options.InCmdLine.Add("LogSizeLimit");
|
||||
break;
|
||||
case "--log_max_files" when i + 1 < args.Length:
|
||||
options.LogMaxFiles = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("LogMaxFiles");
|
||||
break;
|
||||
case "--logtime" when i + 1 < args.Length:
|
||||
options.Logtime = bool.Parse(args[++i]);
|
||||
options.InCmdLine.Add("Logtime");
|
||||
break;
|
||||
case "--logtime_utc":
|
||||
options.LogtimeUTC = true;
|
||||
options.InCmdLine.Add("LogtimeUTC");
|
||||
break;
|
||||
case "--syslog":
|
||||
options.Syslog = true;
|
||||
options.InCmdLine.Add("Syslog");
|
||||
break;
|
||||
case "--remote_syslog" when i + 1 < args.Length:
|
||||
options.RemoteSyslog = args[++i];
|
||||
options.InCmdLine.Add("RemoteSyslog");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +175,14 @@ Log.Logger = logConfig.CreateLogger();
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
using var server = new NatsServer(options, loggerFactory);
|
||||
|
||||
// Store CLI snapshot for reload precedence (CLI flags always win over config file)
|
||||
if (configFile != null && options.InCmdLine.Count > 0)
|
||||
{
|
||||
var cliSnapshot = new NatsOptions();
|
||||
ConfigReloader.MergeCliOverrides(cliSnapshot, options, options.InCmdLine);
|
||||
server.SetCliSnapshot(cliSnapshot, options.InCmdLine);
|
||||
}
|
||||
|
||||
// Register signal handlers
|
||||
server.HandleSignals();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Monitoring;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
@@ -25,8 +26,13 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ServerStats _stats = new();
|
||||
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AuthService _authService;
|
||||
private AuthService _authService;
|
||||
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
|
||||
|
||||
// Config reload state
|
||||
private NatsOptions? _cliSnapshot;
|
||||
private HashSet<string> _cliFlags = [];
|
||||
private string? _configDigest;
|
||||
private readonly Account _globalAccount;
|
||||
private readonly Account _systemAccount;
|
||||
private readonly SslServerAuthenticationOptions? _sslOptions;
|
||||
@@ -222,7 +228,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
_logger.LogWarning("Trapped SIGHUP signal — config reload not yet supported");
|
||||
_logger.LogInformation("Trapped SIGHUP signal — reloading configuration");
|
||||
_ = Task.Run(() => ReloadConfig());
|
||||
}));
|
||||
|
||||
// SIGUSR1 and SIGUSR2 only on non-Windows
|
||||
@@ -284,6 +291,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
|
||||
BuildCachedInfo();
|
||||
|
||||
// Store initial config digest for reload change detection
|
||||
if (options.ConfigFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile);
|
||||
_configDigest = digest;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not compute initial config digest for {ConfigFile}", options.ConfigFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildCachedInfo()
|
||||
@@ -318,8 +339,6 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port);
|
||||
|
||||
// Warn about stub features
|
||||
if (_options.ConfigFile != null)
|
||||
_logger.LogWarning("Config file parsing not yet supported (file: {ConfigFile})", _options.ConfigFile);
|
||||
if (_options.ProfPort > 0)
|
||||
_logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);
|
||||
|
||||
@@ -716,6 +735,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the CLI snapshot and flags so that command-line overrides
|
||||
/// always take precedence during config reload.
|
||||
/// </summary>
|
||||
public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
|
||||
{
|
||||
_cliSnapshot = cliSnapshot;
|
||||
_cliFlags = cliFlags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the configuration file, diffs against current options, validates
|
||||
/// the changes, and applies reloadable settings. CLI overrides are preserved.
|
||||
/// </summary>
|
||||
public void ReloadConfig()
|
||||
{
|
||||
if (_options.ConfigFile == null)
|
||||
{
|
||||
_logger.LogWarning("No config file specified, cannot reload");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile);
|
||||
if (digest == _configDigest)
|
||||
{
|
||||
_logger.LogInformation("Config file unchanged, no reload needed");
|
||||
return;
|
||||
}
|
||||
|
||||
var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile };
|
||||
ConfigProcessor.ApplyConfig(newConfig, newOpts);
|
||||
|
||||
// CLI flags override config
|
||||
if (_cliSnapshot != null)
|
||||
ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags);
|
||||
|
||||
var changes = ConfigReloader.Diff(_options, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var err in errors)
|
||||
_logger.LogError("Config reload error: {Error}", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes to running options
|
||||
ApplyConfigChanges(changes, newOpts);
|
||||
_configDigest = digest;
|
||||
_logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyConfigChanges(List<IConfigChange> changes, NatsOptions newOpts)
|
||||
{
|
||||
bool hasLoggingChanges = false;
|
||||
bool hasAuthChanges = false;
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.IsLoggingChange) hasLoggingChanges = true;
|
||||
if (change.IsAuthChange) hasAuthChanges = true;
|
||||
}
|
||||
|
||||
// Copy reloadable values from newOpts to _options
|
||||
CopyReloadableOptions(newOpts);
|
||||
|
||||
// Trigger side effects
|
||||
if (hasLoggingChanges)
|
||||
{
|
||||
ReOpenLogFile?.Invoke();
|
||||
_logger.LogInformation("Logging configuration reloaded");
|
||||
}
|
||||
|
||||
if (hasAuthChanges)
|
||||
{
|
||||
// Rebuild auth service with new options
|
||||
_authService = AuthService.Build(_options);
|
||||
_logger.LogInformation("Authorization configuration reloaded");
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyReloadableOptions(NatsOptions newOpts)
|
||||
{
|
||||
// Logging
|
||||
_options.Debug = newOpts.Debug;
|
||||
_options.Trace = newOpts.Trace;
|
||||
_options.TraceVerbose = newOpts.TraceVerbose;
|
||||
_options.Logtime = newOpts.Logtime;
|
||||
_options.LogtimeUTC = newOpts.LogtimeUTC;
|
||||
_options.LogFile = newOpts.LogFile;
|
||||
_options.LogSizeLimit = newOpts.LogSizeLimit;
|
||||
_options.LogMaxFiles = newOpts.LogMaxFiles;
|
||||
_options.Syslog = newOpts.Syslog;
|
||||
_options.RemoteSyslog = newOpts.RemoteSyslog;
|
||||
|
||||
// Auth
|
||||
_options.Username = newOpts.Username;
|
||||
_options.Password = newOpts.Password;
|
||||
_options.Authorization = newOpts.Authorization;
|
||||
_options.Users = newOpts.Users;
|
||||
_options.NKeys = newOpts.NKeys;
|
||||
_options.NoAuthUser = newOpts.NoAuthUser;
|
||||
_options.AuthTimeout = newOpts.AuthTimeout;
|
||||
|
||||
// Limits
|
||||
_options.MaxConnections = newOpts.MaxConnections;
|
||||
_options.MaxPayload = newOpts.MaxPayload;
|
||||
_options.MaxPending = newOpts.MaxPending;
|
||||
_options.WriteDeadline = newOpts.WriteDeadline;
|
||||
_options.PingInterval = newOpts.PingInterval;
|
||||
_options.MaxPingsOut = newOpts.MaxPingsOut;
|
||||
_options.MaxControlLine = newOpts.MaxControlLine;
|
||||
_options.MaxSubs = newOpts.MaxSubs;
|
||||
_options.MaxSubTokens = newOpts.MaxSubTokens;
|
||||
_options.MaxTracedMsgLen = newOpts.MaxTracedMsgLen;
|
||||
_options.MaxClosedClients = newOpts.MaxClosedClients;
|
||||
|
||||
// TLS
|
||||
_options.TlsCert = newOpts.TlsCert;
|
||||
_options.TlsKey = newOpts.TlsKey;
|
||||
_options.TlsCaCert = newOpts.TlsCaCert;
|
||||
_options.TlsVerify = newOpts.TlsVerify;
|
||||
_options.TlsMap = newOpts.TlsMap;
|
||||
_options.TlsTimeout = newOpts.TlsTimeout;
|
||||
_options.TlsHandshakeFirst = newOpts.TlsHandshakeFirst;
|
||||
_options.TlsHandshakeFirstFallback = newOpts.TlsHandshakeFirstFallback;
|
||||
_options.AllowNonTls = newOpts.AllowNonTls;
|
||||
_options.TlsRateLimit = newOpts.TlsRateLimit;
|
||||
_options.TlsPinnedCerts = newOpts.TlsPinnedCerts;
|
||||
|
||||
// Misc
|
||||
_options.Tags = newOpts.Tags;
|
||||
_options.LameDuckDuration = newOpts.LameDuckDuration;
|
||||
_options.LameDuckGracePeriod = newOpts.LameDuckGracePeriod;
|
||||
_options.ClientAdvertise = newOpts.ClientAdvertise;
|
||||
_options.DisableSublistCache = newOpts.DisableSublistCache;
|
||||
_options.ConnectErrorReports = newOpts.ConnectErrorReports;
|
||||
_options.ReconnectErrorReports = newOpts.ReconnectErrorReports;
|
||||
_options.NoHeaderSupport = newOpts.NoHeaderSupport;
|
||||
_options.NoSystemAccount = newOpts.NoSystemAccount;
|
||||
_options.SystemAccount = newOpts.SystemAccount;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!IsShuttingDown)
|
||||
|
||||
76
tests/NATS.Server.Tests/ConfigIntegrationTests.cs
Normal file
76
tests/NATS.Server.Tests/ConfigIntegrationTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class ConfigIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Server_WithConfigFile_LoadsOptionsFromFile()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var confPath = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true");
|
||||
|
||||
var opts = ConfigProcessor.ProcessConfigFile(confPath);
|
||||
opts.Port.ShouldBe(14222);
|
||||
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
|
||||
opts.Debug.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CliOverridesConfig()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var confPath = Path.Combine(dir, "test.conf");
|
||||
File.WriteAllText(confPath, "port: 14222\ndebug: true");
|
||||
|
||||
var opts = ConfigProcessor.ProcessConfigFile(confPath);
|
||||
opts.Port.ShouldBe(14222);
|
||||
|
||||
// Simulate CLI override: user passed -p 5222 on command line
|
||||
var cliSnapshot = new NatsOptions { Port = 5222 };
|
||||
var cliFlags = new HashSet<string> { "Port" };
|
||||
ConfigReloader.MergeCliOverrides(opts, cliSnapshot, cliFlags);
|
||||
|
||||
opts.Port.ShouldBe(5222);
|
||||
opts.Debug.ShouldBeTrue(); // Config file value preserved
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reload_ChangingPort_ReturnsError()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Port = 4222 };
|
||||
var newOpts = new NatsOptions { Port = 5222 };
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
errors.Count.ShouldBeGreaterThan(0);
|
||||
errors[0].ShouldContain("Port");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reload_ChangingDebug_IsValid()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Debug = false };
|
||||
var newOpts = new NatsOptions { Debug = true };
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
errors.ShouldBeEmpty();
|
||||
changes.ShouldContain(c => c.IsLoggingChange);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user