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 @@
[](https://sonarcloud.io/dashboard?id=Haproxy.AgentCheck)
[](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"
}
}