From 83ee1af3d45a0e0a0f01d22c385753ac8031665b Mon Sep 17 00:00:00 2001 From: konnta0 Date: Tue, 28 Apr 2026 01:05:24 +0900 Subject: [PATCH 1/9] feat: add RustFs --- .github/workflows/tests.yaml | 1 + CommunityToolkit.Aspire.slnx | 5 + README.md | 6 + ...olkit.Aspire.Hosting.RustFs.AppHost.csproj | 12 + .../Program.cs | 10 + .../Properties/launchSettings.json | 29 +++ .../appsettings.json | 9 + ...munityToolkit.Aspire.Hosting.RustFs.csproj | 14 ++ .../README.md | 28 +++ .../RustFsBuilderExtensions.cs | 211 ++++++++++++++++++ .../RustFsContainerImageTags.cs | 18 ++ .../RustFsResource.cs | 95 ++++++++ .../AddRustFsTests.cs | 110 +++++++++ .../AppHostTests.cs | 38 ++++ ...Toolkit.Aspire.Hosting.RustFs.Tests.csproj | 13 ++ .../RustFsFunctionalTests.cs | 134 +++++++++++ .../RustFsPublicApiTests.cs | 120 ++++++++++ 17 files changed, 853 insertions(+) create mode 100644 examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj create mode 100644 examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Program.cs create mode 100644 examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/Properties/launchSettings.json create mode 100644 examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/appsettings.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AppHostTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsPublicApiTests.cs 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..ef5017957 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs @@ -0,0 +1,211 @@ +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-{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[0]}", + 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}"; + } + } +} 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..69f5127c5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs @@ -0,0 +1,110 @@ +// 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); + } +} 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..f55716787 --- /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..775bcc290 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs @@ -0,0 +1,134 @@ +using Aspire.Components.Common.Tests; +using Aspire.Hosting; +using Aspire.Hosting.Utils; +using Microsoft.Extensions.Hosting; +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); + } +} From 52dd0e659f5d078525277151c01fbefaba1c58bd Mon Sep 17 00:00:00 2001 From: konnta0 Date: Fri, 1 May 2026 00:39:14 +0900 Subject: [PATCH 2/9] fix: remove unused using in RustFsFunctionalTests --- .../RustFsFunctionalTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs index 775bcc290..b86145099 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs @@ -1,7 +1,6 @@ using Aspire.Components.Common.Tests; using Aspire.Hosting; using Aspire.Hosting.Utils; -using Microsoft.Extensions.Hosting; using Xunit.Abstractions; namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; From 0a3e34187fde8be6a89392df99e28af7795040dc Mon Sep 17 00:00:00 2001 From: konnta0 Date: Fri, 1 May 2026 00:39:39 +0900 Subject: [PATCH 3/9] fix: use $(RepoRoot) for shared file include in test csproj --- .../CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f55716787..a3040d558 100644 --- 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 @@ -1,7 +1,7 @@ - + From dc502a6df26c8fb740ef66a24b8cb05f85c59992 Mon Sep 17 00:00:00 2001 From: konnta0 Date: Fri, 1 May 2026 00:40:11 +0900 Subject: [PATCH 4/9] fix: use string interpolation for WithEnvironment, sanitize bucket resource names --- .../RustFsBuilderExtensions.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs index ef5017957..55bbf52bd 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs @@ -60,8 +60,8 @@ public static IResourceBuilder AddRustFs( .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) + .WithEnvironment(AccessKeyEnvVarName, $"{resource.AccessKey}") + .WithEnvironment(SecretKeyEnvVarName, $"{resource.SecretKey}") .WithHttpHealthCheck("/health", 200, RustFsResource.PrimaryEndpointName); return resourceBuilder; @@ -142,7 +142,7 @@ public static IResourceBuilder AddBucket(this IResourceBuilde } return builder.AddBucket( - name: $"{builder.Resource.Name}-create-bucket-{bucketName}", + name: $"{builder.Resource.Name}-create-bucket-{SanitizeForResourceName(bucketName)}", bucketNames: [bucketName]); } @@ -162,7 +162,7 @@ public static IResourceBuilder AddBucket(this IResourceBuilde } return builder.AddBucket( - name: $"{builder.Resource.Name}-create-buckets-{bucketNames[0]}", + name: $"{builder.Resource.Name}-create-buckets", bucketNames: bucketNames); } @@ -208,4 +208,7 @@ static string GetRustFsPrimaryUri(RustFsResource rustFs) return $"{endpoint.Scheme}://{rustFs.Name}:{endpoint.TargetPort}"; } } + + private static string SanitizeForResourceName(string name) => + new(name.Select(static c => char.IsLetterOrDigit(c) || c == '-' ? c : '-').ToArray()); } From 764aeaa1cc725f9e82ca544625200394a8bf3b2a Mon Sep 17 00:00:00 2001 From: konnta0 Date: Fri, 1 May 2026 00:40:29 +0900 Subject: [PATCH 5/9] test: add unit tests for AddBucket --- .../AddRustFsTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs index 69f5127c5..f30437d1e 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs @@ -107,4 +107,62 @@ public void RustFsResourceWithCustomPort() 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); + } } From 193d75c043c5686a77c8d033fbc9f17f431df6ee Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Mon, 25 May 2026 16:07:20 +1000 Subject: [PATCH 6/9] Update examples/rustfs/CommunityToolkit.Aspire.Hosting.RustFs.AppHost/CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj --- .../CommunityToolkit.Aspire.Hosting.RustFs.AppHost.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e9c78e0b4..e3fc739ed 100644 --- 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 @@ -1,4 +1,4 @@ - + Exe From 6adf3aea503c63c57c590df188d2b33691d4a01d Mon Sep 17 00:00:00 2001 From: konnta0 Date: Wed, 27 May 2026 00:29:37 +0900 Subject: [PATCH 7/9] fix: remove SDK properties already set in Directory.Build.props --- .../CommunityToolkit.Aspire.Hosting.RustFs.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj b/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj index cde55da61..0f50b6ebe 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj @@ -3,8 +3,6 @@ hosting rustfs storage s3-compatible An Aspire hosting integration for RustFs - enable - enable From ed181bc7fffc3a4bdfd595efafec9f9519dd542f Mon Sep 17 00:00:00 2001 From: konnta0 Date: Wed, 27 May 2026 00:31:53 +0900 Subject: [PATCH 8/9] feat: add RustFsBucketResource and replace minio/mc with inline HTTP S3 API - Add RustFsBucketResource as a child resource visible in the Aspire Dashboard - Remove minio/mc container dependency; create buckets via signed PUT requests - Implement minimal SigV4 signer (no external deps) in RustFsS3Signer - Use ArgumentException.ThrowIfNullOrWhiteSpace for bucket name validation - Add WithSigningRegion --- .../README.md | 14 ++ .../RustFsBucketResource.cs | 52 +++++ .../RustFsBuilderExtensions.cs | 220 ++++++++++++++---- .../RustFsContainerImageTags.cs | 7 - .../RustFsResource.cs | 7 + .../RustFsS3Signer.cs | 134 +++++++++++ .../AddRustFsTests.cs | 80 ++++++- ...Toolkit.Aspire.Hosting.RustFs.Tests.csproj | 1 + .../RustFsFunctionalTests.cs | 36 ++- .../RustFsS3SignerTests.cs | 127 ++++++++++ 10 files changed, 613 insertions(+), 65 deletions(-) create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBucketResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsS3Signer.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsS3SignerTests.cs diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md index 32f7068ba..ff46e5cae 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md @@ -26,3 +26,17 @@ var rustfs = builder.AddRustFs("rustfs") builder.Build().Run(); ``` +### Buckets + +`AddBucket` registers each bucket as a child `RustFsBucketResource` so it appears in +the Aspire Dashboard. After the RustFs server becomes healthy, the integration issues +a signed S3 `PUT /{bucket}` request from the AppHost process to provision the bucket; +there is no `minio/mc` sidecar container. + +Multiple buckets can be added at once via `AddBucket(IReadOnlyList)`: + +```csharp +builder.AddRustFs("rustfs") + .AddBucket(["images", "documents", "audit-log"]); +``` + diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBucketResource.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBucketResource.cs new file mode 100644 index 000000000..6a54a314c --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBucketResource.cs @@ -0,0 +1,52 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents an S3 bucket created on a RustFs container resource. +/// +public sealed class RustFsBucketResource : Resource, IResourceWithParent, IResourceWithConnectionString +{ + /// + /// Gets the parent that hosts this bucket. + /// + public RustFsResource Parent { get; } + + /// + /// Gets the S3 bucket name as it exists on the RustFs server. + /// + /// + /// This is the actual name used on the wire; it may differ from , + /// which is the Aspire resource identifier. + /// + public string BucketName { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The Aspire resource name. + /// The S3 bucket name on RustFs. + /// The parent RustFs resource. + public RustFsBucketResource([ResourceName] string name, string bucketName, RustFsResource parent) + : base(name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bucketName); + ArgumentNullException.ThrowIfNull(parent); + + BucketName = bucketName; + Parent = parent; + } + + /// + /// Gets the connection string expression for the bucket. + /// + /// + /// Format: Endpoint=http://host:port;AccessKey=key;SecretKey=secret;Bucket=bucket. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"{Parent};Bucket={BucketName}"); + + /// + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() => + Parent.CombineProperties([ + new("Bucket", ReferenceExpression.Create($"{BucketName}")) + ]); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs index 55bbf52bd..60360ea0a 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs @@ -1,6 +1,8 @@ -using System.Text; +using System.Data.Common; using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Hosting.RustFs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting; @@ -127,32 +129,47 @@ public static IResourceBuilder WithDataBindMount(this IResourceB } /// - /// Adds a bucket to the RustFs resource using the MinIO CLI (minio/mc). + /// Sets the AWS signing region used when creating buckets via the S3 API. + /// Defaults to us-east-1 if not specified. /// /// 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) + /// The AWS signing region (e.g. us-east-1, ap-northeast-1). + /// The . + public static IResourceBuilder WithSigningRegion(this IResourceBuilder builder, string region) { ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(region); - if (string.IsNullOrWhiteSpace(bucketName)) - { - throw new ArgumentException("Bucket name cannot be null or empty.", nameof(bucketName)); - } + builder.Resource.SigningRegion = region; + return builder; + } - return builder.AddBucket( - name: $"{builder.Resource.Name}-create-bucket-{SanitizeForResourceName(bucketName)}", - bucketNames: [bucketName]); + /// + /// Adds a bucket to the RustFs resource. The bucket is created by issuing a signed + /// PUT request to the RustFs S3 API after the server resource becomes ready. + /// + /// The resource builder. + /// The name of the bucket to create. + /// A reference to the for the bucket resource. + public static IResourceBuilder AddBucket(this IResourceBuilder builder, string bucketName) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(bucketName); + + return builder.AddBucketCore( + name: $"{builder.Resource.Name}-{SanitizeForResourceName(bucketName)}", + bucketName: bucketName); } /// - /// Adds multiple buckets to the RustFs resource using the MinIO CLI (minio/mc). + /// Adds multiple buckets to the RustFs resource. Each bucket is registered as its own + /// child resource and created via the RustFs S3 API + /// once the server resource becomes ready. /// /// 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) + /// The original for further chaining. + public static IResourceBuilder AddBucket(this IResourceBuilder builder, IReadOnlyList bucketNames) { ArgumentNullException.ThrowIfNull(builder); @@ -161,54 +178,167 @@ public static IResourceBuilder AddBucket(this IResourceBuilde throw new ArgumentException("Bucket names cannot be null or empty.", nameof(bucketNames)); } - return builder.AddBucket( - name: $"{builder.Resource.Name}-create-buckets", - bucketNames: bucketNames); + foreach (var bucketName in bucketNames) + { + if (string.IsNullOrWhiteSpace(bucketName)) + { + continue; + } + + builder.AddBucket(bucketName); + } + + return builder; } - private static IResourceBuilder AddBucket( + private static IResourceBuilder AddBucketCore( this IResourceBuilder builder, [ResourceName] string name, - IReadOnlyList bucketNames) + string bucketName) { - return builder.ApplicationBuilder - .AddContainer(name, RustFsContainerImageTags.McImage, RustFsContainerImageTags.McTag) - .WithImageRegistry(RustFsContainerImageTags.McRegistry) + var parent = builder.Resource; + var bucketResource = new RustFsBucketResource(name, bucketName, parent); + + var bucketBuilder = builder.ApplicationBuilder + .AddResource(bucketResource) .WithParentRelationship(builder) - .WaitFor(builder) - .WithEntrypoint("/bin/sh") - .WithArgs(async ctx => + .WithInitialState(new() { - var rustFsResource = builder.Resource; + ResourceType = "RustFsBucket", + State = new ResourceStateSnapshot("Waiting", KnownResourceStateStyles.Info), + Properties = [new(CustomResourceKnownProperties.Source, bucketName)], + }); - var accessKey = await rustFsResource.AccessKey.GetValueAsync(ctx.CancellationToken); - var secretKey = await rustFsResource.SecretKey.GetValueAsync(ctx.CancellationToken); + builder.ApplicationBuilder.Eventing.Subscribe(parent, (@event, cancellationToken) => + { + _ = Task.Run(() => CreateBucketOnReadyAsync(@event, bucketResource, parent, cancellationToken), cancellationToken); + return Task.CompletedTask; + }); - var sb = new StringBuilder(); + return bucketBuilder; + } - sb.Append($"mc alias set rustfs {GetRustFsPrimaryUri(rustFsResource)} '{accessKey}' '{secretKey}';"); + private static async Task CreateBucketOnReadyAsync( + ResourceReadyEvent @event, + RustFsBucketResource bucketResource, + RustFsResource parent, + CancellationToken cancellationToken) + { + var notificationService = @event.Services.GetRequiredService(); + var logger = @event.Services.GetRequiredService().GetLogger(bucketResource); - foreach (var bucket in bucketNames) - { - if (string.IsNullOrWhiteSpace(bucket)) - { - continue; - } + try + { + await notificationService.PublishUpdateAsync(bucketResource, state => state with + { + State = new ResourceStateSnapshot("Creating", KnownResourceStateStyles.Info), + }).ConfigureAwait(false); - sb.Append($"mc mb rustfs/{bucket} --ignore-existing;"); - } + var accessKey = await parent.AccessKey.GetValueAsync(cancellationToken).ConfigureAwait(false); + var secretKey = await parent.SecretKey.GetValueAsync(cancellationToken).ConfigureAwait(false); - ctx.Args.Add("-c"); - ctx.Args.Add(sb.ToString()); - }); + if (string.IsNullOrEmpty(accessKey) || string.IsNullOrEmpty(secretKey)) + { + throw new InvalidOperationException("RustFs access key or secret key is not available."); + } - static string GetRustFsPrimaryUri(RustFsResource rustFs) + var connectionString = await parent.ConnectionStringExpression.GetValueAsync(cancellationToken).ConfigureAwait(false); + var endpointUri = ParseEndpointUri(connectionString); + + using var httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30), + }; + + await CreateBucketAsync(httpClient, endpointUri, bucketResource.BucketName, accessKey, secretKey, parent.SigningRegion, logger, cancellationToken).ConfigureAwait(false); + + await notificationService.PublishUpdateAsync(bucketResource, state => state with + { + State = new ResourceStateSnapshot(KnownResourceStates.Running, KnownResourceStateStyles.Success), + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) { - var endpoint = rustFs.GetEndpoint(RustFsResource.PrimaryEndpointName); - return $"{endpoint.Scheme}://{rustFs.Name}:{endpoint.TargetPort}"; + logger.LogError(ex, "Failed to create RustFs bucket '{Bucket}'", bucketResource.BucketName); + await notificationService.PublishUpdateAsync(bucketResource, state => state with + { + State = new ResourceStateSnapshot(ex.Message, KnownResourceStateStyles.Error), + }).ConfigureAwait(false); } } + private static Uri ParseEndpointUri(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("RustFs connection string is not available."); + } + + var csb = new DbConnectionStringBuilder { ConnectionString = connectionString }; + + if (!csb.TryGetValue("Endpoint", out var endpointObj) || + endpointObj is not string endpoint || + !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + { + throw new InvalidOperationException("RustFs connection string is missing a valid 'Endpoint' value."); + } + + return uri; + } + + private static async Task CreateBucketAsync( + HttpClient httpClient, + Uri endpointUri, + string bucketName, + string accessKey, + string secretKey, + string signingRegion, + ILogger logger, + CancellationToken cancellationToken) + { + var hostHeader = endpointUri.IsDefaultPort + ? endpointUri.Host + : $"{endpointUri.Host}:{endpointUri.Port}"; + + var headers = RustFsS3Signer.SignPutBucket( + hostHeader: hostHeader, + bucketName: bucketName, + accessKey: accessKey, + secretKey: secretKey, + region: signingRegion, + timestamp: DateTimeOffset.UtcNow); + + using var request = new HttpRequestMessage(HttpMethod.Put, new Uri(endpointUri, Uri.EscapeDataString(bucketName))); + foreach (var (key, value) in headers) + { + request.Headers.TryAddWithoutValidation(key, value); + } + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + logger.LogInformation("Created RustFs bucket '{Bucket}'", bucketName); + return; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.Conflict || + body.Contains("BucketAlreadyOwnedByYou", StringComparison.Ordinal) || + body.Contains("BucketAlreadyExists", StringComparison.Ordinal)) + { + logger.LogInformation("RustFs bucket '{Bucket}' already exists", bucketName); + return; + } + + throw new InvalidOperationException( + $"Failed to create RustFs bucket '{bucketName}': HTTP {(int)response.StatusCode} {response.ReasonPhrase} — {body}"); + } + 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 index d4d351ed4..f96b19636 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs @@ -8,11 +8,4 @@ internal static class RustFsContainerImageTags 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 index f91d4f0ee..3a5a0394c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs @@ -15,6 +15,8 @@ public sealed class RustFsResource(string name, ParameterResource accessKey, Par internal const int PrimaryTargetPort = 9000; internal const int ConsoleTargetPort = 9001; + internal const string DefaultSigningRegion = "us-east-1"; + /// /// Gets the access key parameter resource for RustFs. /// @@ -25,6 +27,11 @@ public sealed class RustFsResource(string name, ParameterResource accessKey, Par /// public ParameterResource SecretKey { get; } = secretKey; + /// + /// Gets the AWS signing region used for S3 API requests. Defaults to us-east-1. + /// + public string SigningRegion { get; internal set; } = DefaultSigningRegion; + private EndpointReference? _primaryEndpoint; /// diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsS3Signer.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsS3Signer.cs new file mode 100644 index 000000000..551b0b318 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsS3Signer.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace CommunityToolkit.Aspire.Hosting.RustFs; + +/// +/// Minimal AWS Signature Version 4 signer scoped to S3 PUT /{bucket} requests +/// against a single host with no query string and an empty body. +/// +/// +/// This is intentionally limited so that the implementation is small, auditable, and easy +/// to test. Do not generalise it without first widening the unit tests. +/// +internal static class RustFsS3Signer +{ + private const string Service = "s3"; + private const string Algorithm = "AWS4-HMAC-SHA256"; + private const string AwsRequest = "aws4_request"; + + private const string EmptyBodySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + internal const string ContentSha256HeaderName = "x-amz-content-sha256"; + internal const string DateHeaderName = "x-amz-date"; + internal const string AuthorizationHeaderName = "Authorization"; + + /// + /// Builds the signed headers required to send a PUT /{bucket} request to an + /// S3-compatible endpoint with an empty body. + /// + /// The literal Host header value (e.g. localhost:9000). + /// The bucket name. Must be a valid S3 bucket name. + /// The access key id. + /// The secret access key. + /// The signing region. RustFs/MinIO defaults to us-east-1. + /// The request timestamp in UTC. + /// A dictionary of header name to header value. + public static IReadOnlyDictionary SignPutBucket( + string hostHeader, + string bucketName, + string accessKey, + string secretKey, + string region, + DateTimeOffset timestamp) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hostHeader); + ArgumentException.ThrowIfNullOrWhiteSpace(bucketName); + ArgumentException.ThrowIfNullOrWhiteSpace(accessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(region); + + var utc = timestamp.ToUniversalTime(); + var amzDate = utc.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture); + var dateStamp = utc.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + var canonicalUri = "/" + UriEncodePathSegment(bucketName); + + var canonicalHeaders = new StringBuilder() + .Append("host:").Append(hostHeader).Append('\n') + .Append(ContentSha256HeaderName).Append(':').Append(EmptyBodySha256).Append('\n') + .Append(DateHeaderName).Append(':').Append(amzDate).Append('\n') + .ToString(); + + const string signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + + var canonicalRequest = new StringBuilder() + .Append("PUT\n") + .Append(canonicalUri).Append('\n') + .Append('\n') + .Append(canonicalHeaders).Append('\n') + .Append(signedHeaders).Append('\n') + .Append(EmptyBodySha256) + .ToString(); + + var credentialScope = $"{dateStamp}/{region}/{Service}/{AwsRequest}"; + + var stringToSign = new StringBuilder() + .Append(Algorithm).Append('\n') + .Append(amzDate).Append('\n') + .Append(credentialScope).Append('\n') + .Append(HexLower(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalRequest)))) + .ToString(); + + var signingKey = DeriveSigningKey(secretKey, dateStamp, region); + var signature = HexLower(HmacSha256(signingKey, stringToSign)); + + var authorization = $"{Algorithm} Credential={accessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}"; + + return new Dictionary(StringComparer.Ordinal) + { + [DateHeaderName] = amzDate, + [ContentSha256HeaderName] = EmptyBodySha256, + [AuthorizationHeaderName] = authorization, + }; + } + + private static byte[] DeriveSigningKey(string secretKey, string dateStamp, string region) + { + var kSecret = Encoding.UTF8.GetBytes("AWS4" + secretKey); + var kDate = HmacSha256(kSecret, dateStamp); + var kRegion = HmacSha256(kDate, region); + var kService = HmacSha256(kRegion, Service); + return HmacSha256(kService, AwsRequest); + } + + private static byte[] HmacSha256(byte[] key, string data) + => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data)); + + private static string HexLower(byte[] bytes) + => Convert.ToHexString(bytes).ToLowerInvariant(); + + private static string UriEncodePathSegment(string segment) + { + var sb = new StringBuilder(segment.Length); + foreach (var c in segment) + { + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c is '-' or '_' or '.' or '~') + { + sb.Append(c); + } + else + { + foreach (var b in Encoding.UTF8.GetBytes([c])) + { + sb.Append('%').Append(b.ToString("X2", CultureInfo.InvariantCulture)); + } + } + } + return sb.ToString(); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs index f30437d1e..c52478de0 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs @@ -109,28 +109,49 @@ public void RustFsResourceWithCustomPort() } [Fact] - public void AddBucketAddsBucketCreationContainer() + public void AddBucketRegistersChildBucketResource() { var builder = DistributedApplication.CreateBuilder(); var rustfs = builder.AddRustFs("rustfs"); - rustfs.AddBucket("mybucket"); + var bucketBuilder = 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")); + var bucket = Assert.Single(appModel.Resources.OfType()); - Assert.True(bucketContainer.TryGetLastAnnotation(out var imageAnnotation)); - Assert.Equal(RustFsContainerImageTags.McImage, imageAnnotation.Image); + Assert.Equal("mybucket", bucket.BucketName); + Assert.Same(rustfs.Resource, bucket.Parent); + Assert.Same(bucket, bucketBuilder.Resource); - var parentAnnotation = bucketContainer.Annotations.OfType() + var parentAnnotation = bucket.Annotations.OfType() .SingleOrDefault(a => a.Type == "Parent"); Assert.NotNull(parentAnnotation); Assert.Same(rustfs.Resource, parentAnnotation.Resource); } + [Fact] + public void AddBucketDoesNotAddMcContainer() + { + var builder = DistributedApplication.CreateBuilder(); + + var rustfs = builder.AddRustFs("rustfs"); + rustfs.AddBucket("mybucket"); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var mcContainers = appModel.Resources + .OfType() + .Where(r => r.Annotations.OfType().Any(a => a.Image == "minio/mc")) + .ToList(); + + Assert.Empty(mcContainers); + } + [Fact] public void AddBucketWithDotInNameUsesValidResourceName() { @@ -143,13 +164,14 @@ public void AddBucketWithDotInNameUsesValidResourceName() var appModel = app.Services.GetRequiredService(); - var bucketContainer = Assert.Single(appModel.Resources.OfType(), r => r.Name.Contains("create-bucket")); + var bucket = Assert.Single(appModel.Resources.OfType()); - Assert.DoesNotContain(".", bucketContainer.Name); + Assert.DoesNotContain(".", bucket.Name); + Assert.Equal("my.bucket", bucket.BucketName); } [Fact] - public void AddBucketListAddsBucketCreationContainer() + public void AddBucketListRegistersOneResourcePerBucket() { var builder = DistributedApplication.CreateBuilder(); @@ -160,9 +182,43 @@ public void AddBucketListAddsBucketCreationContainer() var appModel = app.Services.GetRequiredService(); - var bucketContainer = Assert.Single(appModel.Resources.OfType(), r => r.Name.Contains("create-buckets")); + var buckets = appModel.Resources.OfType().ToList(); + Assert.Equal(2, buckets.Count); + Assert.Contains(buckets, b => b.BucketName == "bucket1"); + Assert.Contains(buckets, b => b.BucketName == "bucket2"); + } + + [Fact] + public void AddBucketListSkipsWhitespaceEntries() + { + var builder = DistributedApplication.CreateBuilder(); + + var rustfs = builder.AddRustFs("rustfs"); + rustfs.AddBucket(["bucket1", " ", "bucket2"]); + + using var app = builder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var buckets = appModel.Resources.OfType().ToList(); + Assert.Equal(2, buckets.Count); + } + + [Fact] + public async Task BucketResourceConnectionStringIncludesBucketName() + { + var builder = DistributedApplication.CreateBuilder(); + + var accessKey = builder.AddParameter("accessKey", "ak"); + var secretKey = builder.AddParameter("secretKey", "sk"); + + var rustfs = builder.AddRustFs("rustfs", accessKey, secretKey) + .WithEndpoint("http", e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 9000)); + + var bucket = rustfs.AddBucket("data"); + + var connectionString = await bucket.Resource.ConnectionStringExpression.GetValueAsync(default); - Assert.True(bucketContainer.TryGetLastAnnotation(out var imageAnnotation)); - Assert.Equal(RustFsContainerImageTags.McImage, imageAnnotation.Image); + Assert.Equal("Endpoint=http://localhost:9000;AccessKey=ak;SecretKey=sk;Bucket=data", connectionString); } } 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 index a3040d558..a9e746214 100644 --- 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 @@ -2,6 +2,7 @@ + diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs index b86145099..810ec72e8 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs @@ -1,13 +1,47 @@ using Aspire.Components.Common.Tests; using Aspire.Hosting; using Aspire.Hosting.Utils; -using Xunit.Abstractions; +using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; [RequiresDocker] public class RustFsFunctionalTests(ITestOutputHelper testOutputHelper) { + [Fact] + public async Task AddBucketCreatesBucketViaHttpApi() + { + 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); + var bucket = rustfs.AddBucket("functional-bucket"); + + await using var app = await distributedApplicationBuilder.BuildAsync(); + await app.StartAsync(); + + var rns = app.Services.GetRequiredService(); + + await rns.WaitForResourceHealthyAsync(rustfs.Resource.Name); + var snapshot = await rns.WaitForResourceAsync( + bucket.Resource.Name, + evt => evt.Snapshot.State?.Style == KnownResourceStateStyles.Success + || evt.Snapshot.State?.Style == KnownResourceStateStyles.Error) + .WaitAsync(TimeSpan.FromMinutes(2)); + + testOutputHelper.WriteLine($"Bucket final state text={snapshot.Snapshot.State?.Text} style={snapshot.Snapshot.State?.Style}"); + Assert.Equal(KnownResourceStates.Running, snapshot.Snapshot.State?.Text); + } + [Fact] public async Task ResourceStartsAndHealthCheckPasses() { diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsS3SignerTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsS3SignerTests.cs new file mode 100644 index 000000000..0e7ba6639 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsS3SignerTests.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommunityToolkit.Aspire.Hosting.RustFs; + +namespace CommunityToolkit.Aspire.Hosting.RustFs.Tests; + +public class RustFsS3SignerTests +{ + private const string EmptyBodySha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + [Fact] + public void SignPutBucket_ReturnsRequiredHeaders() + { + var headers = RustFsS3Signer.SignPutBucket( + hostHeader: "localhost:9000", + bucketName: "mybucket", + accessKey: "ACCESSKEY", + secretKey: "SECRETKEY", + region: "us-east-1", + timestamp: new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero)); + + Assert.Contains("x-amz-date", headers.Keys); + Assert.Contains("x-amz-content-sha256", headers.Keys); + Assert.Contains("Authorization", headers.Keys); + } + + [Fact] + public void SignPutBucket_UsesAmzDateFormat() + { + var headers = RustFsS3Signer.SignPutBucket( + hostHeader: "localhost:9000", + bucketName: "mybucket", + accessKey: "ACCESSKEY", + secretKey: "SECRETKEY", + region: "us-east-1", + timestamp: new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero)); + + Assert.Equal("20240115T120000Z", headers["x-amz-date"]); + } + + [Fact] + public void SignPutBucket_UsesEmptyBodyContentHash() + { + var headers = RustFsS3Signer.SignPutBucket( + hostHeader: "localhost:9000", + bucketName: "mybucket", + accessKey: "ACCESSKEY", + secretKey: "SECRETKEY", + region: "us-east-1", + timestamp: new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero)); + + Assert.Equal(EmptyBodySha256, headers["x-amz-content-sha256"]); + } + + [Fact] + public void SignPutBucket_AuthorizationHeaderHasExpectedStructure() + { + var headers = RustFsS3Signer.SignPutBucket( + hostHeader: "localhost:9000", + bucketName: "mybucket", + accessKey: "ACCESSKEY", + secretKey: "SECRETKEY", + region: "us-east-1", + timestamp: new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero)); + + var authorization = headers["Authorization"]; + + Assert.StartsWith("AWS4-HMAC-SHA256 ", authorization); + Assert.Contains("Credential=ACCESSKEY/20240115/us-east-1/s3/aws4_request", authorization); + Assert.Contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date", authorization); + Assert.Matches(@"Signature=[0-9a-f]{64}$", authorization); + } + + [Fact] + public void SignPutBucket_IsDeterministicForSameInput() + { + var timestamp = new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); + + var first = RustFsS3Signer.SignPutBucket("localhost:9000", "mybucket", "AK", "SK", "us-east-1", timestamp); + var second = RustFsS3Signer.SignPutBucket("localhost:9000", "mybucket", "AK", "SK", "us-east-1", timestamp); + + Assert.Equal(first["Authorization"], second["Authorization"]); + } + + [Fact] + public void SignPutBucket_DifferentBucketsProduceDifferentSignatures() + { + var timestamp = new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); + + var a = RustFsS3Signer.SignPutBucket("localhost:9000", "alpha", "AK", "SK", "us-east-1", timestamp); + var b = RustFsS3Signer.SignPutBucket("localhost:9000", "bravo", "AK", "SK", "us-east-1", timestamp); + + Assert.NotEqual(a["Authorization"], b["Authorization"]); + } + + [Fact] + public void SignPutBucket_DifferentSecretKeysProduceDifferentSignatures() + { + var timestamp = new DateTimeOffset(2024, 1, 15, 12, 0, 0, TimeSpan.Zero); + + var a = RustFsS3Signer.SignPutBucket("localhost:9000", "mybucket", "AK", "SECRET1", "us-east-1", timestamp); + var b = RustFsS3Signer.SignPutBucket("localhost:9000", "mybucket", "AK", "SECRET2", "us-east-1", timestamp); + + Assert.NotEqual(a["Authorization"], b["Authorization"]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SignPutBucket_ThrowsWhenBucketIsNullOrWhitespace(string? bucket) + { + Assert.ThrowsAny(() => RustFsS3Signer.SignPutBucket( + "localhost:9000", bucket!, "AK", "SK", "us-east-1", DateTimeOffset.UtcNow)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SignPutBucket_ThrowsWhenSecretKeyIsNullOrWhitespace(string? secret) + { + Assert.ThrowsAny(() => RustFsS3Signer.SignPutBucket( + "localhost:9000", "mybucket", "AK", secret!, "us-east-1", DateTimeOffset.UtcNow)); + } +} From e1af5957b6779938c634c75023bd6f4872772f09 Mon Sep 17 00:00:00 2001 From: konnta0 Date: Fri, 29 May 2026 01:06:21 +0900 Subject: [PATCH 9/9] fix: set world-writable permissions on bind mount temp dir for Linux On Linux, Directory.CreateTempSubdirectory() creates a directory with restrictive permissions (700). The RustFs container runs as a non-root user and cannot write to the mounted path, causing an immediate exit. Apply File.SetUnixFileMode with 0777 on non-Windows platforms before mounting, matching the pattern used in KurrentDB and SurrealDb tests. --- .../RustFsFunctionalTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs index 810ec72e8..e4cc87bcf 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs @@ -102,6 +102,17 @@ public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) else { bindMountPath = Directory.CreateTempSubdirectory().FullName; + + if (!OperatingSystem.IsWindows()) + { + const UnixFileMode ownershipPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; + + File.SetUnixFileMode(bindMountPath, ownershipPermissions); + } + rustfs1.WithDataBindMount(bindMountPath); }