Files
natsdotnet/docs/plans/2026-02-23-config-parsing-plan.md
Joseph Doherty fadbbf463c docs: add detailed implementation plan for config parsing and hot reload
8 tasks with TDD steps, complete test code, exact file paths,
and dependency chain from lexer through to verification.
2026-02-23 04:12:11 -05:00

1508 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Config File Parsing & Hot Reload Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Port the Go NATS config file parser and hot reload system to .NET, resolving the two remaining high-priority gaps in differences.md.
**Architecture:** A state-machine lexer tokenizes the NATS config format, a parser builds `Dictionary<string, object?>` from the token stream, a config processor maps keys to `NatsOptions` fields, and a reloader diffs old/new options on SIGHUP to apply changes without restart.
**Tech Stack:** .NET 10 / C# 14, xUnit 3, Shouldly, System.IO.Pipelines (existing), Serilog (existing).
---
### Task 1: Token Types and Lexer Infrastructure
**Files:**
- Create: `src/NATS.Server/Configuration/NatsConfToken.cs`
- Create: `src/NATS.Server/Configuration/NatsConfLexer.cs`
- Test: `tests/NATS.Server.Tests/NatsConfLexerTests.cs`
**Step 1: Write failing tests for basic token types**
```csharp
// tests/NATS.Server.Tests/NatsConfLexerTests.cs
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class NatsConfLexerTests
{
[Fact]
public void Lex_SimpleKeyStringValue_ReturnsKeyAndString()
{
var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("foo");
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("bar");
tokens[2].Type.ShouldBe(TokenType.Eof);
}
[Fact]
public void Lex_SingleQuotedString_ReturnsString()
{
var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("bar");
}
[Fact]
public void Lex_IntegerValue_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("port = 4222").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("port");
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("4222");
}
[Fact]
public void Lex_IntegerWithSuffix_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList();
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("64mb");
}
[Fact]
public void Lex_BooleanValues_ReturnsBool()
{
foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" })
{
var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList();
tokens[1].Type.ShouldBe(TokenType.Bool);
}
}
[Fact]
public void Lex_FloatValue_ReturnsFloat()
{
var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList();
tokens[1].Type.ShouldBe(TokenType.Float);
tokens[1].Value.ShouldBe("2.5");
}
[Fact]
public void Lex_NegativeNumber_ReturnsInteger()
{
var tokens = NatsConfLexer.Tokenize("offset = -10").ToList();
tokens[1].Type.ShouldBe(TokenType.Integer);
tokens[1].Value.ShouldBe("-10");
}
[Fact]
public void Lex_DatetimeValue_ReturnsDatetime()
{
var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList();
tokens[1].Type.ShouldBe(TokenType.DateTime);
}
[Fact]
public void Lex_HashComment_IsIgnored()
{
var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList();
// First meaningful token should be the key
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(1);
keys[0].Value.ShouldBe("foo");
}
[Fact]
public void Lex_SlashComment_IsIgnored()
{
var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList();
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(1);
}
[Fact]
public void Lex_MapBlock_ReturnsMapStartEnd()
{
var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[0].Value.ShouldBe("auth");
tokens[1].Type.ShouldBe(TokenType.MapStart);
tokens[2].Type.ShouldBe(TokenType.Key);
tokens[2].Value.ShouldBe("user");
tokens[3].Type.ShouldBe(TokenType.String);
tokens[3].Value.ShouldBe("admin");
tokens[4].Type.ShouldBe(TokenType.MapEnd);
}
[Fact]
public void Lex_Array_ReturnsArrayStartEnd()
{
var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList();
tokens[1].Type.ShouldBe(TokenType.ArrayStart);
tokens[2].Type.ShouldBe(TokenType.Integer);
tokens[2].Value.ShouldBe("1");
tokens[5].Type.ShouldBe(TokenType.ArrayEnd);
}
[Fact]
public void Lex_Variable_ReturnsVariable()
{
var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList();
tokens[1].Type.ShouldBe(TokenType.Variable);
tokens[1].Value.ShouldBe("MY_VAR");
}
[Fact]
public void Lex_Include_ReturnsInclude()
{
var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList();
tokens[0].Type.ShouldBe(TokenType.Include);
tokens[0].Value.ShouldBe("auth.conf");
}
[Fact]
public void Lex_EscapeSequences_AreProcessed()
{
var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("hello\tworld\n");
}
[Fact]
public void Lex_HexEscape_IsProcessed()
{
var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList();
tokens[1].Value.ShouldBe("AB");
}
[Fact]
public void Lex_ColonSeparator_Works()
{
var tokens = NatsConfLexer.Tokenize("foo: bar").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_WhitespaceSeparator_Works()
{
var tokens = NatsConfLexer.Tokenize("foo bar").ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_SemicolonTerminator_IsHandled()
{
var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList();
var keys = tokens.Where(t => t.Type == TokenType.Key).ToList();
keys.Count.ShouldBe(2);
}
[Fact]
public void Lex_EmptyInput_ReturnsEof()
{
var tokens = NatsConfLexer.Tokenize("").ToList();
tokens.Count.ShouldBe(1);
tokens[0].Type.ShouldBe(TokenType.Eof);
}
[Fact]
public void Lex_BlockString_ReturnsString()
{
// Block strings delimited by ( ... ) where ) is on its own line
var input = "desc (\nthis is\na block\n)\n";
var tokens = NatsConfLexer.Tokenize(input).ToList();
tokens[0].Type.ShouldBe(TokenType.Key);
tokens[1].Type.ShouldBe(TokenType.String);
}
[Fact]
public void Lex_IPAddress_ReturnsString()
{
var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("127.0.0.1");
}
[Fact]
public void Lex_TrackLineNumbers()
{
var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList();
tokens[0].Line.ShouldBe(1); // a
tokens[2].Line.ShouldBe(2); // b
tokens[4].Line.ShouldBe(3); // c
}
[Fact]
public void Lex_UnterminatedString_ReturnsError()
{
var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList();
tokens.ShouldContain(t => t.Type == TokenType.Error);
}
[Fact]
public void Lex_StringStartingWithDigit_TreatedAsString()
{
// Go: `foo = 3xyz` → string "3xyz"
var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList();
tokens[1].Type.ShouldBe(TokenType.String);
tokens[1].Value.ShouldBe("3xyz");
}
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfLexerTests" -v normal`
Expected: FAIL — types don't exist yet
**Step 3: Implement token types**
```csharp
// src/NATS.Server/Configuration/NatsConfToken.cs
namespace NATS.Server.Configuration;
public enum TokenType
{
Error,
Eof,
Key,
String,
Bool,
Integer,
Float,
DateTime,
ArrayStart,
ArrayEnd,
MapStart,
MapEnd,
Variable,
Include,
Comment,
}
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
```
**Step 4: Implement the lexer**
Port Go `conf/lex.go` to C#. Create `src/NATS.Server/Configuration/NatsConfLexer.cs`.
Key mapping from Go → C#:
- `stateFn func(lx *lexer) stateFn``delegate LexState? LexState(Lexer lx)`
- `chan item``List<Token>` (no need for channel, we collect eagerly)
- `lx.next()` / `lx.backup()` / `lx.peek()` → same pattern on `ReadOnlySpan<char>` or string with position tracking
- `lx.emit()` → add to `List<Token>`
- `lx.push()` / `lx.pop()``Stack<LexState>`
- `lx.stringParts``List<string>` for escape sequence assembly
- `isWhitespace`, `isNL`, `isKeySeparator`, `isNumberSuffix` → static helper methods
The lexer is ~400 lines. Follow the Go state machine exactly:
- `lexTop``lexKeyStart``lexKey``lexKeyEnd``lexValue`
- `lexValue` dispatches to: `lexArrayValue`, `lexMapKeyStart`, `lexQuotedString`, `lexDubQuotedString`, `lexNegNumberStart`, `lexBlock`, `lexNumberOrDateOrStringOrIPStart`, `lexString`
- `lexStringEscape` handles `\t`, `\n`, `\r`, `\"`, `\\`, `\xHH`
- `lexBlock` looks for `)` on a line by itself
- `lexConvenientNumber` handles size suffixes (`k`, `kb`, `mb`, `gb`, etc.)
Public API: `static IReadOnlyList<Token> Tokenize(string input)`
**Step 5: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfLexerTests" -v normal`
Expected: PASS
**Step 6: Commit**
```bash
git add src/NATS.Server/Configuration/NatsConfToken.cs src/NATS.Server/Configuration/NatsConfLexer.cs tests/NATS.Server.Tests/NatsConfLexerTests.cs
git commit -m "feat: add NATS config file lexer (port of Go conf/lex.go)"
```
---
### Task 2: Config Parser
**Files:**
- Create: `src/NATS.Server/Configuration/NatsConfParser.cs`
- Test: `tests/NATS.Server.Tests/NatsConfParserTests.cs`
**Step 1: Write failing tests**
```csharp
// tests/NATS.Server.Tests/NatsConfParserTests.cs
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class NatsConfParserTests
{
[Fact]
public void Parse_SimpleTopLevel_ReturnsCorrectTypes()
{
var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22");
result["foo"].ShouldBe("1");
result["bar"].ShouldBe(2.2);
result["baz"].ShouldBe(true);
result["boo"].ShouldBe(22L);
}
[Fact]
public void Parse_Booleans_AllVariants()
{
foreach (var (input, expected) in new[] {
("true", true), ("TRUE", true), ("yes", true), ("on", true),
("false", false), ("FALSE", false), ("no", false), ("off", false)
})
{
var result = NatsConfParser.Parse($"flag = {input}");
result["flag"].ShouldBe(expected);
}
}
[Fact]
public void Parse_IntegerWithSuffix_AppliesMultiplier()
{
var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb");
result["a"].ShouldBe(1000L);
result["b"].ShouldBe(2L * 1024 * 1024);
result["c"].ShouldBe(3L * 1000 * 1000 * 1000);
result["d"].ShouldBe(4L * 1024);
}
[Fact]
public void Parse_NestedMap_ReturnsDictionary()
{
var result = NatsConfParser.Parse("auth { user: admin, pass: secret }");
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
auth["user"].ShouldBe("admin");
auth["pass"].ShouldBe("secret");
}
[Fact]
public void Parse_Array_ReturnsList()
{
var result = NatsConfParser.Parse("items = [1, 2, 3]");
var items = result["items"].ShouldBeOfType<List<object?>>();
items.Count.ShouldBe(3);
items[0].ShouldBe(1L);
}
[Fact]
public void Parse_Variable_ResolvesFromContext()
{
var result = NatsConfParser.Parse("index = 22\nfoo = $index");
result["foo"].ShouldBe(22L);
}
[Fact]
public void Parse_NestedVariable_UsesBlockScope()
{
var input = "index = 22\nnest {\n index = 11\n foo = $index\n}\nbar = $index";
var result = NatsConfParser.Parse(input);
var nest = result["nest"].ShouldBeOfType<Dictionary<string, object?>>();
nest["foo"].ShouldBe(11L); // inner scope
result["bar"].ShouldBe(22L); // outer scope
}
[Fact]
public void Parse_EnvironmentVariable_ResolvesFromEnv()
{
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello");
try
{
var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345");
result["val"].ShouldBe("hello");
}
finally
{
Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null);
}
}
[Fact]
public void Parse_UndefinedVariable_Throws()
{
Should.Throw<FormatException>(() =>
NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999"));
}
[Fact]
public void Parse_IncludeDirective_MergesFile()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\"");
File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\"");
var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf"));
result["port"].ShouldBe(4222L);
result["host"].ShouldBe("localhost");
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Parse_MultipleKeySeparators_AllWork()
{
var r1 = NatsConfParser.Parse("a = 1");
var r2 = NatsConfParser.Parse("a : 1");
var r3 = NatsConfParser.Parse("a 1");
r1["a"].ShouldBe(1L);
r2["a"].ShouldBe(1L);
r3["a"].ShouldBe(1L);
}
[Fact]
public void Parse_ErrorOnInvalidInput_Throws()
{
Should.Throw<FormatException>(() => NatsConfParser.Parse("= invalid"));
}
[Fact]
public void Parse_CommentsInsideBlocks_AreIgnored()
{
var input = "auth {\n # comment\n user: admin\n // another comment\n pass: secret\n}";
var result = NatsConfParser.Parse(input);
var auth = result["auth"].ShouldBeOfType<Dictionary<string, object?>>();
auth["user"].ShouldBe("admin");
auth["pass"].ShouldBe("secret");
}
[Fact]
public void Parse_ArrayOfMaps_Works()
{
var input = "users = [\n { user: alice, pass: pw1 }\n { user: bob, pass: pw2 }\n]";
var result = NatsConfParser.Parse(input);
var users = result["users"].ShouldBeOfType<List<object?>>();
users.Count.ShouldBe(2);
var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
first["user"].ShouldBe("alice");
}
[Fact]
public void Parse_BcryptPassword_HandledAsString()
{
// Bcrypt strings starting with $2a$ should be preserved
var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK";
var result = NatsConfParser.Parse(input);
((string)result["pass"]!).ShouldStartWith("$2a$");
}
[Fact]
public void ParseFile_WithDigest_ReturnsStableHash()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var conf = Path.Combine(dir, "test.conf");
File.WriteAllText(conf, "port = 4222\nhost = \"localhost\"");
var (result, digest) = NatsConfParser.ParseFileWithDigest(conf);
result["port"].ShouldBe(4222L);
digest.ShouldStartWith("sha256:");
digest.Length.ShouldBeGreaterThan(10);
// Same input → same digest
var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf);
digest2.ShouldBe(digest);
}
finally
{
Directory.Delete(dir, true);
}
}
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfParserTests" -v normal`
Expected: FAIL — `NatsConfParser` doesn't exist
**Step 3: Implement the parser**
Port Go `conf/parse.go` to C#. Create `src/NATS.Server/Configuration/NatsConfParser.cs`.
Key mapping from Go → C#:
- `parser.mapping``Dictionary<string, object?>`
- `parser.ctx` / `parser.ctxs``Stack<object>` for context (map or list)
- `parser.keys``Stack<string>` for key tracking
- `parser.fp``string` file path for relative `include` resolution
- `processItem` switch on `TokenType` → same pattern
- Integer suffix processing: `"k"→×1000`, `"kb"/"ki"/"kib"→×1024`, etc. (match Go exactly)
- Variable lookup: walk context stack bottom-up, then `Environment.GetEnvironmentVariable()`
- Bcrypt special case: `$2a$...` → treat as literal string
- Include: `ParseFile(Path.Combine(baseDir, includePath))`, merge into current context
- Digest: `SHA256.HashData()` on JSON serialization of result
Public API:
- `static Dictionary<string, object?> Parse(string data)`
- `static Dictionary<string, object?> ParseFile(string filePath)`
- `static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)`
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsConfParserTests" -v normal`
Expected: PASS
**Step 5: Commit**
```bash
git add src/NATS.Server/Configuration/NatsConfParser.cs tests/NATS.Server.Tests/NatsConfParserTests.cs
git commit -m "feat: add NATS config file parser (port of Go conf/parse.go)"
```
---
### Task 3: New NatsOptions Fields
**Files:**
- Modify: `src/NATS.Server/NatsOptions.cs`
- Modify: `src/NATS.Server/NatsServer.cs:23` (MaxClosedClients constant → use options)
- Modify: `tests/NATS.Server.Tests/NatsOptionsTests.cs`
**Step 1: Write failing tests for new defaults**
```csharp
// Add to tests/NATS.Server.Tests/NatsOptionsTests.cs
[Fact]
public void New_fields_have_correct_defaults()
{
var opts = new NatsOptions();
opts.ClientAdvertise.ShouldBeNull();
opts.TraceVerbose.ShouldBeFalse();
opts.MaxTracedMsgLen.ShouldBe(0);
opts.DisableSublistCache.ShouldBeFalse();
opts.ConnectErrorReports.ShouldBe(3600);
opts.ReconnectErrorReports.ShouldBe(1);
opts.NoHeaderSupport.ShouldBeFalse();
opts.MaxClosedClients.ShouldBe(10_000);
opts.NoSystemAccount.ShouldBeFalse();
opts.SystemAccount.ShouldBeNull();
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsOptionsTests.New_fields" -v normal`
Expected: FAIL — properties don't exist
**Step 3: Add new fields to NatsOptions**
Add to `src/NATS.Server/NatsOptions.cs` (after the existing `ProfPort` property, before TLS section):
```csharp
// Extended options for Go parity
public string? ClientAdvertise { get; set; }
public bool TraceVerbose { get; set; }
public int MaxTracedMsgLen { get; set; }
public bool DisableSublistCache { get; set; }
public int ConnectErrorReports { get; set; } = 3600;
public int ReconnectErrorReports { get; set; } = 1;
public bool NoHeaderSupport { get; set; }
public int MaxClosedClients { get; set; } = 10_000;
public bool NoSystemAccount { get; set; }
public string? SystemAccount { get; set; }
```
Also add a `HashSet<string>` for CLI flag tracking:
```csharp
// Tracks which fields were set via CLI flags (for reload precedence)
public HashSet<string> InCmdLine { get; } = [];
```
**Step 4: Update NatsServer.cs to use options.MaxClosedClients**
In `src/NATS.Server/NatsServer.cs`:
- Remove line 23: `private const int MaxClosedClients = 10_000;`
- Change line 650 reference: `while (_closedClients.Count > MaxClosedClients)``while (_closedClients.Count > _options.MaxClosedClients)`
**Step 5: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~NatsOptionsTests" -v normal`
Expected: PASS
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: PASS (all existing tests still pass)
**Step 6: Commit**
```bash
git add src/NATS.Server/NatsOptions.cs src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/NatsOptionsTests.cs
git commit -m "feat: add new NatsOptions fields for Go config parity"
```
---
### Task 4: Config Processor
**Files:**
- Create: `src/NATS.Server/Configuration/ConfigProcessor.cs`
- Create: `tests/NATS.Server.Tests/TestData/basic.conf`
- Create: `tests/NATS.Server.Tests/TestData/auth.conf`
- Create: `tests/NATS.Server.Tests/TestData/tls.conf`
- Create: `tests/NATS.Server.Tests/TestData/full.conf`
- Test: `tests/NATS.Server.Tests/ConfigProcessorTests.cs`
**Step 1: Create test config files**
```
# tests/NATS.Server.Tests/TestData/basic.conf
port: 4222
host: "0.0.0.0"
server_name: "test-server"
max_payload: 2mb
max_connections: 1000
debug: true
trace: false
logtime: true
logtime_utc: false
ping_interval: "30s"
ping_max: 3
write_deadline: "5s"
max_subs: 100
max_sub_tokens: 16
max_control_line: 2048
max_pending: 32mb
lame_duck_duration: "60s"
lame_duck_grace_period: "5s"
http_port: 8222
```
```
# tests/NATS.Server.Tests/TestData/auth.conf
authorization {
user: admin
password: "s3cret"
timeout: 5
users = [
{ user: alice, password: "pw1", permissions: { publish: { allow: ["foo.>"] }, subscribe: { allow: [">"] } } }
{ user: bob, password: "pw2" }
]
}
no_auth_user: "guest"
```
```
# tests/NATS.Server.Tests/TestData/tls.conf
tls {
cert_file: "/path/to/cert.pem"
key_file: "/path/to/key.pem"
ca_file: "/path/to/ca.pem"
verify: true
verify_and_map: true
timeout: 3
connection_rate_limit: 100
pinned_certs: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"]
handshake_first: true
}
allow_non_tls: false
```
```
# tests/NATS.Server.Tests/TestData/full.conf
# Full configuration with all supported options
port: 4222
host: "0.0.0.0"
server_name: "full-test"
client_advertise: "nats://public.example.com:4222"
max_payload: 1mb
max_control_line: 4096
max_connections: 65536
max_pending: 64mb
write_deadline: "10s"
max_subs: 0
max_sub_tokens: 0
max_traced_msg_len: 1024
disable_sublist_cache: false
max_closed_clients: 5000
ping_interval: "2m"
ping_max: 2
debug: false
trace: false
trace_verbose: false
logtime: true
logtime_utc: false
logfile: "/var/log/nats.log"
log_size_limit: 100mb
log_max_num: 5
http_port: 8222
http_base_path: "/nats"
pidfile: "/var/run/nats.pid"
ports_file_dir: "/var/run"
lame_duck_duration: "2m"
lame_duck_grace_period: "10s"
server_tags {
region: "us-east"
env: "production"
}
authorization {
user: admin
password: "secret"
timeout: 2
}
tls {
cert_file: "/path/to/cert.pem"
key_file: "/path/to/key.pem"
ca_file: "/path/to/ca.pem"
verify: true
timeout: 2
handshake_first: true
}
```
**Step 2: Write failing tests**
```csharp
// tests/NATS.Server.Tests/ConfigProcessorTests.cs
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class ConfigProcessorTests
{
private static string TestDataPath(string filename) =>
Path.Combine(AppContext.BaseDirectory, "TestData", filename);
[Fact]
public void ProcessConfigFile_BasicConf_SetsNetworkOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Port.ShouldBe(4222);
opts.Host.ShouldBe("0.0.0.0");
opts.ServerName.ShouldBe("test-server");
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
opts.MaxConnections.ShouldBe(1000);
}
[Fact]
public void ProcessConfigFile_BasicConf_SetsLoggingOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeFalse();
opts.Logtime.ShouldBeTrue();
}
[Fact]
public void ProcessConfigFile_BasicConf_SetsPingOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
opts.MaxPingsOut.ShouldBe(3);
}
[Fact]
public void ProcessConfigFile_BasicConf_SetsLimits()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5));
opts.MaxSubs.ShouldBe(100);
opts.MaxSubTokens.ShouldBe(16);
opts.MaxControlLine.ShouldBe(2048);
opts.MaxPending.ShouldBe(32L * 1024 * 1024);
}
[Fact]
public void ProcessConfigFile_BasicConf_SetsLifecycle()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60));
opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void ProcessConfigFile_BasicConf_SetsMonitoring()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MonitorPort.ShouldBe(8222);
}
[Fact]
public void ProcessConfigFile_AuthConf_SetsAuthOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.Username.ShouldBe("admin");
opts.Password.ShouldBe("s3cret");
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5));
opts.NoAuthUser.ShouldBe("guest");
}
[Fact]
public void ProcessConfigFile_AuthConf_ParsesUsers()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.Users.ShouldNotBeNull();
opts.Users!.Count.ShouldBe(2);
opts.Users[0].Username.ShouldBe("alice");
opts.Users[0].Permissions.ShouldNotBeNull();
opts.Users[0].Permissions!.Publish!.Allow!.ShouldContain("foo.>");
opts.Users[1].Username.ShouldBe("bob");
}
[Fact]
public void ProcessConfigFile_TlsConf_SetsTlsOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsCert.ShouldBe("/path/to/cert.pem");
opts.TlsKey.ShouldBe("/path/to/key.pem");
opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
opts.TlsVerify.ShouldBeTrue();
opts.TlsMap.ShouldBeTrue();
opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3));
opts.TlsRateLimit.ShouldBe(100);
opts.TlsPinnedCerts.ShouldNotBeNull();
opts.TlsPinnedCerts!.Count.ShouldBe(1);
opts.TlsHandshakeFirst.ShouldBeTrue();
opts.AllowNonTls.ShouldBeFalse();
}
[Fact]
public void ProcessConfigFile_FullConf_SetsExtendedOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222");
opts.MaxTracedMsgLen.ShouldBe(1024);
opts.MaxClosedClients.ShouldBe(5000);
opts.DisableSublistCache.ShouldBeFalse();
}
[Fact]
public void ProcessConfigFile_FullConf_ParsesTags()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.Tags.ShouldNotBeNull();
opts.Tags!["region"].ShouldBe("us-east");
opts.Tags["env"].ShouldBe("production");
}
[Fact]
public void ProcessConfigFile_ListenCombined_SetsHostAndPort()
{
var opts = ConfigProcessor.ProcessConfig("listen: \"127.0.0.1:5222\"");
opts.Host.ShouldBe("127.0.0.1");
opts.Port.ShouldBe(5222);
}
[Fact]
public void ProcessConfigFile_HttpCombined_SetsMonitorHostAndPort()
{
var opts = ConfigProcessor.ProcessConfig("http: \"0.0.0.0:9222\"");
opts.MonitorHost.ShouldBe("0.0.0.0");
opts.MonitorPort.ShouldBe(9222);
}
[Fact]
public void ProcessConfigFile_DurationAsNumber_TreatedAsSeconds()
{
// Backward compat: bare number = seconds
var opts = ConfigProcessor.ProcessConfig("ping_interval: 30");
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
}
[Fact]
public void ProcessConfigFile_UnknownKeys_SilentlyIgnored()
{
// Cluster/JetStream keys should not cause errors
var opts = ConfigProcessor.ProcessConfig("port: 4222\ncluster { port: 6222 }\njetstream: true");
opts.Port.ShouldBe(4222);
}
[Fact]
public void ProcessConfigFile_ServerNameWithSpaces_ThrowsError()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("server_name: \"has spaces\""));
}
[Fact]
public void ProcessConfigFile_MaxSubTokensTooLarge_ThrowsError()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
}
}
```
**Step 2b: Ensure TestData files are copied to output**
Add to `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`:
```xml
<ItemGroup>
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
```
**Step 3: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigProcessorTests" -v normal`
Expected: FAIL — `ConfigProcessor` doesn't exist
**Step 4: Implement the config processor**
Create `src/NATS.Server/Configuration/ConfigProcessor.cs`.
Port the option processing from Go `opts.go:1050-1400` (the `processConfigFileLine` switch). Key methods:
```csharp
public static class ConfigProcessor
{
public static NatsOptions ProcessConfigFile(string filePath)
{
var config = NatsConfParser.ParseFile(filePath);
var opts = new NatsOptions { ConfigFile = filePath };
ApplyConfig(config, opts);
return opts;
}
public static NatsOptions ProcessConfig(string configText)
{
var config = NatsConfParser.Parse(configText);
var opts = new NatsOptions();
ApplyConfig(config, opts);
return opts;
}
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts) { ... }
}
```
`ApplyConfig` iterates the dictionary, switching on `key.ToLowerInvariant()`:
- `"listen"``ParseListen(value)` to split `"host:port"` into `opts.Host`/`opts.Port`
- `"port"``opts.Port = (int)(long)value`
- `"host"`, `"net"``opts.Host = (string)value`
- `"server_name"` → validate no spaces, set `opts.ServerName`
- `"debug"``opts.Debug = (bool)value`
- `"trace"``opts.Trace = (bool)value`
- `"trace_verbose"``opts.TraceVerbose = (bool)value; opts.Trace = (bool)value`
- `"authorization"``ParseAuthorization((Dictionary<string,object?>)value, opts)`
- `"tls"``ParseTls((Dictionary<string,object?>)value, opts)`
- `"server_tags"``ParseTags((Dictionary<string,object?>)value, opts)`
- Duration fields (`ping_interval`, `write_deadline`, `lame_duck_duration`, `lame_duck_grace_period`) → `ParseDuration()` helper
- Unknown keys → silently ignored (for cluster/JetStream forward compat)
Helper methods:
- `ParseListen(object value)` → splits `"host:port"` string
- `ParseDuration(string field, object value)` → handles string durations (`"30s"`, `"2m"`) and bare numbers (seconds)
- `ParseAuthorization(dict, opts)` → processes `user`, `password`, `token`, `timeout`, `users` array
- `ParseUsers(List<object?> users)` → builds `List<User>` with optional permissions
- `ParsePermissions(dict)` → builds `Permissions` with `SubjectPermission` (allow/deny lists)
- `ParseTls(dict, opts)` → maps TLS block keys to TLS options
- `ParseTags(dict, opts)` → builds `Dictionary<string, string>` tags
Also create `ConfigProcessorException` for error reporting:
```csharp
public sealed class ConfigProcessorException(string message, List<string> errors)
: Exception(message)
{
public IReadOnlyList<string> Errors => errors;
}
```
Errors are collected into a `List<string>` during processing. If any errors exist after processing all keys, throw `ConfigProcessorException` with all errors.
**Step 5: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigProcessorTests" -v normal`
Expected: PASS
**Step 6: Commit**
```bash
git add src/NATS.Server/Configuration/ConfigProcessor.cs tests/NATS.Server.Tests/ConfigProcessorTests.cs tests/NATS.Server.Tests/TestData/ tests/NATS.Server.Tests/NATS.Server.Tests.csproj
git commit -m "feat: add config processor mapping parsed config to NatsOptions"
```
---
### Task 5: Hot Reload System
**Files:**
- Create: `src/NATS.Server/Configuration/IConfigChange.cs`
- Create: `src/NATS.Server/Configuration/ConfigReloader.cs`
- Modify: `src/NATS.Server/NatsServer.cs` (SIGHUP handler + `ReloadConfig()`)
- Test: `tests/NATS.Server.Tests/ConfigReloadTests.cs`
**Step 1: Write failing tests**
```csharp
// tests/NATS.Server.Tests/ConfigReloadTests.cs
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class ConfigReloadTests
{
[Fact]
public void Diff_NoChanges_ReturnsEmpty()
{
var old = new NatsOptions { Port = 4222, Debug = true };
var @new = new NatsOptions { Port = 4222, Debug = true };
var changes = ConfigReloader.Diff(old, @new);
changes.ShouldBeEmpty();
}
[Fact]
public void Diff_ReloadableChange_ReturnsChange()
{
var old = new NatsOptions { Debug = false };
var @new = new NatsOptions { Debug = true };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(1);
changes[0].Name.ShouldBe("Debug");
changes[0].IsLoggingChange.ShouldBeTrue();
}
[Fact]
public void Diff_NonReloadableChange_ReturnsNonReloadableChange()
{
var old = new NatsOptions { Port = 4222 };
var @new = new NatsOptions { Port = 5222 };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(1);
changes[0].IsNonReloadable.ShouldBeTrue();
}
[Fact]
public void Diff_MultipleChanges_ReturnsAll()
{
var old = new NatsOptions { Debug = false, MaxPayload = 1024 };
var @new = new NatsOptions { Debug = true, MaxPayload = 2048 };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(2);
}
[Fact]
public void Diff_AuthChange_MarkedCorrectly()
{
var old = new NatsOptions { Username = "alice" };
var @new = new NatsOptions { Username = "bob" };
var changes = ConfigReloader.Diff(old, @new);
changes[0].IsAuthChange.ShouldBeTrue();
}
[Fact]
public void Diff_TlsChange_MarkedCorrectly()
{
var old = new NatsOptions { TlsCert = "/old/cert.pem" };
var @new = new NatsOptions { TlsCert = "/new/cert.pem" };
var changes = ConfigReloader.Diff(old, @new);
changes[0].IsTlsChange.ShouldBeTrue();
}
[Fact]
public void Validate_NonReloadableChanges_ReturnsErrors()
{
var changes = new List<IConfigChange>
{
new ConfigChange("Port", isNonReloadable: true),
};
var errors = ConfigReloader.Validate(changes);
errors.Count.ShouldBe(1);
errors[0].ShouldContain("Port");
}
[Fact]
public void MergeWithCli_CliOverridesConfig()
{
var fromConfig = new NatsOptions { Port = 5222, Debug = true };
var cliFlags = new HashSet<string> { "Port" };
var cliValues = new NatsOptions { Port = 4222 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Port.ShouldBe(4222); // CLI wins
fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI)
}
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigReloadTests" -v normal`
Expected: FAIL
**Step 3: Implement the change interface and reloader**
```csharp
// src/NATS.Server/Configuration/IConfigChange.cs
namespace NATS.Server.Configuration;
public interface IConfigChange
{
string Name { get; }
bool IsLoggingChange { get; }
bool IsAuthChange { get; }
bool IsTlsChange { get; }
bool IsNonReloadable { get; }
}
public sealed class ConfigChange(
string name,
bool isLoggingChange = false,
bool isAuthChange = false,
bool isTlsChange = false,
bool isNonReloadable = false) : IConfigChange
{
public string Name => name;
public bool IsLoggingChange => isLoggingChange;
public bool IsAuthChange => isAuthChange;
public bool IsTlsChange => isTlsChange;
public bool IsNonReloadable => isNonReloadable;
}
```
```csharp
// src/NATS.Server/Configuration/ConfigReloader.cs
namespace NATS.Server.Configuration;
public static class ConfigReloader
{
// Non-reloadable options (match Go server)
private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];
// Logging-related options
private static readonly HashSet<string> LoggingOptions =
["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
"LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];
// Auth-related options
private static readonly HashSet<string> AuthOptions =
["Username", "Password", "Authorization", "Users", "NKeys",
"NoAuthUser", "AuthTimeout"];
// TLS-related options
private static readonly HashSet<string> TlsOptions =
["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
"TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
"AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts) { ... }
public static List<string> Validate(List<IConfigChange> changes) { ... }
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags) { ... }
}
```
`Diff` compares each property of `NatsOptions` using reflection or explicit property-by-property checks. For each changed property, creates a `ConfigChange` with the appropriate flags.
`Validate` returns error messages for any `IsNonReloadable` changes.
`MergeCliOverrides` copies properties listed in `cliFlags` from `cliValues` back to `fromConfig`.
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigReloadTests" -v normal`
Expected: PASS
**Step 5: Commit**
```bash
git add src/NATS.Server/Configuration/IConfigChange.cs src/NATS.Server/Configuration/ConfigReloader.cs tests/NATS.Server.Tests/ConfigReloadTests.cs
git commit -m "feat: add config reloader with diff, validate, and CLI merge"
```
---
### Task 6: Server Integration (Config Loading + SIGHUP Reload)
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs` (constructor, SIGHUP handler, new `ReloadConfig()`)
- Modify: `src/NATS.Server.Host/Program.cs` (config file loading, CLI tracking)
- Test: `tests/NATS.Server.Tests/ConfigIntegrationTests.cs` (new file)
**Step 1: Write failing integration tests**
```csharp
// tests/NATS.Server.Tests/ConfigIntegrationTests.cs
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);
// Simulate CLI override
opts.InCmdLine.Add("Port");
var cliOpts = new NatsOptions { Port = 5222 };
ConfigReloader.MergeCliOverrides(opts, cliOpts, opts.InCmdLine);
opts.Port.ShouldBe(5222); // CLI wins
opts.Debug.ShouldBeTrue(); // config 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);
}
}
```
**Step 2: Run tests to verify they fail (or pass if processor already works)**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ConfigIntegrationTests" -v normal`
**Step 3: Update NatsServer.cs**
Add `ReloadConfig()` method and update SIGHUP handler.
In the constructor (around line 248), after storing options:
```csharp
// Store a snapshot of CLI-set flags for reload precedence
_cliSnapshot = new NatsOptions { /* copy current values */ };
_cliFlags = options.InCmdLine;
```
Add new fields:
```csharp
private readonly NatsOptions? _cliSnapshot;
private readonly HashSet<string> _cliFlags;
private string? _configDigest;
```
Update SIGHUP handler (line 223-227):
```csharp
_signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
ctx.Cancel = true;
_logger.LogInformation("Trapped SIGHUP signal — reloading configuration");
_ = Task.Run(() => ReloadConfig());
}));
```
Add `ReloadConfig()`:
```csharp
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
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);
}
}
```
Add `ApplyConfigChanges()` which copies new values to `_options` and triggers side effects:
- If any `IsLoggingChange` → invoke `ReOpenLogFile` callback
- If any `IsAuthChange` → rebuild `_authService`
- If any `IsTlsChange` → rebuild `_sslOptions` (log warning, actual TLS reload requires cert reload hook)
- Update limits directly on `_options`
**Step 4: Update Program.cs**
Before CLI parsing, add config file loading:
```csharp
// After extracting -c flag but before other CLI args
string? configFile = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-c" && i + 1 < args.Length)
{
configFile = args[i + 1];
break;
}
}
if (configFile != null)
{
options = ConfigProcessor.ProcessConfigFile(configFile);
options.ConfigFile = configFile;
}
// Then apply CLI args (overriding config values) and track in InCmdLine
```
Update the CLI switch to track overrides:
```csharp
case "-p" or "--port" when i + 1 < args.Length:
options.Port = int.Parse(args[++i]);
options.InCmdLine.Add("Port");
break;
// ... same pattern for all other CLI flags
```
Remove the startup warning about config file not being supported:
```csharp
// DELETE these lines from NatsServer.cs StartAsync:
// if (_options.ConfigFile != null)
// _logger.LogWarning("Config file parsing not yet supported...");
```
**Step 5: Run all tests**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: PASS
**Step 6: Commit**
```bash
git add src/NATS.Server/NatsServer.cs src/NATS.Server.Host/Program.cs tests/NATS.Server.Tests/ConfigIntegrationTests.cs
git commit -m "feat: integrate config file loading and SIGHUP hot reload"
```
---
### Task 7: Update differences.md
**Files:**
- Modify: `differences.md`
**Step 1: Update the config parsing entries**
In section 6 (Configuration), update:
- `Config file parsing | Y | N``Config file parsing | Y | Y | Custom NATS conf parser ported from Go`
- `Hot reload (SIGHUP) | Y | N``Hot reload (SIGHUP) | Y | Y | Reloads logging, auth, limits, TLS certs on SIGHUP`
- `Config change detection | Y | N``Config change detection | Y | Y | SHA256 digest comparison; CLI flag precedence tracking`
- Update `~62` options count to reflect new fields
In section 1 (Core Server Lifecycle):
- `Config file validation on startup | Y | Stub``Config file validation on startup | Y | Y | Full config parsing with error collection`
In section 1 (Signal Handling):
- `SIGHUP (config reload) | Y | Stub``SIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset`
In Summary section:
- Move config file parsing and hot reload from "Remaining High Priority" to "Resolved Since Initial Audit"
- Remove the "Remaining High Priority" section entirely if empty
**Step 2: Verify the document is accurate**
Read through the full differences.md to ensure all updated entries match actual implementation.
**Step 3: Commit**
```bash
git add differences.md
git commit -m "docs: update differences.md to reflect config parsing and hot reload implementation"
```
---
### Task 8: Full Test Suite Verification
**Step 1: Run the full test suite**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: ALL PASS
**Step 2: Build the solution**
Run: `dotnet build`
Expected: Build succeeded with 0 errors
**Step 3: Smoke test with config file**
Create a temp config file and verify the server starts with it:
```bash
echo 'port: 14222\ndebug: true\nmax_payload: 2mb' > /tmp/test-nats.conf
dotnet run --project src/NATS.Server.Host -- -c /tmp/test-nats.conf &
sleep 2
curl -s http://localhost:8222/varz | grep -q "14222" # verify port from config
kill %1
```
**Step 4: Final commit (if any fixes needed)**
```bash
git add -A
git commit -m "fix: address issues found during full test verification"
```