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