diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index a5701e0..b053d09 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -17,10 +17,10 @@ jobs: - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - run: git fetch --prune --unshallow - - name: Setup .NET Core 8 + - name: Setup .NET 10 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: Install dependencies run: dotnet restore @@ -34,19 +34,8 @@ jobs: id: gitversion # step id used as reference for output values uses: gittools/actions/gitversion/execute@51d325634925d7d9ce0a7efc2c586c0bc2b9eee6 # v3.2.1 -# - name: Setup SonarScanner -# run: dotnet tool install --tool-path artifacts dotnet-sonarscanner - -# - name: SonarScanner begin -# run: artifacts/dotnet-sonarscanner begin /k:"Haproxy.AgentCheck" /o:"lucca" /d:sonar.login=${{ secrets.SONAR_TOKEN }} /d:sonar.host.url="https://sonarcloud.io/" /d:sonar.cs.opencover.reportsPaths="./coverage.opencover.xml" - - name: Test - run: dotnet test --no-restore --verbosity minimal /p:CollectCoverage=true /p:CoverletOutput=../ /p:CoverletOutputFormat=opencover --logger:"console;verbosity=detailed" - -# - name: SonarScanner end -# run: artifacts/dotnet-sonarscanner end /d:sonar.login=${{ secrets.SONAR_TOKEN }} -# env: -# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: dotnet test --solution Haproxy.AgentCheck.sln --coverage --report-github - name: Publish win-x64 run: | diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a72112..cd27056 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,30 +4,22 @@ true - + + - - - - + - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + - + \ No newline at end of file diff --git a/Haproxy.AgentCheck.Tests/CountersStateTests.cs b/Haproxy.AgentCheck.Tests/CountersStateTests.cs new file mode 100644 index 0000000..a047eb4 --- /dev/null +++ b/Haproxy.AgentCheck.Tests/CountersStateTests.cs @@ -0,0 +1,198 @@ +using Lucca.Infra.Haproxy.AgentCheck.Config; +using Lucca.Infra.Haproxy.AgentCheck.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class CountersStateTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public void UpdateState_WithCounterValue_UpdatesWeight() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "custom-counter", + Source = RuleSource.Counters, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear, + MinValue = 0, + MaxValue = 100, + MinWeight = 0, + MaxWeight = 100 + } + }); + + var sut = serviceProvider.GetRequiredService(); + sut.UpdateState(new CountersState + { + Values = new Dictionary { ["custom-counter"] = 50 } + }); + + Assert.True(sut.IsUp); + Assert.Equal(50, sut.Weight, 0.01); + } + + [Fact] + public void UpdateState_WithCounterAboveThreshold_BreaksCircuit() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "error-rate", + Source = RuleSource.Counters, + Failure = new FailureRule + { + EnterThreshold = 10, + LeaveThreshold = 5 + } + }); + + var sut = serviceProvider.GetRequiredService(); + sut.UpdateState(new CountersState + { + Values = new Dictionary { ["error-rate"] = 15 } + }); + + Assert.False(sut.IsUp); + Assert.Contains("Counters/error-rate", sut.BrokenCircuitsBreakers); + } + + [Fact] + public void UpdateState_WhenCounterRemoved_RemovesBrokenCircuit() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "temp-counter", + Source = RuleSource.Counters, + Failure = new FailureRule + { + EnterThreshold = 10, + LeaveThreshold = 5 + } + }); + + var sut = serviceProvider.GetRequiredService(); + + // First update with counter above threshold + sut.UpdateState(new CountersState + { + Values = new Dictionary { ["temp-counter"] = 15 } + }); + Assert.Contains("Counters/temp-counter", sut.BrokenCircuitsBreakers); + + // Second update without the counter + sut.UpdateState(new CountersState + { + Values = new Dictionary() + }); + + Assert.DoesNotContain("Counters/temp-counter", sut.BrokenCircuitsBreakers); + } + + [Fact] + public void UpdateState_WithMultipleCounters_UsesLowestWeight() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "counter1", + Source = RuleSource.Counters, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear, + MinValue = 0, + MaxValue = 100, + MinWeight = 0, + MaxWeight = 100 + } + }, + new RuleConfig + { + Name = "counter2", + Source = RuleSource.Counters, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear, + MinValue = 0, + MaxValue = 100, + MinWeight = 0, + MaxWeight = 100 + } + }); + + var sut = serviceProvider.GetRequiredService(); + sut.UpdateState(new CountersState + { + Values = new Dictionary + { + ["counter1"] = 20, // Weight would be 80 + ["counter2"] = 90 // Weight would be 10 + } + }); + + Assert.True(sut.IsUp); + Assert.Equal(10, sut.Weight, 0.01); + } + + [Fact] + public void UpdateState_CombineSystemAndCounters_UsesLowestWeight() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear, + MinValue = 0, + MaxValue = 100, + MinWeight = 0, + MaxWeight = 100 + } + }, + new RuleConfig + { + Name = "custom-metric", + Source = RuleSource.Counters, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear, + MinValue = 0, + MaxValue = 100, + MinWeight = 0, + MaxWeight = 100 + } + }); + + var sut = serviceProvider.GetRequiredService(); + + // CPU at 30% -> weight 70 + sut.UpdateState(new SystemState { CpuPercent = 30 }); + + // Custom metric at 80 -> weight 20 + sut.UpdateState(new CountersState + { + Values = new Dictionary { ["custom-metric"] = 80 } + }); + + Assert.True(sut.IsUp); + Assert.Equal(20, sut.Weight, 0.01); + } + + private IServiceProvider CreateServiceProvider(params RuleConfig[] rules) + { + return new ServiceCollection() + .AddOptions() + .Configure(o => o.AddRange(rules)) + .AddFakeLogging(o => o.OutputSink = testOutputHelper.WriteLine) + .AddSingleton() + .AddSingleton(p => p.GetRequiredService()) + .AddSingleton() + .BuildServiceProvider(); + } +} diff --git a/Haproxy.AgentCheck.Tests/Haproxy.AgentCheck.Tests.csproj b/Haproxy.AgentCheck.Tests/Haproxy.AgentCheck.Tests.csproj index dabc78e..fff5fc7 100644 --- a/Haproxy.AgentCheck.Tests/Haproxy.AgentCheck.Tests.csproj +++ b/Haproxy.AgentCheck.Tests/Haproxy.AgentCheck.Tests.csproj @@ -1,22 +1,16 @@  - net8.0 + net10.0 + Exe + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/Haproxy.AgentCheck.Tests/HttpEndpointTests.cs b/Haproxy.AgentCheck.Tests/HttpEndpointTests.cs new file mode 100644 index 0000000..25fe3ec --- /dev/null +++ b/Haproxy.AgentCheck.Tests/HttpEndpointTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class HttpEndpointTests(ITestOutputHelper outputHelper) : IClassFixture> +{ + [Fact] + public async Task GetRoot_ReturnsJsonWithStateInfo() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/", TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.Contains("isUp", content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("weight", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MaintenanceEndpoint_WithoutAuth_ReturnsUnauthorized() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsync("/admin/maintenance", null, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ReadyEndpoint_WithoutAuth_ReturnsUnauthorized() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsync("/admin/ready", null, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task MaintenanceEndpoint_WithBasicAuth_SetsMaintenanceMode() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = CreateBasicAuthHeader("admin", "admin"); + + var response = await client.PostAsync("/admin/maintenance", null, TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + + // Verify maintenance mode is set + var maintenanceStatus = factory.Services.GetRequiredService(); + Assert.True(maintenanceStatus.IsMaintenance); + } + + [Fact] + public async Task ReadyEndpoint_WithBasicAuth_ClearsMaintenanceMode() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = CreateBasicAuthHeader("admin", "admin"); + + // First set maintenance mode + var maintenanceStatus = factory.Services.GetRequiredService(); + maintenanceStatus.IsMaintenance = true; + + var response = await client.PostAsync("/admin/ready", null, TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + Assert.False(maintenanceStatus.IsMaintenance); + } + + [Fact] + public async Task MaintenanceEndpoint_WithWrongCredentials_ReturnsUnauthorized() + { + await using var factory = CreateFactory(); + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = CreateBasicAuthHeader("wrong", "credentials"); + + var response = await client.PostAsync("/admin/maintenance", null, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + private WebApplicationFactory CreateFactory() + { + return new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddFakeLogging(o => o.OutputSink = outputHelper.WriteLine); + }); + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Authentication:Schemes:Basic:Username"] = "admin", + ["Authentication:Schemes:Basic:Password"] = "admin" + }); + }); + }); + } + + private static AuthenticationHeaderValue CreateBasicAuthHeader(string username, string password) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } +} diff --git a/Haproxy.AgentCheck.Tests/IntegrationTests.cs b/Haproxy.AgentCheck.Tests/IntegrationTests.cs index 4f2caed..d2058c4 100644 --- a/Haproxy.AgentCheck.Tests/IntegrationTests.cs +++ b/Haproxy.AgentCheck.Tests/IntegrationTests.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Logging; using Xunit; -using Xunit.Abstractions; namespace Lucca.Infra.Haproxy.AgentCheck.Tests; #pragma warning disable S1144 @@ -24,7 +23,7 @@ public async Task StartAndGatherTcp() }); }); var client = f.CreateClient(); - var s = await client.GetStringAsync(""); + var s = await client.GetStringAsync("", TestContext.Current.CancellationToken); Assert.Equal("CPU : 0%\nIIS Requests : 0", s); Assert.True(true); } @@ -47,10 +46,10 @@ private static async Task AssertTcpReports() // request await stream.WriteAsync(Encoding.ASCII.GetBytes("")); // response - var buffer = new byte[8].AsMemory(); - await stream.ReadAsync(buffer); + var buffer = new byte[8]; + var bytesRead = await stream.ReadAsync(buffer.AsMemory()); - var response = Encoding.ASCII.GetString(buffer.Span); + var response = Encoding.ASCII.GetString(buffer, 0, bytesRead); Assert.StartsWith("up", response); } } diff --git a/Haproxy.AgentCheck.Tests/MaintenanceStatusTests.cs b/Haproxy.AgentCheck.Tests/MaintenanceStatusTests.cs new file mode 100644 index 0000000..6696e59 --- /dev/null +++ b/Haproxy.AgentCheck.Tests/MaintenanceStatusTests.cs @@ -0,0 +1,34 @@ +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class MaintenanceStatusTests +{ + [Fact] + public void MaintenanceStatus_DefaultValue_IsFalse() + { + var sut = new MaintenanceStatus(); + + Assert.False(sut.IsMaintenance); + } + + [Fact] + public void MaintenanceStatus_SetToTrue_ReturnsTrue() + { + var sut = new MaintenanceStatus(); + + sut.IsMaintenance = true; + + Assert.True(sut.IsMaintenance); + } + + [Fact] + public void MaintenanceStatus_SetToFalse_ReturnsFalse() + { + var sut = new MaintenanceStatus { IsMaintenance = true }; + + sut.IsMaintenance = false; + + Assert.False(sut.IsMaintenance); + } +} diff --git a/Haproxy.AgentCheck.Tests/RuleConfigTests.cs b/Haproxy.AgentCheck.Tests/RuleConfigTests.cs new file mode 100644 index 0000000..f19eeb0 --- /dev/null +++ b/Haproxy.AgentCheck.Tests/RuleConfigTests.cs @@ -0,0 +1,135 @@ +using Lucca.Infra.Haproxy.AgentCheck.Config; +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class RuleConfigTests +{ + [Fact] + public void RuleConfig_WithWeightRule_IsValid() + { + var rule = new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule() + }; + + Assert.True(rule.IsValid()); + } + + [Fact] + public void RuleConfig_WithFailureRule_IsValid() + { + var rule = new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Failure = new FailureRule + { + EnterThreshold = 80, + LeaveThreshold = 60 + } + }; + + Assert.True(rule.IsValid()); + } + + [Fact] + public void RuleConfig_WithBothRules_IsValid() + { + var rule = new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule(), + Failure = new FailureRule + { + EnterThreshold = 80, + LeaveThreshold = 60 + } + }; + + Assert.True(rule.IsValid()); + } + + [Fact] + public void RuleConfig_WithNoRules_IsNotValid() + { + var rule = new RuleConfig + { + Name = "CPU", + Source = RuleSource.System + }; + + Assert.False(rule.IsValid()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RuleConfig_WithInvalidName_IsNotValid(string? name) + { + var rule = new RuleConfig + { + Name = name!, + Source = RuleSource.System, + Weight = new WeightRule() + }; + + Assert.False(rule.IsValid()); + } + + [Fact] + public void RulesConfig_AllValid_ReturnsTrue() + { + var rules = new RulesConfig + { + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule() + }, + new RuleConfig + { + Name = "IisRequests", + Source = RuleSource.System, + Failure = new FailureRule { EnterThreshold = 100, LeaveThreshold = 50 } + } + }; + + Assert.True(rules.AreValid()); + } + + [Fact] + public void RulesConfig_OneInvalid_ReturnsFalse() + { + var rules = new RulesConfig + { + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule() + }, + new RuleConfig + { + Name = "Invalid", + Source = RuleSource.System + // No Weight or Failure rule + } + }; + + Assert.False(rules.AreValid()); + } + + [Fact] + public void RulesConfig_Empty_ReturnsTrue() + { + var rules = new RulesConfig(); + + Assert.True(rules.AreValid()); + } +} diff --git a/Haproxy.AgentCheck.Tests/StateTests.cs b/Haproxy.AgentCheck.Tests/StateTests.cs index 9209f7e..40ac0ac 100644 --- a/Haproxy.AgentCheck.Tests/StateTests.cs +++ b/Haproxy.AgentCheck.Tests/StateTests.cs @@ -1,8 +1,7 @@ -using Lucca.Infra.Haproxy.AgentCheck.Config; +using Lucca.Infra.Haproxy.AgentCheck.Config; using Lucca.Infra.Haproxy.AgentCheck.Metrics; using Microsoft.Extensions.DependencyInjection; using Xunit; -using Xunit.Abstractions; namespace Lucca.Infra.Haproxy.AgentCheck.Tests; @@ -25,7 +24,7 @@ public void Check_linear_weight_rule(WeightRuleData data) { Name = "CPU", Source = RuleSource.System, - Weight = new() + Weight = new WeightRule { SystemResponse = SystemResponse.Linear, MinValue = data.MinValue, @@ -49,7 +48,7 @@ public void Check_firstOrder_weight_rule(WeightRuleData data) { Name = "CPU", Source = RuleSource.System, - Weight = new() + Weight = new WeightRule { SystemResponse = SystemResponse.FirstOrder, MinValue = data.MinValue, @@ -68,20 +67,20 @@ public void Check_firstOrder_weight_rule(WeightRuleData data) public void Check_that_the_lowest_weight_is_used() { var serviceProvider = CreateServiceProvider( - new() + new RuleConfig { Name = "CPU", Source = RuleSource.System, - Weight = new() + Weight = new WeightRule { SystemResponse = SystemResponse.Linear } }, - new() + new RuleConfig { Name = "IisRequests", Source = RuleSource.System, - Weight = new() + Weight = new WeightRule { SystemResponse = SystemResponse.Linear } @@ -100,7 +99,7 @@ public void Check_that_given_metric_below_threshold_state_is_up() { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40 @@ -120,7 +119,7 @@ public void Check_that_given_metric_above_threshold_state_is_down() { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40 @@ -136,21 +135,21 @@ public void Check_that_given_metric_above_threshold_state_is_down() public void Check_that_given_one_metric_above_threshold_state_is_down() { var serviceProvider = CreateServiceProvider( - new() + new RuleConfig { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40 } }, - new() + new RuleConfig { Name = "IisRequests", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 10, LeaveThreshold = 10 @@ -171,7 +170,7 @@ public void Check_that_given_broken_circuit_breaker_with_metric_between_threshol { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40 @@ -192,7 +191,7 @@ public void Check_that_given_broken_circuit_breaker_with_metric_below_threshold_ { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40 @@ -213,7 +212,7 @@ public void Check_that_given_broken_circuit_breaker_with_metric_below_threshold_ { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40, @@ -235,7 +234,7 @@ public void Check_that_given_broken_circuit_breaker_with_metric_below_threshold_ { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40, @@ -259,7 +258,7 @@ public void Check_that_given_fixed_circuit_breaker_with_metric_not_below_thresho { Name = "CPU", Source = RuleSource.System, - Failure = new() + Failure = new FailureRule { EnterThreshold = 60, LeaveThreshold = 40, @@ -277,42 +276,41 @@ public void Check_that_given_fixed_circuit_breaker_with_metric_not_below_thresho public static TheoryData GetLinearResponseTestData() { - return new TheoryData - { - new(0, 100, 0, 100, 0, 100), - new(50, 50, 0, 100, 0, 100), - new(100, 0, 0, 100, 0, 100), + return new TheoryData( + new WeightRuleData(0, 100, 0, 100, 0, 100), + new WeightRuleData(50, 50, 0, 100, 0, 100), + new WeightRuleData(100, 0, 0, 100, 0, 100), - new(0, 80, 0, 100, 10, 80), - new(50, 45, 0, 100, 10, 80), - new(100, 10, 0, 100, 10, 80), + new WeightRuleData(0, 80, 0, 100, 10, 80), + new WeightRuleData(50, 45, 0, 100, 10, 80), + new WeightRuleData(100, 10, 0, 100, 10, 80), - new(0, 100, 10, 80, 0, 100), - new(10, 100, 10, 80, 0, 100), - new(50, 42.85, 10, 80, 0, 100), - new(80, 0, 10, 80, 0, 100), - new(100, 0, 10, 80, 0, 100) - }; + new WeightRuleData(0, 100, 10, 80, 0, 100), + new WeightRuleData(10, 100, 10, 80, 0, 100), + new WeightRuleData(50, 42.85, 10, 80, 0, 100), + new WeightRuleData(80, 0, 10, 80, 0, 100), + new WeightRuleData(100, 0, 10, 80, 0, 100) + ); } public static TheoryData GetFirstOrderResponseTestData() { - return new TheoryData - { - new(0, 100, 0, 100, 0, 100), - new(50, 9.05, 0, 100, 0, 100), - new(100, 0, 0, 100, 0, 100), + return new TheoryData( + + new WeightRuleData(0, 100, 0, 100, 0, 100), + new WeightRuleData(50, 9.05, 0, 100, 0, 100), + new WeightRuleData(100, 0, 0, 100, 0, 100), - new(0, 80, 0, 100, 10, 80), - new(50, 17.42, 0, 100, 10, 80), - new(100, 10, 0, 100, 10, 80), + new WeightRuleData( 0, 80, 0, 100, 10, 80), + new WeightRuleData(50, 17.42, 0, 100, 10, 80), + new WeightRuleData(100, 10, 0, 100, 10, 80), - new(0, 100, 10, 80, 0, 100), - new(10, 100, 10, 80, 0, 100), - new(45, 9.05, 10, 80, 0, 100), - new(80, 0, 10, 80, 0, 100), - new(100, 0, 10, 80, 0, 100) - }; + new WeightRuleData(0, 100, 10, 80, 0, 100), + new WeightRuleData(10, 100, 10, 80, 0, 100), + new WeightRuleData(45, 9.05, 10, 80, 0, 100), + new WeightRuleData(80, 0, 10, 80, 0, 100), + new WeightRuleData(100, 0, 10, 80, 0, 100) + ); } private IServiceProvider CreateServiceProvider(params RuleConfig[] rules) diff --git a/Haproxy.AgentCheck.Tests/TcpResponseTests.cs b/Haproxy.AgentCheck.Tests/TcpResponseTests.cs new file mode 100644 index 0000000..252418b --- /dev/null +++ b/Haproxy.AgentCheck.Tests/TcpResponseTests.cs @@ -0,0 +1,145 @@ +using Lucca.Infra.Haproxy.AgentCheck.Config; +using Lucca.Infra.Haproxy.AgentCheck.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class TcpResponseTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public void TcpResponse_WhenUp_ReturnsUpWithWeight() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule + { + SystemResponse = SystemResponse.Linear + } + }); + + var state = serviceProvider.GetRequiredService(); + var maintenanceStatus = serviceProvider.GetRequiredService(); + + state.UpdateState(new SystemState { CpuPercent = 30 }); + + var response = FormatTcpResponse(state, maintenanceStatus); + + Assert.StartsWith("70%", response); + Assert.Contains("up", response); + } + + [Fact] + public void TcpResponse_WhenDown_ReturnsDownWithFailures() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Failure = new FailureRule + { + EnterThreshold = 80, + LeaveThreshold = 60 + } + }); + + var state = serviceProvider.GetRequiredService(); + var maintenanceStatus = serviceProvider.GetRequiredService(); + + state.UpdateState(new SystemState { CpuPercent = 90 }); + + var response = FormatTcpResponse(state, maintenanceStatus); + + Assert.Contains("down", response); + Assert.Contains("System/CPU", response); + } + + [Fact] + public void TcpResponse_WhenMaintenance_ReturnsStopped() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Weight = new WeightRule() + }); + + var state = serviceProvider.GetRequiredService(); + var maintenanceStatus = serviceProvider.GetRequiredService(); + maintenanceStatus.IsMaintenance = true; + + state.UpdateState(new SystemState { CpuPercent = 10 }); + + var response = FormatTcpResponse(state, maintenanceStatus); + + Assert.Contains("stopped", response); + Assert.Contains("Requested maintenance", response); + } + + [Fact] + public void TcpResponse_MultipleFailures_ListsAllBrokenCircuits() + { + var serviceProvider = CreateServiceProvider( + new RuleConfig + { + Name = "CPU", + Source = RuleSource.System, + Failure = new FailureRule { EnterThreshold = 50, LeaveThreshold = 30 } + }, + new RuleConfig + { + Name = "IisRequests", + Source = RuleSource.System, + Failure = new FailureRule { EnterThreshold = 100, LeaveThreshold = 50 } + }); + + var state = serviceProvider.GetRequiredService(); + var maintenanceStatus = serviceProvider.GetRequiredService(); + + state.UpdateState(new SystemState { CpuPercent = 60, IisRequests = 150 }); + + var response = FormatTcpResponse(state, maintenanceStatus); + + Assert.Contains("down", response); + Assert.Contains("#2 failures", response); + Assert.Contains("System/CPU", response); + Assert.Contains("System/IisRequests", response); + } + + /// + /// Simulates the TCP response format from TcpHandler + /// + private static string FormatTcpResponse(State state, MaintenanceStatus maintenanceStatus) + { + var up = state.IsUp ? "up" : "down"; + if (!state.IsUp) + { + up += $" #{state.BrokenCircuitsBreakers.Count} failures ({string.Join(",", state.BrokenCircuitsBreakers)})"; + } + + if (maintenanceStatus.IsMaintenance) + { + up = "stopped #Requested maintenance"; + } + + return $"{state.Weight:F0}% {up}\n"; + } + + private IServiceProvider CreateServiceProvider(params RuleConfig[] rules) + { + return new ServiceCollection() + .AddOptions() + .Configure(o => o.AddRange(rules)) + .AddFakeLogging(o => o.OutputSink = testOutputHelper.WriteLine) + .AddSingleton() + .AddSingleton(p => p.GetRequiredService()) + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + } +} diff --git a/Haproxy.AgentCheck.Tests/WeightRuleTests.cs b/Haproxy.AgentCheck.Tests/WeightRuleTests.cs new file mode 100644 index 0000000..3aeaba8 --- /dev/null +++ b/Haproxy.AgentCheck.Tests/WeightRuleTests.cs @@ -0,0 +1,68 @@ +using Lucca.Infra.Haproxy.AgentCheck.Config; +using Lucca.Infra.Haproxy.AgentCheck.Metrics; +using Xunit; + +namespace Lucca.Infra.Haproxy.AgentCheck.Tests; + +public class WeightRuleTests +{ + [Fact] + public void WeightRule_DefaultValues_AreCorrect() + { + var rule = new WeightRule(); + + Assert.Equal(0, rule.MinValue); + Assert.Equal(100, rule.MaxValue); + Assert.Equal(0, rule.MinWeight); + Assert.Equal(100, rule.MaxWeight); + Assert.Equal(SystemResponse.Linear, rule.SystemResponse); + } + + [Theory] + [InlineData(0, 100)] + [InlineData(50, 50)] + [InlineData(100, 0)] + [InlineData(25, 75)] + [InlineData(75, 25)] + public void LinearResponse_StandardRange_CalculatesCorrectWeight(double cpuValue, double expectedWeight) + { + // This test validates the linear response curve + // When CPU is 0%, weight should be 100% + // When CPU is 100%, weight should be 0% + // Linear interpolation in between + + var actualWeight = CalculateLinearWeight(cpuValue, 0, 100, 0, 100); + + Assert.Equal(expectedWeight, actualWeight, 0.01); + } + + [Theory] + [InlineData(0, 80)] + [InlineData(100, 20)] + [InlineData(50, 50)] + public void LinearResponse_CustomWeightRange_CalculatesCorrectWeight(double value, double expectedWeight) + { + var actualWeight = CalculateLinearWeight(value, 0, 100, 20, 80); + + Assert.Equal(expectedWeight, actualWeight, 0.01); + } + + [Theory] + [InlineData(10, 100)] // Below min value -> clamped to max weight + [InlineData(90, 0)] // Above max value -> clamped to min weight + public void LinearResponse_OutOfRange_ClampedCorrectly(double value, double expectedWeight) + { + // When value is outside the range, weight should be clamped + var actualWeight = CalculateLinearWeight(value, 20, 80, 0, 100); + + // The calculation gives a value that should be clamped + var clampedWeight = Math.Clamp(actualWeight, 0, 100); + + Assert.Equal(expectedWeight, clampedWeight, 0.01); + } + + private static double CalculateLinearWeight(double currentValue, double minValue, double maxValue, double minWeight, double maxWeight) + { + return 1d * (maxValue - currentValue) / (maxValue - minValue) * (maxWeight - minWeight) + minWeight; + } +} diff --git a/Haproxy.AgentCheck/Haproxy.AgentCheck.csproj b/Haproxy.AgentCheck/Haproxy.AgentCheck.csproj index ded1c01..27bb4b0 100644 --- a/Haproxy.AgentCheck/Haproxy.AgentCheck.csproj +++ b/Haproxy.AgentCheck/Haproxy.AgentCheck.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net10.0 $(NoWarn);S1118 @@ -11,12 +11,10 @@ - - diff --git a/Haproxy.AgentCheck/Hosting/HostBuilderExtensions.cs b/Haproxy.AgentCheck/Hosting/HostBuilderExtensions.cs index 1d91407..be5a25c 100644 --- a/Haproxy.AgentCheck/Hosting/HostBuilderExtensions.cs +++ b/Haproxy.AgentCheck/Hosting/HostBuilderExtensions.cs @@ -17,10 +17,7 @@ public static void UseSystemService(this IHostBuilder hostBuilder) { hostBuilder.UseSystemd(); } - else - { - throw new NotSupportedException("Unsupported platform"); - } + // On other platforms (macOS), run as a console application } public static void UseKestrelOnPorts(this IWebHostBuilder webHostBuilder, int http, int tcp) @@ -48,7 +45,13 @@ public static void AddMetricCollector(this IServiceCollection services) } else { - throw new PlatformNotSupportedException("Only windows and linux platforms are supported"); + // On other platforms (macOS), use a no-op collector for development/testing + services.AddSingleton(); } } } + +internal sealed class NullStateCollector : IStateCollector +{ + public void Collect() { } +} diff --git a/README.md b/README.md index 668aa53..3efafee 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Haproxy.AgentCheck&metric=security_rating)](https://sonarcloud.io/dashboard?id=Haproxy.AgentCheck) [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=Haproxy.AgentCheck&metric=sqale_index)](https://sonarcloud.io/dashboard?id=Haproxy.AgentCheck) -This application is a lightweight external agent installed on servers / VM / pods, exposing TCP & HTTP endpoints to report the server’s state to Haproxy LB. It's based on Kestrel / .NET Core 3.1, so it's very light. +This application is a lightweight external agent installed on servers / VM / pods, exposing TCP & HTTP endpoints to report the server's state to Haproxy LB. It's based on Kestrel / .NET 10, so it's very light. With the reported health metric, Haproxy can dynamically adjust backend weight, and evenly load balance traffic between hosts. When a host metric spikes (ex a CPU going to 100% because of a VM on a failing host, or an infinite loop made by a tired developer), the reported weight to Haproxy is minimum, telling Haproxy to schedule the minimum traffic to this host. diff --git a/global.json b/global.json index 34140ff..dc46def 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,9 @@ { "sdk": { - "version": "8.0.409", + "version": "10.0.100", "rollForward": "latestMajor" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } }