diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/CreationMode.cs b/src/NetEvolve.HealthChecks.AWS.EC2/CreationMode.cs new file mode 100644 index 00000000..420fbe86 --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/CreationMode.cs @@ -0,0 +1,12 @@ +namespace NetEvolve.HealthChecks.AWS.EC2; + +/// +/// Specifies the creation mode for the AWS EC2 health check client. +/// +public enum CreationMode +{ + /// + /// Use basic authentication for client creation. + /// + BasicAuthentication = 0, +} \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/DependencyInjectionExtensions.cs b/src/NetEvolve.HealthChecks.AWS.EC2/DependencyInjectionExtensions.cs new file mode 100644 index 00000000..2f1f61fc --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/DependencyInjectionExtensions.cs @@ -0,0 +1,62 @@ +namespace NetEvolve.HealthChecks.AWS.EC2; + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NetEvolve.HealthChecks.Abstractions; + +/// +/// Extensions methods for with custom Health Checks. +/// +public static class DependencyInjectionExtensions +{ + private static readonly string[] _defaultTags = ["aws", "ec2", "compute"]; + + /// + /// Add a health check for AWS Elastic Compute Cloud (EC2). + /// + /// The . + /// The name of the . + /// An optional action to configure. + /// A list of additional tags that can be used to filter sets of health checks. Optional. + /// The is . + /// The is . + /// The is or whitespace. + /// The is already in use. + /// The is . + public static IHealthChecksBuilder AddAWSEC2( + [NotNull] this IHealthChecksBuilder builder, + [NotNull] string name, + Action? options = null, + params string[] tags + ) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(tags); + + if (!builder.IsServiceTypeRegistered()) + { + _ = builder + .Services.AddSingleton() + .AddSingleton() + .ConfigureOptions(); + } + + builder.ThrowIfNameIsAlreadyUsed(name); + + if (options is not null) + { + _ = builder.Services.Configure(name, options); + } + + return builder.AddCheck( + name, + HealthStatus.Unhealthy, + _defaultTags.Union(tags, StringComparer.OrdinalIgnoreCase) + ); + } + + private sealed partial class ElasticComputeCloudCheckMarker; +} \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudConfigure.cs b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudConfigure.cs new file mode 100644 index 00000000..2a3aeabd --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudConfigure.cs @@ -0,0 +1,55 @@ +namespace NetEvolve.HealthChecks.AWS.EC2; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +internal sealed class ElasticComputeCloudConfigure + : IConfigureNamedOptions, + IValidateOptions +{ + private readonly IConfiguration _configuration; + + public ElasticComputeCloudConfigure(IConfiguration configuration) => _configuration = configuration; + + public void Configure(string? name, ElasticComputeCloudOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + _configuration.Bind($"HealthChecks:AWSEC2:{name}", options); + } + + public void Configure(ElasticComputeCloudOptions options) => Configure(Options.DefaultName, options); + + public ValidateOptionsResult Validate(string? name, ElasticComputeCloudOptions options) + { + if (string.IsNullOrWhiteSpace(name)) + { + return ValidateOptionsResult.Fail("The name cannot be null or whitespace."); + } + + if (options is null) + { + return ValidateOptionsResult.Fail("The option cannot be null."); + } + + if (options.Timeout < Timeout.Infinite) + { + return ValidateOptionsResult.Fail( + "The timeout value must be a positive number in milliseconds or -1 for an infinite timeout." + ); + } + + if (options.Mode is CreationMode.BasicAuthentication) + { + if (string.IsNullOrWhiteSpace(options.AccessKey)) + { + return ValidateOptionsResult.Fail("The access key cannot be null or whitespace."); + } + if (string.IsNullOrWhiteSpace(options.SecretKey)) + { + return ValidateOptionsResult.Fail("The secret key cannot be null or whitespace."); + } + } + + return ValidateOptionsResult.Success; + } +} \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudHealthCheck.cs b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudHealthCheck.cs new file mode 100644 index 00000000..df854f5f --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudHealthCheck.cs @@ -0,0 +1,56 @@ +namespace NetEvolve.HealthChecks.AWS.EC2; + +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Amazon.EC2; +using Amazon.EC2.Model; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using NetEvolve.Extensions.Tasks; +using NetEvolve.HealthChecks.Abstractions; + +internal sealed class ElasticComputeCloudHealthCheck(IOptionsMonitor optionsMonitor) + : ConfigurableHealthCheckBase(optionsMonitor) +{ + protected override async ValueTask ExecuteHealthCheckAsync( + string name, + HealthStatus failureStatus, + ElasticComputeCloudOptions options, + CancellationToken cancellationToken + ) + { + using var client = CreateClient(options); + + var (isTimelyResponse, response) = await client + .DescribeRegionsAsync(new DescribeRegionsRequest { MaxResults = 1 }, cancellationToken) + .WithTimeoutAsync(options.Timeout, cancellationToken) + .ConfigureAwait(false); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + return HealthCheckUnhealthy( + failureStatus, + name, + $"Unexpected HTTP status code: {response.HttpStatusCode}." + ); + } + + return HealthCheckState(isTimelyResponse, name); + } + + private static AmazonEC2Client CreateClient(ElasticComputeCloudOptions options) + { + var config = new AmazonEC2Config { ServiceURL = options.ServiceUrl }; + + var credentials = options.GetCredentials(); + + return (credentials is not null, options.RegionEndpoint is not null) switch + { + (true, true) => new AmazonEC2Client(credentials, options.RegionEndpoint), + (true, false) => new AmazonEC2Client(credentials, config), + (false, true) => new AmazonEC2Client(options.RegionEndpoint), + _ => new AmazonEC2Client(config), + }; + } +} \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudOptions.cs b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudOptions.cs new file mode 100644 index 00000000..6c8f92be --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/ElasticComputeCloudOptions.cs @@ -0,0 +1,49 @@ +namespace NetEvolve.HealthChecks.AWS.EC2; + +using Amazon; +using Amazon.Runtime; + +/// +/// Represents configuration options for the AWS EC2 health check. +/// +public sealed record ElasticComputeCloudOptions +{ + /// + /// Gets or sets the AWS access key used for authentication. + /// + public string? AccessKey { get; set; } + + /// + /// Gets or sets the creation mode for the EC2 client. + /// + public CreationMode? Mode { get; set; } + + /// + public RegionEndpoint? RegionEndpoint { get; set; } + + /// + /// Gets or sets the AWS secret key used for authentication. + /// + public string? SecretKey { get; set; } + +#pragma warning disable CA1056 // URI-like properties should not be strings + /// + public string? ServiceUrl { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Gets or sets the timeout in milliseconds for the health check operation. Default is 100 ms. + /// + public int Timeout { get; set; } = 100; + + /// + /// Gets the AWS credentials based on the configured mode. + /// + /// The AWS credentials or null if not configured. + internal AWSCredentials? GetCredentials() => + Mode switch + { + CreationMode.BasicAuthentication => new BasicAWSCredentials(AccessKey, SecretKey), + _ => null, + }; +} \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/NetEvolve.HealthChecks.AWS.EC2.csproj b/src/NetEvolve.HealthChecks.AWS.EC2/NetEvolve.HealthChecks.AWS.EC2.csproj new file mode 100644 index 00000000..1cc65118 --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/NetEvolve.HealthChecks.AWS.EC2.csproj @@ -0,0 +1,14 @@ + + + $(_ProjectTargetFrameworks) + Contains HealthChecks for AWS Elastic Compute Cloud (EC2). + $(PackageTags);aws;ec2 + + + + + + + + + \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS.EC2/README.md b/src/NetEvolve.HealthChecks.AWS.EC2/README.md new file mode 100644 index 00000000..06d2d85f --- /dev/null +++ b/src/NetEvolve.HealthChecks.AWS.EC2/README.md @@ -0,0 +1,87 @@ +# NetEvolve.HealthChecks.AWS.EC2 + +[![NuGet](https://img.shields.io/nuget/v/NetEvolve.HealthChecks.AWS.EC2?logo=nuget)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.EC2/) +[![NuGet](https://img.shields.io/nuget/dt/NetEvolve.HealthChecks.AWS.EC2?logo=nuget)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.EC2/) + +This package provides a health check for AWS Elastic Compute Cloud (EC2), based on the [AWS SDK for .NET](https://www.nuget.org/packages/AWSSDK.EC2/) package. +The main purpose is to check that the EC2 service is reachable and that the client can connect to it. + +:bulb: This package is available for .NET 8.0 and later. + +## Installation +To use this package, you need to add the package to your project. You can do this by using the NuGet package manager or by using the dotnet CLI. +```powershell +dotnet add package NetEvolve.HealthChecks.AWS.EC2 +``` + +## Health Check - AWS EC2 Liveness +The health check is a liveness check. It will check that the EC2 service is reachable and that the client can connect to it. +If the service needs longer than the configured timeout to respond, the health check will return `Degraded`. +If the service is not reachable, the health check will return `Unhealthy`. + +### Usage +After adding the package, you need to import the namespace and add the health check to the service collection. +```csharp +using NetEvolve.HealthChecks.AWS.EC2; +``` + +Optionally, you can also add the namespace globally to your project by adding the following to your `GlobalUsings.cs` file. +```csharp +global using NetEvolve.HealthChecks.AWS.EC2; +``` + +### Parameters +- `name`: The name of the health check. The name is used to identify the health check in the UI. **Required**. +- `options`: The configuration options for the health check. **Optional**. +- `tags`: The tags for the health check. **Optional**. + - `aws`: This tag is always assigned to the health check. + - `ec2`: This tag is always assigned to the health check. + - `compute`: This tag is always assigned to the health check. + +### Variant 1: Configuration based +The first one is to use the configuration based approach. Therefore, you have to add the configuration section `HealthChecks:AWSEC2` to your `appsettings.json` file. +```csharp +var builder = services.AddHealthChecks(); + +builder.AddAWSEC2(""); +``` + +The configuration looks like this: +```json +{ + ..., // other configuration + "HealthChecks": { + "AWSEC2": { + "": { + "Region": "", // optional, uses default AWS region if not specified + "AccessKey": "", // optional, uses default AWS credentials if not specified + "SecretKey": "", // optional, uses default AWS credentials if not specified + "Timeout": "" // optional, default is 100 milliseconds + } + } + } +} +``` + +### Variant 2: Builder based +The second approach is to use the builder based approach. This approach is recommended if you only have one EC2 service to check or dynamic programmatic values. +```csharp +var builder = services.AddHealthChecks(); + +builder.AddAWSEC2("", options => +{ + options.Region = ""; // optional, uses default AWS region if not specified + options.AccessKey = ""; // optional, uses default AWS credentials if not specified + options.SecretKey = ""; // optional, uses default AWS credentials if not specified + options.Timeout = ""; // optional, default is 100 milliseconds + ... // other configuration +}); +``` + +### :bulb: You can always provide tags to all health checks, for grouping or filtering. + +```csharp +var builder = services.AddHealthChecks(); + +builder.AddAWSEC2("", options => ..., "ec2"); +``` \ No newline at end of file diff --git a/src/NetEvolve.HealthChecks.AWS/NetEvolve.HealthChecks.AWS.csproj b/src/NetEvolve.HealthChecks.AWS/NetEvolve.HealthChecks.AWS.csproj index ac43bdf0..1b8be2cd 100644 --- a/src/NetEvolve.HealthChecks.AWS/NetEvolve.HealthChecks.AWS.csproj +++ b/src/NetEvolve.HealthChecks.AWS/NetEvolve.HealthChecks.AWS.csproj @@ -5,6 +5,7 @@ $(PackageTags);aws;bundle + diff --git a/src/NetEvolve.HealthChecks.AWS/README.md b/src/NetEvolve.HealthChecks.AWS/README.md index d3d698bc..f7302721 100644 --- a/src/NetEvolve.HealthChecks.AWS/README.md +++ b/src/NetEvolve.HealthChecks.AWS/README.md @@ -9,6 +9,7 @@ This bundle package provides health checks for various AWS services. For specifi ## Supported AWS Services +- [AWS Elastic Compute Cloud (EC2)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.EC2/) - [AWS Simple Notification Service (SNS)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.SNS/) - [AWS Simple Queue Service (SQS)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.SQS/) - [AWS Simple Storage Service (S3)](https://www.nuget.org/packages/NetEvolve.HealthChecks.AWS.S3/) diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs b/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs new file mode 100644 index 00000000..60882041 --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs @@ -0,0 +1,149 @@ +namespace NetEvolve.HealthChecks.Tests.Integration.AWS.EC2; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.AWS.EC2; +using NetEvolve.HealthChecks.Tests.Integration.AWS; + +[TestGroup($"{nameof(AWS)}.{nameof(EC2)}")] +[ClassDataSource(Shared = InstanceSharedType.AWS)] +public class ElasticComputeCloudHealthCheckTests : HealthCheckTestBase +{ + private readonly LocalStackInstance _instance; + + public ElasticComputeCloudHealthCheckTests(LocalStackInstance instance) => _instance = instance; + + [Test] + public async Task AddAWSEC2_UseOptionsCreate_Healthy() => + await RunAndVerify( + healthChecks => + { + _ = healthChecks.AddAWSEC2( + "TestContainerHealthy", + options => + { + options.AccessKey = LocalStackInstance.AccessKey; + options.SecretKey = LocalStackInstance.SecretKey; + options.ServiceUrl = _instance.ConnectionString; + options.Mode = CreationMode.BasicAuthentication; + options.Timeout = 1000; // Set a reasonable timeout + } + ); + }, + HealthStatus.Healthy + ); + + [Test] + public async Task AddAWSEC2_UseOptionsCreate_Degraded() => + await RunAndVerify( + healthChecks => + { + _ = healthChecks.AddAWSEC2( + "TestContainerDegraded", + options => + { + options.AccessKey = LocalStackInstance.AccessKey; + options.SecretKey = LocalStackInstance.SecretKey; + options.ServiceUrl = _instance.ConnectionString; + options.Timeout = 0; + options.Mode = CreationMode.BasicAuthentication; + } + ); + }, + HealthStatus.Degraded + ); + + // Configuration-based tests + + [Test] + public async Task AddAWSEC2_UseConfiguration_Healthy() => + await RunAndVerify( + healthChecks => healthChecks.AddAWSEC2("TestContainerHealthy"), + HealthStatus.Healthy, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + ["HealthChecks:AWSEC2:TestContainerHealthy:AccessKey"] = LocalStackInstance.AccessKey, + ["HealthChecks:AWSEC2:TestContainerHealthy:SecretKey"] = LocalStackInstance.SecretKey, + ["HealthChecks:AWSEC2:TestContainerHealthy:ServiceUrl"] = _instance.ConnectionString, + ["HealthChecks:AWSEC2:TestContainerHealthy:Mode"] = "BasicAuthentication", + ["HealthChecks:AWSEC2:TestContainerHealthy:Timeout"] = "1000", + }; + + _ = config.AddInMemoryCollection(values); + } + ); + + [Test] + public async Task AddAWSEC2_UseConfiguration_Degraded() => + await RunAndVerify( + healthChecks => healthChecks.AddAWSEC2("TestContainerDegraded"), + HealthStatus.Degraded, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + ["HealthChecks:AWSEC2:TestContainerDegraded:AccessKey"] = LocalStackInstance.AccessKey, + ["HealthChecks:AWSEC2:TestContainerDegraded:SecretKey"] = LocalStackInstance.SecretKey, + ["HealthChecks:AWSEC2:TestContainerDegraded:ServiceUrl"] = _instance.ConnectionString, + ["HealthChecks:AWSEC2:TestContainerDegraded:Mode"] = "BasicAuthentication", + ["HealthChecks:AWSEC2:TestContainerDegraded:Timeout"] = "0", + }; + + _ = config.AddInMemoryCollection(values); + } + ); + + [Test] + public async Task AddAWSEC2_UseConfiguration_WithAdditionalTags() + { + await RunAndVerify( + healthChecks => healthChecks.AddAWSEC2("TestContainerHealthy", tags: ["custom"]), + HealthStatus.Healthy, + config => + { + var values = new Dictionary(StringComparer.Ordinal) + { + ["HealthChecks:AWSEC2:TestContainerHealthy:AccessKey"] = LocalStackInstance.AccessKey, + ["HealthChecks:AWSEC2:TestContainerHealthy:SecretKey"] = LocalStackInstance.SecretKey, + ["HealthChecks:AWSEC2:TestContainerHealthy:ServiceUrl"] = _instance.ConnectionString, + ["HealthChecks:AWSEC2:TestContainerHealthy:Mode"] = "BasicAuthentication", + ["HealthChecks:AWSEC2:TestContainerHealthy:Timeout"] = "1000", + }; + + _ = config.AddInMemoryCollection(values); + } + ); + + using (Assert.Multiple()) + { + _ = await Assert + .That(_response.Entries) + .HasCount() + .EqualTo(1); + + var (name, healthReportEntry) = _response.Entries.Single(); + + _ = await Assert.That(name).IsEqualTo("TestContainerHealthy"); + _ = await Assert.That(healthReportEntry.Status).IsEqualTo(HealthStatus.Healthy); + + _ = await Assert + .That(healthReportEntry.Tags) + .ContainsValue("aws"); + _ = await Assert + .That(healthReportEntry.Tags) + .ContainsValue("ec2"); + _ = await Assert + .That(healthReportEntry.Tags) + .ContainsValue("compute"); + _ = await Assert + .That(healthReportEntry.Tags) + .ContainsValue("custom"); + } + } +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj b/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj index f74427d0..b8c52725 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj +++ b/tests/NetEvolve.HealthChecks.Tests.Integration/NetEvolve.HealthChecks.Tests.Integration.csproj @@ -7,6 +7,7 @@ + @@ -96,6 +97,7 @@ + diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs b/tests/NetEvolve.HealthChecks.Tests.Unit/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs new file mode 100644 index 00000000..2f01ae6d --- /dev/null +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/AWS.EC2/ElasticComputeCloudHealthCheckTests.cs @@ -0,0 +1,67 @@ +namespace NetEvolve.HealthChecks.Tests.Unit.AWS.EC2; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using NetEvolve.Extensions.TUnit; +using NetEvolve.HealthChecks.AWS.EC2; +using NSubstitute; + +[TestGroup($"{nameof(AWS)}.{nameof(EC2)}")] +public sealed class ElasticComputeCloudHealthCheckTests +{ + [Test] + public async Task CheckHealthAsync_WhenContextNull_ThrowArgumentNullException() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var check = new ElasticComputeCloudHealthCheck(optionsMonitor); + + // Act + async Task Act() => _ = await check.CheckHealthAsync(null!, default); + + // Assert + _ = await Assert.ThrowsAsync("context", Act); + } + + [Test] + public async Task CheckHealthAsync_WhenCancellationTokenIsCancelled_ShouldReturnUnhealthy() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var check = new ElasticComputeCloudHealthCheck(optionsMonitor); + var context = new HealthCheckContext { Registration = new HealthCheckRegistration("Test", check, null, null) }; + var cancellationToken = new CancellationToken(true); + + // Act + var result = await check.CheckHealthAsync(context, cancellationToken); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(result.Status).IsEqualTo(HealthStatus.Unhealthy); + _ = await Assert.That(result.Description).IsEqualTo("Test: Cancellation requested."); + } + } + + [Test] + public async Task CheckHealthAsync_WhenOptionsAreNull_ShouldReturnUnhealthy() + { + // Arrange + var optionsMonitor = Substitute.For>(); + var check = new ElasticComputeCloudHealthCheck(optionsMonitor); + var context = new HealthCheckContext { Registration = new HealthCheckRegistration("Test", check, null, null) }; + + // Act + var result = await check.CheckHealthAsync(context); + + // Assert + using (Assert.Multiple()) + { + _ = await Assert.That(result.Status).IsEqualTo(HealthStatus.Unhealthy); + _ = await Assert.That(result.Description).IsEqualTo("Test: Missing configuration."); + } + } +} \ No newline at end of file diff --git a/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj b/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj index 0516e5af..a3faaa30 100644 --- a/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj +++ b/tests/NetEvolve.HealthChecks.Tests.Unit/NetEvolve.HealthChecks.Tests.Unit.csproj @@ -24,6 +24,7 @@ +