From b8f85d04a0fa272e9ed6b9e4355f7f00e0faa47a Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 28 Jul 2026 14:44:22 +1000 Subject: [PATCH] Add file system body storage disk space custom check Introduces a custom check to monitor the available disk space on the drive hosting ServiceControl's file system message body storage. This check allows configuring a `DataSpaceRemainingThreshold` (percentage) to alert when disk space falls below a critical level. --- .../persistence.manifest | 6 +- .../persistence.manifest | 6 +- .../Abstractions/BasePersistence.cs | 3 + .../EFPersistenceConfigurationBase.cs | 18 +- .../FileSystemBodyStorageSettings.cs | 4 + .../BodyStorage/DriveInfoSpaceProvider.cs | 11 + .../FileSystemBodyStorageCustomCheck.cs | 53 +++++ .../BodyStorage/IDriveSpaceProvider.cs | 8 + ...ontrol.Persistence.Tests.PostgreSql.csproj | 1 - .../API/APIApprovals.cs | 68 ------ ...IApprovals.CustomCheckDetails.approved.txt | 5 - .../CustomCheckTests.cs | 2 +- ...Control.Persistence.Tests.SqlServer.csproj | 1 - .../EFCore/BodyStorageConfigurationTests.cs | 36 +++- .../FileSystemBodyStorageCustomCheckTests.cs | 199 ++++++++++++++++++ 15 files changed, 337 insertions(+), 84 deletions(-) create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/DriveInfoSpaceProvider.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/FileSystemBodyStorageCustomCheck.cs create mode 100644 src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/IDriveSpaceProvider.cs delete mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/API/APIApprovals.cs delete mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/APIApprovals.CustomCheckDetails.approved.txt rename src/{ServiceControl.Persistence.Tests => ServiceControl.Persistence.Tests.RavenDB}/CustomCheckTests.cs (94%) create mode 100644 src/ServiceControl.Persistence.Tests/EFCore/FileSystemBodyStorageCustomCheckTests.cs diff --git a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest index 8e667275c6..433c31a5ae 100644 --- a/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest +++ b/src/ServiceControl.Persistence.EFCore.PostgreSql/persistence.manifest @@ -18,7 +18,11 @@ "Mandatory": true }, { - "Name": "ServiceControl/MessageBody/StoragePath", + "Name": "ServiceControl/MessageBody/FileSystem/StoragePath", + "Mandatory": false + }, + { + "Name": "ServiceControl/MessageBody/FileSystem/DataSpaceRemainingThreshold", "Mandatory": false }, { diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest index 51de2f2ee8..9c1f04c024 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/persistence.manifest @@ -18,7 +18,11 @@ "Mandatory": true }, { - "Name": "ServiceControl/MessageBody/StoragePath", + "Name": "ServiceControl/MessageBody/FileSystem/StoragePath", + "Mandatory": false + }, + { + "Name": "ServiceControl/MessageBody/FileSystem/DataSpaceRemainingThreshold", "Mandatory": false }, { diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs index ef5bd0aa00..d0639f3c0a 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/BasePersistence.cs @@ -4,6 +4,7 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; using Microsoft.Extensions.DependencyInjection.Extensions; using NServiceBus.Unicast.Subscriptions.MessageDrivenSubscriptions; using Particular.LicensingComponent.Persistence; +using ServiceControl.CustomChecks; using ServiceControl.Operations.BodyStorage; using ServiceControl.Persistence.EFCore.Implementation; using ServiceControl.Persistence.EFCore.Implementation.BodyStorage; @@ -64,6 +65,8 @@ static void RegisterBodyStorage(IServiceCollection services, EFPersisterSettings case FileSystemBodyStorageSettings fileSystem: services.TryAddSingleton(fileSystem); services.AddSingleton(); + services.AddSingleton(); + services.AddCustomCheck(); break; case AzureBlobBodyStorageSettings azureBlob: services.TryAddSingleton(azureBlob); diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs index bb15945cb0..b14cf2b24d 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/EFPersistenceConfigurationBase.cs @@ -9,7 +9,8 @@ public abstract class EFPersistenceConfigurationBase : IPersistenceConfiguration const string ConnectionStringKey = "Database/ConnectionString"; const string CommandTimeoutKey = "Database/CommandTimeout"; const string BodyStorageTypeKey = "MessageBody/StorageType"; - const string MessageBodyStoragePathKey = "MessageBody/StoragePath"; + const string FileSystemStoragePathKey = "MessageBody/FileSystem/StoragePath"; + const string FileSystemDataSpaceRemainingThresholdKey = "MessageBody/FileSystem/DataSpaceRemainingThreshold"; const string AzureConnectionStringKey = "MessageBody/Azure/ConnectionString"; const string AzureServiceUriKey = "MessageBody/Azure/ServiceUri"; const string AzureManagedIdentityClientIdKey = "MessageBody/Azure/ManagedIdentityClientId"; @@ -51,7 +52,8 @@ static BodyStorageSettings CreateBodyStorageSettings(SettingsRootNamespace setti { BodyStorageType.FileSystem => new FileSystemBodyStorageSettings { - StoragePath = GetRequiredSetting(settingsRootNamespace, MessageBodyStoragePathKey) + StoragePath = GetRequiredSetting(settingsRootNamespace, FileSystemStoragePathKey), + DataSpaceRemainingThreshold = ReadDataSpaceRemainingThreshold(settingsRootNamespace) }, BodyStorageType.AzureBlob => CreateAzureBlobSettings(settingsRootNamespace), BodyStorageType.S3 => CreateS3Settings(settingsRootNamespace), @@ -160,6 +162,18 @@ static BodyStorageType ReadBodyStorageType(SettingsRootNamespace settingsRootNam return value; } + static int ReadDataSpaceRemainingThreshold(SettingsRootNamespace settingsRootNamespace) + { + var threshold = SettingsReader.Read(settingsRootNamespace, FileSystemDataSpaceRemainingThresholdKey, FileSystemBodyStorageSettings.DefaultDataSpaceRemainingThreshold); + + if (threshold is < 0 or > 100) + { + throw new Exception($"Setting {FileSystemDataSpaceRemainingThresholdKey} value '{threshold}' is not valid. The value is a percentage between 0 and 100."); + } + + return threshold; + } + static int ReadMaxBodySizeToStore(SettingsRootNamespace settingsRootNamespace) { var maxBodySizeToStore = SettingsReader.Read(settingsRootNamespace, MaxBodySizeToStoreKey, BodyStorageSettings.DefaultMaxBodySizeToStore); diff --git a/src/ServiceControl.Persistence.EFCore/Abstractions/FileSystemBodyStorageSettings.cs b/src/ServiceControl.Persistence.EFCore/Abstractions/FileSystemBodyStorageSettings.cs index 6045cfd7a5..8da22700a7 100644 --- a/src/ServiceControl.Persistence.EFCore/Abstractions/FileSystemBodyStorageSettings.cs +++ b/src/ServiceControl.Persistence.EFCore/Abstractions/FileSystemBodyStorageSettings.cs @@ -2,5 +2,9 @@ namespace ServiceControl.Persistence.EFCore.Abstractions; public sealed class FileSystemBodyStorageSettings : BodyStorageSettings { + public const int DefaultDataSpaceRemainingThreshold = 15; + public required string StoragePath { get; set; } + + public int DataSpaceRemainingThreshold { get; set; } = DefaultDataSpaceRemainingThreshold; } diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/DriveInfoSpaceProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/DriveInfoSpaceProvider.cs new file mode 100644 index 0000000000..3cdff0f71b --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/DriveInfoSpaceProvider.cs @@ -0,0 +1,11 @@ +namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage; + +public class DriveInfoSpaceProvider : IDriveSpaceProvider +{ + public DriveSpace GetDriveSpace(string pathRoot) + { + var driveInfo = new DriveInfo(pathRoot); + + return new DriveSpace(driveInfo.Name, driveInfo.AvailableFreeSpace, driveInfo.TotalSize); + } +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/FileSystemBodyStorageCustomCheck.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/FileSystemBodyStorageCustomCheck.cs new file mode 100644 index 0000000000..425c2f5140 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/FileSystemBodyStorageCustomCheck.cs @@ -0,0 +1,53 @@ +namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage; + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NServiceBus.CustomChecks; +using ServiceControl.Persistence.EFCore.Abstractions; + +public class FileSystemBodyStorageCustomCheck(FileSystemBodyStorageSettings settings, IDriveSpaceProvider driveSpaceProvider, ILogger logger) + : CustomCheck("ServiceControl body storage", "Storage space", TimeSpan.FromMinutes(15)) +{ + public override Task PerformCheck(CancellationToken cancellationToken = default) + { + logger.LogDebug("Check ServiceControl body storage drive space remaining custom check starting. Threshold {PercentageThreshold:P0}", percentageThreshold); + + if (string.IsNullOrEmpty(dataPathRoot)) + { + return CheckResult.Failed($"Unable to find the root of the message body storage path '{settings.StoragePath}'. An absolute path is required."); + } + + DriveSpace driveSpace; + + try + { + driveSpace = driveSpaceProvider.GetDriveSpace(dataPathRoot); + } + catch (Exception ex) + { + // Custom checks report an exception as an opaque failure, so the reason is captured here instead. + logger.LogError(ex, "Unable to read the free space of drive '{DataPathRoot}' for the message body storage path '{StoragePath}'", dataPathRoot, settings.StoragePath); + + return CheckResult.Failed($"Unable to read the free space of drive '{dataPathRoot}' for the message body storage path '{settings.StoragePath}' on '{Environment.MachineName}': {ex.Message}"); + } + + if (driveSpace.TotalSize <= 0) + { + return CheckResult.Failed($"Unable to determine the size of drive '{driveSpace.Name}' for the message body storage path '{settings.StoragePath}' on '{Environment.MachineName}'."); + } + + var percentRemaining = (decimal)driveSpace.AvailableFreeSpace / driveSpace.TotalSize; + + logger.LogDebug("Free space: {FreeSpaceTotalBytesFree:N0}B | Total: {FreeSpaceTotalBytesAvailable:N0}B | Remaining {PercentRemaining:P1}", driveSpace.AvailableFreeSpace, driveSpace.TotalSize, percentRemaining); + + return percentRemaining > percentageThreshold + ? CheckResult.Pass + : CheckResult.Failed($"{percentRemaining:P0} disk space remaining on the message body storage drive '{driveSpace.Name}' ({settings.StoragePath}) on '{Environment.MachineName}'."); + } + + readonly string? dataPathRoot = Path.GetPathRoot(settings.StoragePath); + readonly decimal percentageThreshold = settings.DataSpaceRemainingThreshold / 100m; +} diff --git a/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/IDriveSpaceProvider.cs b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/IDriveSpaceProvider.cs new file mode 100644 index 0000000000..7dd2ffff24 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore/Implementation/BodyStorage/IDriveSpaceProvider.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.EFCore.Implementation.BodyStorage; + +public interface IDriveSpaceProvider +{ + DriveSpace GetDriveSpace(string pathRoot); +} + +public readonly record struct DriveSpace(string Name, long AvailableFreeSpace, long TotalSize); diff --git a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj index 3c63181a00..f236877629 100644 --- a/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj +++ b/src/ServiceControl.Persistence.Tests.PostgreSql/ServiceControl.Persistence.Tests.PostgreSql.csproj @@ -40,7 +40,6 @@ - diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/API/APIApprovals.cs b/src/ServiceControl.Persistence.Tests.RavenDB/API/APIApprovals.cs deleted file mode 100644 index 6ae3e3aec8..0000000000 --- a/src/ServiceControl.Persistence.Tests.RavenDB/API/APIApprovals.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace ServiceControl.UnitTests.API -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using NServiceBus.CustomChecks; - using NUnit.Framework; - using Particular.Approvals; - using Persistence.RavenDB; - using ServiceBus.Management.Infrastructure.Settings; - - [TestFixture] - class APIApprovals - { - [Test] - public void CustomCheckDetails() - { - // HINT: Custom checks are documented on the docs site and Id and Category are published in integration events - // If any changes have been made to custom checks, this may break customer integration subscribers. - Approver.Verify( - string.Join(Environment.NewLine, - from check in GetCustomChecks() - orderby check.Category, check.Id - select $"{check.Category}: {check.Id}" - ) - ); - } - - static IEnumerable GetCustomChecks() - { - var serviceControlTypes = typeof(RavenPersistenceConfiguration).Assembly - .GetTypes() - .Where(t => t.IsAbstract == false); - - var customCheckTypes = serviceControlTypes.Where(t => typeof(ICustomCheck).IsAssignableFrom(t)); - - var supportedConstructorArguments = new List() - { - new Settings(), - new RavenPersisterSettings() - }; - - object MapConstructorParameter(ParameterInfo pi) - { - foreach (var obj in supportedConstructorArguments) - { - if (obj.GetType() == pi.ParameterType) - { - return obj; - } - } - - return null; - } - - foreach (var customCheckType in customCheckTypes) - { - var constructor = customCheckType.GetConstructors().Single(); - var constructorParameters = constructor.GetParameters() - .Select(MapConstructorParameter) - .ToArray(); - var instance = (ICustomCheck)constructor.Invoke(constructorParameters); - yield return instance; - } - } - } -} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/APIApprovals.CustomCheckDetails.approved.txt b/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/APIApprovals.CustomCheckDetails.approved.txt deleted file mode 100644 index ecb6bed984..0000000000 --- a/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/APIApprovals.CustomCheckDetails.approved.txt +++ /dev/null @@ -1,5 +0,0 @@ -ServiceControl Health: Error Database Index Errors -ServiceControl Health: Error Database Index Lag -ServiceControl Health: Message Ingestion Process -ServiceControl Health: RavenDB dirty memory -Storage space: ServiceControl database \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests/CustomCheckTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/CustomCheckTests.cs similarity index 94% rename from src/ServiceControl.Persistence.Tests/CustomCheckTests.cs rename to src/ServiceControl.Persistence.Tests.RavenDB/CustomCheckTests.cs index 8d0fce088e..31572a7855 100644 --- a/src/ServiceControl.Persistence.Tests/CustomCheckTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/CustomCheckTests.cs @@ -1,4 +1,4 @@ -namespace ServiceControl.Persistence.Tests +namespace ServiceControl.Persistence.Tests.RavenDB { using System; using System.Linq; diff --git a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj index c29c79b2e3..4301efc8a4 100644 --- a/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj +++ b/src/ServiceControl.Persistence.Tests.SqlServer/ServiceControl.Persistence.Tests.SqlServer.csproj @@ -40,7 +40,6 @@ - diff --git a/src/ServiceControl.Persistence.Tests/EFCore/BodyStorageConfigurationTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/BodyStorageConfigurationTests.cs index ffb0f507a7..a9f37514d3 100644 --- a/src/ServiceControl.Persistence.Tests/EFCore/BodyStorageConfigurationTests.cs +++ b/src/ServiceControl.Persistence.Tests/EFCore/BodyStorageConfigurationTests.cs @@ -19,7 +19,8 @@ class BodyStorageConfigurationTests "SERVICECONTROL_DATABASE_CONNECTIONSTRING", "SERVICECONTROL_ERRORRETENTIONPERIOD", "SERVICECONTROL_MESSAGEBODY_STORAGETYPE", - "SERVICECONTROL_MESSAGEBODY_STORAGEPATH", + "SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", + "SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD", "SERVICECONTROL_MESSAGEBODY_MINCOMPRESSIONSIZE", "SERVICECONTROL_MESSAGEBODY_AZURE_CONNECTIONSTRING", "SERVICECONTROL_MESSAGEBODY_AZURE_SERVICEURI", @@ -48,12 +49,39 @@ public void SetUp() public void File_system_storage_yields_a_populated_path() { Set("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem"); - Set("SERVICECONTROL_MESSAGEBODY_STORAGEPATH", "/var/bodies"); + Set("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", "/var/bodies"); var bodyStorage = CreateBodyStorageSettings(); Assert.That(bodyStorage, Is.TypeOf()); - Assert.That(((FileSystemBodyStorageSettings)bodyStorage).StoragePath, Is.EqualTo("/var/bodies")); + using (Assert.EnterMultipleScope()) + { + Assert.That(((FileSystemBodyStorageSettings)bodyStorage).StoragePath, Is.EqualTo("/var/bodies")); + Assert.That(((FileSystemBodyStorageSettings)bodyStorage).DataSpaceRemainingThreshold, Is.EqualTo(FileSystemBodyStorageSettings.DefaultDataSpaceRemainingThreshold)); + } + } + + [Test] + public void File_system_storage_reads_the_data_space_remaining_threshold() + { + Set("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem"); + Set("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", "/var/bodies"); + Set("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD", "30"); + + var bodyStorage = (FileSystemBodyStorageSettings)CreateBodyStorageSettings(); + + Assert.That(bodyStorage.DataSpaceRemainingThreshold, Is.EqualTo(30)); + } + + [TestCase("-1")] + [TestCase("101")] + public void A_data_space_remaining_threshold_outside_the_percentage_range_is_rejected(string threshold) + { + Set("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem"); + Set("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", "/var/bodies"); + Set("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_DATASPACEREMAININGTHRESHOLD", threshold); + + Assert.That(CreateBodyStorageSettings, Throws.Exception.With.Message.Contains("MessageBody/FileSystem/DataSpaceRemainingThreshold")); } [Test] @@ -137,7 +165,7 @@ public void File_system_storage_without_a_path_is_rejected() { Set("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem"); - Assert.That(CreateBodyStorageSettings, Throws.Exception.With.Message.Contains("MessageBody/StoragePath")); + Assert.That(CreateBodyStorageSettings, Throws.Exception.With.Message.Contains("MessageBody/FileSystem/StoragePath")); } [Test] diff --git a/src/ServiceControl.Persistence.Tests/EFCore/FileSystemBodyStorageCustomCheckTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/FileSystemBodyStorageCustomCheckTests.cs new file mode 100644 index 0000000000..490fb87625 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/FileSystemBodyStorageCustomCheckTests.cs @@ -0,0 +1,199 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NServiceBus.CustomChecks; +using NUnit.Framework; +using ServiceControl.Persistence.EFCore.Abstractions; +using ServiceControl.Persistence.EFCore.Implementation.BodyStorage; + +[TestFixture] +class FileSystemBodyStorageCustomCheckTests : BasePersistence +{ + const long TotalSize = 1000; + + [Test] + public async Task Passes_when_free_space_is_above_the_threshold() + { + var check = CreateCheck(availableFreeSpace: 200, threshold: 15); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.False, result.FailureReason); + } + + [Test] + public async Task Fails_when_free_space_is_exactly_at_the_threshold() + { + var check = CreateCheck(availableFreeSpace: 150, threshold: 15); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True); + } + + [Test] + public async Task Fails_when_free_space_is_below_the_threshold() + { + var check = CreateCheck(availableFreeSpace: 100, threshold: 15); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.FailureReason, Does.Contain(StoragePath)); + Assert.That(result.FailureReason, Does.Contain(Environment.MachineName)); + } + } + + [Test] + public async Task Honours_a_configured_threshold() + { + var check = CreateCheck(availableFreeSpace: 200, threshold: 25); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True, "20% remaining is below the configured 25% threshold"); + } + + [Test] + public async Task Fails_instead_of_dividing_by_zero_when_the_drive_reports_no_size() + { + var check = CreateCheck(availableFreeSpace: 0, threshold: 15, totalSize: 0); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True); + Assert.That(result.FailureReason, Does.Contain(StoragePath)); + } + + [Test] + public async Task Fails_when_the_drive_cannot_be_read() + { + var check = new FileSystemBodyStorageCustomCheck( + CreateSettings(StoragePath, 15), + new ThrowingDriveSpaceProvider(new DriveNotFoundException("Could not find the drive 'Z:\\'.")), + NullLogger.Instance); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.FailureReason, Does.Contain(StoragePath)); + Assert.That(result.FailureReason, Does.Contain("Could not find the drive")); + } + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("bodies")] + [TestCase("bodies/nested")] + public async Task Fails_when_the_storage_path_has_no_drive_root(string storagePath) + { + var provider = new FakeDriveSpaceProvider(1000, TotalSize); + var check = new FileSystemBodyStorageCustomCheck( + CreateSettings(storagePath, 15), + provider, + NullLogger.Instance); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(result.FailureReason, Does.Contain("An absolute path is required.")); + Assert.That(provider.WasCalled, Is.False); + } + } + + [TestCase(0, false)] + [TestCase(100, true)] + public async Task Reads_the_real_drive_hosting_the_storage_path(int threshold, bool expectedToFail) + { + var check = new FileSystemBodyStorageCustomCheck( + CreateSettings(Path.GetTempPath(), threshold), + new DriveInfoSpaceProvider(), + NullLogger.Instance); + + var result = await check.PerformCheck(); + + Assert.That(result.HasFailed, Is.EqualTo(expectedToFail), result.FailureReason); + } + + [Test] + public void Is_registered_and_resolvable_for_file_system_body_storage() + { + var services = new ServiceCollection(); + services.AddLogging(); + RegisterDataStores(services, new TestPersisterSettings + { + ConnectionString = "Server=nowhere", + BodyStorage = CreateSettings(StoragePath, 15) + }); + + var check = services.BuildServiceProvider().GetServices().OfType().SingleOrDefault(); + + Assert.That(check, Is.Not.Null); + } + + [Test] + public void Is_not_registered_for_other_body_storage_types() + { + var services = new ServiceCollection(); + RegisterDataStores(services, new TestPersisterSettings + { + ConnectionString = "Server=nowhere", + BodyStorage = new S3BodyStorageSettings { BucketName = "bodies" } + }); + + Assert.That(services.Any(descriptor => descriptor.ImplementationType == typeof(FileSystemBodyStorageCustomCheck)), Is.False); + } + + [Test] + public void Reports_itself_under_a_stable_identity() + { + var check = CreateCheck(availableFreeSpace: 200, threshold: 15); + + using (Assert.EnterMultipleScope()) + { + Assert.That(check.Id, Is.EqualTo("ServiceControl body storage")); + Assert.That(check.Category, Is.EqualTo("Storage space")); + Assert.That(check.Interval, Is.EqualTo(TimeSpan.FromMinutes(15))); + } + } + + static FileSystemBodyStorageCustomCheck CreateCheck(long availableFreeSpace, int threshold, long totalSize = TotalSize) => + new(CreateSettings(StoragePath, threshold), + new FakeDriveSpaceProvider(availableFreeSpace, totalSize), + NullLogger.Instance); + + static FileSystemBodyStorageSettings CreateSettings(string storagePath, int threshold) => + new() { StoragePath = storagePath, DataSpaceRemainingThreshold = threshold }; + + static string StoragePath { get; } = Path.Combine(Path.GetTempPath(), "bodies"); + + class FakeDriveSpaceProvider(long availableFreeSpace, long totalSize) : IDriveSpaceProvider + { + public bool WasCalled { get; private set; } + + public DriveSpace GetDriveSpace(string pathRoot) + { + WasCalled = true; + + return new DriveSpace(pathRoot, availableFreeSpace, totalSize); + } + } + + class ThrowingDriveSpaceProvider(Exception exception) : IDriveSpaceProvider + { + public DriveSpace GetDriveSpace(string pathRoot) => throw exception; + } + + sealed class TestPersisterSettings : EFPersisterSettings; +}