diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 38a3df7e6..a70d0fbc3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -57,6 +57,7 @@ jobs: Hosting.RavenDB.Tests, Hosting.Redis.Extensions.Tests, Hosting.Rust.Tests, + Hosting.RustFs.Tests, Hosting.Sftp.Tests, Hosting.Solr.Tests, Hosting.SqlDatabaseProjects.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 9d30dd237..5bf7b941e 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -153,6 +153,9 @@ + + + @@ -220,6 +223,7 @@ + @@ -280,6 +284,7 @@ + diff --git a/README.md b/README.md index 7091b1954..08397e862 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`Hosting.MySql.Extensions`][mysql-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.MySql.Extensions][mysql-ext-shields]][mysql-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.MySql.Extensions][mysql-ext-shields-preview]][mysql-ext-nuget-preview] | An integration that contains some additional extensions for hosting MySql container. | | - **Learn More**: [`Hosting.MinIO`][minio-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Minio][minio-hosting-shields]][minio-hosting-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Minio][minio-hosting-shields-preview]][minio-hosting-nuget-preview] | An Aspire hosting integration to setup a [MinIO S3](https://min.io/) storage. | | - **Learn More**: [`MinIO.Client`][minio-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Minio.Client][minio-client-shields]][minio-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Client.Minio][minio-client-shields-preview]][minio-client-nuget-preview] | An Aspire client integration for the [MinIO](https://github.com/minio/minio-dotnet) package. | +| - **Learn More**: [`Hosting.RustFs`][rustfs-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.RustFs][rustfs-hosting-shields]][rustfs-hosting-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.RustFs][rustfs-hosting-shields-preview]][rustfs-hosting-nuget-preview] | An Aspire hosting integration to setup a [RustFs](https://github.com/rustfs/rustfs) S3-compatible storage. | | - **Learn More**: [`Hosting.SurrealDb`][surrealdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.SurrealDb][surrealdb-shields]][surrealdb-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.SurrealDb][surrealdb-shields-preview]][surrealdb-nuget-preview] | An Aspire hosting integration leveraging the [SurrealDB](https://surrealdb.com/) container. | | - **Learn More**: [`SurrealDb`][surrealdb-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.SurrealDb][surrealdb-client-shields]][surrealdb-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.SurrealDb][surrealdb-client-shields-preview]][surrealdb-client-nuget-preview] | An Aspire client integration for the [SurrealDB](https://github.com/surrealdb/surrealdb.net/) package. | | - **Learn More**: [`Hosting.Elasticsearch.Extensions`][elasticsearch-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Elasticsearch.Extensions][elasticsearch-ext-shields]][elasticsearch-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Elasticsearch.Extensions][elasticsearch-ext-shields-preview]][elasticsearch-ext-nuget-preview] | An integration that contains some additional extensions for hosting Elasticsearch container. | @@ -268,6 +269,11 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [minio-client-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Minio.Client/ [minio-client-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Minio.Client?label=nuget%20(preview) [minio-client-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Minio.Client/absoluteLatest +[rustfs-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-rustfs +[rustfs-hosting-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.RustFs +[rustfs-hosting-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RustFs/ +[rustfs-hosting-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.RustFs?label=nuget%20(preview) +[rustfs-hosting-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RustFs/absoluteLatest [surrealdb-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-surrealdb [surrealdb-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.SurrealDb [surrealdb-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.SurrealDb/ diff --git a/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj new file mode 100644 index 000000000..e9c78e0b4 --- /dev/null +++ b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj @@ -0,0 +1,12 @@ + + + + Exe + d1a3e2c4-5b6f-7890-abcd-ef1234567890 + + + + + + + diff --git a/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Program.cs b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Program.cs new file mode 100644 index 000000000..d56e1c039 --- /dev/null +++ b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Program.cs @@ -0,0 +1,10 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var accessKey = builder.AddParameter("accessKey", "rustfsadmin"); +var secretKey = builder.AddParameter("secretKey", "rustfsadmin", secret: true); + +var rustfs = builder.AddRustFs("rustfs", accessKey, secretKey) + .WithDataVolume() + .AddBucket("mybucket"); + +builder.Build().Run(); diff --git a/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Properties/launchSettings.json b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..1b756fdb0 --- /dev/null +++ b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:18130;http://localhost:12048", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:32486", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:12810" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15183", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19197", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:21262" + } + } + } +} diff --git a/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/appsettings.json b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj b/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj new file mode 100644 index 000000000..cde55da61 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj @@ -0,0 +1,14 @@ + + + + hosting rustfs storage s3-compatible + An Aspire hosting integration for RustFs + enable + enable + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md new file mode 100644 index 000000000..32f7068ba --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md @@ -0,0 +1,28 @@ +# CommunityToolkit.Aspire.Hosting.RustFs library + +Provides extension methods and resource definitions for the Aspire AppHost to support running [RustFs](https://github.com/rustfs/rustfs) containers. + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.RustFs +``` + +### Example usage + +Then, in the _Program.cs_ file of `AppHost`, add a RustFs resource and consume the connection using the following methods: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var rustfs = builder.AddRustFs("rustfs") + .WithDataVolume() + .AddBucket("mybucket"); + +builder.Build().Run(); +``` + diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs new file mode 100644 index 000000000..55bbf52bd --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs @@ -0,0 +1,214 @@ +using System.Text; +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.RustFs; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding RustFs resources to an . +/// +public static class RustFsBuilderExtensions +{ + private const string AccessKeyEnvVarName = "RUSTFS_ACCESS_KEY"; + private const string SecretKeyEnvVarName = "RUSTFS_SECRET_KEY"; + + /// + /// Adds a RustFs container to the application model. The default image is "rustfs/rustfs". + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The parameter used to provide the access key for the RustFs resource. If a random key will be generated. + /// The parameter used to provide the secret key for the RustFs resource. If a random key will be generated. + /// The host port for the RustFs S3-compatible API endpoint. + /// The host port for the RustFs console endpoint. + /// A reference to the . + public static IResourceBuilder AddRustFs( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + IResourceBuilder? accessKey = null, + IResourceBuilder? secretKey = null, + int? port = null, + int? consolePort = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + var accessKeyParameter = accessKey?.Resource ?? + ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, + $"{name}-accessKey"); + var secretKeyParameter = secretKey?.Resource ?? + ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, + $"{name}-secretKey"); + + var resource = new RustFsResource(name, accessKeyParameter, secretKeyParameter); + + var resourceBuilder = builder.AddResource(resource) + .WithImage(RustFsContainerImageTags.Image, RustFsContainerImageTags.Tag) + .WithImageRegistry(RustFsContainerImageTags.Registry) + .WithHttpEndpoint(name: RustFsResource.PrimaryEndpointName, port: port, + targetPort: RustFsResource.PrimaryTargetPort) + .WithHttpEndpoint(name: RustFsResource.ConsoleEndpointName, port: consolePort, + targetPort: RustFsResource.ConsoleTargetPort) + .WithUrlForEndpoint(RustFsResource.PrimaryEndpointName, annot => + { + annot.DisplayText = "Primary"; + }) + .WithUrlForEndpoint(RustFsResource.ConsoleEndpointName, annot => + { + annot.DisplayText = "Console"; + }) + .WithEnvironment("STORAGE_TYPE", "rustfs") + .WithEnvironment("RUSTFS_ADDRESS", ":" + RustFsResource.PrimaryTargetPort.ToString()) + .WithEnvironment("RUSTFS_CONSOLE_ADDRESS", ":" + RustFsResource.ConsoleTargetPort.ToString()) + .WithEnvironment(AccessKeyEnvVarName, $"{resource.AccessKey}") + .WithEnvironment(SecretKeyEnvVarName, $"{resource.SecretKey}") + .WithHttpHealthCheck("/health", 200, RustFsResource.PrimaryEndpointName); + + return resourceBuilder; + } + + /// + /// Adds a named volume for the data folder to a RustFs container resource. + /// + /// The resource builder. + /// The name of the volume. Defaults to an auto-generated name based on the application and resource names. + /// The . + /// + /// + /// Add a RustFs container to the application model and reference it in a .NET project. Additionally, in this + /// example a data volume is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var rustfs = builder.AddRustFs("rustfs") + /// .WithDataVolume(); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(rustfs); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data"); + } + + /// + /// Adds a bind mount for the data folder to a RustFs container resource. + /// + /// The resource builder. + /// The source directory on the host to mount into the container. + /// The . + /// + /// + /// Add a RustFs container to the application model and reference it in a .NET project. Additionally, in this + /// example a bind mount is added to the container to allow data to be persisted across container restarts. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var rustfs = builder.AddRustFs("rustfs") + /// .WithDataBindMount("./data/rustfs/data"); + /// var api = builder.AddProject<Projects.Api>("api") + /// .WithReference(rustfs); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(source); + + return builder.WithBindMount(source, "/data"); + } + + /// + /// Adds a bucket to the RustFs resource using the MinIO CLI (minio/mc). + /// + /// The resource builder. + /// The name of the bucket to create. + /// A reference to the for the bucket creation container. + public static IResourceBuilder AddBucket(this IResourceBuilder builder, string bucketName) + { + ArgumentNullException.ThrowIfNull(builder); + + if (string.IsNullOrWhiteSpace(bucketName)) + { + throw new ArgumentException("Bucket name cannot be null or empty.", nameof(bucketName)); + } + + return builder.AddBucket( + name: $"{builder.Resource.Name}-create-bucket-{SanitizeForResourceName(bucketName)}", + bucketNames: [bucketName]); + } + + /// + /// Adds multiple buckets to the RustFs resource using the MinIO CLI (minio/mc). + /// + /// The resource builder. + /// The names of the buckets to create. + /// A reference to the for the bucket creation container. + public static IResourceBuilder AddBucket(this IResourceBuilder builder, IReadOnlyList bucketNames) + { + ArgumentNullException.ThrowIfNull(builder); + + if (bucketNames is null || bucketNames.Count is 0) + { + throw new ArgumentException("Bucket names cannot be null or empty.", nameof(bucketNames)); + } + + return builder.AddBucket( + name: $"{builder.Resource.Name}-create-buckets", + bucketNames: bucketNames); + } + + private static IResourceBuilder AddBucket( + this IResourceBuilder builder, + [ResourceName] string name, + IReadOnlyList bucketNames) + { + return builder.ApplicationBuilder + .AddContainer(name, RustFsContainerImageTags.McImage, RustFsContainerImageTags.McTag) + .WithImageRegistry(RustFsContainerImageTags.McRegistry) + .WithParentRelationship(builder) + .WaitFor(builder) + .WithEntrypoint("/bin/sh") + .WithArgs(async ctx => + { + var rustFsResource = builder.Resource; + + var accessKey = await rustFsResource.AccessKey.GetValueAsync(ctx.CancellationToken); + var secretKey = await rustFsResource.SecretKey.GetValueAsync(ctx.CancellationToken); + + var sb = new StringBuilder(); + + sb.Append($"mc alias set rustfs {GetRustFsPrimaryUri(rustFsResource)} '{accessKey}' '{secretKey}';"); + + foreach (var bucket in bucketNames) + { + if (string.IsNullOrWhiteSpace(bucket)) + { + continue; + } + + sb.Append($"mc mb rustfs/{bucket} --ignore-existing;"); + } + + ctx.Args.Add("-c"); + ctx.Args.Add(sb.ToString()); + }); + + static string GetRustFsPrimaryUri(RustFsResource rustFs) + { + var endpoint = rustFs.GetEndpoint(RustFsResource.PrimaryEndpointName); + return $"{endpoint.Scheme}://{rustFs.Name}:{endpoint.TargetPort}"; + } + } + + private static string SanitizeForResourceName(string name) => + new(name.Select(static c => char.IsLetterOrDigit(c) || c == '-' ? c : '-').ToArray()); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs new file mode 100644 index 000000000..d4d351ed4 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs @@ -0,0 +1,18 @@ +namespace CommunityToolkit.Aspire.Hosting.RustFs; + +internal static class RustFsContainerImageTags +{ + /// docker.io + public const string Registry = "docker.io"; + /// rustfs/rustfs + public const string Image = "rustfs/rustfs"; + /// 1.0.0-alpha.82 + public const string Tag = "1.0.0-alpha.82"; + + /// docker.io + public const string McRegistry = "docker.io"; + /// minio/mc + public const string McImage = "minio/mc"; + /// RELEASE.2025-08-13T08-35-41Z + public const string McTag = "RELEASE.2025-08-13T08-35-41Z"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs new file mode 100644 index 000000000..f91d4f0ee --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs @@ -0,0 +1,95 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a RustFs S3-compatible storage container. +/// +/// The name of the resource. +/// A parameter that contains the RustFs access key. +/// A parameter that contains the RustFs secret key. +public sealed class RustFsResource(string name, ParameterResource accessKey, ParameterResource secretKey) + : ContainerResource(name), IResourceWithConnectionString +{ + internal const string PrimaryEndpointName = "http"; + internal const string ConsoleEndpointName = "console"; + + internal const int PrimaryTargetPort = 9000; + internal const int ConsoleTargetPort = 9001; + + /// + /// Gets the access key parameter resource for RustFs. + /// + public ParameterResource AccessKey { get; } = accessKey; + + /// + /// Gets the secret key parameter resource for RustFs. + /// + public ParameterResource SecretKey { get; } = secretKey; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the primary endpoint for the RustFs resource. This endpoint is used for all S3-compatible API calls over HTTP. + /// + public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName); + + /// + /// Gets the host endpoint reference for this resource. + /// + public EndpointReferenceExpression Host => PrimaryEndpoint.Property(EndpointProperty.Host); + + /// + /// Gets the port endpoint reference for this resource. + /// + public EndpointReferenceExpression Port => PrimaryEndpoint.Property(EndpointProperty.Port); + + /// + /// Gets the connection string expression for the RustFs resource. + /// + public ReferenceExpression ConnectionStringExpression => GetConnectionString(); + + /// + /// Gets the connection URI expression for the RustFs server. + /// + /// + /// Format: http://{host}:{port}. + /// + public ReferenceExpression UriExpression => ReferenceExpression.Create($"http://{Host}:{Port}"); + + /// + /// Gets the connection string for the RustFs server. + /// + /// A to observe while waiting for the task to complete. + /// A connection string for the RustFs server in the form "Endpoint=http://host:port;AccessKey=key;SecretKey=secret". + public ValueTask GetConnectionStringAsync(CancellationToken cancellationToken = default) + { + if (this.TryGetLastAnnotation(out var connectionStringAnnotation)) + { + return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken); + } + + return ConnectionStringExpression.GetValueAsync(cancellationToken); + } + + private ReferenceExpression GetConnectionString() + { + var builder = new ReferenceExpressionBuilder(); + + builder.Append( + $"Endpoint=http://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); + + builder.Append($";AccessKey={AccessKey}"); + builder.Append($";SecretKey={SecretKey}"); + + return builder.Build(); + } + + /// + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Host", ReferenceExpression.Create($"{Host}")); + yield return new("Port", ReferenceExpression.Create($"{Port}")); + yield return new("AccessKey", ReferenceExpression.Create($"{AccessKey}")); + yield return new("SecretKey", ReferenceExpression.Create($"{SecretKey}")); + yield return new("Uri", UriExpression); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs new file mode 100644 index 000000000..f30437d1e --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; + +public class AddRustFsTests +{ + [Fact] + public void RustFsResourceGetsAdded() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddRustFs("rustfs"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("rustfs", resource.Name); + } + + [Fact] + public void RustFsResourceHasHealthCheck() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddRustFs("rustfs"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = appModel.Resources.OfType().SingleOrDefault(); + + Assert.NotNull(resource); + + Assert.Equal("rustfs", resource.Name); + + var result = resource.TryGetAnnotationsOfType(out var annotations); + + Assert.True(result); + Assert.NotNull(annotations); + + Assert.Single(annotations); + } + + [Fact] + public void RustFsResourceHasCorrectEndpoints() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddRustFs("rustfs"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var endpoints = resource.Annotations.OfType().ToList(); + + Assert.Equal(2, endpoints.Count); + + var primaryEndpoint = Assert.Single(endpoints, e => e.Name == "http"); + Assert.Equal(9000, primaryEndpoint.TargetPort); + + var consoleEndpoint = Assert.Single(endpoints, e => e.Name == "console"); + Assert.Equal(9001, consoleEndpoint.TargetPort); + } + + [Fact] + public async Task RustFsResourceConnectionString() + { + var builder = DistributedApplication.CreateBuilder(); + + var accessKey = builder.AddParameter("accessKey", "testaccesskey"); + var secretKey = builder.AddParameter("secretKey", "testsecretkey"); + + var rustfs = builder.AddRustFs("rustfs", accessKey, secretKey) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + + var connectionString = await rustfs.Resource.GetConnectionStringAsync(); + + Assert.Equal("Endpoint=http://localhost:2000;AccessKey=testaccesskey;SecretKey=testsecretkey", connectionString); + } + + [Fact] + public void RustFsResourceWithCustomPort() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddRustFs("rustfs", port: 3000, consolePort: 3001); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var resource = Assert.Single(appModel.Resources.OfType()); + + var primaryEndpoint = Assert.Single(resource.Annotations.OfType(), e => e.Name == "http"); + Assert.Equal(3000, primaryEndpoint.Port); + + var consoleEndpoint = Assert.Single(resource.Annotations.OfType(), e => e.Name == "console"); + Assert.Equal(3001, consoleEndpoint.Port); + } + + [Fact] + public void AddBucketAddsBucketCreationContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var rustfs = builder.AddRustFs("rustfs"); + rustfs.AddBucket("mybucket"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var bucketContainer = Assert.Single(appModel.Resources.OfType(), r => r.Name.Contains("create-bucket")); + + Assert.True(bucketContainer.TryGetLastAnnotation(out var imageAnnotation)); + Assert.Equal(RustFsContainerImageTags.McImage, imageAnnotation.Image); + + var parentAnnotation = bucketContainer.Annotations.OfType() + .SingleOrDefault(a => a.Type == "Parent"); + Assert.NotNull(parentAnnotation); + Assert.Same(rustfs.Resource, parentAnnotation.Resource); + } + + [Fact] + public void AddBucketWithDotInNameUsesValidResourceName() + { + var builder = DistributedApplication.CreateBuilder(); + + var rustfs = builder.AddRustFs("rustfs"); + rustfs.AddBucket("my.bucket"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var bucketContainer = Assert.Single(appModel.Resources.OfType(), r => r.Name.Contains("create-bucket")); + + Assert.DoesNotContain(".", bucketContainer.Name); + } + + [Fact] + public void AddBucketListAddsBucketCreationContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var rustfs = builder.AddRustFs("rustfs"); + rustfs.AddBucket(["bucket1", "bucket2"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var bucketContainer = Assert.Single(appModel.Resources.OfType(), r => r.Name.Contains("create-buckets")); + + Assert.True(bucketContainer.TryGetLastAnnotation(out var imageAnnotation)); + Assert.Equal(RustFsContainerImageTags.McImage, imageAnnotation.Image); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AppHostTests.cs new file mode 100644 index 000000000..d069159a5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AppHostTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; + +[RequiresDocker] +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndRespondsOk() + { + var resourceName = "rustfs"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName); + + var response = await httpClient.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ResourceStartsAndConsoleResponds() + { + var resourceName = "rustfs"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName, "console"); + + var response = await httpClient.GetAsync("/"); + + // The console endpoint requires authentication, so we expect either OK or Forbidden + Assert.True( + response.StatusCode is HttpStatusCode.OK or HttpStatusCode.Forbidden, + $"Expected OK or Forbidden, but got {response.StatusCode}"); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj new file mode 100644 index 000000000..a3040d558 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs new file mode 100644 index 000000000..b86145099 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs @@ -0,0 +1,133 @@ +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using Xunit.Abstractions; + +namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; + +[RequiresDocker] +public class RustFsFunctionalTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task ResourceStartsAndHealthCheckPasses() + { + using var distributedApplicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var accessKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(distributedApplicationBuilder, + "accessKey"); + distributedApplicationBuilder.Configuration["Parameters:accessKey"] = await accessKeyParameter.GetValueAsync(default); + var accessKey = distributedApplicationBuilder.AddParameter(accessKeyParameter.Name); + + var secretKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(distributedApplicationBuilder, + "secretKey"); + distributedApplicationBuilder.Configuration["Parameters:secretKey"] = await secretKeyParameter.GetValueAsync(default); + var secretKey = distributedApplicationBuilder.AddParameter(secretKeyParameter.Name); + + var rustfs = distributedApplicationBuilder.AddRustFs("rustfs", accessKey, secretKey); + + await using var app = await distributedApplicationBuilder.BuildAsync(); + + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(rustfs.Resource.Name); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) + { + string? volumeName = null; + string? bindMountPath = null; + + try + { + using var builder1 = TestDistributedApplicationBuilder.Create(testOutputHelper); + + var accessKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder1, + "accessKey"); + builder1.Configuration["Parameters:accessKey"] = await accessKeyParameter.GetValueAsync(default); + var accessKey1 = builder1.AddParameter(accessKeyParameter.Name); + + var secretKeyParameter = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder1, + "secretKey"); + builder1.Configuration["Parameters:secretKey"] = await secretKeyParameter.GetValueAsync(default); + var secretKey1 = builder1.AddParameter(secretKeyParameter.Name); + + var rustfs1 = builder1.AddRustFs("rustfs", accessKey1, secretKey1); + + if (useVolume) + { + volumeName = VolumeNameGenerator.Generate(rustfs1, nameof(WithDataShouldPersistStateBetweenUsages)); + + DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: true); + rustfs1.WithDataVolume(volumeName); + } + else + { + bindMountPath = Directory.CreateTempSubdirectory().FullName; + rustfs1.WithDataBindMount(bindMountPath); + } + + using (var app = builder1.Build()) + { + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(rustfs1.Resource.Name); + + await app.StopAsync(); + } + + using var builder2 = TestDistributedApplicationBuilder.Create(testOutputHelper); + builder2.Configuration["Parameters:accessKey"] = await accessKeyParameter.GetValueAsync(default); + var accessKey2 = builder2.AddParameter(accessKeyParameter.Name); + builder2.Configuration["Parameters:secretKey"] = await secretKeyParameter.GetValueAsync(default); + var secretKey2 = builder2.AddParameter(secretKeyParameter.Name); + + var rustfs2 = builder2.AddRustFs("rustfs", accessKey2, secretKey2); + + if (useVolume) + { + rustfs2.WithDataVolume(volumeName); + } + else + { + rustfs2.WithDataBindMount(bindMountPath!); + } + + using (var app = builder2.Build()) + { + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(rustfs2.Resource.Name); + + await app.StopAsync(); + } + } + finally + { + if (volumeName is not null) + { + DockerUtils.AttemptDeleteDockerVolume(volumeName); + } + + if (bindMountPath is not null) + { + try + { + Directory.Delete(bindMountPath, recursive: true); + } + catch + { + // Don't fail test if we can't clean the temporary folder + } + } + } + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsPublicApiTests.cs new file mode 100644 index 000000000..8f154cc9f --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsPublicApiTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; + +public class RustFsPublicApiTests +{ + [Fact] + public void AddRustFsShouldThrowWhenBuilderIsNull() + { + IDistributedApplicationBuilder builder = null!; + const string name = "rustfs"; + + var action = () => builder.AddRustFs(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddRustFsShouldThrowWhenNameIsNull() + { + IDistributedApplicationBuilder builder = new DistributedApplicationBuilder([]); + string name = null!; + + var action = () => builder.AddRustFs(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void WithDataShouldThrowWhenBuilderIsNull(bool useVolume) + { + IResourceBuilder builder = null!; + + Func>? action = null; + + if (useVolume) + { + action = () => builder.WithDataVolume(); + } + else + { + const string source = "/data"; + + action = () => builder.WithDataBindMount(source); + } + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void WithDataBindMountShouldThrowWhenSourceIsNull() + { + var builder = new DistributedApplicationBuilder([]); + var resourceBuilder = builder.AddRustFs("rustfs"); + + string source = null!; + + var action = () => resourceBuilder.WithDataBindMount(source); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(source), exception.ParamName); + } + + [Fact] + public void AddBucketShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + + var action = () => builder.AddBucket("mybucket"); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddBucketShouldThrowWhenBucketNameIsEmpty() + { + var builder = new DistributedApplicationBuilder([]); + var resourceBuilder = builder.AddRustFs("rustfs"); + + var action = () => resourceBuilder.AddBucket(string.Empty); + + Assert.Throws(action); + } + + [Fact] + public void AddBucketListShouldThrowWhenBuilderIsNull() + { + IResourceBuilder builder = null!; + IReadOnlyList bucketNames = ["mybucket"]; + + var action = () => builder.AddBucket(bucketNames); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public async Task VerifyRustFsConnectionStringWithCustomCredentials() + { + var builder = DistributedApplication.CreateBuilder(); + var accessKey = "myaccesskey"; + var secretKey = "mysecretkey"; + var accessKeyParam = builder.AddParameter("accessKey", accessKey); + var secretKeyParam = builder.AddParameter("secretKey", secretKey); + var rustfs = builder.AddRustFs("rustfs", accessKeyParam, secretKeyParam) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 2000)); + + var connectionString = await rustfs.Resource.GetConnectionStringAsync(); + Assert.Equal($"Endpoint=http://localhost:2000;AccessKey={accessKey};SecretKey={secretKey}", connectionString); + } +}