From 693b3b36d1901031a456c97fa51b090cc248b1b7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 7 Feb 2026 21:37:49 -0600 Subject: [PATCH 1/4] Migrates to Aspire Azure Storage Emulator instead of MinIO for local dev Replaces MinIO with Azure Storage for blob and queue storage. This change aligns with the move to Azure services and simplifies the infrastructure setup. It also updates the configuration to use Azure Storage connection strings. --- .../Exceptionless.AppHost.csproj | 2 +- .../Extensions/MinIoExtensions.cs | 143 ------------------ src/Exceptionless.AppHost/Program.cs | 19 ++- .../Configuration/QueueOptions.cs | 35 +++-- .../Configuration/StorageOptions.cs | 27 ++-- .../Extensions/DictionaryExtensions.cs | 9 ++ 6 files changed, 60 insertions(+), 175 deletions(-) delete mode 100644 src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index d98349cb4f..1a9fe56086 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -8,10 +8,10 @@ + - diff --git a/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs b/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs deleted file mode 100644 index bfd8214b46..0000000000 --- a/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs +++ /dev/null @@ -1,143 +0,0 @@ -using Foundatio.Storage; -using Minio.DataModel.Args; - -namespace Aspire.Hosting; - -public static class MinIoExtensions -{ - public static IResourceBuilder AddMinIo( - this IDistributedApplicationBuilder builder, - string name, - Action? configure = null) - { - var options = new MinIoBuilder(); - configure?.Invoke(options); - - string bucket = options.Bucket ?? "storage"; - var resource = new MinIoResource(name, options.AccessKey, options.SecretKey, bucket); - string? connectionString; - - builder.Eventing.Subscribe(resource, async (_, ct) => - { - connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct); - - if (connectionString == null) - throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{resource.Name}' resource but the connection string was null."); - - var storage = new MinioFileStorage(o => o.ConnectionString(connectionString)); - try - { - bool found = await storage.Client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), ct); - if (!found) - await storage.Client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct); - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } - }); - - return builder.AddResource(resource) - .WithImage(MinIoContainerImageTags.Image) - .WithImageRegistry(MinIoContainerImageTags.Registry) - .WithImageTag(MinIoContainerImageTags.Tag) - .WithArgs("server", "/data", "--console-address", $":{MinIoResource.DefaultConsolePort}") - .WithEndpoint(port: options.ApiPort, targetPort: MinIoResource.DefaultApiPort, name: MinIoResource.ApiEndpointName) - .WithHttpEndpoint(port: options.ConsolePort, targetPort: MinIoResource.DefaultConsolePort, name: MinIoResource.ConsoleEndpointName) - .WithUrlForEndpoint(MinIoResource.ConsoleEndpointName, u => u.DisplayText = "Storage") - .ConfigureCredentials(options) - .ConfigureVolume(options); - } - - private static IResourceBuilder ConfigureCredentials( - this IResourceBuilder builder, - MinIoBuilder options) - { - return builder - .WithEnvironment("MINIO_ROOT_USER", options.AccessKey ?? "minioadmin") - .WithEnvironment("MINIO_ROOT_PASSWORD", options.SecretKey ?? "minioadmin"); - } - - private static IResourceBuilder ConfigureVolume( - this IResourceBuilder builder, - MinIoBuilder options) - { - if (!string.IsNullOrEmpty(options.DataVolumePath)) - builder = builder.WithVolume(options.DataVolumePath, "/data"); - - return builder; - } -} - -public class MinIoResource(string name, string? accessKey = null, string? secretKey = null, string? bucket = "storage") - : ContainerResource(name), IResourceWithConnectionString -{ - internal const string ApiEndpointName = "api"; - internal const string ConsoleEndpointName = "console"; - internal const int DefaultApiPort = 9000; - internal const int DefaultConsolePort = 9001; - - private EndpointReference? _apiReference; - private EndpointReference? _consoleReference; - - private EndpointReference ApiEndpoint => - _apiReference ??= new EndpointReference(this, ApiEndpointName); - - private EndpointReference ConsoleEndpoint => - _consoleReference ??= new EndpointReference(this, ConsoleEndpointName); - - public ReferenceExpression ConnectionStringExpression => - ReferenceExpression.Create( - $"EndPoint=http://{ApiEndpoint.Property(EndpointProperty.Host)}:{ApiEndpoint.Property(EndpointProperty.Port)};" + - $"AccessKey={AccessKey ?? "minioadmin"};" + - $"SecretKey={SecretKey ?? "minioadmin"};" + - $"Bucket={Bucket};" - ); - - public string? AccessKey { get; } = accessKey; - public string? SecretKey { get; } = secretKey; - public string? Bucket { get; } = bucket; -} - -public class MinIoBuilder -{ - public int? ApiPort { get; set; } - public int? ConsolePort { get; set; } - public string? AccessKey { get; set; } - public string? SecretKey { get; set; } - public string? Bucket { get; set; } - public string? DataVolumePath { get; set; } - - public MinIoBuilder WithPorts(int? apiPort = null, int? consolePort = null) - { - ApiPort = apiPort; - ConsolePort = consolePort; - return this; - } - - public MinIoBuilder WithCredentials(string accessKey, string secretKey) - { - AccessKey = accessKey; - SecretKey = secretKey; - return this; - } - - public MinIoBuilder WithBucket(string bucket) - { - Bucket = bucket; - return this; - } - - public MinIoBuilder WithDataVolume(string path) - { - DataVolumePath = path; - return this; - } -} - -internal static class MinIoContainerImageTags -{ - internal const string Registry = "docker.io"; - internal const string Image = "minio/minio"; - internal const string Tag = "RELEASE.2025-07-23T15-54-02Z"; -} diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs index 4326c42006..3949c876fc 100644 --- a/src/Exceptionless.AppHost/Program.cs +++ b/src/Exceptionless.AppHost/Program.cs @@ -6,9 +6,16 @@ .WithDataVolume("exceptionless.data.v1") .WithKibana(b => b.WithLifetime(ContainerLifetime.Persistent).WithContainerName("Exceptionless-Kibana")); -var storage = builder.AddMinIo("Storage", s => s.WithCredentials("guest", "password").WithPorts(9000).WithBucket("ex-events")) - .WithLifetime(ContainerLifetime.Persistent) - .WithContainerName("Exceptionless-Storage"); +var storage = builder.AddAzureStorage("Storage") + .RunAsEmulator(c => + { + c.WithLifetime(ContainerLifetime.Persistent); + c.WithContainerName("Exceptionless-Storage"); + c.WithDataVolume(); + }); + +var storageBlobs = storage.AddBlobs("StorageBlobs"); +var storageQueues = storage.AddQueues("StorageQueues"); var cache = builder.AddRedis("Redis", port: 6379) .WithImageTag("7.4") @@ -28,7 +35,8 @@ builder.AddProject("Jobs", "AllJobs") .WithReference(cache) .WithReference(elastic) - .WithReference(storage, "MinIO") + .WithReference(storageBlobs, "AzureStorage") + .WithReference(storageQueues, "AzureQueues") .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") .WaitFor(elastic) .WaitFor(cache) @@ -39,7 +47,8 @@ var api = builder.AddProject("Api", "Exceptionless") .WithReference(cache) .WithReference(elastic) - .WithReference(storage, "MinIO") + .WithReference(storageBlobs, "AzureStorage") + .WithReference(storageQueues, "AzureQueues") .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025") .WithEnvironment("RunJobsInProcess", "false") .WaitFor(elastic) diff --git a/src/Exceptionless.Core/Configuration/QueueOptions.cs b/src/Exceptionless.Core/Configuration/QueueOptions.cs index 5955eb25fd..c67ed035a7 100644 --- a/src/Exceptionless.Core/Configuration/QueueOptions.cs +++ b/src/Exceptionless.Core/Configuration/QueueOptions.cs @@ -9,7 +9,7 @@ public class QueueOptions { public string? ConnectionString { get; internal set; } public string? Provider { get; internal set; } - public Dictionary Data { get; internal set; } = null!; + public Dictionary Data { get; internal set; } = new(StringComparer.OrdinalIgnoreCase); public string Scope { get; internal set; } = null!; public string ScopePrefix { get; internal set; } = null!; @@ -18,36 +18,45 @@ public class QueueOptions public static QueueOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions) { - var options = new QueueOptions { Scope = appOptions.AppScope }; - options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty; + var options = new QueueOptions + { + Scope = appOptions.AppScope, + ScopePrefix = !String.IsNullOrEmpty(appOptions.AppScope) ? $"{appOptions.AppScope}-" : String.Empty, + MetricsPollingInterval = appOptions.AppMode == AppMode.Development ? TimeSpan.FromSeconds(15) : TimeSpan.FromSeconds(5) + }; string? cs = config.GetConnectionString("Queue"); - if (cs != null) + if (!String.IsNullOrWhiteSpace(cs)) { options.Data = cs.ParseConnectionString(); options.Provider = options.Data.GetString(nameof(options.Provider)); } else { - var redisConnectionString = config.GetConnectionString("Redis"); + string? azureStorageConnectionString = config.GetConnectionString("AzureQueues"); + if (!String.IsNullOrEmpty(azureStorageConnectionString)) + { + options.Provider = "azurestorage"; + options.ConnectionString = azureStorageConnectionString; + options.Data = azureStorageConnectionString.ParseConnectionString(); + return options; + } + + string? redisConnectionString = config.GetConnectionString("Redis"); if (!String.IsNullOrEmpty(redisConnectionString)) { options.Provider = "redis"; + options.ConnectionString = redisConnectionString; + options.Data = redisConnectionString.ParseConnectionString(); + return options; } } string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null; if (!String.IsNullOrEmpty(providerConnectionString)) - { - var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server"); - options.Data ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - options.Data.AddRange(providerOptions); - } + options.Data.AddRange(providerConnectionString.ParseConnectionString()); options.ConnectionString = options.Data.BuildConnectionString(new HashSet { nameof(options.Provider) }); - - options.MetricsPollingInterval = appOptions.AppMode == AppMode.Development ? TimeSpan.FromSeconds(15) : TimeSpan.FromSeconds(5); - return options; } } diff --git a/src/Exceptionless.Core/Configuration/StorageOptions.cs b/src/Exceptionless.Core/Configuration/StorageOptions.cs index 2ccb47faee..713767660f 100644 --- a/src/Exceptionless.Core/Configuration/StorageOptions.cs +++ b/src/Exceptionless.Core/Configuration/StorageOptions.cs @@ -9,41 +9,42 @@ public class StorageOptions { public string? ConnectionString { get; internal set; } public string? Provider { get; internal set; } - public Dictionary Data { get; internal set; } = null!; + public Dictionary Data { get; internal set; } = new(StringComparer.OrdinalIgnoreCase); public string Scope { get; internal set; } = null!; public string ScopePrefix { get; internal set; } = null!; public static StorageOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions) { - var options = new StorageOptions { Scope = appOptions.AppScope }; - options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty; + var options = new StorageOptions + { + Scope = appOptions.AppScope, + ScopePrefix = !String.IsNullOrEmpty(appOptions.AppScope) ? $"{appOptions.AppScope}-" : String.Empty + }; string? cs = config.GetConnectionString("Storage"); - if (cs != null) + if (!String.IsNullOrWhiteSpace(cs)) { options.Data = cs.ParseConnectionString(); options.Provider = options.Data.GetString(nameof(options.Provider)); } else { - string? minioConnectionString = config.GetConnectionString("MinIO"); - if (!String.IsNullOrEmpty(minioConnectionString)) + string? azureStorageConnectionString = config.GetConnectionString("AzureStorage"); + if (!String.IsNullOrEmpty(azureStorageConnectionString)) { - options.Provider = "minio"; + options.Provider = "azurestorage"; + options.ConnectionString = azureStorageConnectionString; + options.Data = azureStorageConnectionString.ParseConnectionString(); + return options; } } string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null; if (!String.IsNullOrEmpty(providerConnectionString)) - { - var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server"); - options.Data ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - options.Data.AddRange(providerOptions); - } + options.Data.AddRange(providerConnectionString.ParseConnectionString()); options.ConnectionString = options.Data.BuildConnectionString(new HashSet { nameof(options.Provider) }); - return options; } } diff --git a/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs b/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs index 933ae2c544..d973b3840b 100644 --- a/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs +++ b/src/Exceptionless.Core/Extensions/DictionaryExtensions.cs @@ -4,6 +4,15 @@ namespace Exceptionless.Core.Extensions; public static class DictionaryExtensions { + public static void AddRange(this IDictionary? dictionary, IDictionary? range) + { + if (dictionary is null || range is null) + return; + + foreach (var r in range) + dictionary[r.Key] = r.Value; + } + public static void Trim(this HashSet items, Predicate itemsToRemove, Predicate itemsToAlwaysInclude, int maxLength) { items.RemoveWhere(itemsToRemove); From f2b7cedde9a83ca930b46bf9e4560a4af4e26abc Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 7 Feb 2026 21:45:18 -0600 Subject: [PATCH 2/4] removed minio --- src/Exceptionless.Insulation/Bootstrapper.cs | 11 ----------- .../Exceptionless.Insulation.csproj | 1 - 2 files changed, 12 deletions(-) diff --git a/src/Exceptionless.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs index 34713fb956..2301172fc3 100644 --- a/src/Exceptionless.Insulation/Bootstrapper.cs +++ b/src/Exceptionless.Insulation/Bootstrapper.cs @@ -230,17 +230,6 @@ private static void RegisterStorage(IServiceCollection container, StorageOptions return storage; }); } - else if (String.Equals(options.Provider, "minio")) - { - container.ReplaceSingleton(s => new MinioFileStorage(new MinioFileStorageOptions - { - ConnectionString = options.ConnectionString, - Serializer = s.GetRequiredService(), - TimeProvider = s.GetRequiredService(), - ResiliencePolicyProvider = s.GetRequiredService(), - LoggerFactory = s.GetRequiredService() - })); - } else if (String.Equals(options.Provider, "s3")) { container.ReplaceSingleton(s => new S3FileStorage(o => o diff --git a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj index 9cc8ff6666..9d672f4910 100644 --- a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj +++ b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj @@ -5,7 +5,6 @@ - From 963c9130e1450ee6878e289c07210509ed958a66 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 7 Feb 2026 21:52:52 -0600 Subject: [PATCH 3/4] Excludes .agents directory from linting Updates eslint and prettier ignore files to exclude the .agents directory. This prevents linting and formatting of generated agent files, which are third-party and should not be modified. This change supports the aspire-azure-storage feature branch. --- src/Exceptionless.Web/ClientApp/.prettierignore | 3 +++ src/Exceptionless.Web/ClientApp/eslint.config.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/.prettierignore b/src/Exceptionless.Web/ClientApp/.prettierignore index 05a7333c4c..d1ad98febd 100644 --- a/src/Exceptionless.Web/ClientApp/.prettierignore +++ b/src/Exceptionless.Web/ClientApp/.prettierignore @@ -6,5 +6,8 @@ yarn.lock # Generated files src/lib/generated +# Third-party +.agents/ + # UI components (shadcn) src/lib/features/shared/components/ui diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 7bbf909fc5..9bfd564cf6 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -37,7 +37,7 @@ export default ts.config( } }, { - ignores: ['build/', '.svelte-kit/', 'dist/', 'src/lib/generated/', 'src/lib/features/shared/components/ui/'] + ignores: ['.agents/', 'build/', '.svelte-kit/', 'dist/', 'src/lib/generated/', 'src/lib/features/shared/components/ui/'] }, { rules: { From 61f0f5d1cf646b93accd7f895e129e3d2fad7458 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 8 Feb 2026 12:46:44 -0600 Subject: [PATCH 4/4] Fixed build errors. --- .../Exceptionless.Web.csproj | 2 +- .../Controllers/Data/openapi.json | 25 +++++++++---------- .../Controllers/OpenApiControllerTests.cs | 2 +- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj index 9305b69304..ebb7310d3e 100644 --- a/src/Exceptionless.Web/Exceptionless.Web.csproj +++ b/src/Exceptionless.Web/Exceptionless.Web.csproj @@ -19,7 +19,7 @@ - + diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 773637ca70..fa5fdb40d6 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -7405,7 +7405,8 @@ "required": [ "provider", "provider_user_id", - "username" + "username", + "extra_data" ], "type": "object", "properties": { @@ -7419,14 +7420,10 @@ "type": "string" }, "extra_data": { - "type": [ - "null", - "object" - ], + "type": "object", "additionalProperties": { "type": "string" - }, - "readOnly": true + } } } }, @@ -7981,7 +7978,9 @@ "full_name", "email_address", "id", + "organization_ids", "password_reset_token_expiration", + "o_auth_accounts", "email_notifications_enabled", "is_email_address_verified", "verify_email_address_token_expiration", @@ -8005,8 +8004,7 @@ "items": { "type": "string" }, - "description": "The organizations that the user has access to.", - "readOnly": true + "description": "The organizations that the user has access to." }, "password": { "type": [ @@ -8034,8 +8032,7 @@ "type": "array", "items": { "$ref": "#/components/schemas/OAuthAccount" - }, - "readOnly": true + } }, "full_name": { "type": "string", @@ -8658,14 +8655,16 @@ } }, "WorkInProgressResult": { + "required": [ + "workers" + ], "type": "object", "properties": { "workers": { "type": "array", "items": { "type": "string" - }, - "readOnly": true + } } } } diff --git a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs index 1af851c767..7d2c199a3f 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs @@ -10,7 +10,7 @@ public OpenApiControllerTests(ITestOutputHelper output, AppWebHostFactory factor } [Fact] - public async Task GetSwaggerJson_Default_ReturnsExpectedBaseline() + public async Task GetOpenApiJson_Default_ReturnsExpectedBaseline() { // Arrange string baselinePath = Path.Combine(AppContext.BaseDirectory, "Controllers", "Data", "openapi.json");