diff --git a/docs/specs/rabbitmq-health-checks.md b/docs/specs/rabbitmq-health-checks.md
new file mode 100644
index 00000000000..a898dd23354
--- /dev/null
+++ b/docs/specs/rabbitmq-health-checks.md
@@ -0,0 +1,138 @@
+# RabbitMQ child-resource health check design
+
+This document captures the design intent and key decisions for how health checks work across
+`Aspire.Hosting.RabbitMQ` child resources. It is aimed at contributors extending the integration.
+
+For the user-facing contract see the [README](../../src/Aspire.Hosting.RabbitMQ/README.md#health-checks).
+
+## Guiding principle
+
+A child resource is `Healthy` iff it has been provisioned **exactly as declared** in the AppHost,
+including every cross-cutting configuration that affects its runtime behaviour, and a live probe
+confirms it still exists on the broker.
+
+"Exactly as declared" is the key phrase. A queue with a TTL policy that failed to apply is not
+what the user declared — it must be `Unhealthy` so that `WaitFor(queue)` blocks dependents.
+
+## Design decisions
+
+### Two signals per resource: lifecycle and health
+
+Every RabbitMQ child resource participates in two independent signaling channels:
+
+**Lifecycle signal** (Aspire resource state — `Starting` / `Running` / `FailedToStart`):
+Reflects whether the provisioning attempt has been made and what its outcome was.
+The resource transitions to `Running` once the broker call completes successfully.
+If the broker call fails, the resource transitions to `FailedToStart`.
+Resources that have not yet been reached by the provisioner remain in `Starting`.
+
+**Health signal** (`ProvisionedTask` — a read-only `Task`):
+A gate the health check awaits. Pending means provisioning has not completed yet (health check
+returns `Degraded`). Completed means provisioning succeeded and the live probe can run.
+Faulted means provisioning failed (health check returns `Unhealthy`).
+
+These two channels are intentionally separate: lifecycle state is visible in the Aspire dashboard
+resource list; health state is visible in the health check panel and gates `WaitFor` dependents.
+
+### Resource owns both signals
+
+Each resource owns its provisioning signal (`ProvisionedTask`) and is responsible for publishing
+its own lifecycle state transitions. The provisioner calls `ApplyAsync` (and for exchanges,
+`ApplyBindingsAsync`) and the resource handles everything else internally.
+
+The `TaskCompletionSource` is `private readonly` inside each resource. The interface exposes only
+the read side: `Task ProvisionedTask { get; }`. This prevents the provisioner from signaling
+arbitrary states independently of the actual broker call result.
+
+`ApplyAsync` receives a `ResourceNotificationService` parameter so the resource can publish
+`Starting` at entry and `Running` or `FailedToStart` at exit without any external coordination.
+
+### Per-resource provisioning signal
+
+Each resource owns a `Task ProvisionedTask` that is completed (or faulted) when its own
+provisioning step finishes. This isolates failures: if one queue fails to declare, only that
+queue's health check reports `Unhealthy`. Sibling queues, exchanges, and shovels are unaffected.
+
+When a vhost fails to create, the provisioner returns early and child resources are never reached.
+Children remain in `Starting` with `ProvisionedTask` still pending — the health check returns
+`Degraded` ("provisioning in progress"), which is semantically correct: provisioning never ran.
+There is no cascade-fault of children; `FailedToStart` is reserved for resources whose own
+provisioning attempt was made and failed.
+
+### Lifecycle and health state matrix
+
+| Situation | Lifecycle state | Health check result |
+|---|---|---|
+| Provisioner has not reached this resource yet | `Starting` | `Degraded` |
+| Provisioning succeeded | `Running` | `Degraded` → `Healthy` after live probe |
+| Provisioning failed | `FailedToStart` | `Unhealthy` |
+| Exchange declared; bindings in progress | `Running` | `Degraded` |
+| Exchange declared; bindings failed | `Running` | `Unhealthy` |
+
+### Exchange is a special case: two-phase provisioning
+
+Exchanges are declared in phase 2 and have their bindings applied in phase 3. The exchange
+transitions to `Running` after successful declaration (it is live on the broker at that point).
+`ProvisionedTask` is not completed until bindings also succeed. If bindings fail, the exchange
+stays `Running` but `ProvisionedTask` faults, so the health check reports `Unhealthy`.
+
+If declaration itself fails, the exchange transitions to `FailedToStart` and `ProvisionedTask`
+faults immediately. The provisioner skips phase 3 for that exchange by checking
+`exchange.ProvisionedTask.IsFaulted`.
+
+### Two-stage health check: provisioning signal + live probe
+
+The provisioning signal proves "we sent the declare and the broker accepted it." The live probe
+proves "the entity still exists" — catching out-of-band deletion by an operator. Both stages are
+required for correctness.
+
+### Resource owns its own health semantics
+
+Each resource type knows how to verify itself (existence check, connection check, state check).
+This keeps health-check registration in the builder extensions trivial and uniform — every `Add*`
+call site uses the same one-liner helper with no per-resource parameters.
+
+### Probe result type is separate from `HealthCheckResult`
+
+Resource classes return a lightweight domain type rather than `HealthCheckResult` directly. This
+keeps `Microsoft.Extensions.Diagnostics.HealthChecks` out of the resource model layer, which is
+important for testability and layering.
+
+### Binding failures are attributed to the source exchange only
+
+Bindings are declared on the exchange; routing is the exchange's responsibility. The destination
+queue's own behaviour is unaffected by a missing binding. Propagating to the destination would
+fan-in failures from many exchanges onto one queue and obscure the root cause.
+
+### Shovel failures are isolated to the shovel resource
+
+Shovels move messages between otherwise-independent endpoints. If a shovel fails, the source queue
+still exists and is correctly configured. The shovel's live-state probe naturally catches downstream
+breakage without needing to cascade to source or destination.
+
+### Policy failures cascade to matching queues/exchanges
+
+Unlike bindings, a policy changes the behaviour of the entity itself (TTL, max-length, DLX, HA).
+A queue without its declared TTL policy will silently retain messages forever — a correctness bug
+the user cannot observe from "queue exists = true". Therefore a policy failure marks every
+queue/exchange whose name matches the policy pattern as `Unhealthy`.
+
+Policy-to-entity matching is resolved once after the model is fully built (not at `AddPolicy` call
+time, to avoid order-dependency) and cached on each entity via `AppliedPolicies`. The same
+resolution pass adds a dashboard relationship edge so the cascade is visible without reading logs.
+This behaviour is implemented and covered by tests.
+
+## Extension guidance
+
+When adding a new provisionable resource type:
+
+- Keep the `TaskCompletionSource` `private readonly`; expose only `Task ProvisionedTask { get; }`.
+- In `ApplyAsync`, publish `Starting` at entry, then do the broker work.
+ On success: complete the TCS and publish `Running`.
+ On failure: fault the TCS and publish `FailedToStart`.
+- Implement a live probe appropriate to the entity type (existence, state, connectivity).
+- Declare health dependencies if the resource's correctness depends on other provisionables
+ (e.g. policies applied to it).
+- Register the health check using the shared helper — no bespoke registration logic.
+- Add the resource to the appropriate provisioner phase; capture failures per-entity without
+ short-circuiting siblings. The provisioner must not touch the TCS directly.
diff --git a/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj b/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj
index 978533a1125..ab300655f3f 100644
--- a/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj
+++ b/src/Aspire.Hosting.RabbitMQ/Aspire.Hosting.RabbitMQ.csproj
@@ -15,4 +15,8 @@
+
+
+
+
diff --git a/src/Aspire.Hosting.RabbitMQ/IRabbitMQServerChild.cs b/src/Aspire.Hosting.RabbitMQ/IRabbitMQServerChild.cs
new file mode 100644
index 00000000000..a5f5e82312c
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/IRabbitMQServerChild.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Marker interface for resources that are children of a RabbitMQ server, reachable via a virtual host.
+///
+///
+/// Implemented by (returns this),
+/// (queues and exchanges, returns the parent virtual host),
+/// , and .
+/// Used internally to derive the server name for health-check registration without requiring
+/// it to be passed explicitly at every call site.
+///
+internal interface IRabbitMQServerChild
+{
+ /// Gets the virtual host that owns this resource.
+ RabbitMQVirtualHostResource VirtualHost { get; }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/IResourceWithExchangeArguments.cs b/src/Aspire.Hosting.RabbitMQ/IResourceWithExchangeArguments.cs
new file mode 100644
index 00000000000..a8c2fa5c16d
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/IResourceWithExchangeArguments.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Indicates that a RabbitMQ resource exposes exchange-specific arguments such as the alternate exchange
+/// for unroutable messages.
+///
+///
+/// Implemented by and .
+/// Use or
+/// to configure these settings.
+///
+public interface IResourceWithExchangeArguments : IResource
+{
+ ///
+ /// Gets the exchange arguments for this resource.
+ ///
+ RabbitMQExchangeArguments ExchangeArguments { get; }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/IResourceWithQueueArguments.cs b/src/Aspire.Hosting.RabbitMQ/IResourceWithQueueArguments.cs
new file mode 100644
index 00000000000..14e590fbb3d
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/IResourceWithQueueArguments.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Indicates that a RabbitMQ resource exposes queue-specific arguments such as message TTL,
+/// length limits, and dead-letter routing.
+///
+///
+/// Implemented by and .
+/// Use or
+/// to configure these settings.
+///
+public interface IResourceWithQueueArguments : IResource
+{
+ ///
+ /// Gets the queue arguments for this resource.
+ ///
+ RabbitMQQueueArguments QueueArguments { get; }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisionable.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisionable.cs
new file mode 100644
index 00000000000..c1b5b933ae5
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisionable.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.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+///
+/// Implemented by RabbitMQ resources that can be provisioned against a live broker and verify their own health.
+///
+internal interface IRabbitMQProvisionable
+{
+ /// Gets the resource name, used in health-check error messages.
+ string Name { get; }
+
+ ///
+ /// Completes when this resource has been fully provisioned; faulted if provisioning failed.
+ ///
+ Task ProvisionedTask { get; }
+
+ ///
+ /// Applies this resource to the broker. Implementations must not throw; all failures are captured in .
+ ///
+ Task ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken);
+
+ ///
+ /// Returns the set of other provisionable resources that must complete successfully before this resource's health check reports Healthy.
+ /// Defaults to an empty sequence.
+ ///
+ IEnumerable HealthDependencies => [];
+
+ ///
+ /// Performs a live broker probe to verify that this resource exists and is in the expected state.
+ /// Defaults to (no probe needed).
+ ///
+ ValueTask ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ => ValueTask.FromResult(RabbitMQProbeResult.Healthy);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisioningClient.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisioningClient.cs
new file mode 100644
index 00000000000..1b6e63aab48
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/IRabbitMQProvisioningClient.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+internal interface IRabbitMQProvisioningClient : IAsyncDisposable
+{
+ Task CanConnectAsync(string vhost, CancellationToken ct);
+
+ // AMQP
+ Task DeclareExchangeAsync(string vhost, string name, string type, bool durable, bool autoDelete, IDictionary? args, CancellationToken ct);
+ Task DeclareQueueAsync(string vhost, string name, bool durable, bool exclusive, bool autoDelete, IDictionary? args, CancellationToken ct);
+ Task BindQueueAsync(string vhost, string sourceExchange, string queue, string routingKey, IDictionary? args, CancellationToken ct);
+ Task BindExchangeAsync(string vhost, string sourceExchange, string destExchange, string routingKey, IDictionary? args, CancellationToken ct);
+ Task QueueExistsAsync(string vhost, string name, CancellationToken ct);
+ Task ExchangeExistsAsync(string vhost, string name, CancellationToken ct);
+
+ // Management HTTP
+ Task CreateVirtualHostAsync(string vhost, CancellationToken ct);
+ Task PutShovelAsync(string vhost, string name, RabbitMQShovelDefinition def, CancellationToken ct);
+ Task GetShovelStateAsync(string vhost, string name, CancellationToken ct);
+ Task PutPolicyAsync(string vhost, string name, RabbitMQPolicyDefinition def, CancellationToken ct);
+ Task PolicyExistsAsync(string vhost, string name, CancellationToken ct);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQPolicyDefinition.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQPolicyDefinition.cs
new file mode 100644
index 00000000000..d6217bda3fd
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQPolicyDefinition.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+///
+/// JSON payload for PUT /api/policies/{vhost}/{name}.
+///
+internal sealed record RabbitMQPolicyDefinition(
+ [property: JsonPropertyName("pattern")] string Pattern,
+ [property: JsonPropertyName("apply-to")] string ApplyTo,
+ [property: JsonPropertyName("definition")] IDictionary Definition,
+ [property: JsonPropertyName("priority")] int Priority);
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProbeResult.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProbeResult.cs
new file mode 100644
index 00000000000..77923b13351
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProbeResult.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+///
+/// The result of a live broker probe performed by a RabbitMQ resource health check.
+/// Kept separate from HealthCheckResult so that resource model classes do not
+/// take a dependency on Microsoft.Extensions.Diagnostics.HealthChecks.
+///
+internal readonly record struct RabbitMQProbeResult(bool IsHealthy, string? Description = null)
+{
+ /// Gets a healthy probe result.
+ public static RabbitMQProbeResult Healthy { get; } = new(true);
+
+ /// Returns an unhealthy probe result with the supplied description.
+ public static RabbitMQProbeResult Unhealthy(string description) => new(false, description);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisionableHealthCheck.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisionableHealthCheck.cs
new file mode 100644
index 00000000000..9f0af494ee3
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisionableHealthCheck.cs
@@ -0,0 +1,73 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+///
+/// Shared implementation for all RabbitMQ child resources (virtual hosts, queues, exchanges, shovels, policies).
+///
+///
+/// The check proceeds in four stages: returns while provisioning is in progress,
+/// then awaits , then awaits each
+/// task, and finally calls
+/// for a live broker verification.
+///
+internal sealed class RabbitMQProvisionableHealthCheck(IRabbitMQProvisionable self, IRabbitMQProvisioningClient client, ILogger logger) : IHealthCheck
+{
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ // Stage 1: return Degraded immediately if provisioning hasn't completed yet
+ if (!self.ProvisionedTask.IsCompleted)
+ {
+ return HealthCheckResult.Degraded($"Provisioning of '{self.Name}' is in progress.");
+ }
+
+ // Stage 2: own provisioning
+ try
+ {
+ await self.ProvisionedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ var message = $"Provisioning of '{self.Name}' failed: {ex.Message}";
+ logger.LogWarning(ex, "{Message}", message);
+ return HealthCheckResult.Unhealthy(message, ex);
+ }
+
+ // Stage 3: health dependencies (e.g. policies that apply to this queue/exchange)
+ foreach (var dep in self.HealthDependencies)
+ {
+ try
+ {
+ await dep.ProvisionedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ var message = $"Dependent resource '{dep.Name}' failed to provision: {ex.Message}";
+ logger.LogWarning(ex, "{Message}", message);
+ return HealthCheckResult.Unhealthy(message, ex);
+ }
+ }
+
+ // Stage 4: live broker probe
+ var probe = await self.ProbeAsync(client, cancellationToken).ConfigureAwait(false);
+ if (!probe.IsHealthy)
+ {
+ logger.LogWarning("Health probe for '{Resource}' failed: {Reason}", self.Name, probe.Description);
+ return HealthCheckResult.Unhealthy(probe.Description);
+ }
+
+ return HealthCheckResult.Healthy();
+ }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisioningClient.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisioningClient.cs
new file mode 100644
index 00000000000..d0764f68a5f
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQProvisioningClient.cs
@@ -0,0 +1,294 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Logging;
+using RabbitMQ.Client;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+internal sealed class RabbitMQProvisioningClient : IRabbitMQProvisioningClient
+{
+ private readonly RabbitMQServerResource _server;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _channels = new(StringComparer.Ordinal);
+ private HttpClient? _http;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+
+ public RabbitMQProvisioningClient(RabbitMQServerResource server, ILogger logger)
+ {
+ _server = server;
+ _logger = logger;
+ }
+
+ public async ValueTask GetOrCreateConnectionAsync(string vhost, CancellationToken ct)
+ {
+ await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ return _channels[vhost].Item1;
+ }
+
+ public async Task CanConnectAsync(string vhost, CancellationToken ct)
+ {
+ try
+ {
+ await GetOrCreateConnectionAsync(vhost, ct).ConfigureAwait(false);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ internal async ValueTask GetOrCreateChannelAsync(string vhost, CancellationToken ct)
+ {
+ if (_channels.TryGetValue(vhost, out var existing) && existing.Item2.IsOpen)
+ {
+ return existing.Item2;
+ }
+
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_channels.TryGetValue(vhost, out var racy) && racy.Item2.IsOpen)
+ {
+ return racy.Item2;
+ }
+
+ // Dispose the stale connection/channel before replacing it to avoid leaking resources.
+ if (_channels.TryRemove(vhost, out var stale))
+ {
+ try { await stale.Item2.CloseAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch { }
+ try { stale.Item2.Dispose(); } catch { }
+ try { await stale.Item1.CloseAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); } catch { }
+ try { stale.Item1.Dispose(); } catch { }
+ }
+
+ var cs = await _server.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+ var f = new ConnectionFactory { Uri = new Uri(cs!), VirtualHost = vhost };
+ var conn = await f.CreateConnectionAsync(ct).ConfigureAwait(false);
+ var ch = await conn.CreateChannelAsync(cancellationToken: ct).ConfigureAwait(false);
+ _channels[vhost] = (conn, ch);
+ return ch;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async ValueTask GetOrCreateHttpClientAsync(CancellationToken ct)
+ {
+ if (_http is not null)
+ {
+ return _http;
+ }
+
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_http is not null)
+ {
+ return _http;
+ }
+
+ var mgmt = await _server.ManagementEndpoint.GetValueAsync(ct).ConfigureAwait(false)
+ ?? throw new DistributedApplicationException(
+ "Management endpoint is not exposed. Call WithManagementPlugin().");
+ var user = await _server.UserNameReference.GetValueAsync(ct).ConfigureAwait(false);
+ var pass = await _server.PasswordParameter.GetValueAsync(ct).ConfigureAwait(false);
+ _http = new HttpClient { BaseAddress = new Uri(mgmt) };
+ _http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{pass}")));
+ return _http;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async Task DeclareExchangeAsync(string vhost, string name, string type, bool durable, bool autoDelete, IDictionary? args, CancellationToken ct)
+ {
+ _logger.LogDebug("Declaring exchange '{Exchange}' (type={Type}) on vhost '{Vhost}'.", name, type, vhost);
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ await AmqpAsync(
+ () => ch.ExchangeDeclareAsync(name, type, durable, autoDelete, args, cancellationToken: ct),
+ $"Failed to declare exchange '{name}' on vhost '{vhost}'").ConfigureAwait(false);
+ }
+
+ public async Task DeclareQueueAsync(string vhost, string name, bool durable, bool exclusive, bool autoDelete, IDictionary? args, CancellationToken ct)
+ {
+ _logger.LogDebug("Declaring queue '{Queue}' on vhost '{Vhost}'.", name, vhost);
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ await AmqpAsync(
+ () => ch.QueueDeclareAsync(name, durable, exclusive, autoDelete, args, cancellationToken: ct),
+ $"Failed to declare queue '{name}' on vhost '{vhost}'").ConfigureAwait(false);
+ }
+
+ public async Task BindQueueAsync(string vhost, string sourceExchange, string queue, string routingKey, IDictionary? args, CancellationToken ct)
+ {
+ _logger.LogDebug("Binding queue '{Queue}' to exchange '{Exchange}' on vhost '{Vhost}'.", queue, sourceExchange, vhost);
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ await AmqpAsync(
+ () => ch.QueueBindAsync(queue, sourceExchange, routingKey, args, cancellationToken: ct),
+ $"Failed to bind queue '{queue}' to exchange '{sourceExchange}' on vhost '{vhost}'").ConfigureAwait(false);
+ }
+
+ public async Task BindExchangeAsync(string vhost, string sourceExchange, string destExchange, string routingKey, IDictionary? args, CancellationToken ct)
+ {
+ _logger.LogDebug("Binding exchange '{Dest}' to exchange '{Source}' on vhost '{Vhost}'.", destExchange, sourceExchange, vhost);
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ await AmqpAsync(
+ () => ch.ExchangeBindAsync(destExchange, sourceExchange, routingKey, args, cancellationToken: ct),
+ $"Failed to bind exchange '{destExchange}' to exchange '{sourceExchange}' on vhost '{vhost}'").ConfigureAwait(false);
+ }
+
+ public async Task QueueExistsAsync(string vhost, string name, CancellationToken ct)
+ {
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ return await SafeAmqpAsync(() => ch.QueueDeclarePassiveAsync(name, cancellationToken: ct)).ConfigureAwait(false);
+ }
+
+ public async Task ExchangeExistsAsync(string vhost, string name, CancellationToken ct)
+ {
+ var ch = await GetOrCreateChannelAsync(vhost, ct).ConfigureAwait(false);
+ return await SafeAmqpAsync(() => ch.ExchangeDeclarePassiveAsync(name, cancellationToken: ct)).ConfigureAwait(false);
+ }
+
+ public async Task CreateVirtualHostAsync(string vhost, CancellationToken ct)
+ {
+ _logger.LogDebug("Creating virtual host '{Vhost}'.", vhost);
+ await HttpPutAsync($"/api/vhosts/{Uri.EscapeDataString(vhost)}", (object?)null, $"Failed to create virtual host '{vhost}'", ct).ConfigureAwait(false);
+ }
+
+ public async Task PutShovelAsync(string vhost, string name, RabbitMQShovelDefinition def, CancellationToken ct)
+ {
+ _logger.LogDebug("Creating shovel '{Shovel}' on vhost '{Vhost}'.", name, vhost);
+ await HttpPutAsync($"/api/parameters/shovel/{Uri.EscapeDataString(vhost)}/{Uri.EscapeDataString(name)}", def, $"Failed to create shovel '{name}' on vhost '{vhost}'", ct).ConfigureAwait(false);
+ }
+
+ public async Task GetShovelStateAsync(string vhost, string name, CancellationToken ct)
+ {
+ var http = await GetOrCreateHttpClientAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var response = await http.GetAsync($"/api/shovels/{Uri.EscapeDataString(vhost)}", ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ var shovels = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false);
+ var shovel = shovels?.FirstOrDefault(s => s.Name == name);
+ return shovel?.State;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public async Task PutPolicyAsync(string vhost, string name, RabbitMQPolicyDefinition def, CancellationToken ct)
+ {
+ _logger.LogDebug("Applying policy '{Policy}' on vhost '{Vhost}'.", name, vhost);
+ await HttpPutAsync($"/api/policies/{Uri.EscapeDataString(vhost)}/{Uri.EscapeDataString(name)}", def, $"Failed to apply policy '{name}' on vhost '{vhost}'", ct).ConfigureAwait(false);
+ }
+
+ public async Task PolicyExistsAsync(string vhost, string name, CancellationToken ct)
+ {
+ var http = await GetOrCreateHttpClientAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var response = await http.GetAsync($"/api/policies/{Uri.EscapeDataString(vhost)}/{Uri.EscapeDataString(name)}", ct).ConfigureAwait(false);
+ return response.IsSuccessStatusCode;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _gate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ foreach (var (_, (conn, ch)) in _channels)
+ {
+ try { await ch.CloseAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Suppressed channel close error during disposal."); }
+ try { await ch.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Suppressed channel dispose error during disposal."); }
+ try { await conn.CloseAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Suppressed connection close error during disposal."); }
+ try { await conn.DisposeAsync().ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Suppressed connection dispose error during disposal."); }
+ }
+ _channels.Clear();
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ _http?.Dispose();
+ _gate.Dispose();
+ }
+
+ ///
+ /// Executes an AMQP operation and wraps any exception in a .
+ ///
+ private static async Task AmqpAsync(Func action, string errorMessage)
+ {
+ try
+ {
+ await action().ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ throw new DistributedApplicationException($"{errorMessage}: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Executes an AMQP passive-declare operation and returns if it succeeds,
+ /// if the entity does not exist (any exception is swallowed).
+ ///
+ private static async Task SafeAmqpAsync(Func action)
+ {
+ try
+ {
+ await action().ConfigureAwait(false);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Sends an HTTP PUT to the management API and wraps any failure in a .
+ ///
+ private async Task HttpPutAsync(string path, T? body, string errorMessage, CancellationToken ct)
+ {
+ var http = await GetOrCreateHttpClientAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var response = body is null
+ ? await http.PutAsync(path, null, ct).ConfigureAwait(false)
+ : await http.PutAsJsonAsync(path, body, cancellationToken: ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+ catch (Exception ex)
+ {
+ throw new DistributedApplicationException($"{errorMessage}: {ex.Message}", ex);
+ }
+ }
+
+ private sealed class RabbitMQShovelStatus
+ {
+ public string? Name { get; set; }
+ public string? State { get; set; }
+ }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQShovelDefinition.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQShovelDefinition.cs
new file mode 100644
index 00000000000..5e0986ced80
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQShovelDefinition.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+internal sealed record RabbitMQShovelDefinition
+{
+ [JsonPropertyName("value")]
+ public required RabbitMQShovelDefinitionValue Value { get; init; }
+}
+
+internal sealed record RabbitMQShovelDefinitionValue
+{
+ [JsonPropertyName("src-uri")]
+ public required string SrcUri { get; init; }
+
+ [JsonPropertyName("src-queue")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SrcQueue { get; init; }
+
+ [JsonPropertyName("src-exchange")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SrcExchange { get; init; }
+
+ [JsonPropertyName("src-exchange-key")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SrcExchangeKey { get; init; }
+
+ [JsonPropertyName("dest-uri")]
+ public required string DestUri { get; init; }
+
+ [JsonPropertyName("dest-queue")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DestQueue { get; init; }
+
+ [JsonPropertyName("dest-exchange")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DestExchange { get; init; }
+
+ [JsonPropertyName("dest-exchange-key")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DestExchangeKey { get; init; }
+
+ [JsonPropertyName("ack-mode")]
+ public required string AckMode { get; init; }
+
+ [JsonPropertyName("reconnect-delay")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? ReconnectDelay { get; init; }
+
+ [JsonPropertyName("src-delete-after")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SrcDeleteAfter { get; init; }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQTopologyProvisioner.cs b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQTopologyProvisioner.cs
new file mode 100644
index 00000000000..2d4754a2727
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/Provisioning/RabbitMQTopologyProvisioner.cs
@@ -0,0 +1,66 @@
+// 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.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.RabbitMQ.Provisioning;
+
+internal sealed class RabbitMQTopologyProvisioner
+{
+ public static async Task ProvisionTopologyAsync(RabbitMQServerResource server, IServiceProvider serviceProvider, CancellationToken cancellationToken)
+ {
+ if (server.VirtualHosts.Count == 0)
+ {
+ return;
+ }
+
+ var client = serviceProvider.GetRequiredKeyedService(server.Name);
+ var logger = serviceProvider.GetRequiredService>();
+ var resourceLogger = serviceProvider.GetRequiredService();
+ var notifications = serviceProvider.GetRequiredService();
+
+ logger.LogInformation("Provisioning RabbitMQ topology for server '{Server}' ({VirtualHostCount} virtual host(s)).", server.Name, server.VirtualHosts.Count);
+
+ await Task.WhenAll(server.VirtualHosts.Select(vhost => ProvisionVirtualHostAsync(vhost, client, logger, resourceLogger, notifications, cancellationToken))).ConfigureAwait(false);
+
+ logger.LogInformation("RabbitMQ topology provisioning complete for server '{Server}'.", server.Name);
+ }
+
+ ///
+ /// Provisions all entities within a single virtual host in ordered phases: virtual host creation, policies, queues and exchanges, then bindings and shovels.
+ /// Each resource's owns its own failure signalling and never throws.
+ ///
+ private static async Task ProvisionVirtualHostAsync(RabbitMQVirtualHostResource vhost, IRabbitMQProvisioningClient client, ILogger logger, ResourceLoggerService resourceLogger, ResourceNotificationService notifications, CancellationToken cancellationToken)
+ {
+ await ((IRabbitMQProvisionable)vhost).ApplyAsync(client, notifications, resourceLogger, cancellationToken).ConfigureAwait(false);
+
+ if (((IRabbitMQProvisionable)vhost).ProvisionedTask.IsFaulted)
+ {
+ // Children remain pending (Starting) — no cascade fault. The health check returns Degraded.
+ logger.LogError(((IRabbitMQProvisionable)vhost).ProvisionedTask.Exception!.InnerException, "Failed to create virtual host '{VirtualHost}'.", vhost.VirtualHostName);
+ return;
+ }
+
+ await Task.WhenAll(vhost.Policies.Select(p => ((IRabbitMQProvisionable)p).ApplyAsync(client, notifications, resourceLogger, cancellationToken))).ConfigureAwait(false);
+
+ // Phase 2: queues and exchange declarations run in parallel.
+ // Exchanges are not fully provisioned yet — bindings come in phase 3.
+ var phase2Tasks = vhost.Queues
+ .Select(q => ((IRabbitMQProvisionable)q).ApplyAsync(client, notifications, resourceLogger, cancellationToken))
+ .Concat(vhost.Exchanges.Select(e => ((IRabbitMQProvisionable)e).ApplyAsync(client, notifications, resourceLogger, cancellationToken)));
+
+ await Task.WhenAll(phase2Tasks).ConfigureAwait(false);
+
+ // Phase 3: exchange bindings and shovels run in parallel.
+ // Exchanges whose declaration faulted in phase 2 skip binding — their TCS is already faulted.
+ var phase3Tasks = vhost.Exchanges
+ .Select(e => ((IRabbitMQProvisionable)e).ProvisionedTask.IsFaulted
+ ? Task.CompletedTask
+ : e.ApplyBindingsAsync(client, resourceLogger, cancellationToken))
+ .Concat(vhost.Shovels.Select(s => ((IRabbitMQProvisionable)s).ApplyAsync(client, notifications, resourceLogger, cancellationToken)));
+
+ await Task.WhenAll(phase3Tasks).ConfigureAwait(false);
+ }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/README.md b/src/Aspire.Hosting.RabbitMQ/README.md
index 513fbbb2fc3..840cf09952b 100644
--- a/src/Aspire.Hosting.RabbitMQ/README.md
+++ b/src/Aspire.Hosting.RabbitMQ/README.md
@@ -23,14 +23,135 @@ var myService = builder.AddProject()
.WithReference(rmq);
```
+## Virtual hosts, queues, exchanges, bindings, and shovels
+
+You can declare RabbitMQ topology as first-class Aspire resources. Topology is provisioned automatically after the container is healthy, and `WaitFor(child)` blocks until the child resource is fully applied.
+
+### Virtual hosts
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq");
+
+// Add a named virtual host (auto-enables the management plugin)
+var orders = rmq.AddVirtualHost("orders");
+
+// Add queue and exchange on the virtual host
+var inbox = orders.AddQueue("inbox");
+var events = orders.AddExchange("events", RabbitMQExchangeType.Topic);
+
+// Bind the exchange to the queue
+events.WithBinding(inbox, routingKey: "order.*");
+
+// Reference the queue from a service — connection string includes the vhost segment
+builder.AddProject("api")
+ .WithReference(inbox);
+```
+
+### Server-level convenience overloads (default `/` vhost)
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq");
+
+// These create resources on the default "/" virtual host
+var queue = rmq.AddQueue("my-queue");
+var exchange = rmq.AddExchange("my-exchange", RabbitMQExchangeType.Fanout);
+```
+
+### Shovels
+
+Shovels move messages between queues or exchanges, including across virtual hosts:
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq");
+var orders = rmq.AddVirtualHost("orders");
+var billing = rmq.AddVirtualHost("billing");
+
+var ordersInbox = orders.AddQueue("inbox");
+var billingInbox = billing.AddQueue("inbox");
+
+// Shovel from orders/inbox → billing/inbox (auto-enables shovel plugins)
+orders.AddShovel("orders-to-billing", source: ordersInbox, destination: billingInbox);
+
+// WaitFor blocks until the shovel is running
+builder.AddProject("worker")
+ .WaitFor(billingInbox);
+```
+
+### Policies
+
+Policies apply server-side configuration (TTL, dead-lettering, queue length limits, HA) to queues and/or exchanges whose names match a regex pattern. The order of `AddPolicy` relative to `AddQueue`/`AddExchange` calls does not matter — pattern matching is resolved at model-freeze time (during the `BeforeStartEvent`), after all resources have been registered.
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq");
+var vhost = rmq.AddVirtualHost("orders");
+
+var dlx = vhost.AddExchange("dlx");
+var orders = vhost.AddQueue("orders");
+var dead = vhost.AddQueue("orders-dead");
+
+// Apply TTL + dead-letter policy to all queues matching "^orders"
+vhost.AddPolicy("orders-policy", "^orders", RabbitMQPolicyApplyTo.Queues)
+ .WithProperties(p =>
+ {
+ p.Definition["message-ttl"] = 60_000;
+ p.Definition["dead-letter-exchange"] = "dlx";
+ });
+
+// WaitFor(orders) blocks until the queue AND its policy are applied
+builder.AddProject("worker")
+ .WaitFor(orders);
+```
+
+If a policy fails to apply, every queue or exchange whose name matched the pattern reports `Unhealthy` — preventing dependents from starting against a mis-configured entity.
+
+### Queue and exchange properties
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq");
+var vhost = rmq.AddVirtualHost("orders");
+
+var queue = vhost.AddQueue("inbox", type: RabbitMQQueueType.Quorum)
+ .WithProperties(q =>
+ {
+ q.Durable = true;
+ q.AutoDelete = false;
+ });
+
+var exchange = vhost.AddExchange("events", RabbitMQExchangeType.Topic)
+ .WithProperties(e => e.Durable = true);
+```
+
+## Plugin customization
+
+Use `WithPlugin` to enable additional RabbitMQ plugins. The management-image default set (`rabbitmq_management`, `rabbitmq_management_agent`, `rabbitmq_web_dispatch`, `rabbitmq_prometheus`) is always included so behaviour never regresses.
+
+```csharp
+var rmq = builder.AddRabbitMQ("rmq")
+ .WithPlugin(RabbitMQPlugin.Prometheus) // enum overload
+ .WithPlugin("rabbitmq_mqtt"); // string overload
+```
+
+Plugins are automatically enabled when child resources require them:
+
+| Action | Auto-enabled plugins |
+|---|---|
+| `AddVirtualHost("name")` (non-`/`) | `rabbitmq_management` |
+| `AddShovel(...)` | `rabbitmq_management`, `rabbitmq_shovel`, `rabbitmq_shovel_management` |
+
+## Health checks
+
+Every RabbitMQ child resource registers its own health check. The Aspire dashboard shows each resource's status independently, and `WaitFor(resource)` blocks until that specific resource is healthy — meaning it was provisioned successfully and a live broker probe confirms it still exists.
+
+Failures are isolated: if one queue fails to declare, only that queue's health check reports `Unhealthy`. Sibling queues, exchanges, and shovels are unaffected. The only cascade is a vhost-creation failure, which marks every child in that vhost as `Unhealthy`, because nothing can exist without the vhost.
+
+Bindings are owned by the source exchange. If a binding fails, the exchange is `Unhealthy`; the destination queue is not affected.
+
## Connection Properties
When you reference a RabbitMQ resource using `WithReference`, the following connection properties are made available to the consuming project:
### RabbitMQ server
-The RabbitMQ server resource exposes the following connection properties:
-
| Property Name | Description |
|---------------|-------------|
| `Host` | The hostname or IP address of the RabbitMQ server |
@@ -39,7 +160,32 @@ The RabbitMQ server resource exposes the following connection properties:
| `Password` | The password for authentication |
| `Uri` | The connection URI, with the format `amqp://{Username}:{Password}@{Host}:{Port}` |
-Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPERTY]`. For instance, the `Uri` property of a resource called `db1` becomes `DB1_URI`.
+### RabbitMQ virtual host
+
+Inherits all server properties, plus:
+
+| Property Name | Description |
+|---------------|-------------|
+| `VirtualHost` | The name of the virtual host |
+| `Uri` | The connection URI including the vhost segment, e.g. `amqp://user:pass@host:port/orders` |
+
+### RabbitMQ queue
+
+Inherits all virtual host properties, plus:
+
+| Property Name | Description |
+|---------------|-------------|
+| `QueueName` | The name of the queue |
+
+### RabbitMQ exchange
+
+Inherits all virtual host properties, plus:
+
+| Property Name | Description |
+|---------------|-------------|
+| `ExchangeName` | The name of the exchange |
+
+Aspire exposes each property as an environment variable named `[RESOURCE]_[PROPERTY]`. For instance, the `Uri` property of a resource called `inbox` becomes `INBOX_URI`.
## Additional documentation
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQBinding.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQBinding.cs
new file mode 100644
index 00000000000..c11e7e91199
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQBinding.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a binding between a RabbitMQ exchange and a destination (queue or exchange).
+///
+[AspireDto]
+public sealed class RabbitMQBinding
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The destination of the binding.
+ /// The routing key for the binding.
+ ///
+ /// The headers-exchange match arguments for the binding.
+ /// Used when the source exchange is of type to specify
+ /// which message headers must match for the binding to be selected.
+ ///
+ public RabbitMQBinding(RabbitMQDestination destination, string routingKey, Dictionary? matchHeaders = null)
+ {
+ ArgumentNullException.ThrowIfNull(destination);
+ ArgumentNullException.ThrowIfNull(routingKey);
+
+ Destination = destination;
+ RoutingKey = routingKey;
+ MatchHeaders = matchHeaders;
+ }
+
+ ///
+ /// Gets the destination of the binding.
+ ///
+ public RabbitMQDestination Destination { get; }
+
+ ///
+ /// Gets the routing key for the binding.
+ ///
+ public string RoutingKey { get; }
+
+ ///
+ /// Gets the headers-exchange match arguments for the binding.
+ ///
+ ///
+ /// Used when the source exchange is of type to specify
+ /// which message headers must match for the binding to be selected.
+ ///
+ public Dictionary? MatchHeaders { get; }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs
index 9884faecc7c..fd1f3e775eb 100644
--- a/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQBuilderExtensions.cs
@@ -4,8 +4,10 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.RabbitMQ;
+using Aspire.Hosting.RabbitMQ.Provisioning;
using Microsoft.Extensions.DependencyInjection;
-using RabbitMQ.Client;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
@@ -54,22 +56,26 @@ public static IResourceBuilder AddRabbitMQ(this IDistrib
});
var healthCheckKey = $"{name}_check";
- // cache the connection so it is reused on subsequent calls to the health check
- IConnection? connection = null;
+
+ builder.Services.AddKeyedSingleton(
+ rabbitMq.Name,
+ (sp, _) => new RabbitMQProvisioningClient(rabbitMq, sp.GetRequiredService>()));
+
+ builder.Eventing.Subscribe(rabbitMq, async (@event, ct) =>
+ {
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(rabbitMq, @event.Services, ct).ConfigureAwait(false);
+ });
+
builder.Services.AddHealthChecks().AddRabbitMQ(async (sp) =>
{
// NOTE: Ensure that execution of this setup callback is deferred until after
// the container is built & started.
- return connection ??= await CreateConnection(connectionString!).ConfigureAwait(false);
-
- static Task CreateConnection(string connectionString)
- {
- var factory = new ConnectionFactory
- {
- Uri = new Uri(connectionString)
- };
- return factory.CreateConnectionAsync();
- }
+ // The cast to RabbitMQProvisioningClient is intentional: AddRabbitMQ (the AspNetCore health-check
+ // extension) requires an IConnection, which is a RabbitMQ.Client type. Exposing IConnection on
+ // IRabbitMQProvisioningClient would leak the client library into the internal facade, so we keep
+ // the cast here — the concrete type is internal and registered by us.
+ var client = (RabbitMQProvisioningClient)sp.GetRequiredKeyedService(rabbitMq.Name);
+ return await client.GetOrCreateConnectionAsync("/", default).ConfigureAwait(false);
}, healthCheckKey);
var rabbitmq = builder.AddResource(rabbitMq)
@@ -138,29 +144,643 @@ public static IResourceBuilder WithManagementPlugin(this
return builder.WithManagementPlugin(port: null);
}
- [AspireExport("withManagementPlugin", Description = "Enables the RabbitMQ management plugin")]
- internal static IResourceBuilder WithManagementPluginForPolyglot(
+ ///
+ /// Adds a RabbitMQ virtual host to the server.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The name of the resource.
+ /// The name of the virtual host. If not provided, defaults to the resource name.
+ /// A reference to the .
+ [AspireExport(Description = "Adds a RabbitMQ virtual host")]
+ public static IResourceBuilder AddVirtualHost(
this IResourceBuilder builder,
- int? port = null)
+ [ResourceName] string name,
+ string? virtualHostName = null)
{
ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ if (virtualHostName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(virtualHostName, nameof(virtualHostName));
+ }
- return builder.WithManagementPlugin(port);
+ var vhostName = virtualHostName ?? name;
+ if (builder.Resource.VirtualHosts.Any(v => v.VirtualHostName == vhostName))
+ {
+ throw new DistributedApplicationException($"A virtual host with the name '{vhostName}' already exists on server '{builder.Resource.Name}'.");
+ }
+
+ var vhost = new RabbitMQVirtualHostResource(name, vhostName, builder.Resource);
+
+ builder.Resource.VirtualHosts.Add(vhost);
+
+ if (vhostName != "/")
+ {
+ builder.WithManagementPlugin();
+ }
+
+ return builder.ApplicationBuilder.AddResource(vhost)
+ .WithProvisionableHealthCheck();
}
- ///
+ internal static IResourceBuilder GetOrAddDefaultVirtualHost(this IResourceBuilder server)
+ {
+ var defaultVhost = server.Resource.VirtualHosts.FirstOrDefault(v => v.VirtualHostName == "/");
+ if (defaultVhost is not null)
+ {
+ return server.ApplicationBuilder.CreateResourceBuilder(defaultVhost);
+ }
+
+ return server.AddVirtualHost($"{server.Resource.Name}-default-vhost", "/");
+ }
+
+ ///
+ /// Adds a queue to a RabbitMQ virtual host.
+ ///
+ /// The RabbitMQ virtual host resource builder.
+ /// The name of the resource.
+ /// The name of the queue. Defaults to the resource name when not provided.
+ /// The type of the queue. Defaults to .
+ /// A reference to the .
+ [AspireExport(Description = "Adds a queue to a RabbitMQ virtual host")]
+ public static IResourceBuilder AddQueue(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string? queueName = null,
+ RabbitMQQueueType type = RabbitMQQueueType.Classic)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ if (queueName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(queueName, nameof(queueName));
+ }
+
+ var qName = queueName ?? name;
+ if (builder.Resource.Queues.Any(q => q.QueueName == qName))
+ {
+ throw new DistributedApplicationException($"A queue with the name '{qName}' already exists in virtual host '{builder.Resource.VirtualHostName}'.");
+ }
+
+ var queue = new RabbitMQQueueResource(name, qName, builder.Resource, type);
+
+ builder.Resource.Queues.Add(queue);
+
+ return builder.ApplicationBuilder.AddResource(queue)
+ .WithProvisionableHealthCheck();
+ }
+
+ ///
+ /// Adds a queue to the default / virtual host of a RabbitMQ server.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The name of the resource.
+ /// The name of the queue. Defaults to the resource name when not provided.
+ /// The type of the queue. Defaults to .
+ /// A reference to the .
+ [AspireExport("addQueueOnServer", MethodName = "addQueue", Description = "Adds a queue to the default '/' virtual host")]
+ public static IResourceBuilder AddQueue(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string? queueName = null,
+ RabbitMQQueueType type = RabbitMQQueueType.Classic)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ return builder.GetOrAddDefaultVirtualHost().AddQueue(name, queueName, type);
+ }
+
+ ///
+ /// Adds an exchange to a RabbitMQ virtual host.
+ ///
+ /// The RabbitMQ virtual host resource builder.
+ /// The name of the resource.
+ /// The type of the exchange. Defaults to .
+ /// The name of the exchange. Defaults to the resource name when not provided.
+ /// A reference to the .
+ [AspireExport(Description = "Adds an exchange to a RabbitMQ virtual host")]
+ public static IResourceBuilder AddExchange(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ RabbitMQExchangeType type = RabbitMQExchangeType.Direct,
+ string? exchangeName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ if (exchangeName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(exchangeName, nameof(exchangeName));
+ }
+
+ var exName = exchangeName ?? name;
+ if (builder.Resource.Exchanges.Any(e => e.ExchangeName == exName))
+ {
+ throw new DistributedApplicationException($"An exchange with the name '{exName}' already exists in virtual host '{builder.Resource.VirtualHostName}'.");
+ }
+
+ var exchange = new RabbitMQExchangeResource(name, exName, builder.Resource, type);
+
+ builder.Resource.Exchanges.Add(exchange);
+
+ return builder.ApplicationBuilder.AddResource(exchange)
+ .WithProvisionableHealthCheck();
+ }
+
+ ///
+ /// Adds an exchange to the default / virtual host of a RabbitMQ server.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The name of the resource.
+ /// The type of the exchange. Defaults to .
+ /// The name of the exchange. Defaults to the resource name when not provided.
+ /// A reference to the .
+ [AspireExport("addExchangeOnServer", MethodName = "addExchange", Description = "Adds an exchange to the default '/' virtual host")]
+ public static IResourceBuilder AddExchange(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ RabbitMQExchangeType type = RabbitMQExchangeType.Direct,
+ string? exchangeName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ return builder.GetOrAddDefaultVirtualHost().AddExchange(name, type, exchangeName);
+ }
+
+ ///
+ /// Configures properties of a RabbitMQ queue.
+ ///
/// The resource builder.
- /// The host port that can be used to access the management UI page when running locally.
- ///
+ /// The configuration action.
+ /// A reference to the .
+ [AspireExport("withQueueProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure)
+ => WithPropertiesCore(builder, configure);
+
+ ///
+ /// Configures properties of a RabbitMQ exchange.
+ ///
+ /// The resource builder.
+ /// The configuration action.
+ /// A reference to the .
+ [AspireExport("withExchangeProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure)
+ => WithPropertiesCore(builder, configure);
+
+ ///
+ /// Configures properties of a RabbitMQ shovel.
+ ///
+ /// The resource builder.
+ /// The configuration action.
+ /// A reference to the .
+ [AspireExport("withShovelProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure)
+ => WithPropertiesCore(builder, configure);
+
+ ///
+ /// Adds a binding from an exchange to a destination.
+ ///
+ /// The type of the destination resource.
+ /// The exchange resource builder.
+ /// The destination resource builder.
+ /// The routing key for the binding.
+ ///
+ /// The headers-exchange match arguments for the binding.
+ /// Used when the source exchange is of type to specify
+ /// which message headers must match for the binding to be selected.
+ ///
+ /// A reference to the .
+ [AspireExport(Description = "Adds a binding from an exchange to a queue or another exchange")]
+ public static IResourceBuilder WithBinding(
+ this IResourceBuilder exchange,
+ IResourceBuilder destination,
+ string routingKey = "",
+ Dictionary? matchHeaders = null)
+ where TDestination : RabbitMQDestination
+ {
+ ArgumentNullException.ThrowIfNull(exchange);
+ ArgumentNullException.ThrowIfNull(destination);
+ ArgumentNullException.ThrowIfNull(routingKey);
+
+ if (exchange.Resource.VirtualHost != destination.Resource.VirtualHost)
+ {
+ throw new DistributedApplicationException($"Cannot bind exchange '{exchange.Resource.Name}' to destination '{destination.Resource.Name}' because they are in different virtual hosts.");
+ }
+
+ exchange.Resource.Bindings.Add(new RabbitMQBinding(destination.Resource, routingKey, matchHeaders));
+ return exchange.WithRelationship(destination.Resource, "Binding");
+ }
+
+ ///
+ /// Configures queue settings such as message TTL, length limits, and dead-letter routing.
+ ///
+ ///
+ /// The resource type. Accepts (to set per-queue arguments)
+ /// and (to apply the same settings via a broker policy).
+ ///
+ /// The resource builder.
+ /// An action that sets properties on the .
+ /// A reference to the .
+ ///
+ /// Set a TTL and a maximum queue length on a queue:
+ ///
+ /// vhost.AddQueue("orders")
+ /// .WithQueueArguments(a =>
+ /// {
+ /// a.MessageTtl = TimeSpan.FromMinutes(5);
+ /// a.MaxLength = 10_000;
+ /// });
+ ///
+ ///
+ [AspireExport(Description = "Configures typed queue x-arguments", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithQueueArguments(
+ this IResourceBuilder builder,
+ Action configure)
+ where T : IResourceWithQueueArguments
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+ configure(builder.Resource.QueueArguments);
+ return builder;
+ }
+
+ ///
+ /// Configures exchange settings such as the alternate exchange for unroutable messages.
+ ///
+ ///
+ /// The resource type. Accepts (to set per-exchange arguments)
+ /// and (to apply the same settings via a broker policy).
+ ///
+ /// The resource builder.
+ /// An action that sets properties on the .
+ /// A reference to the .
///
- /// Use to specify a port to access the RabbitMQ management UI page.
+ /// Route unroutable messages to a dedicated exchange via a policy:
///
- /// var rabbitmq = builder.AddRabbitMQ("rabbitmq")
- /// .WithDataVolume()
- /// .WithManagementPlugin(port: 15672);
+ /// vhost.AddPolicy("ae-policy", ".*", RabbitMQPolicyApplyTo.Exchanges)
+ /// .WithExchangeArguments(a => a.AlternateExchange = unroutable.Resource);
///
///
+ [AspireExport(Description = "Configures typed exchange x-arguments", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithExchangeArguments(
+ this IResourceBuilder builder,
+ Action configure)
+ where T : IResourceWithExchangeArguments
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+ configure(builder.Resource.ExchangeArguments);
+ return builder;
+ }
+
+ ///
+ /// Routes dead-lettered messages from this queue to the specified exchange.
+ ///
+ /// The resource type. Accepts and .
+ /// The resource builder.
+ /// The exchange that will receive dead-lettered messages.
+ ///
+ /// The routing key to use when republishing dead-lettered messages.
+ /// When , the original routing key of the message is preserved.
+ ///
+ /// A reference to the .
+ ///
+ /// Thrown when is in a different virtual host than the queue.
+ ///
+ ///
+ /// Send expired or rejected messages to a dedicated dead-letter exchange:
+ ///
+ /// var dlx = vhost.AddExchange("dead-letters");
+ ///
+ /// vhost.AddQueue("orders")
+ /// .WithQueueArguments(a => a.MessageTtl = TimeSpan.FromMinutes(5))
+ /// .WithDeadLetterExchange(dlx);
+ ///
+ ///
+ [AspireExportIgnore(Reason = "Generic constraint uses IResourceWithParent which is not ATS-compatible. Use WithQueueArguments to set DeadLetterExchange directly in polyglot app hosts.")]
+ public static IResourceBuilder WithDeadLetterExchange(
+ this IResourceBuilder builder,
+ IResourceBuilder dlx,
+ string? routingKey = null)
+ where T : Resource, IResourceWithQueueArguments, IResourceWithParent
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(dlx);
+
+ if (dlx.Resource.VirtualHost != ((IResourceWithParent)builder.Resource).Parent)
+ {
+ throw new DistributedApplicationException(
+ $"Dead-letter exchange '{dlx.Resource.Name}' must be in the same virtual host as '{builder.Resource.Name}'.");
+ }
+
+ builder.Resource.QueueArguments.SetDeadLetterExchange(dlx.Resource, routingKey);
+ return builder.WithRelationship(dlx.Resource, "DeadLetter");
+ }
+
+ ///
+ /// Routes messages that cannot be delivered by this exchange to the specified alternate exchange.
+ ///
+ /// The resource type. Accepts and .
+ /// The resource builder.
+ /// The exchange that will receive unroutable messages.
+ /// A reference to the .
+ ///
+ /// Thrown when is in a different virtual host than the exchange.
+ ///
+ ///
+ /// Capture unroutable messages in a dedicated exchange:
+ ///
+ /// var unroutable = vhost.AddExchange("unroutable");
+ ///
+ /// vhost.AddExchange("orders")
+ /// .WithAlternateExchange(unroutable);
+ ///
+ ///
+ [AspireExportIgnore(Reason = "Generic constraint uses IResourceWithParent which is not ATS-compatible. Use WithExchangeArguments to set AlternateExchange directly in polyglot app hosts.")]
+ public static IResourceBuilder WithAlternateExchange(
+ this IResourceBuilder builder,
+ IResourceBuilder ae)
+ where T : Resource, IResourceWithExchangeArguments, IResourceWithParent
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(ae);
+
+ if (ae.Resource.VirtualHost != ((IResourceWithParent)builder.Resource).Parent)
+ {
+ throw new DistributedApplicationException(
+ $"Alternate exchange '{ae.Resource.Name}' must be in the same virtual host as '{builder.Resource.Name}'.");
+ }
+
+ builder.Resource.ExchangeArguments.SetAlternateExchange(ae.Resource);
+ return builder.WithRelationship(ae.Resource, "AlternateExchange");
+ }
+
+ ///
+ /// Adds a shovel to a RabbitMQ virtual host.
+ ///
+ /// The type of the source resource.
+ /// The type of the destination resource.
+ /// The RabbitMQ virtual host resource builder.
+ /// The name of the resource.
+ /// The source resource builder.
+ /// The destination resource builder.
+ /// The name of the shovel in RabbitMQ. If not provided, defaults to the resource name.
+ /// A reference to the .
+ [AspireExport(Description = "Adds a shovel to a RabbitMQ virtual host")]
+ public static IResourceBuilder AddShovel(
+ this IResourceBuilder vhost,
+ [ResourceName] string name,
+ IResourceBuilder source,
+ IResourceBuilder destination,
+ string? shovelName = null)
+ where TSrc : RabbitMQDestination
+ where TDest : RabbitMQDestination
+ {
+ ArgumentNullException.ThrowIfNull(vhost);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(destination);
+ if (shovelName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(shovelName, nameof(shovelName));
+ }
+
+ var wireName = shovelName ?? name;
+ if (vhost.Resource.Shovels.Any(s => s.ShovelName == wireName))
+ {
+ throw new DistributedApplicationException($"A shovel with the name '{wireName}' already exists in virtual host '{vhost.Resource.VirtualHostName}'.");
+ }
+
+ if (source.Resource.VirtualHost.Parent != vhost.Resource.Parent)
+ {
+ throw new DistributedApplicationException($"Cannot add shovel '{name}' because the source destination '{source.Resource.Name}' is on a different RabbitMQ server.");
+ }
+
+ if (destination.Resource.VirtualHost.Parent != vhost.Resource.Parent)
+ {
+ throw new DistributedApplicationException($"Cannot add shovel '{name}' because the destination '{destination.Resource.Name}' is on a different RabbitMQ server.");
+ }
+
+ var shovel = new RabbitMQShovelResource(name, wireName, vhost.Resource, source.Resource, destination.Resource);
+ vhost.Resource.Shovels.Add(shovel);
+
+ var server = vhost.ApplicationBuilder.CreateResourceBuilder(vhost.Resource.Parent);
+ server.WithManagementPlugin();
+ server.WithPlugin(RabbitMQPlugin.Shovel);
+ server.WithPlugin(RabbitMQPlugin.ShovelManagement);
+
+ return vhost.ApplicationBuilder.AddResource(shovel)
+ .WithRelationship(source.Resource, "Source")
+ .WithRelationship(destination.Resource, "Destination")
+ .WithProvisionableHealthCheck();
+ }
+
+ ///
+ /// Adds a shovel to the default '/' virtual host of a RabbitMQ server.
+ ///
+ /// The type of the source resource.
+ /// The type of the destination resource.
+ /// The RabbitMQ server resource builder.
+ /// The name of the resource.
+ /// The source resource builder.
+ /// The destination resource builder.
+ /// The name of the shovel in RabbitMQ. If not provided, defaults to the resource name.
+ /// A reference to the .
+ [AspireExport("addShovelOnServer", MethodName = "addShovel", Description = "Adds a shovel to the default '/' virtual host")]
+ public static IResourceBuilder AddShovel(
+ this IResourceBuilder server,
+ [ResourceName] string name,
+ IResourceBuilder source,
+ IResourceBuilder destination,
+ string? shovelName = null)
+ where TSrc : RabbitMQDestination
+ where TDest : RabbitMQDestination
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ return server.GetOrAddDefaultVirtualHost().AddShovel(name, source, destination, shovelName);
+ }
+
+ ///
+ /// Adds a policy to a RabbitMQ virtual host.
+ ///
+ ///
+ /// Policies are applied to queues and/or exchanges whose names match (a regex)
+ /// and configure runtime behaviour such as message TTL, dead-letter routing, and queue length limits.
+ /// Policies require the management plugin, which is enabled automatically when a non-default virtual host is added.
///
+ /// The RabbitMQ virtual host resource builder.
+ /// The name of the resource.
+ /// The regex pattern that determines which queues and/or exchanges the policy applies to.
+ /// Which entity types the policy applies to. Defaults to .
+ /// The policy priority. Higher values take precedence when multiple policies match the same entity. Defaults to 0.
+ /// The name of the policy in RabbitMQ. Defaults to the resource name when not provided.
+ /// A reference to the .
+ [AspireExport(Description = "Adds a policy to a RabbitMQ virtual host")]
+ public static IResourceBuilder AddPolicy(
+ this IResourceBuilder builder,
+ [ResourceName] string name,
+ string pattern,
+ RabbitMQPolicyApplyTo applyTo = RabbitMQPolicyApplyTo.All,
+ int priority = 0,
+ string? policyName = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(pattern);
+ if (policyName is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(policyName, nameof(policyName));
+ }
+
+ var wireName = policyName ?? name;
+ if (builder.Resource.Policies.Any(p => p.PolicyName == wireName))
+ {
+ throw new DistributedApplicationException($"A policy with the name '{wireName}' already exists in virtual host '{builder.Resource.VirtualHostName}'.");
+ }
+
+ var policy = new RabbitMQPolicyResource(name, wireName, pattern, builder.Resource, applyTo, priority);
+ builder.Resource.Policies.Add(policy);
+
+ var policyBuilder = builder.ApplicationBuilder.AddResource(policy);
+
+ // Resolve which queues and exchanges this policy applies to at model-freeze time (BeforeStartEvent).
+ // Using BeforeStartEvent (not AddPolicy call time) ensures that entities added after the policy are also matched.
+ builder.ApplicationBuilder.Eventing.Subscribe((@event, ct) =>
+ {
+ ResolveAndApplyPolicyMatches(policy, builder.Resource, policyBuilder);
+ return Task.CompletedTask;
+ });
+
+ return policyBuilder.WithProvisionableHealthCheck();
+ }
+
+ ///
+ /// Resolves which queues and exchanges in match
+ /// and wires up the applied-policies lists and dashboard relationships.
+ /// Exposed internally for testing.
+ ///
+ internal static void ResolveAndApplyPolicyMatches(
+ RabbitMQPolicyResource policy,
+ RabbitMQVirtualHostResource vhost,
+ IResourceBuilder policyBuilder)
+ {
+ foreach (var queue in vhost.Queues)
+ {
+ if (policy.AppliesTo(queue.QueueName, RabbitMQDestinationKind.Queue))
+ {
+ queue.AppliedPolicies.Add(policy);
+ policyBuilder.WithRelationship(queue, "Policy");
+ }
+ }
+
+ foreach (var exchange in vhost.Exchanges)
+ {
+ if (policy.AppliesTo(exchange.ExchangeName, RabbitMQDestinationKind.Exchange))
+ {
+ exchange.AppliedPolicies.Add(policy);
+ policyBuilder.WithRelationship(exchange, "Policy");
+ }
+ }
+ }
+
+ ///
+ /// Adds a policy to the default / virtual host of a RabbitMQ server.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The name of the resource.
+ /// The regex pattern that determines which queues and/or exchanges the policy applies to.
+ /// Which entity types the policy applies to. Defaults to .
+ /// The policy priority. Higher values take precedence when multiple policies match the same entity. Defaults to 0.
+ /// The name of the policy in RabbitMQ. Defaults to the resource name when not provided.
+ /// A reference to the .
+ [AspireExport("addPolicyOnServer", MethodName = "addPolicy", Description = "Adds a policy to the default '/' virtual host")]
+ public static IResourceBuilder AddPolicy(
+ this IResourceBuilder server,
+ [ResourceName] string name,
+ string pattern,
+ RabbitMQPolicyApplyTo applyTo = RabbitMQPolicyApplyTo.All,
+ int priority = 0,
+ string? policyName = null)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ return server.GetOrAddDefaultVirtualHost().AddPolicy(name, pattern, applyTo, priority, policyName);
+ }
+
+ ///
+ /// Configures additional policy settings such as .
+ /// To configure typed queue or exchange arguments, use or instead.
+ ///
+ /// The resource builder.
+ /// The configuration action.
+ /// A reference to the .
+ [AspireExport("withPolicyProperties", MethodName = "withProperties", RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithProperties(this IResourceBuilder builder, Action configure)
+ => WithPropertiesCore(builder, configure);
+
+ ///
+ /// Enables a RabbitMQ plugin.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The plugin to enable.
+ /// A reference to the .
+ [AspireExport(Description = "Enables a RabbitMQ plugin")]
+ public static IResourceBuilder WithPlugin(
+ this IResourceBuilder builder,
+ RabbitMQPlugin plugin)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ return builder.WithPlugin(plugin.ToPluginName());
+ }
+
+ ///
+ /// Enables a RabbitMQ plugin by name.
+ ///
+ /// The RabbitMQ server resource builder.
+ /// The name of the plugin to enable.
+ /// A reference to the .
+ [AspireExport("withPluginByName", MethodName = "withPlugin", Description = "Enables a RabbitMQ plugin by name")]
+ public static IResourceBuilder WithPlugin(
+ this IResourceBuilder builder,
+ string pluginName)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
+
+ builder.WithAnnotation(new RabbitMQPluginAnnotation(pluginName));
+
+ if (!builder.Resource.HasPluginFileCallback)
+ {
+ builder.Resource.HasPluginFileCallback = true;
+ builder.WithContainerFiles("/etc/rabbitmq", (context, ct) =>
+ {
+ var plugins = builder.Resource.Annotations
+ .OfType()
+ .Select(a => a.PluginName)
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(x => x, StringComparer.Ordinal);
+
+ var content = $"[{string.Join(",", plugins)}].";
+ IEnumerable items =
+ [
+ new ContainerFile { Name = "enabled_plugins", Contents = content }
+ ];
+ return Task.FromResult(items);
+ });
+ }
+
+ return builder;
+ }
+
+ [AspireExport("withManagementPlugin", Description = "Enables the RabbitMQ management plugin")]
+ internal static IResourceBuilder WithManagementPluginForPolyglot(
+ this IResourceBuilder builder,
+ int? port = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithManagementPlugin(port);
+ }
+
+ ///
+ /// The resource builder.
+ /// The host port used to access the management UI when running locally.
[AspireExportIgnore(Reason = "Polyglot app hosts use the internal withManagementPlugin dispatcher export.")]
public static IResourceBuilder WithManagementPlugin(this IResourceBuilder builder, int? port)
{
@@ -220,12 +840,47 @@ public static IResourceBuilder WithManagementPlugin(this
if (handled)
{
builder.WithHttpEndpoint(port: port, targetPort: 15672, name: RabbitMQServerResource.ManagementEndpointName);
+
+ // Register the plugins that the management image bundles so that the enabled_plugins file
+ // reflects the full set when WithPlugin is also called.
+ builder.WithPlugin(RabbitMQPlugin.Management);
+ builder.WithPlugin(RabbitMQPlugin.ManagementAgent);
+ builder.WithPlugin(RabbitMQPlugin.WebDispatch);
+ builder.WithPlugin(RabbitMQPlugin.Prometheus);
+
return builder;
}
throw new DistributedApplicationException($"Cannot configure the RabbitMQ resource '{builder.Resource.Name}' to enable the management plugin as it uses an unrecognized container image registry, name, or tag.");
}
+ ///
+ /// Registers a provisioning health check for the given resource and wires it up.
+ /// The server name is derived from so it
+ /// does not need to be passed explicitly at every call site.
+ ///
+ private static IResourceBuilder WithProvisionableHealthCheck(
+ this IResourceBuilder builder)
+ where T : Resource, IRabbitMQProvisionable, IRabbitMQServerChild
+ {
+ var resource = builder.Resource;
+ var serverName = resource.VirtualHost.Parent.Name;
+ var healthCheckKey = $"{resource.Name}_check";
+
+ builder.ApplicationBuilder.Services.AddHealthChecks().Add(new HealthCheckRegistration(
+ healthCheckKey,
+ sp =>
+ {
+ var client = sp.GetRequiredKeyedService(serverName);
+ var logger = sp.GetRequiredService().CreateLogger();
+ return new RabbitMQProvisionableHealthCheck(resource, client, logger);
+ },
+ failureStatus: null,
+ tags: null));
+
+ return builder.WithHealthCheck(healthCheckKey);
+ }
+
private static bool IsVersion(string tag)
{
// Must not be empty or null
@@ -284,4 +939,14 @@ private static IResourceBuilder RunWithStableNodeName(th
return builder;
}
+
+ private static IResourceBuilder WithPropertiesCore(IResourceBuilder builder, Action configure)
+ where T : Resource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+ configure(builder.Resource);
+ return builder;
+ }
}
+
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQDestination.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQDestination.cs
new file mode 100644
index 00000000000..dd8f4c86745
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQDestination.cs
@@ -0,0 +1,61 @@
+// 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.RabbitMQ.Provisioning;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Base class for RabbitMQ destinations (queues and exchanges).
+///
+///
+/// The two concrete subtypes are and .
+/// The connection string expression is forwarded from the parent virtual host.
+///
+public abstract class RabbitMQDestination : Resource,
+ IResourceWithConnectionString,
+ IResourceWithParent,
+ IRabbitMQServerChild
+{
+ internal RabbitMQDestination(string name, RabbitMQVirtualHostResource virtualHost) : base(name)
+ {
+ ArgumentNullException.ThrowIfNull(virtualHost);
+ VirtualHost = virtualHost;
+ }
+
+ ///
+ /// Gets the virtual host that contains this destination.
+ ///
+ public RabbitMQVirtualHostResource VirtualHost { get; }
+
+ ///
+ /// Explicit implementation of that returns .
+ ///
+ RabbitMQVirtualHostResource IResourceWithParent.Parent => VirtualHost;
+
+ ///
+ /// Gets the wire name of the entity as known to the broker.
+ ///
+ public abstract string ProvisionedName { get; }
+
+ ///
+ /// Gets the kind of the destination.
+ ///
+ public abstract RabbitMQDestinationKind Kind { get; }
+
+ ///
+ /// Gets the connection string expression for this destination, forwarded from the parent virtual host.
+ ///
+ public ReferenceExpression ConnectionStringExpression => VirtualHost.ConnectionStringExpression;
+
+ ///
+ /// Binds this destination to using the provisioning client.
+ ///
+ internal abstract Task BindAsync(
+ IRabbitMQProvisioningClient client,
+ string vhost,
+ string sourceExchange,
+ string routingKey,
+ Dictionary? args,
+ CancellationToken ct);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQDestinationKind.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQDestinationKind.cs
new file mode 100644
index 00000000000..55f0cdd0dd0
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQDestinationKind.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Identifies whether a RabbitMQ destination is a queue or an exchange.
+///
+public enum RabbitMQDestinationKind
+{
+ ///
+ /// The destination is a .
+ ///
+ Queue,
+
+ ///
+ /// The destination is a .
+ ///
+ Exchange
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeArguments.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeArguments.cs
new file mode 100644
index 00000000000..ac4456996b7
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeArguments.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Frozen;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Configures exchange-specific x-arguments such as the alternate exchange for unroutable messages.
+///
+///
+/// Use to configure these settings on a
+/// or a that targets exchanges.
+///
+[AspireDto]
+public sealed class RabbitMQExchangeArguments
+{
+ ///
+ /// Gets the alternate exchange for this exchange (alternate-exchange).
+ ///
+ ///
+ /// Use to set this value.
+ ///
+ public RabbitMQExchangeResource? AlternateExchange { get; private set; }
+
+ ///
+ /// Gets additional exchange x-arguments not covered by the typed properties above.
+ ///
+ ///
+ /// Do not repeat a key that already has a typed property (e.g. alternate-exchange); doing so will throw at startup.
+ /// Entries may be added until the application starts. Mutations after are ignored.
+ ///
+ public Dictionary AdditionalArguments { get; } = [];
+
+ /// Sets the alternate exchange; called by .
+ internal void SetAlternateExchange(RabbitMQExchangeResource ae)
+ {
+ AlternateExchange = ae;
+ }
+
+ internal const string XArgAlternateExchange = "alternate-exchange";
+
+ internal static readonly FrozenSet s_reservedKeys = new[]
+ {
+ XArgAlternateExchange,
+ }.ToFrozenSet(StringComparer.Ordinal);
+
+ /// Validates and merges all arguments into .
+ /// The dictionary to merge into.
+ /// Human-readable resource description (e.g. Exchange 'orders') used in error messages.
+ /// Thrown when contains a key already handled by a typed property.
+ internal void FlattenInto(IDictionary target, string resourceDescription)
+ {
+ foreach (var key in AdditionalArguments.Keys)
+ {
+ if (s_reservedKeys.Contains(key))
+ {
+ throw new DistributedApplicationException(
+ $"{resourceDescription}: '{key}' in AdditionalArguments is already handled by a typed property on {nameof(RabbitMQExchangeArguments)}. " +
+ $"Use the corresponding typed property instead.");
+ }
+ }
+
+ foreach (var (k, v) in AdditionalArguments)
+ {
+ target[k] = v;
+ }
+
+ if (AlternateExchange is { } ae)
+ {
+ target[XArgAlternateExchange] = ae.ExchangeName;
+ }
+ }
+
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeResource.cs
new file mode 100644
index 00000000000..76718acb225
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeResource.cs
@@ -0,0 +1,181 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a RabbitMQ exchange resource that is declared on the broker during provisioning.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, ExchangeName = {ExchangeName}")]
+[AspireExport(ExposeProperties = true)]
+public class RabbitMQExchangeResource : RabbitMQDestination, IResourceWithConnectionString, IRabbitMQProvisionable, IResourceWithExchangeArguments
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the resource.
+ /// The name of the exchange.
+ /// The RabbitMQ virtual host resource associated with this exchange.
+ /// The type of the exchange. Defaults to .
+ public RabbitMQExchangeResource(string name, string exchangeName, RabbitMQVirtualHostResource virtualHost, RabbitMQExchangeType exchangeType = RabbitMQExchangeType.Direct) : base(name, virtualHost)
+ {
+ ArgumentNullException.ThrowIfNull(exchangeName);
+
+ ExchangeName = exchangeName;
+ ExchangeType = exchangeType;
+ }
+
+ ///
+ /// Gets the name of the exchange.
+ ///
+ public string ExchangeName { get; }
+
+ ///
+ /// Gets the routing algorithm used by this exchange. Set via the type parameter of AddExchange.
+ ///
+ public RabbitMQExchangeType ExchangeType { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the exchange is durable.
+ ///
+ public bool Durable { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the exchange is auto-deleted.
+ ///
+ public bool AutoDelete { get; set; }
+
+ ///
+ /// Gets the exchange arguments for this exchange declaration, such as the alternate exchange for unroutable messages.
+ ///
+ ///
+ /// Use to configure these settings.
+ ///
+ public RabbitMQExchangeArguments ExchangeArguments { get; } = new();
+
+ internal List Bindings { get; } = [];
+
+ ///
+ /// Gets the policies that apply to this exchange, resolved at startup from matching AddPolicy calls on the parent virtual host.
+ ///
+ internal List AppliedPolicies { get; } = [];
+
+ IEnumerable IRabbitMQProvisionable.HealthDependencies
+ {
+ get
+ {
+ foreach (var policy in AppliedPolicies)
+ {
+ yield return policy;
+ }
+
+ // The alternate exchange must be provisioned before this exchange's health is meaningful.
+ if (ExchangeArguments.AlternateExchange is { } ae)
+ {
+ yield return ae;
+ }
+ }
+ }
+
+ ///
+ public override string ProvisionedName => ExchangeName;
+
+ ///
+ public override RabbitMQDestinationKind Kind => RabbitMQDestinationKind.Exchange;
+
+ ///
+ /// Gets the connection string properties for this exchange, including the exchange name.
+ ///
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties() =>
+ VirtualHost.CombineProperties([
+ new("ExchangeName", ReferenceExpression.Create($"{ExchangeName}")),
+ ]);
+
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Task IRabbitMQProvisionable.ProvisionedTask => _tcs.Task;
+
+ ///
+ /// Declares the exchange on the broker and publishes Running.
+ ///
+ ///
+ /// The provisioned task is not signalled here — that happens after bindings are applied in .
+ /// On failure, faults the provisioned task and publishes FailedToStart.
+ ///
+ async Task IRabbitMQProvisionable.ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Starting }).ConfigureAwait(false);
+ try
+ {
+ var typeString = ExchangeType.ToString().ToLowerInvariant();
+ var args = new Dictionary();
+
+ ExchangeArguments.FlattenInto(args, $"Exchange '{ExchangeName}'");
+
+ await client.DeclareExchangeAsync(
+ VirtualHost.VirtualHostName,
+ ExchangeName,
+ typeString,
+ Durable,
+ AutoDelete,
+ args.Count > 0 ? args : null,
+ cancellationToken).ConfigureAwait(false);
+
+ // Exchange IS running — it exists on the broker. Bindings are phase 3.
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false);
+ // ProvisionedTask stays pending until ApplyBindingsAsync completes.
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to declare exchange '{Exchange}'.", ExchangeName);
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Applies all bindings for this exchange and signals the provisioned task.
+ ///
+ ///
+ /// On success, completes and the lifecycle stays Running.
+ /// On failure, the provisioned task is faulted but the lifecycle stays Running because the exchange itself was declared successfully.
+ ///
+ internal async Task ApplyBindingsAsync(IRabbitMQProvisioningClient client, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ try
+ {
+ foreach (var binding in Bindings)
+ {
+ await binding.Destination.BindAsync(
+ client,
+ VirtualHost.VirtualHostName,
+ ExchangeName,
+ binding.RoutingKey,
+ binding.MatchHeaders,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ _tcs.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to apply bindings for exchange '{Exchange}'.", ExchangeName);
+ }
+ }
+
+ async ValueTask IRabbitMQProvisionable.ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ {
+ var exists = await client.ExchangeExistsAsync(VirtualHost.VirtualHostName, ExchangeName, cancellationToken).ConfigureAwait(false);
+ return exists
+ ? RabbitMQProbeResult.Healthy
+ : RabbitMQProbeResult.Unhealthy($"Exchange '{ExchangeName}' does not exist in virtual host '{VirtualHost.VirtualHostName}'.");
+ }
+
+ internal override Task BindAsync(IRabbitMQProvisioningClient client, string vhost, string sourceExchange, string routingKey, Dictionary? args, CancellationToken ct)
+ => client.BindExchangeAsync(vhost, sourceExchange, ExchangeName, routingKey, args, ct);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeType.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeType.cs
new file mode 100644
index 00000000000..23b38ac6dc8
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQExchangeType.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Specifies the type of a RabbitMQ exchange resource.
+///
+public enum RabbitMQExchangeType
+{
+ ///
+ /// A direct exchange.
+ ///
+ Direct,
+
+ ///
+ /// A topic exchange.
+ ///
+ Topic,
+
+ ///
+ /// A fanout exchange.
+ ///
+ Fanout,
+
+ ///
+ /// A headers exchange.
+ ///
+ Headers
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQPlugin.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQPlugin.cs
new file mode 100644
index 00000000000..e0e1a11dbbf
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQPlugin.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a well-known RabbitMQ plugin that can be enabled via
+/// .
+///
+public enum RabbitMQPlugin
+{
+ /// The rabbitmq_management plugin, which provides the HTTP-based management API and UI.
+ Management,
+
+ /// The rabbitmq_management_agent plugin, which is required by the management plugin on every node.
+ ManagementAgent,
+
+ /// The rabbitmq_web_dispatch plugin, which provides the HTTP listener used by the management plugin.
+ WebDispatch,
+
+ /// The rabbitmq_shovel plugin, which enables dynamic shovels for moving messages between brokers.
+ Shovel,
+
+ /// The rabbitmq_shovel_management plugin, which adds shovel management to the HTTP API.
+ ShovelManagement,
+
+ /// The rabbitmq_federation plugin, which enables federated exchanges and queues.
+ Federation,
+
+ /// The rabbitmq_federation_management plugin, which adds federation management to the HTTP API.
+ FederationManagement,
+
+ /// The rabbitmq_stream plugin, which enables the RabbitMQ Streams protocol.
+ Stream,
+
+ /// The rabbitmq_stream_management plugin, which adds stream management to the HTTP API.
+ StreamManagement,
+
+ /// The rabbitmq_mqtt plugin, which enables the MQTT protocol adapter.
+ Mqtt,
+
+ /// The rabbitmq_stomp plugin, which enables the STOMP protocol adapter.
+ Stomp,
+
+ /// The rabbitmq_web_mqtt plugin, which enables MQTT over WebSockets.
+ WebMqtt,
+
+ /// The rabbitmq_web_stomp plugin, which enables STOMP over WebSockets.
+ WebStomp,
+
+ /// The rabbitmq_prometheus plugin, which exposes metrics in Prometheus format.
+ Prometheus,
+
+ /// The rabbitmq_amqp1_0 plugin, which enables the AMQP 1.0 protocol adapter.
+ Amqp10
+}
+
+///
+/// Provides the canonical broker plugin name for each value.
+///
+internal static class RabbitMQPluginNames
+{
+ ///
+ /// Returns the canonical broker plugin name (e.g. rabbitmq_management) for the given .
+ ///
+ /// The plugin enum value.
+ /// The broker-level plugin name string.
+ /// Thrown when is not a recognised value.
+ internal static string ToPluginName(this RabbitMQPlugin plugin) => plugin switch
+ {
+ RabbitMQPlugin.Management => "rabbitmq_management",
+ RabbitMQPlugin.ManagementAgent => "rabbitmq_management_agent",
+ RabbitMQPlugin.WebDispatch => "rabbitmq_web_dispatch",
+ RabbitMQPlugin.Shovel => "rabbitmq_shovel",
+ RabbitMQPlugin.ShovelManagement => "rabbitmq_shovel_management",
+ RabbitMQPlugin.Federation => "rabbitmq_federation",
+ RabbitMQPlugin.FederationManagement => "rabbitmq_federation_management",
+ RabbitMQPlugin.Stream => "rabbitmq_stream",
+ RabbitMQPlugin.StreamManagement => "rabbitmq_stream_management",
+ RabbitMQPlugin.Mqtt => "rabbitmq_mqtt",
+ RabbitMQPlugin.Stomp => "rabbitmq_stomp",
+ RabbitMQPlugin.WebMqtt => "rabbitmq_web_mqtt",
+ RabbitMQPlugin.WebStomp => "rabbitmq_web_stomp",
+ RabbitMQPlugin.Prometheus => "rabbitmq_prometheus",
+ RabbitMQPlugin.Amqp10 => "rabbitmq_amqp1_0",
+ _ => throw new ArgumentOutOfRangeException(nameof(plugin), plugin, null)
+ };
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQPluginAnnotation.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQPluginAnnotation.cs
new file mode 100644
index 00000000000..60f6648ca64
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQPluginAnnotation.cs
@@ -0,0 +1,16 @@
+// 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.ApplicationModel;
+
+namespace Aspire.Hosting.RabbitMQ;
+
+internal sealed class RabbitMQPluginAnnotation : IResourceAnnotation
+{
+ public string PluginName { get; }
+
+ public RabbitMQPluginAnnotation(string pluginName)
+ {
+ PluginName = pluginName;
+ }
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyApplyTo.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyApplyTo.cs
new file mode 100644
index 00000000000..a5d3cdb87a8
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyApplyTo.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Specifies which entity types a RabbitMQ policy applies to.
+///
+public enum RabbitMQPolicyApplyTo
+{
+ ///
+ /// The policy applies to queues only.
+ ///
+ Queues,
+
+ ///
+ /// The policy applies to exchanges only.
+ ///
+ Exchanges,
+
+ ///
+ /// The policy applies to both queues and exchanges.
+ ///
+ All,
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyResource.cs
new file mode 100644
index 00000000000..cdf3dc44fc7
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQPolicyResource.cs
@@ -0,0 +1,191 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a RabbitMQ policy resource that is applied to matching queues and/or exchanges during provisioning.
+///
+///
+/// Policies are applied to queues and/or exchanges whose names match the regex
+/// and configure runtime behaviour such as message TTL, dead-letter routing, and queue length limits.
+/// Setting on a policy whose is
+/// , or on a policy
+/// whose is , will throw at startup.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, PolicyName = {PolicyName}")]
+[AspireExport(ExposeProperties = true)]
+public class RabbitMQPolicyResource : Resource, IResourceWithParent, IRabbitMQProvisionable, IResourceWithQueueArguments, IResourceWithExchangeArguments, IRabbitMQServerChild
+{
+ private Regex? _compiledPattern;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the resource.
+ /// The name of the policy in RabbitMQ.
+ /// The regex pattern that determines which queues and/or exchanges the policy applies to.
+ /// The RabbitMQ virtual host resource associated with this policy.
+ /// Which entity types the policy applies to.
+ /// The policy priority. Higher values take precedence when multiple policies match.
+ public RabbitMQPolicyResource(string name, string policyName, string pattern, RabbitMQVirtualHostResource parent,
+ RabbitMQPolicyApplyTo applyTo = RabbitMQPolicyApplyTo.All, int priority = 0) : base(name)
+ {
+ ArgumentNullException.ThrowIfNull(policyName);
+ ArgumentNullException.ThrowIfNull(pattern);
+ ArgumentNullException.ThrowIfNull(parent);
+
+ PolicyName = policyName;
+ Pattern = pattern;
+ Parent = parent;
+ ApplyTo = applyTo;
+ Priority = priority;
+ }
+
+ ///
+ /// Gets the name of the policy as known to the broker.
+ ///
+ public string PolicyName { get; }
+
+ ///
+ /// Gets the regex pattern that determines which queues and/or exchanges this policy applies to.
+ ///
+ public string Pattern { get; }
+
+ ///
+ /// Gets the virtual host in which this policy is defined.
+ ///
+ public RabbitMQVirtualHostResource Parent { get; }
+
+ ///
+ /// Gets which entity types (queues, exchanges, or both) this policy applies to.
+ ///
+ public RabbitMQPolicyApplyTo ApplyTo { get; }
+
+ ///
+ /// Gets the policy priority. Higher values take precedence when multiple policies match the same entity.
+ ///
+ public int Priority { get; }
+
+ ///
+ /// Gets the queue arguments applied by this policy to matching queues.
+ ///
+ ///
+ /// Only used when is or .
+ ///
+ public RabbitMQQueueArguments QueueArguments { get; } = new();
+
+ ///
+ /// Gets the exchange arguments applied by this policy to matching exchanges.
+ ///
+ ///
+ /// Only used when is or .
+ ///
+ public RabbitMQExchangeArguments ExchangeArguments { get; } = new();
+
+ ///
+ /// Gets additional policy definition keys not covered by or ,
+ /// such as ha-mode, federation-upstream, or ha-sync-mode.
+ ///
+ ///
+ /// Entries may be added until the application starts. Mutations after are ignored.
+ ///
+ public Dictionary AdditionalArguments { get; } = [];
+
+ ///
+ /// Returns if this policy applies to the entity with the given name and kind.
+ ///
+ /// The broker wire name of the queue or exchange.
+ /// The kind of the entity (queue or exchange).
+ internal bool AppliesTo(string entityName, RabbitMQDestinationKind kind)
+ {
+ var scopeMatches = ApplyTo switch
+ {
+ RabbitMQPolicyApplyTo.Queues => kind == RabbitMQDestinationKind.Queue,
+ RabbitMQPolicyApplyTo.Exchanges => kind == RabbitMQDestinationKind.Exchange,
+ RabbitMQPolicyApplyTo.All => true,
+ _ => false,
+ };
+
+ if (!scopeMatches)
+ {
+ return false;
+ }
+
+ _compiledPattern ??= new Regex(Pattern, RegexOptions.None, TimeSpan.FromSeconds(1));
+ return _compiledPattern.IsMatch(entityName);
+ }
+
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Task IRabbitMQProvisionable.ProvisionedTask => _tcs.Task;
+
+ async Task IRabbitMQProvisionable.ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Starting }).ConfigureAwait(false);
+ try
+ {
+ if (ApplyTo == RabbitMQPolicyApplyTo.Exchanges &&
+ (QueueArguments.MessageTtl is not null || QueueArguments.MaxLength is not null ||
+ QueueArguments.MaxLengthBytes is not null || QueueArguments.Expires is not null ||
+ QueueArguments.DeadLetterExchange is not null || QueueArguments.DeadLetterRoutingKey is not null ||
+ QueueArguments.AdditionalArguments.Count > 0))
+ {
+ throw new DistributedApplicationException(
+ $"Policy '{PolicyName}' has QueueArguments set but ApplyTo is '{nameof(RabbitMQPolicyApplyTo.Exchanges)}'. " +
+ $"Queue arguments are ignored when a policy only targets exchanges. " +
+ $"Set ApplyTo to '{nameof(RabbitMQPolicyApplyTo.Queues)}' or '{nameof(RabbitMQPolicyApplyTo.All)}', or clear QueueArguments.");
+ }
+
+ if (ApplyTo == RabbitMQPolicyApplyTo.Queues &&
+ (ExchangeArguments.AlternateExchange is not null || ExchangeArguments.AdditionalArguments.Count > 0))
+ {
+ throw new DistributedApplicationException(
+ $"Policy '{PolicyName}' has ExchangeArguments set but ApplyTo is '{nameof(RabbitMQPolicyApplyTo.Queues)}'. " +
+ $"Exchange arguments are ignored when a policy only targets queues. " +
+ $"Set ApplyTo to '{nameof(RabbitMQPolicyApplyTo.Exchanges)}' or '{nameof(RabbitMQPolicyApplyTo.All)}', or clear ExchangeArguments.");
+ }
+
+ var definition = new Dictionary();
+ QueueArguments.FlattenInto(definition, $"Policy '{PolicyName}'");
+ ExchangeArguments.FlattenInto(definition, $"Policy '{PolicyName}'");
+
+ foreach (var (k, v) in AdditionalArguments)
+ {
+ definition[k] = v;
+ }
+
+ var def = new RabbitMQPolicyDefinition(
+ Pattern,
+ ApplyTo.ToString().ToLowerInvariant(),
+ definition,
+ Priority);
+
+ await client.PutPolicyAsync(Parent.VirtualHostName, PolicyName, def, cancellationToken).ConfigureAwait(false);
+
+ _tcs.TrySetResult();
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to apply policy '{Policy}'.", PolicyName);
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false);
+ }
+ }
+
+ async ValueTask IRabbitMQProvisionable.ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ {
+ var exists = await client.PolicyExistsAsync(Parent.VirtualHostName, PolicyName, cancellationToken).ConfigureAwait(false);
+ return exists
+ ? RabbitMQProbeResult.Healthy
+ : RabbitMQProbeResult.Unhealthy($"Policy '{PolicyName}' does not exist in virtual host '{Parent.VirtualHostName}'.");
+ }
+
+ RabbitMQVirtualHostResource IRabbitMQServerChild.VirtualHost => Parent;
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueArguments.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueArguments.cs
new file mode 100644
index 00000000000..063c3c94f9d
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueArguments.cs
@@ -0,0 +1,139 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Frozen;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Configures queue-specific x-arguments such as message TTL, length limits, and dead-lettering.
+///
+///
+/// Use to configure these settings on a
+/// or a that targets queues.
+///
+[AspireDto]
+public sealed class RabbitMQQueueArguments
+{
+ ///
+ /// Gets or sets the per-message TTL for the queue (x-message-ttl).
+ ///
+ public TimeSpan? MessageTtl { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of messages the queue will hold (x-max-length).
+ ///
+ public int? MaxLength { get; set; }
+
+ ///
+ /// Gets or sets the maximum total size in bytes of all messages the queue will hold (x-max-length-bytes).
+ ///
+ public long? MaxLengthBytes { get; set; }
+
+ ///
+ /// Gets or sets how long a queue can remain unused before it is deleted (x-expires).
+ ///
+ public TimeSpan? Expires { get; set; }
+
+ ///
+ /// Gets the dead-letter exchange for this queue (x-dead-letter-exchange).
+ ///
+ ///
+ /// Use to set this value.
+ ///
+ public RabbitMQExchangeResource? DeadLetterExchange { get; private set; }
+
+ ///
+ /// Gets the routing key used when dead-lettering messages (x-dead-letter-routing-key).
+ ///
+ ///
+ /// Use to set this value.
+ ///
+ public string? DeadLetterRoutingKey { get; private set; }
+
+ ///
+ /// Gets additional queue x-arguments not covered by the typed properties above, such as x-overflow.
+ ///
+ ///
+ /// Do not repeat a key that already has a typed property (e.g. x-message-ttl); doing so will throw at startup.
+ /// Entries may be added until the application starts. Mutations after are ignored.
+ ///
+ public Dictionary AdditionalArguments { get; } = [];
+
+ /// Sets the dead-letter exchange and optional routing key; called by .
+ internal void SetDeadLetterExchange(RabbitMQExchangeResource dlx, string? routingKey)
+ {
+ DeadLetterExchange = dlx;
+ DeadLetterRoutingKey = routingKey;
+ }
+
+ internal const string XArgMessageTtl = "x-message-ttl";
+ internal const string XArgMaxLength = "x-max-length";
+ internal const string XArgMaxLengthBytes = "x-max-length-bytes";
+ internal const string XArgExpires = "x-expires";
+ internal const string XArgDeadLetterExchange = "x-dead-letter-exchange";
+ internal const string XArgDeadLetterRoutingKey = "x-dead-letter-routing-key";
+
+ internal static readonly FrozenSet s_reservedKeys = new[]
+ {
+ XArgMessageTtl,
+ XArgMaxLength,
+ XArgMaxLengthBytes,
+ XArgExpires,
+ XArgDeadLetterExchange,
+ XArgDeadLetterRoutingKey,
+ }.ToFrozenSet(StringComparer.Ordinal);
+
+ /// Validates and merges all arguments into .
+ /// The dictionary to merge into.
+ /// Human-readable resource description (e.g. Queue 'orders') used in error messages.
+ /// Thrown when contains a key already handled by a typed property.
+ internal void FlattenInto(IDictionary target, string resourceDescription)
+ {
+ foreach (var key in AdditionalArguments.Keys)
+ {
+ if (s_reservedKeys.Contains(key))
+ {
+ throw new DistributedApplicationException(
+ $"{resourceDescription}: '{key}' in AdditionalArguments is already handled by a typed property on {nameof(RabbitMQQueueArguments)}. " +
+ $"Use the corresponding typed property instead.");
+ }
+ }
+
+ foreach (var (k, v) in AdditionalArguments)
+ {
+ target[k] = v;
+ }
+
+ if (MessageTtl is { } ttl)
+ {
+ target[XArgMessageTtl] = (long)ttl.TotalMilliseconds;
+ }
+
+ if (MaxLength is { } ml)
+ {
+ target[XArgMaxLength] = ml;
+ }
+
+ if (MaxLengthBytes is { } mlb)
+ {
+ target[XArgMaxLengthBytes] = mlb;
+ }
+
+ if (Expires is { } exp)
+ {
+ target[XArgExpires] = (long)exp.TotalMilliseconds;
+ }
+
+ if (DeadLetterExchange is { } dlx)
+ {
+ target[XArgDeadLetterExchange] = dlx.ExchangeName;
+ }
+
+ if (DeadLetterRoutingKey is { } drk)
+ {
+ target[XArgDeadLetterRoutingKey] = drk;
+ }
+ }
+
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueResource.cs
new file mode 100644
index 00000000000..3e072e600d4
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueResource.cs
@@ -0,0 +1,150 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a RabbitMQ queue resource that is declared on the broker during provisioning.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, QueueName = {QueueName}")]
+[AspireExport(ExposeProperties = true)]
+public class RabbitMQQueueResource : RabbitMQDestination, IResourceWithConnectionString, IRabbitMQProvisionable, IResourceWithQueueArguments
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the resource.
+ /// The name of the queue.
+ /// The RabbitMQ virtual host resource associated with this queue.
+ /// The type of the queue. Defaults to .
+ public RabbitMQQueueResource(string name, string queueName, RabbitMQVirtualHostResource virtualHost, RabbitMQQueueType queueType = RabbitMQQueueType.Classic) : base(name, virtualHost)
+ {
+ ArgumentNullException.ThrowIfNull(queueName);
+
+ QueueName = queueName;
+ QueueType = queueType;
+ }
+
+ ///
+ /// Gets the name of the queue.
+ ///
+ public string QueueName { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the queue is durable.
+ ///
+ public bool Durable { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the queue is exclusive.
+ ///
+ public bool Exclusive { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the queue is auto-deleted.
+ ///
+ public bool AutoDelete { get; set; }
+
+ ///
+ /// Gets the type of the queue (classic, quorum, or stream). Set via the type parameter of AddQueue.
+ ///
+ public RabbitMQQueueType QueueType { get; }
+
+ ///
+ /// Gets the queue arguments for this queue declaration, such as TTL, length limits, and dead-lettering.
+ ///
+ ///
+ /// Use to configure these settings.
+ /// For settings that should apply to multiple queues, use AddPolicy on the virtual host instead.
+ ///
+ public RabbitMQQueueArguments QueueArguments { get; } = new();
+
+ ///
+ public override string ProvisionedName => QueueName;
+
+ ///
+ public override RabbitMQDestinationKind Kind => RabbitMQDestinationKind.Queue;
+
+ ///
+ /// Gets the connection string properties for this queue, including the queue name.
+ ///
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties() =>
+ VirtualHost.CombineProperties([
+ new("QueueName", ReferenceExpression.Create($"{QueueName}")),
+ ]);
+
+ ///
+ /// Gets the policies that apply to this queue, resolved at startup from matching AddPolicy calls.
+ ///
+ internal List AppliedPolicies { get; } = [];
+
+ IEnumerable IRabbitMQProvisionable.HealthDependencies
+ {
+ get
+ {
+ foreach (var policy in AppliedPolicies)
+ {
+ yield return policy;
+ }
+
+ // The dead-letter exchange must be provisioned before this queue's health is meaningful.
+ if (QueueArguments.DeadLetterExchange is { } dlx)
+ {
+ yield return dlx;
+ }
+ }
+ }
+
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Task IRabbitMQProvisionable.ProvisionedTask => _tcs.Task;
+
+ async Task IRabbitMQProvisionable.ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Starting }).ConfigureAwait(false);
+ try
+ {
+ var args = new Dictionary();
+
+ if (QueueType != RabbitMQQueueType.Classic)
+ {
+ args["x-queue-type"] = QueueType.ToString().ToLowerInvariant();
+ }
+
+ QueueArguments.FlattenInto(args, $"Queue '{QueueName}'");
+
+ await client.DeclareQueueAsync(
+ VirtualHost.VirtualHostName,
+ QueueName,
+ Durable,
+ Exclusive,
+ AutoDelete,
+ args.Count > 0 ? args : null,
+ cancellationToken).ConfigureAwait(false);
+
+ _tcs.TrySetResult();
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to declare queue '{Queue}'.", QueueName);
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false);
+ }
+ }
+
+ async ValueTask IRabbitMQProvisionable.ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ {
+ var exists = await client.QueueExistsAsync(VirtualHost.VirtualHostName, QueueName, cancellationToken).ConfigureAwait(false);
+ return exists
+ ? RabbitMQProbeResult.Healthy
+ : RabbitMQProbeResult.Unhealthy($"Queue '{QueueName}' does not exist in virtual host '{VirtualHost.VirtualHostName}'.");
+ }
+
+ internal override Task BindAsync(IRabbitMQProvisioningClient client, string vhost, string sourceExchange, string routingKey, Dictionary? args, CancellationToken ct)
+ => client.BindQueueAsync(vhost, sourceExchange, QueueName, routingKey, args, ct);
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueType.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueType.cs
new file mode 100644
index 00000000000..6c3ff039850
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQQueueType.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Specifies the type of a RabbitMQ queue resource.
+///
+public enum RabbitMQQueueType
+{
+ ///
+ /// A classic queue.
+ ///
+ Classic,
+
+ ///
+ /// A quorum queue.
+ ///
+ Quorum,
+
+ ///
+ /// A stream queue.
+ ///
+ Stream
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQServerResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQServerResource.cs
index e75272a3571..571cb9067bd 100644
--- a/src/Aspire.Hosting.RabbitMQ/RabbitMQServerResource.cs
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQServerResource.cs
@@ -112,4 +112,7 @@ IEnumerable> IResourceWithConnectionSt
yield return new("Password", ReferenceExpression.Create($"{PasswordParameter}"));
yield return new("Uri", UriExpression);
}
+
+ internal List VirtualHosts { get; } = [];
+ internal bool HasPluginFileCallback { get; set; }
}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelAckMode.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelAckMode.cs
new file mode 100644
index 00000000000..c0d8e2261fd
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelAckMode.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents the acknowledgment mode for a RabbitMQ shovel.
+///
+public enum RabbitMQShovelAckMode
+{
+ ///
+ /// Acknowledge messages after they have been confirmed by the destination.
+ ///
+ OnConfirm,
+
+ ///
+ /// Acknowledge messages after they have been published to the destination.
+ ///
+ OnPublish,
+
+ ///
+ /// Do not acknowledge messages.
+ ///
+ NoAck
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelResource.cs
new file mode 100644
index 00000000000..ff47c99a354
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQShovelResource.cs
@@ -0,0 +1,136 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Globalization;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a RabbitMQ dynamic shovel resource that moves messages from a source to a destination during provisioning.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, ShovelName = {ShovelName}")]
+[AspireExport(ExposeProperties = true)]
+public class RabbitMQShovelResource : Resource, IResourceWithParent, IRabbitMQProvisionable, IRabbitMQServerChild
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the resource.
+ /// The name of the shovel.
+ /// The RabbitMQ virtual host resource associated with this shovel.
+ /// The source destination for the shovel.
+ /// The destination for the shovel.
+ public RabbitMQShovelResource(string name, string shovelName, RabbitMQVirtualHostResource parent, RabbitMQDestination source, RabbitMQDestination destination) : base(name)
+ {
+ ArgumentNullException.ThrowIfNull(shovelName);
+ ArgumentNullException.ThrowIfNull(parent);
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(destination);
+
+ ShovelName = shovelName;
+ Parent = parent;
+ Source = source;
+ Destination = destination;
+ }
+
+ ///
+ /// Gets the name of the shovel as known to the broker.
+ ///
+ public string ShovelName { get; }
+
+ ///
+ /// Gets the virtual host in which this shovel is defined.
+ ///
+ public RabbitMQVirtualHostResource Parent { get; }
+
+ ///
+ /// Gets the source queue or exchange from which messages are consumed.
+ ///
+ public RabbitMQDestination Source { get; }
+
+ ///
+ /// Gets the destination queue or exchange to which messages are forwarded.
+ ///
+ public RabbitMQDestination Destination { get; }
+
+ ///
+ /// Gets or sets the acknowledgment mode for the shovel. Defaults to .
+ ///
+ public RabbitMQShovelAckMode AckMode { get; set; } = RabbitMQShovelAckMode.OnConfirm;
+
+ ///
+ /// Gets or sets the reconnect delay for the shovel. When , the broker default is used.
+ ///
+ public TimeSpan? ReconnectDelay { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of messages to transfer before the shovel is deleted.
+ /// When , the shovel runs indefinitely.
+ ///
+ public int? SrcDeleteAfter { get; set; }
+
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Task IRabbitMQProvisionable.ProvisionedTask => _tcs.Task;
+
+ async ValueTask IRabbitMQProvisionable.ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ {
+ var state = await client.GetShovelStateAsync(Parent.VirtualHostName, ShovelName, cancellationToken).ConfigureAwait(false);
+ return state == "running"
+ ? RabbitMQProbeResult.Healthy
+ : RabbitMQProbeResult.Unhealthy($"Shovel '{ShovelName}' is in state '{state ?? "unknown"}'.");
+ }
+
+ async Task IRabbitMQProvisionable.ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Starting }).ConfigureAwait(false);
+ try
+ {
+ var srcUri = await Source.ConnectionStringExpression.GetValueAsync(cancellationToken).ConfigureAwait(false)
+ ?? throw new DistributedApplicationException($"Could not resolve source URI for shovel '{ShovelName}'.");
+ var destUri = await Destination.ConnectionStringExpression.GetValueAsync(cancellationToken).ConfigureAwait(false)
+ ?? throw new DistributedApplicationException($"Could not resolve destination URI for shovel '{ShovelName}'.");
+
+ var ackModeString = AckMode switch
+ {
+ RabbitMQShovelAckMode.OnConfirm => "on-confirm",
+ RabbitMQShovelAckMode.OnPublish => "on-publish",
+ RabbitMQShovelAckMode.NoAck => "no-ack",
+ _ => "on-confirm"
+ };
+
+ var def = new RabbitMQShovelDefinitionValue
+ {
+ SrcUri = srcUri,
+ SrcQueue = Source.Kind == RabbitMQDestinationKind.Queue ? Source.ProvisionedName : null,
+ SrcExchange = Source.Kind == RabbitMQDestinationKind.Exchange ? Source.ProvisionedName : null,
+ DestUri = destUri,
+ DestQueue = Destination.Kind == RabbitMQDestinationKind.Queue ? Destination.ProvisionedName : null,
+ DestExchange = Destination.Kind == RabbitMQDestinationKind.Exchange ? Destination.ProvisionedName : null,
+ AckMode = ackModeString,
+ ReconnectDelay = ReconnectDelay.HasValue ? (int)ReconnectDelay.Value.TotalSeconds : null,
+ SrcDeleteAfter = SrcDeleteAfter?.ToString(CultureInfo.InvariantCulture)
+ };
+
+ await client.PutShovelAsync(
+ Parent.VirtualHostName,
+ ShovelName,
+ new RabbitMQShovelDefinition { Value = def },
+ cancellationToken).ConfigureAwait(false);
+
+ _tcs.TrySetResult();
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to create shovel '{Shovel}'.", ShovelName);
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false);
+ }
+ }
+
+ RabbitMQVirtualHostResource IRabbitMQServerChild.VirtualHost => Parent;
+}
diff --git a/src/Aspire.Hosting.RabbitMQ/RabbitMQVirtualHostResource.cs b/src/Aspire.Hosting.RabbitMQ/RabbitMQVirtualHostResource.cs
new file mode 100644
index 00000000000..5eaeb56ec42
--- /dev/null
+++ b/src/Aspire.Hosting.RabbitMQ/RabbitMQVirtualHostResource.cs
@@ -0,0 +1,114 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// Represents a RabbitMQ virtual host resource that can be provisioned against a live broker.
+///
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, VirtualHostName = {VirtualHostName}")]
+[AspireExport(ExposeProperties = true)]
+public class RabbitMQVirtualHostResource : Resource, IResourceWithParent, IResourceWithConnectionString, IRabbitMQProvisionable, IRabbitMQServerChild
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the resource.
+ /// The name of the virtual host.
+ /// The RabbitMQ server resource associated with this virtual host.
+ public RabbitMQVirtualHostResource(string name, string virtualHostName, RabbitMQServerResource parent) : base(name)
+ {
+ ArgumentNullException.ThrowIfNull(virtualHostName);
+ ArgumentNullException.ThrowIfNull(parent);
+
+ VirtualHostName = virtualHostName;
+ Parent = parent;
+ }
+
+ ///
+ /// Gets the name of the virtual host as known to the broker (e.g. / for the default virtual host).
+ ///
+ public string VirtualHostName { get; }
+
+ ///
+ /// Gets the parent RabbitMQ server resource.
+ ///
+ public RabbitMQServerResource Parent { get; }
+
+ ///
+ /// Gets the AMQP connection string expression for this virtual host, including the vhost path segment.
+ ///
+ public ReferenceExpression ConnectionStringExpression
+ {
+ get
+ {
+ var builder = new ReferenceExpressionBuilder();
+ builder.Append($"{Parent.ConnectionStringExpression}");
+ if (VirtualHostName != "/")
+ {
+ builder.AppendLiteral("/");
+ builder.AppendLiteral(Uri.EscapeDataString(VirtualHostName));
+ }
+ return builder.Build();
+ }
+ }
+
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties() =>
+ Parent.CombineProperties([
+ new("Uri", ConnectionStringExpression),
+ new("VirtualHost", ReferenceExpression.Create($"{VirtualHostName}")),
+ ]);
+
+ internal List Queues { get; } = [];
+ internal List Exchanges { get; } = [];
+ internal List Shovels { get; } = [];
+ internal List Policies { get; } = [];
+
+ ///
+ /// Enumerates all child provisionable resources in this virtual host in provisioning order: policies, queues, exchanges, then shovels.
+ ///
+ internal IEnumerable EnumerateChildren()
+ => Policies.Cast()
+ .Concat(Queues)
+ .Concat(Exchanges)
+ .Concat(Shovels);
+
+ private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ Task IRabbitMQProvisionable.ProvisionedTask => _tcs.Task;
+
+ async Task IRabbitMQProvisionable.ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken cancellationToken)
+ {
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Starting }).ConfigureAwait(false);
+ try
+ {
+ if (VirtualHostName != "/")
+ {
+ await client.CreateVirtualHostAsync(VirtualHostName, cancellationToken).ConfigureAwait(false);
+ }
+
+ _tcs.TrySetResult();
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.Running }).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _tcs.TrySetException(ex);
+ resourceLogger.GetLogger(Name).LogError(ex, "Failed to create virtual host '{VirtualHost}'.", VirtualHostName);
+ await notifications.PublishUpdateAsync(this, s => s with { State = KnownResourceStates.FailedToStart }).ConfigureAwait(false);
+ }
+ }
+
+ async ValueTask IRabbitMQProvisionable.ProbeAsync(IRabbitMQProvisioningClient client, CancellationToken cancellationToken)
+ {
+ var connected = await client.CanConnectAsync(VirtualHostName, cancellationToken).ConfigureAwait(false);
+ return connected
+ ? RabbitMQProbeResult.Healthy
+ : RabbitMQProbeResult.Unhealthy($"Cannot connect to virtual host '{VirtualHostName}'.");
+ }
+
+ RabbitMQVirtualHostResource IRabbitMQServerChild.VirtualHost => this;
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQChildResourcesTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQChildResourcesTests.cs
new file mode 100644
index 00000000000..c147c0991a2
--- /dev/null
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQChildResourcesTests.cs
@@ -0,0 +1,336 @@
+// 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.ApplicationModel;
+
+namespace Aspire.Hosting.RabbitMQ.Tests;
+
+public class AddRabbitMQChildResourcesTests
+{
+ [Fact]
+ public void AddVirtualHost_CreatesResourceAndAddsToParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ var vhost = server.AddVirtualHost("myvhost");
+
+ Assert.Single(server.Resource.VirtualHosts);
+ Assert.Equal(vhost.Resource, server.Resource.VirtualHosts[0]);
+ Assert.Equal("myvhost", vhost.Resource.Name);
+ Assert.Equal("myvhost", vhost.Resource.VirtualHostName);
+ Assert.Equal(server.Resource, vhost.Resource.Parent);
+ }
+
+ [Fact]
+ public void AddVirtualHost_WithCustomName_CreatesResourceAndAddsToParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ var vhost = server.AddVirtualHost("myvhost", "custom-vhost");
+
+ Assert.Single(server.Resource.VirtualHosts);
+ Assert.Equal(vhost.Resource, server.Resource.VirtualHosts[0]);
+ Assert.Equal("myvhost", vhost.Resource.Name);
+ Assert.Equal("custom-vhost", vhost.Resource.VirtualHostName);
+ Assert.Equal(server.Resource, vhost.Resource.Parent);
+ }
+
+ [Fact]
+ public void AddVirtualHost_NonDefault_EnablesManagementPlugin()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.AddVirtualHost("myvhost");
+
+ var endpoints = server.Resource.Annotations.OfType();
+ Assert.Contains(endpoints, e => e.Name == RabbitMQServerResource.ManagementEndpointName);
+ }
+
+ [Fact]
+ public void AddVirtualHost_Default_DoesNotEnableManagementPlugin()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.AddVirtualHost("default", "/");
+
+ var endpoints = server.Resource.Annotations.OfType();
+ Assert.DoesNotContain(endpoints, e => e.Name == RabbitMQServerResource.ManagementEndpointName);
+ }
+
+ [Fact]
+ public void GetOrAddDefaultVirtualHost_IsIdempotent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ var vhost1 = server.GetOrAddDefaultVirtualHost();
+ var vhost2 = server.GetOrAddDefaultVirtualHost();
+
+ Assert.Same(vhost1.Resource, vhost2.Resource);
+ Assert.Single(server.Resource.VirtualHosts);
+ Assert.Equal("/", vhost1.Resource.VirtualHostName);
+ }
+
+ [Fact]
+ public void AddQueue_OnVirtualHost_CreatesResourceAndAddsToParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var queue = vhost.AddQueue("myqueue");
+
+ Assert.Single(vhost.Resource.Queues);
+ Assert.Equal(queue.Resource, vhost.Resource.Queues[0]);
+ Assert.Equal("myqueue", queue.Resource.Name);
+ Assert.Equal("myqueue", queue.Resource.QueueName);
+ Assert.Equal(vhost.Resource, queue.Resource.VirtualHost);
+ Assert.Equal(RabbitMQQueueType.Classic, queue.Resource.QueueType);
+ }
+
+ [Fact]
+ public void AddQueue_OnServer_CreatesDefaultVirtualHostAndAddsQueue()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ var queue = server.AddQueue("myqueue");
+
+ Assert.Single(server.Resource.VirtualHosts);
+ var vhost = server.Resource.VirtualHosts[0];
+ Assert.Equal("/", vhost.VirtualHostName);
+
+ Assert.Single(vhost.Queues);
+ Assert.Equal(queue.Resource, vhost.Queues[0]);
+ }
+
+ [Fact]
+ public void AddExchange_OnVirtualHost_CreatesResourceAndAddsToParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var exchange = vhost.AddExchange("myexchange");
+
+ Assert.Single(vhost.Resource.Exchanges);
+ Assert.Equal(exchange.Resource, vhost.Resource.Exchanges[0]);
+ Assert.Equal("myexchange", exchange.Resource.Name);
+ Assert.Equal("myexchange", exchange.Resource.ExchangeName);
+ Assert.Equal(vhost.Resource, exchange.Resource.VirtualHost);
+ Assert.Equal(RabbitMQExchangeType.Direct, exchange.Resource.ExchangeType);
+ }
+
+ [Fact]
+ public void AddExchange_OnServer_CreatesDefaultVirtualHostAndAddsExchange()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ var exchange = server.AddExchange("myexchange");
+
+ Assert.Single(server.Resource.VirtualHosts);
+ var vhost = server.Resource.VirtualHosts[0];
+ Assert.Equal("/", vhost.VirtualHostName);
+
+ Assert.Single(vhost.Exchanges);
+ Assert.Equal(exchange.Resource, vhost.Exchanges[0]);
+ }
+
+ [Fact]
+ public void WithBinding_AddsBindingToExchange()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+ var exchange = vhost.AddExchange("myexchange");
+ var queue = vhost.AddQueue("myqueue");
+
+ exchange.WithBinding(queue, "myroutingkey");
+
+ Assert.Single(exchange.Resource.Bindings);
+ var binding = exchange.Resource.Bindings[0];
+ Assert.Equal(queue.Resource, binding.Destination);
+ Assert.Equal("myroutingkey", binding.RoutingKey);
+ }
+
+ [Fact]
+ public void WithBinding_CrossVirtualHost_ThrowsException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost1 = server.AddVirtualHost("vhost1");
+ var vhost2 = server.AddVirtualHost("vhost2");
+ var exchange = vhost1.AddExchange("myexchange");
+ var queue = vhost2.AddQueue("myqueue");
+
+ var ex = Assert.Throws(() => exchange.WithBinding(queue, "myroutingkey"));
+ Assert.Contains("different virtual hosts", ex.Message);
+ }
+
+ [Fact]
+ public void AddShovel_OnVirtualHost_CreatesResourceAndAddsToParent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost1 = server.AddVirtualHost("vhost1");
+ var vhost2 = server.AddVirtualHost("vhost2");
+ var queue1 = vhost1.AddQueue("queue1");
+ var queue2 = vhost2.AddQueue("queue2");
+
+ var shovel = vhost1.AddShovel("myshovel", queue1, queue2);
+
+ Assert.Single(vhost1.Resource.Shovels);
+ Assert.Equal(shovel.Resource, vhost1.Resource.Shovels[0]);
+ Assert.Equal("myshovel", shovel.Resource.Name);
+ Assert.Equal("myshovel", shovel.Resource.ShovelName);
+ Assert.Equal(vhost1.Resource, shovel.Resource.Parent);
+ Assert.Equal(queue1.Resource, shovel.Resource.Source);
+ Assert.Equal(queue2.Resource, shovel.Resource.Destination);
+ }
+
+ [Fact]
+ public void AddShovel_SourceInDifferentVirtualHostOnSameServer_Succeeds()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost1 = server.AddVirtualHost("vhost1");
+ var vhost2 = server.AddVirtualHost("vhost2");
+ var queue1 = vhost1.AddQueue("queue1");
+ var queue2 = vhost2.AddQueue("queue2");
+
+ // Cross-vhost shovels on the same broker are allowed
+ var shovel = vhost2.AddShovel("myshovel", queue1, queue2);
+ Assert.NotNull(shovel);
+ Assert.Single(vhost2.Resource.Shovels);
+ }
+
+ [Fact]
+ public void AddShovel_SourceOnDifferentServer_ThrowsException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server1 = builder.AddRabbitMQ("rabbit1");
+ var server2 = builder.AddRabbitMQ("rabbit2");
+ var vhost1 = server1.AddVirtualHost("vhost1");
+ var vhost2 = server2.AddVirtualHost("vhost2");
+ var queue1 = vhost1.AddQueue("queue1");
+ var queue2 = vhost2.AddQueue("queue2");
+
+ var ex = Assert.Throws(() => vhost2.AddShovel("myshovel", queue1, queue2));
+ Assert.Contains("different RabbitMQ server", ex.Message);
+ }
+
+ [Fact]
+ public void AddShovel_EnablesRequiredPlugins()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var queue1 = server.AddQueue("queue1");
+ var queue2 = server.AddQueue("queue2");
+
+ server.AddShovel("myshovel", queue1, queue2);
+
+ var endpoints = server.Resource.Annotations.OfType();
+ Assert.Contains(endpoints, e => e.Name == RabbitMQServerResource.ManagementEndpointName);
+
+ var pluginAnnotations = server.Resource.Annotations.OfType();
+ Assert.Contains(pluginAnnotations, a => a.PluginName == "rabbitmq_shovel");
+ Assert.Contains(pluginAnnotations, a => a.PluginName == "rabbitmq_shovel_management");
+ }
+
+ [Fact]
+ public async Task ConnectionStringExpressions_AreCorrect()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue = vhost.AddQueue("myqueue");
+ var exchange = vhost.AddExchange("myexchange");
+
+ var serverCs = await server.Resource.ConnectionStringExpression.GetValueAsync(default);
+ var vhostCs = await vhost.Resource.ConnectionStringExpression.GetValueAsync(default);
+ var queueCs = await queue.Resource.ConnectionStringExpression.GetValueAsync(default);
+ var exchangeCs = await exchange.Resource.ConnectionStringExpression.GetValueAsync(default);
+
+ Assert.StartsWith("amqp://", serverCs);
+ Assert.Equal($"{serverCs}/myvhost", vhostCs);
+ Assert.Equal(vhostCs, queueCs);
+ Assert.Equal(vhostCs, exchangeCs);
+ }
+
+ [Fact]
+ public void AddQueue_DuplicateQueueName_ThrowsException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+ vhost.AddQueue("myqueue");
+
+ var ex = Assert.Throws(() => vhost.AddQueue("myqueue2", "myqueue"));
+ Assert.Contains("myqueue", ex.Message);
+ }
+
+ [Fact]
+ public void AddExchange_DuplicateExchangeName_ThrowsException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+ vhost.AddExchange("myexchange");
+
+ var ex = Assert.Throws(() => vhost.AddExchange("myexchange2", exchangeName: "myexchange"));
+ Assert.Contains("myexchange", ex.Message);
+ }
+
+ [Fact]
+ public void AddShovel_DuplicateShovelName_ThrowsException()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue1 = vhost.AddQueue("queue1");
+ var queue2 = vhost.AddQueue("queue2");
+ vhost.AddShovel("myshovel", queue1, queue2);
+
+ var ex = Assert.Throws(() => vhost.AddShovel("myshovel", queue1, queue2));
+ Assert.Contains("myshovel", ex.Message);
+ }
+
+ [Fact]
+ public void AddShovel_WithCustomShovelName_UsesWireName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue1 = vhost.AddQueue("queue1");
+ var queue2 = vhost.AddQueue("queue2");
+
+ var shovel = vhost.AddShovel("myshovel", queue1, queue2, shovelName: "custom-shovel");
+
+ Assert.Equal("myshovel", shovel.Resource.Name);
+ Assert.Equal("custom-shovel", shovel.Resource.ShovelName);
+ }
+
+ [Fact]
+ public void WithProperties_Queue_SetsArguments()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var queue = vhost.AddQueue("myqueue")
+ .WithQueueArguments(a =>
+ {
+ a.MessageTtl = TimeSpan.FromMilliseconds(60_000);
+ a.MaxLength = 1000;
+ });
+
+ Assert.Equal(TimeSpan.FromMilliseconds(60_000), queue.Resource.QueueArguments.MessageTtl);
+ Assert.Equal(1000, queue.Resource.QueueArguments.MaxLength);
+ }
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs
index 30150ca02ba..273f0a14ff7 100644
--- a/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/AddRabbitMQTests.cs
@@ -1,10 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Net.Sockets;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
-using System.Net.Sockets;
namespace Aspire.Hosting.RabbitMQ.Tests;
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/Aspire.Hosting.RabbitMQ.Tests.csproj b/tests/Aspire.Hosting.RabbitMQ.Tests/Aspire.Hosting.RabbitMQ.Tests.csproj
index 5dc6463da84..d2773968e0d 100644
--- a/tests/Aspire.Hosting.RabbitMQ.Tests/Aspire.Hosting.RabbitMQ.Tests.csproj
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/Aspire.Hosting.RabbitMQ.Tests.csproj
@@ -12,7 +12,6 @@
-
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/ConnectionPropertiesTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/ConnectionPropertiesTests.cs
index fb20daf0d02..d37485e9e67 100644
--- a/tests/Aspire.Hosting.RabbitMQ.Tests/ConnectionPropertiesTests.cs
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/ConnectionPropertiesTests.cs
@@ -58,13 +58,20 @@ public async Task VerifyManifestWithConnectionProperties()
userName: builder.AddParameter("username", "some#User"),
password: builder.AddParameter("password", "p@ssw0rd!", secret: true));
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue = vhost.AddQueue("myqueue");
+ var exchange = vhost.AddExchange("myexchange");
+
// Force connection properties to be generated
var app = builder.AddExecutable("app", "command", ".")
.WithReference(server)
- .WithReference(serverWithParameters);
+ .WithReference(serverWithParameters)
+ .WithReference(vhost)
+ .WithReference(queue)
+ .WithReference(exchange);
var manifest = await ManifestUtils.GetManifest(app.Resource);
await Verify(manifest.ToString(), "json");
}
-}
\ No newline at end of file
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs
index 9a37860c7cf..3d2ace92266 100644
--- a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQFunctionalTests.cs
@@ -2,10 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
-using Aspire.TestUtilities;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
+using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -17,6 +17,7 @@ namespace Aspire.Hosting.RabbitMQ.Tests;
public class RabbitMQFunctionalTests(ITestOutputHelper testOutputHelper)
{
[Fact]
+ [OuterloopTest]
[RequiresFeature(TestFeature.Docker)]
public async Task VerifyWaitForOnRabbitMQBlocksDependentResources()
{
@@ -54,6 +55,7 @@ public async Task VerifyWaitForOnRabbitMQBlocksDependentResources()
}
[Fact]
+ [OuterloopTest]
[RequiresFeature(TestFeature.Docker)]
public async Task VerifyRabbitMQResource()
{
@@ -92,6 +94,7 @@ public async Task VerifyRabbitMQResource()
[Theory]
[InlineData(true)]
[InlineData(false)]
+ [OuterloopTest]
[RequiresFeature(TestFeature.Docker)]
public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume)
{
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPluginTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPluginTests.cs
new file mode 100644
index 00000000000..7656e9c993c
--- /dev/null
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPluginTests.cs
@@ -0,0 +1,144 @@
+// 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.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.RabbitMQ.Tests;
+
+public class RabbitMQPluginTests
+{
+ [Fact]
+ public void WithPlugin_Enum_AddsAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.WithPlugin(RabbitMQPlugin.Prometheus);
+
+ var annotations = server.Resource.Annotations.OfType();
+ Assert.Contains(annotations, a => a.PluginName == "rabbitmq_prometheus");
+ }
+
+ [Fact]
+ public void WithPlugin_String_AddsAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.WithPlugin("my_custom_plugin");
+
+ var annotations = server.Resource.Annotations.OfType();
+ Assert.Contains(annotations, a => a.PluginName == "my_custom_plugin");
+ }
+
+ [Fact]
+ public void WithPlugin_String_ThrowsOnNullOrWhiteSpace()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ Assert.Throws(() => server.WithPlugin(""));
+ Assert.Throws(() => server.WithPlugin(" "));
+ Assert.Throws(() => server.WithPlugin((string)null!));
+ }
+
+ [Fact]
+ public async Task WithPlugin_GeneratesEnabledPluginsFile_ContainingOnlyRequestedPlugins()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ // Use WithPlugin directly (not AddShovel, which transitively enables management)
+ server.WithPlugin(RabbitMQPlugin.Shovel);
+ server.WithPlugin("my_custom_plugin");
+
+ var containerFiles = server.Resource.Annotations.OfType();
+ var annotation = Assert.Single(containerFiles);
+ Assert.Equal("/etc/rabbitmq", annotation.DestinationPath);
+
+ var context = new ContainerFileSystemCallbackContext
+ {
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ Model = server.Resource
+ };
+ var items = await annotation.Callback(context, default);
+
+ var file = Assert.Single(items) as ContainerFile;
+ Assert.NotNull(file);
+ Assert.Equal("enabled_plugins", file.Name);
+
+ var content = file.Contents;
+ Assert.NotNull(content);
+
+ // Should contain exactly the requested plugins — no hard-coded extras
+ Assert.Contains("rabbitmq_shovel", content);
+ Assert.Contains("my_custom_plugin", content);
+ Assert.DoesNotContain("rabbitmq_management_agent", content);
+ Assert.DoesNotContain("rabbitmq_web_dispatch", content);
+ Assert.DoesNotContain("rabbitmq_prometheus", content);
+
+ // Should be formatted as an Erlang list
+ Assert.StartsWith("[", content);
+ Assert.EndsWith("].", content);
+ }
+
+ [Fact]
+ public async Task WithManagementPlugin_ThenWithPlugin_IncludesManagementPluginsInFile()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit").WithManagementPlugin();
+
+ server.WithPlugin("my_custom_plugin");
+
+ var containerFiles = server.Resource.Annotations.OfType();
+ var annotation = Assert.Single(containerFiles);
+
+ var context = new ContainerFileSystemCallbackContext
+ {
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ Model = server.Resource
+ };
+ var items = await annotation.Callback(context, default);
+
+ var file = Assert.Single(items) as ContainerFile;
+ Assert.NotNull(file);
+ var content = file.Contents;
+ Assert.NotNull(content);
+
+ // Management plugin annotations are added explicitly by WithManagementPlugin
+ Assert.Contains("rabbitmq_management", content);
+ Assert.Contains("rabbitmq_management_agent", content);
+ Assert.Contains("rabbitmq_web_dispatch", content);
+ Assert.Contains("rabbitmq_prometheus", content);
+ Assert.Contains("my_custom_plugin", content);
+ }
+
+ [Fact]
+ public async Task WithPlugin_DeduplicatesPlugins()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.WithPlugin(RabbitMQPlugin.Prometheus);
+ server.WithPlugin("rabbitmq_prometheus");
+
+ var containerFiles = server.Resource.Annotations.OfType();
+ var annotation = Assert.Single(containerFiles);
+
+ var context = new ContainerFileSystemCallbackContext
+ {
+ ServiceProvider = new ServiceCollection().BuildServiceProvider(),
+ Model = server.Resource
+ };
+ var items = await annotation.Callback(context, default);
+
+ var file = Assert.Single(items) as ContainerFile;
+ Assert.NotNull(file);
+ var content = file.Contents;
+
+ // Should only appear once
+ var count = content!.Split("rabbitmq_prometheus").Length - 1;
+ Assert.Equal(1, count);
+ }
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPolicyTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPolicyTests.cs
new file mode 100644
index 00000000000..58970467cd3
--- /dev/null
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPolicyTests.cs
@@ -0,0 +1,388 @@
+// 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.ApplicationModel;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Aspire.Hosting.RabbitMQ.Tests.TestServices;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Aspire.Hosting.RabbitMQ.Tests;
+
+public class RabbitMQPolicyTests
+{
+ // ── AddPolicy wiring ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void AddPolicy_AddsToVhostPoliciesList()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ vhost.AddPolicy("ttl-policy", "^orders\\.");
+
+ Assert.Single(vhost.Resource.Policies);
+ Assert.Equal("ttl-policy", vhost.Resource.Policies[0].PolicyName);
+ Assert.Equal("^orders\\.", vhost.Resource.Policies[0].Pattern);
+ }
+
+ [Fact]
+ public void AddPolicy_WithCustomPolicyName_UsesWireName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ vhost.AddPolicy("my-resource", "^orders\\.", policyName: "my-wire-policy");
+
+ Assert.Equal("my-wire-policy", vhost.Resource.Policies[0].PolicyName);
+ Assert.Equal("my-resource", vhost.Resource.Policies[0].Name);
+ }
+
+ [Fact]
+ public void AddPolicy_DuplicatePolicyName_Throws()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ vhost.AddPolicy("ttl-policy", "^orders\\.");
+
+ var ex = Assert.Throws(() =>
+ vhost.AddPolicy("ttl-policy-2", "^orders\\.", policyName: "ttl-policy"));
+ Assert.Contains("ttl-policy", ex.Message);
+ }
+
+ [Fact]
+ public void AddPolicy_OnServer_AddsToDefaultVhost()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+
+ server.AddPolicy("ttl-policy", "^orders\\.");
+
+ var defaultVhost = server.Resource.VirtualHosts.Single(v => v.VirtualHostName == "/");
+ Assert.Single(defaultVhost.Policies);
+ Assert.Equal("ttl-policy", defaultVhost.Policies[0].PolicyName);
+ }
+
+ [Fact]
+ public void AddPolicy_WithProperties_SetsArguments()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ vhost.AddPolicy("ttl-policy", "^orders\\.", priority: 10)
+ .WithQueueArguments(a =>
+ {
+ a.MessageTtl = TimeSpan.FromMilliseconds(60_000);
+ a.AdditionalArguments["ha-mode"] = "all";
+ });
+
+ var policy = vhost.Resource.Policies[0];
+ Assert.Equal(TimeSpan.FromMilliseconds(60_000), policy.QueueArguments.MessageTtl);
+ Assert.Equal("all", policy.QueueArguments.AdditionalArguments["ha-mode"]);
+ Assert.Equal(10, policy.Priority);
+ }
+
+ // ── BeforeStartEvent matching (via ResolveAndApplyPolicyMatches) ──────────
+ //
+ // The production code resolves matches in a BeforeStartEvent handler.
+ // Tests call RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches directly
+ // to avoid triggering the full BeforeStartEvent pipeline (which requires DCP).
+
+ [Fact]
+ public void AddPolicy_MatchesQueueAddedBeforePolicy()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ // Simulate BeforeStartEvent resolution
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ Assert.Single(queue.Resource.AppliedPolicies);
+ Assert.Equal("ttl-policy", queue.Resource.AppliedPolicies[0].PolicyName);
+ }
+
+ [Fact]
+ public void AddPolicy_MatchesQueueAddedAfterPolicy()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ // Policy added BEFORE queue — lazy resolution means order doesn't matter
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+
+ // Simulate BeforeStartEvent resolution (after all entities are registered)
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ Assert.Single(queue.Resource.AppliedPolicies);
+ Assert.Equal("ttl-policy", queue.Resource.AppliedPolicies[0].PolicyName);
+ }
+
+ [Fact]
+ public void AddPolicy_NonMatchingQueueUnaffected()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var matchingQueue = vhost.AddQueue("orders-queue", "orders");
+ var nonMatchingQueue = vhost.AddQueue("payments-queue", "payments");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ Assert.Single(matchingQueue.Resource.AppliedPolicies);
+ Assert.Empty(nonMatchingQueue.Resource.AppliedPolicies);
+ }
+
+ [Fact]
+ public void AddPolicy_MatchesExchangeWhenApplyToExchanges()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var exchange = vhost.AddExchange("orders-exchange", exchangeName: "orders");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var policyBuilder = vhost.AddPolicy("exchange-policy", "^orders", RabbitMQPolicyApplyTo.Exchanges);
+
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ Assert.Single(exchange.Resource.AppliedPolicies);
+ Assert.Empty(queue.Resource.AppliedPolicies); // Queues not matched when ApplyTo=Exchanges
+ }
+
+ [Fact]
+ public void AddPolicy_MatchesBothWhenApplyToAll()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var exchange = vhost.AddExchange("orders-exchange", exchangeName: "orders");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var policyBuilder = vhost.AddPolicy("all-policy", "^orders", RabbitMQPolicyApplyTo.All);
+
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ Assert.Single(exchange.Resource.AppliedPolicies);
+ Assert.Single(queue.Resource.AppliedPolicies);
+ }
+
+ [Fact]
+ public void AddPolicy_MultiplePoliciesCanMatchSameQueue()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit");
+ var vhost = server.AddVirtualHost("myvhost");
+
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var pb1 = vhost.AddPolicy("ttl-policy", "^orders");
+ var pb2 = vhost.AddPolicy("dlx-policy", "^orders");
+
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(pb1.Resource, vhost.Resource, pb1);
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(pb2.Resource, vhost.Resource, pb2);
+
+ Assert.Equal(2, queue.Resource.AppliedPolicies.Count);
+ }
+
+ // ── Provisioner ordering ──────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ProvisionTopologyAsync_PoliciesAppliedBeforeEntities()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ vhost.AddPolicy("ttl-policy", "^orders");
+
+ var fakeClient = new FakeRabbitMQProvisioningClient();
+ builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient);
+
+ using var app = builder.Build();
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default);
+
+ var policyIndex = fakeClient.Calls.FindIndex(c => c.StartsWith("PutPolicyAsync(myvhost, ttl-policy,"));
+ var queueIndex = fakeClient.Calls.FindIndex(c => c.StartsWith("DeclareQueueAsync(myvhost, orders,"));
+
+ Assert.True(policyIndex >= 0, "PutPolicyAsync should have been called");
+ Assert.True(queueIndex >= 0, "DeclareQueueAsync should have been called");
+ Assert.True(policyIndex < queueIndex, "Policy must be applied before queue declaration");
+ }
+
+ [Fact]
+ public async Task ProvisionTopologyAsync_PolicyFails_PolicyTcsFaulted_QueueTcsUnaffected()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ var fakeClient = new FakeRabbitMQProvisioningClient();
+ fakeClient.FailPolicyNames.Add("ttl-policy");
+ builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient);
+
+ using var app = builder.Build();
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default);
+
+ Assert.True(((IRabbitMQProvisionable)policyBuilder.Resource).ProvisionedTask.IsFaulted, "Policy TCS should be faulted");
+ Assert.True(((IRabbitMQProvisionable)queue.Resource).ProvisionedTask.IsCompletedSuccessfully, "Queue TCS should succeed independently");
+ }
+
+ [Fact]
+ public async Task ProvisionTopologyAsync_VhostFails_PolicyTcsStaysPending()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ var failingClient = new FailingFakeRabbitMQProvisioningClient();
+ builder.Services.AddKeyedSingleton(server.Resource.Name, failingClient);
+
+ using var app = builder.Build();
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default);
+
+ // Vhost itself is faulted
+ Assert.True(((IRabbitMQProvisionable)vhost.Resource).ProvisionedTask.IsFaulted);
+ // Children stay pending (Starting) — no cascade fault in the new design
+ Assert.False(((IRabbitMQProvisionable)policyBuilder.Resource).ProvisionedTask.IsCompleted, "Policy TCS should stay pending when vhost fails");
+ }
+
+ // ── Health dependency ─────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task HealthCheck_PolicyFails_MatchingQueueUnhealthy()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var queue = vhost.AddQueue("orders-queue", "orders");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ // Simulate BeforeStartEvent resolution
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ var fakeClient = new FakeRabbitMQProvisioningClient();
+ fakeClient.FailPolicyNames.Add("ttl-policy");
+ builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient);
+
+ using var app = builder.Build();
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default);
+
+ // Queue itself provisioned OK, but its policy failed
+ Assert.True(((IRabbitMQProvisionable)queue.Resource).ProvisionedTask.IsCompletedSuccessfully);
+ Assert.True(((IRabbitMQProvisionable)policyBuilder.Resource).ProvisionedTask.IsFaulted);
+
+ // The queue's HealthDependencies should include the policy
+ Assert.Single(queue.Resource.AppliedPolicies);
+
+ // Simulate the health check: queue's own TCS is OK, but dependency (policy) is faulted
+ var check = new RabbitMQProvisionableHealthCheck(queue.Resource, fakeClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+ var context = new HealthCheckContext
+ {
+ Registration = new HealthCheckRegistration("test", _ => null!, null, null)
+ };
+ var result = await check.CheckHealthAsync(context);
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("ttl-policy", result.Description);
+ }
+
+ [Fact]
+ public async Task HealthCheck_PolicyFails_NonMatchingQueueHealthy()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var server = builder.AddRabbitMQ("rabbit")
+ .WithEndpoint(RabbitMQServerResource.PrimaryEndpointName, e => e.AllocatedEndpoint = new AllocatedEndpoint(e, "localhost", 5672));
+ var vhost = server.AddVirtualHost("myvhost");
+ var matchingQueue = vhost.AddQueue("orders-queue", "orders");
+ var nonMatchingQueue = vhost.AddQueue("payments-queue", "payments");
+ var policyBuilder = vhost.AddPolicy("ttl-policy", "^orders");
+
+ // Simulate BeforeStartEvent resolution
+ RabbitMQBuilderExtensions.ResolveAndApplyPolicyMatches(policyBuilder.Resource, vhost.Resource, policyBuilder);
+
+ // Non-matching queue has no policy dependency
+ Assert.Empty(nonMatchingQueue.Resource.AppliedPolicies);
+
+ var fakeClient = new FakeRabbitMQProvisioningClient();
+ fakeClient.FailPolicyNames.Add("ttl-policy");
+ builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient);
+
+ using var app = builder.Build();
+ await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default);
+
+ var check = new RabbitMQProvisionableHealthCheck(nonMatchingQueue.Resource, fakeClient, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
+ var context = new HealthCheckContext
+ {
+ Registration = new HealthCheckRegistration("test", _ => null!, null, null)
+ };
+ var result = await check.CheckHealthAsync(context);
+
+ Assert.Equal(HealthStatus.Healthy, result.Status);
+ }
+
+ // ── RabbitMQPolicyResource.AppliesTo ─────────────────────────────────────
+
+ [Fact]
+ public void AppliesTo_MatchingQueueName_ReturnsTrue()
+ {
+ var server = new RabbitMQServerResource("rabbit", userName: null,
+ password: new ParameterResource("pw", _ => "pw", secret: true));
+ var vhost = new RabbitMQVirtualHostResource("myvhost", "myvhost", server);
+ var policy = new RabbitMQPolicyResource("p", "p", "^orders", vhost);
+
+ Assert.True(policy.AppliesTo("orders", RabbitMQDestinationKind.Queue));
+ Assert.True(policy.AppliesTo("orders-dlq", RabbitMQDestinationKind.Queue));
+ }
+
+ [Fact]
+ public void AppliesTo_NonMatchingQueueName_ReturnsFalse()
+ {
+ var server = new RabbitMQServerResource("rabbit", userName: null,
+ password: new ParameterResource("pw", _ => "pw", secret: true));
+ var vhost = new RabbitMQVirtualHostResource("myvhost", "myvhost", server);
+ var policy = new RabbitMQPolicyResource("p", "p", "^orders", vhost);
+
+ Assert.False(policy.AppliesTo("payments", RabbitMQDestinationKind.Queue));
+ }
+
+ [Fact]
+ public void AppliesTo_ExchangeWhenApplyToQueues_ReturnsFalse()
+ {
+ var server = new RabbitMQServerResource("rabbit", userName: null,
+ password: new ParameterResource("pw", _ => "pw", secret: true));
+ var vhost = new RabbitMQVirtualHostResource("myvhost", "myvhost", server);
+ var policy = new RabbitMQPolicyResource("p", "p", "^orders", vhost, RabbitMQPolicyApplyTo.Queues);
+
+ Assert.False(policy.AppliesTo("orders", RabbitMQDestinationKind.Exchange));
+ }
+
+ [Fact]
+ public void AppliesTo_QueueWhenApplyToExchanges_ReturnsFalse()
+ {
+ var server = new RabbitMQServerResource("rabbit", userName: null,
+ password: new ParameterResource("pw", _ => "pw", secret: true));
+ var vhost = new RabbitMQVirtualHostResource("myvhost", "myvhost", server);
+ var policy = new RabbitMQPolicyResource("p", "p", "^orders", vhost, RabbitMQPolicyApplyTo.Exchanges);
+
+ Assert.False(policy.AppliesTo("orders", RabbitMQDestinationKind.Queue));
+ }
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQProvisionableHealthCheckTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQProvisionableHealthCheckTests.cs
new file mode 100644
index 00000000000..7baa3d1dabf
--- /dev/null
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQProvisionableHealthCheckTests.cs
@@ -0,0 +1,319 @@
+// 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.ApplicationModel;
+using Aspire.Hosting.RabbitMQ.Provisioning;
+using Aspire.Hosting.RabbitMQ.Tests.TestServices;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aspire.Hosting.RabbitMQ.Tests;
+
+public class RabbitMQProvisionableHealthCheckTests
+{
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private static (RabbitMQServerResource server, RabbitMQVirtualHostResource vhost) BuildVhost()
+ {
+ var server = new RabbitMQServerResource("rabbit", userName: null,
+ password: new ParameterResource("pw", _ => "pw", secret: true));
+ var vhost = new RabbitMQVirtualHostResource("myvhost", "myvhost", server);
+ return (server, vhost);
+ }
+
+ private static HealthCheckContext MakeContext() =>
+ new() { Registration = new HealthCheckRegistration("test", _ => null!, null, null) };
+
+#pragma warning disable CS0618 // obsolete constructor is fine for tests
+ private static ResourceNotificationService MakeNotifications() =>
+ new(NullLogger.Instance, new NullHostApplicationLifetime());
+#pragma warning restore CS0618
+
+ private static ResourceLoggerService MakeResourceLogger() => new();
+
+ // ── RabbitMQProvisionableHealthCheck ─────────────────────────────────────
+
+ [Fact]
+ public async Task CheckHealthAsync_SelfPending_ReturnsDegraded()
+ {
+ var (_, vhost) = BuildVhost();
+ var client = new FakeRabbitMQProvisioningClient();
+ var check = new RabbitMQProvisionableHealthCheck(vhost, client, NullLogger.Instance);
+
+ // ProvisionedTask is pending — no ApplyAsync called yet.
+ var result = await check.CheckHealthAsync(MakeContext());
+
+ Assert.Equal(HealthStatus.Degraded, result.Status);
+ Assert.Contains("in progress", result.Description);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_SelfFaulted_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var client = new FakeRabbitMQProvisioningClient();
+ var check = new RabbitMQProvisionableHealthCheck(vhost, client, NullLogger.Instance);
+
+ // Drive the resource through ApplyAsync with a failing client to fault the TCS.
+ var failingClient = new FakeRabbitMQProvisioningClient();
+ failingClient.FailVirtualHostNames.Add("myvhost");
+ await ((IRabbitMQProvisionable)vhost).ApplyAsync(failingClient, MakeNotifications(), MakeResourceLogger(), default);
+
+ var result = await check.CheckHealthAsync(MakeContext());
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("boom", result.Description);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_DependencyFaulted_ReturnsUnhealthyWithDepName()
+ {
+ var (_, vhost) = BuildVhost();
+ var queue = new RabbitMQQueueResource("myqueue", "myqueue", vhost);
+ var client = new FakeRabbitMQProvisioningClient();
+
+ // Simulate a policy dependency by injecting a pre-faulted stub.
+ var dep = new StubProvisionable("mypolicy", Task.FromException(new DistributedApplicationException("policy failed")));
+
+ var check = new RabbitMQProvisionableHealthCheckWithDeps(queue, [dep], client);
+
+ // Drive queue through ApplyAsync to complete its TCS.
+ await ((IRabbitMQProvisionable)queue).ApplyAsync(client, MakeNotifications(), MakeResourceLogger(), default);
+
+ var result = await check.CheckHealthAsync(MakeContext());
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("mypolicy", result.Description);
+ Assert.Contains("policy failed", result.Description);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_AllCompleteProbeHealthy_ReturnsHealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var client = new FakeRabbitMQProvisioningClient();
+ var check = new RabbitMQProvisionableHealthCheck(vhost, client, NullLogger.Instance);
+
+ await ((IRabbitMQProvisionable)vhost).ApplyAsync(client, MakeNotifications(), MakeResourceLogger(), default);
+
+ var result = await check.CheckHealthAsync(MakeContext());
+
+ Assert.Equal(HealthStatus.Healthy, result.Status);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_AllCompleteProbeUnhealthy_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ // Use a client that fails CanConnectAsync for the probe stage.
+ var probeClient = new FakeRabbitMQProvisioningClient { CanConnect = false };
+ var check = new RabbitMQProvisionableHealthCheck(vhost, probeClient, NullLogger.Instance);
+
+ // Use a separate client that succeeds for ApplyAsync.
+ var applyClient = new FakeRabbitMQProvisioningClient();
+ await ((IRabbitMQProvisionable)vhost).ApplyAsync(applyClient, MakeNotifications(), MakeResourceLogger(), default);
+
+ var result = await check.CheckHealthAsync(MakeContext());
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("Cannot connect", result.Description);
+ }
+
+ // ── Per-resource ProbeAsync ───────────────────────────────────────────────
+
+ [Fact]
+ public async Task QueueProbeAsync_QueueExists_ReturnsHealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var queue = new RabbitMQQueueResource("q", "myqueue", vhost);
+ var client = new FakeRabbitMQProvisioningClient();
+
+ var result = await ((IRabbitMQProvisionable)queue).ProbeAsync(client, default);
+
+ Assert.True(result.IsHealthy);
+ }
+
+ [Fact]
+ public async Task QueueProbeAsync_QueueMissing_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var queue = new RabbitMQQueueResource("q", "myqueue", vhost);
+ var client = new FakeRabbitMQProvisioningClient();
+ client.FailQueueNames.Add("myqueue");
+
+ var result = await ((IRabbitMQProvisionable)queue).ProbeAsync(client, default);
+
+ Assert.False(result.IsHealthy);
+ Assert.Contains("myqueue", result.Description);
+ }
+
+ [Fact]
+ public async Task ExchangeProbeAsync_ExchangeExists_ReturnsHealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var exchange = new RabbitMQExchangeResource("e", "myexchange", vhost);
+ var client = new FakeRabbitMQProvisioningClient();
+
+ var result = await ((IRabbitMQProvisionable)exchange).ProbeAsync(client, default);
+
+ Assert.True(result.IsHealthy);
+ }
+
+ [Fact]
+ public async Task ExchangeProbeAsync_ExchangeMissing_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var exchange = new RabbitMQExchangeResource("e", "myexchange", vhost);
+ var client = new FakeRabbitMQProvisioningClient();
+ client.FailExchangeNames.Add("myexchange");
+
+ var result = await ((IRabbitMQProvisionable)exchange).ProbeAsync(client, default);
+
+ Assert.False(result.IsHealthy);
+ Assert.Contains("myexchange", result.Description);
+ }
+
+ [Fact]
+ public async Task VhostProbeAsync_CanConnect_ReturnsHealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var client = new FakeRabbitMQProvisioningClient { CanConnect = true };
+
+ var result = await ((IRabbitMQProvisionable)vhost).ProbeAsync(client, default);
+
+ Assert.True(result.IsHealthy);
+ }
+
+ [Fact]
+ public async Task VhostProbeAsync_CannotConnect_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var client = new FakeRabbitMQProvisioningClient { CanConnect = false };
+
+ var result = await ((IRabbitMQProvisionable)vhost).ProbeAsync(client, default);
+
+ Assert.False(result.IsHealthy);
+ Assert.Contains("myvhost", result.Description);
+ }
+
+ [Fact]
+ public async Task ShovelProbeAsync_Running_ReturnsHealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var queue = new RabbitMQQueueResource("q", "q", vhost);
+ var exchange = new RabbitMQExchangeResource("e", "e", vhost);
+ var shovel = new RabbitMQShovelResource("s", "myshovel", vhost, queue, exchange);
+ var client = new FakeRabbitMQProvisioningClient();
+
+ var result = await ((IRabbitMQProvisionable)shovel).ProbeAsync(client, default);
+
+ Assert.True(result.IsHealthy);
+ }
+
+ [Fact]
+ public async Task ShovelProbeAsync_NotRunning_ReturnsUnhealthy()
+ {
+ var (_, vhost) = BuildVhost();
+ var queue = new RabbitMQQueueResource("q", "q", vhost);
+ var exchange = new RabbitMQExchangeResource("e", "e", vhost);
+ var shovel = new RabbitMQShovelResource("s", "myshovel", vhost, queue, exchange);
+
+ var client = new FixedShovelStateClient("starting");
+
+ var result = await ((IRabbitMQProvisionable)shovel).ProbeAsync(client, default);
+
+ Assert.False(result.IsHealthy);
+ Assert.Contains("starting", result.Description);
+ }
+
+ // ── Private test helpers ──────────────────────────────────────────────────
+
+ ///
+ /// A minimal stub used to inject a pre-completed or faulted
+ /// into tests without needing a real resource.
+ ///
+ private sealed class StubProvisionable(string name, Task provisionedTask) : IRabbitMQProvisionable
+ {
+ public string Name => name;
+ public Task ProvisionedTask => provisionedTask;
+ public Task ApplyAsync(IRabbitMQProvisioningClient client, ResourceNotificationService notifications, ResourceLoggerService resourceLogger, CancellationToken ct) => Task.CompletedTask;
+ }
+
+ ///
+ /// Wraps but injects explicit dependencies,
+ /// allowing tests to verify the dependency-awaiting stage without needing real policy resources.
+ ///
+ private sealed class RabbitMQProvisionableHealthCheckWithDeps(
+ IRabbitMQProvisionable self,
+ IEnumerable deps,
+ IRabbitMQProvisioningClient client) : IHealthCheck
+ {
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await self.ProvisionedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy($"Provisioning of '{self.Name}' failed: {ex.Message}", ex);
+ }
+
+ foreach (var dep in deps)
+ {
+ try
+ {
+ await dep.ProvisionedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return HealthCheckResult.Unhealthy(
+ $"Dependent resource '{dep.Name}' failed to provision: {ex.Message}", ex);
+ }
+ }
+
+ var probe = await self.ProbeAsync(client, cancellationToken).ConfigureAwait(false);
+ return probe.IsHealthy ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy(probe.Description);
+ }
+ }
+
+ ///
+ /// A minimal that returns a fixed shovel state
+ /// and delegates everything else to no-ops.
+ ///
+ private sealed class FixedShovelStateClient(string state) : IRabbitMQProvisioningClient
+ {
+ public Task GetShovelStateAsync(string vhost, string name, CancellationToken ct)
+ => Task.FromResult(state);
+
+ public Task CanConnectAsync(string vhost, CancellationToken ct) => Task.FromResult(false);
+ public Task DeclareExchangeAsync(string vhost, string name, string type, bool durable, bool autoDelete, IDictionary? args, CancellationToken ct) => Task.CompletedTask;
+ public Task DeclareQueueAsync(string vhost, string name, bool durable, bool exclusive, bool autoDelete, IDictionary? args, CancellationToken ct) => Task.CompletedTask;
+ public Task BindQueueAsync(string vhost, string sourceExchange, string queue, string routingKey, IDictionary? args, CancellationToken ct) => Task.CompletedTask;
+ public Task BindExchangeAsync(string vhost, string sourceExchange, string destExchange, string routingKey, IDictionary? args, CancellationToken ct) => Task.CompletedTask;
+ public Task QueueExistsAsync(string vhost, string name, CancellationToken ct) => Task.FromResult(true);
+ public Task ExchangeExistsAsync(string vhost, string name, CancellationToken ct) => Task.FromResult(true);
+ public Task CreateVirtualHostAsync(string vhost, CancellationToken ct) => Task.CompletedTask;
+ public Task PutShovelAsync(string vhost, string name, RabbitMQShovelDefinition def, CancellationToken ct) => Task.CompletedTask;
+ public Task PutPolicyAsync(string vhost, string name, RabbitMQPolicyDefinition def, CancellationToken ct) => Task.CompletedTask;
+ public Task PolicyExistsAsync(string vhost, string name, CancellationToken ct) => Task.FromResult(true);
+ public ValueTask DisposeAsync() => default;
+ }
+
+ private sealed class NullHostApplicationLifetime : IHostApplicationLifetime
+ {
+ public CancellationToken ApplicationStarted => default;
+ public CancellationToken ApplicationStopping => default;
+ public CancellationToken ApplicationStopped => default;
+ public void StopApplication() { }
+ }
+}
diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPublicApiTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPublicApiTests.cs
index 80b02a20ea1..e66c502fe64 100644
--- a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPublicApiTests.cs
+++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQPublicApiTests.cs
@@ -130,4 +130,217 @@ public void CtorRabbitMQServerResourceShouldThrowWhenPasswordIsNull()
var exception = Assert.Throws(action);
Assert.Equal(nameof(password), exception.ParamName);
}
+
+ [Fact]
+ public void AddVirtualHostShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.AddVirtualHost("vhost");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void AddVirtualHostShouldThrowWhenNameIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var name = isNull ? null! : string.Empty;
+
+ var action = () => rabbitMQ.AddVirtualHost(name);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddQueueOnVhostShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.AddQueue("queue");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void AddQueueOnVhostShouldThrowWhenNameIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var vhost = rabbitMQ.AddVirtualHost("vhost");
+ var name = isNull ? null! : string.Empty;
+
+ var action = () => vhost.AddQueue(name);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddQueueOnServerShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.AddQueue("queue");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddExchangeOnVhostShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.AddExchange("exchange");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void AddExchangeOnVhostShouldThrowWhenNameIsNullOrEmpty(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var vhost = rabbitMQ.AddVirtualHost("vhost");
+ var name = isNull ? null! : string.Empty;
+
+ var action = () => vhost.AddExchange(name);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddExchangeOnServerShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.AddExchange("exchange");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithQueuePropertiesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithProperties(q => q.Durable = true);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithQueuePropertiesShouldThrowWhenConfigureIsNull()
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var queue = rabbitMQ.AddQueue("queue");
+ Action configure = null!;
+
+ var action = () => queue.WithProperties(configure);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(configure), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithExchangePropertiesShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithProperties(e => e.Durable = true);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithBindingShouldThrowWhenExchangeBuilderIsNull()
+ {
+ IResourceBuilder exchange = null!;
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var vhost = rabbitMQ.AddVirtualHost("vhost");
+ var queue = vhost.AddQueue("queue");
+
+ var action = () => exchange.WithBinding(queue);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(exchange), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithBindingShouldThrowWhenDestinationIsNull()
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var vhost = rabbitMQ.AddVirtualHost("vhost");
+ var exchange = vhost.AddExchange("exchange");
+ IResourceBuilder destination = null!;
+
+ var action = () => exchange.WithBinding(destination);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(destination), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithPluginEnumShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithPlugin(RabbitMQPlugin.Prometheus);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Fact]
+ public void WithPluginStringShouldThrowWhenBuilderIsNull()
+ {
+ IResourceBuilder builder = null!;
+
+ var action = () => builder.WithPlugin("rabbitmq_prometheus");
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void WithPluginStringShouldThrowWhenPluginNameIsNullOrWhiteSpace(bool isNull)
+ {
+ var builder = TestDistributedApplicationBuilder.CreateWithTestContainerRegistry(testOutputHelper);
+ var rabbitMQ = builder.AddRabbitMQ("rabbitMQ");
+ var pluginName = isNull ? null! : " ";
+
+ var action = () => rabbitMQ.WithPlugin(pluginName);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws