diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a904504fa..6940d5e3f 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -157,6 +157,9 @@
+
+
+
@@ -236,6 +239,7 @@
+
@@ -299,6 +303,7 @@
+
diff --git a/README.md b/README.md
index c5f35f90d..a875784ea 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,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. |
@@ -274,6 +275,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..e3fc739ed
--- /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..0f50b6ebe
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/CommunityToolkit.Aspire.Hosting.RustFs.csproj
@@ -0,0 +1,12 @@
+
+
+
+ hosting rustfs storage s3-compatible
+ An Aspire hosting integration for RustFs
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md
new file mode 100644
index 000000000..ff46e5cae
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/README.md
@@ -0,0 +1,42 @@
+# 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();
+```
+
+### 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
new file mode 100644
index 000000000..60360ea0a
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsBuilderExtensions.cs
@@ -0,0 +1,344 @@
+using System.Data.Common;
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.RustFs;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+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");
+ }
+
+ ///
+ /// 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 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);
+
+ builder.Resource.SigningRegion = region;
+ return builder;
+ }
+
+ ///
+ /// 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. 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.
+ /// The original for further chaining.
+ 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));
+ }
+
+ foreach (var bucketName in bucketNames)
+ {
+ if (string.IsNullOrWhiteSpace(bucketName))
+ {
+ continue;
+ }
+
+ builder.AddBucket(bucketName);
+ }
+
+ return builder;
+ }
+
+ private static IResourceBuilder AddBucketCore(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string bucketName)
+ {
+ var parent = builder.Resource;
+ var bucketResource = new RustFsBucketResource(name, bucketName, parent);
+
+ var bucketBuilder = builder.ApplicationBuilder
+ .AddResource(bucketResource)
+ .WithParentRelationship(builder)
+ .WithInitialState(new()
+ {
+ ResourceType = "RustFsBucket",
+ State = new ResourceStateSnapshot("Waiting", KnownResourceStateStyles.Info),
+ Properties = [new(CustomResourceKnownProperties.Source, bucketName)],
+ });
+
+ builder.ApplicationBuilder.Eventing.Subscribe(parent, (@event, cancellationToken) =>
+ {
+ _ = Task.Run(() => CreateBucketOnReadyAsync(@event, bucketResource, parent, cancellationToken), cancellationToken);
+ return Task.CompletedTask;
+ });
+
+ return bucketBuilder;
+ }
+
+ 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);
+
+ try
+ {
+ await notificationService.PublishUpdateAsync(bucketResource, state => state with
+ {
+ State = new ResourceStateSnapshot("Creating", KnownResourceStateStyles.Info),
+ }).ConfigureAwait(false);
+
+ var accessKey = await parent.AccessKey.GetValueAsync(cancellationToken).ConfigureAwait(false);
+ var secretKey = await parent.SecretKey.GetValueAsync(cancellationToken).ConfigureAwait(false);
+
+ if (string.IsNullOrEmpty(accessKey) || string.IsNullOrEmpty(secretKey))
+ {
+ throw new InvalidOperationException("RustFs access key or secret key is not available.");
+ }
+
+ 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)
+ {
+ 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
new file mode 100644
index 000000000..f96b19636
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsContainerImageTags.cs
@@ -0,0 +1,11 @@
+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";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs
new file mode 100644
index 000000000..3a5a0394c
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.RustFs/RustFsResource.cs
@@ -0,0 +1,102 @@
+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;
+
+ internal const string DefaultSigningRegion = "us-east-1";
+
+ ///
+ /// 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;
+
+ ///
+ /// 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;
+
+ ///
+ /// 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/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
new file mode 100644
index 000000000..c52478de0
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/AddRustFsTests.cs
@@ -0,0 +1,224 @@
+// 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 AddBucketRegistersChildBucketResource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var rustfs = builder.AddRustFs("rustfs");
+ var bucketBuilder = rustfs.AddBucket("mybucket");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var bucket = Assert.Single(appModel.Resources.OfType());
+
+ Assert.Equal("mybucket", bucket.BucketName);
+ Assert.Same(rustfs.Resource, bucket.Parent);
+ Assert.Same(bucket, bucketBuilder.Resource);
+
+ 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()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var rustfs = builder.AddRustFs("rustfs");
+ rustfs.AddBucket("my.bucket");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var bucket = Assert.Single(appModel.Resources.OfType());
+
+ Assert.DoesNotContain(".", bucket.Name);
+ Assert.Equal("my.bucket", bucket.BucketName);
+ }
+
+ [Fact]
+ public void AddBucketListRegistersOneResourcePerBucket()
+ {
+ 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);
+ 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.Equal("Endpoint=http://localhost:9000;AccessKey=ak;SecretKey=sk;Bucket=data", connectionString);
+ }
+}
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..a9e746214
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..e4cc87bcf
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.RustFs.Tests/RustFsFunctionalTests.cs
@@ -0,0 +1,178 @@
+using Aspire.Components.Common.Tests;
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+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()
+ {
+ 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;
+
+ 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);
+ }
+
+ 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);
+ }
+}
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));
+ }
+}