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(action); + Assert.Equal(nameof(pluginName), exception.ParamName); + } } diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQTopologyProvisionerTests.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQTopologyProvisionerTests.cs new file mode 100644 index 00000000000..c84a9ed764c --- /dev/null +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/RabbitMQTopologyProvisionerTests.cs @@ -0,0 +1,133 @@ +// 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; + +namespace Aspire.Hosting.RabbitMQ.Tests; + +public class RabbitMQTopologyProvisionerTests +{ + [Fact] + public async Task ProvisionTopologyAsync_AppliesResourcesInCorrectOrder() + { + 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 exchange = vhost.AddExchange("myexchange"); + var queue = vhost.AddQueue("myqueue"); + exchange.WithBinding(queue, "myroutingkey"); + var shovel = vhost.AddShovel("myshovel", queue, exchange); + + var fakeClient = new FakeRabbitMQProvisioningClient(); + builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient); + + using var app = builder.Build(); + + await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default); + + Assert.True(((IRabbitMQProvisionable)vhost.Resource).ProvisionedTask.IsCompletedSuccessfully); + Assert.True(((IRabbitMQProvisionable)queue.Resource).ProvisionedTask.IsCompletedSuccessfully); + Assert.True(((IRabbitMQProvisionable)exchange.Resource).ProvisionedTask.IsCompletedSuccessfully); + Assert.True(((IRabbitMQProvisionable)shovel.Resource).ProvisionedTask.IsCompletedSuccessfully); + + // CreateVirtualHostAsync must be first + Assert.Equal("CreateVirtualHostAsync(myvhost)", fakeClient.Calls[0]); + + // Exchange and queue declares happen in phase 2 (parallel — order may vary) + Assert.Contains(fakeClient.Calls, c => c == "DeclareExchangeAsync(myvhost, myexchange, direct, True, False)"); + Assert.Contains(fakeClient.Calls, c => c == "DeclareQueueAsync(myvhost, myqueue, True, False, False)"); + + // Binding and shovel happen in phase 3 (after phase 2) + var exchangeDeclareIndex = fakeClient.Calls.IndexOf("DeclareExchangeAsync(myvhost, myexchange, direct, True, False)"); + var queueDeclareIndex = fakeClient.Calls.IndexOf("DeclareQueueAsync(myvhost, myqueue, True, False, False)"); + var lastDeclareIndex = Math.Max(exchangeDeclareIndex, queueDeclareIndex); + + var bindIndex = fakeClient.Calls.FindIndex(c => c == "BindQueueAsync(myvhost, myexchange, myqueue, myroutingkey)"); + var shovelIndex = fakeClient.Calls.FindIndex(c => c.StartsWith("PutShovelAsync(myvhost, myshovel,")); + + Assert.True(bindIndex > lastDeclareIndex, "BindQueueAsync must come after all declares"); + Assert.True(shovelIndex > lastDeclareIndex, "PutShovelAsync must come after all declares"); + } + + [Fact] + public async Task ProvisionTopologyAsync_VhostFails_FaultsVhostAndChildrenStayPending() + { + var builder = DistributedApplication.CreateBuilder(); + var server = builder.AddRabbitMQ("rabbit"); + var vhost = server.AddVirtualHost("myvhost"); + var queue = vhost.AddQueue("myqueue"); + var exchange = vhost.AddExchange("myexchange"); + + var fakeClient = new FailingFakeRabbitMQProvisioningClient(); + builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient); + + using var app = builder.Build(); + + // Provisioner no longer throws — it captures failures into TCSs + 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 + Assert.False(((IRabbitMQProvisionable)queue.Resource).ProvisionedTask.IsCompleted); + Assert.False(((IRabbitMQProvisionable)exchange.Resource).ProvisionedTask.IsCompleted); + } + + [Fact] + public async Task ProvisionTopologyAsync_QueueBFails_OnlyQueueBTCSFaulted() + { + 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 queueA = vhost.AddQueue("queueA"); + var queueB = vhost.AddQueue("queueB"); + + var fakeClient = new FakeRabbitMQProvisioningClient(); + fakeClient.FailQueueNames.Add("queueB"); + builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient); + + using var app = builder.Build(); + + await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default); + + // Queue A succeeded + Assert.True(((IRabbitMQProvisionable)queueA.Resource).ProvisionedTask.IsCompletedSuccessfully); + // Queue B failed + Assert.True(((IRabbitMQProvisionable)queueB.Resource).ProvisionedTask.IsFaulted); + // Vhost itself succeeded + Assert.True(((IRabbitMQProvisionable)vhost.Resource).ProvisionedTask.IsCompletedSuccessfully); + } + + [Fact] + public async Task ProvisionTopologyAsync_BindingFails_OnlyExchangeTCSFaulted_DestQueueUnaffected() + { + 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 exchange = vhost.AddExchange("myexchange"); + var queue = vhost.AddQueue("myqueue"); + exchange.WithBinding(queue, "key"); + + var fakeClient = new FakeRabbitMQProvisioningClient(); + fakeClient.FailBindingSourceExchangeNames.Add("myexchange"); + builder.Services.AddKeyedSingleton(server.Resource.Name, fakeClient); + + using var app = builder.Build(); + + await RabbitMQTopologyProvisioner.ProvisionTopologyAsync(server.Resource, app.Services, default); + + // Exchange is faulted (binding failed) + Assert.True(((IRabbitMQProvisionable)exchange.Resource).ProvisionedTask.IsFaulted); + // Destination queue is unaffected — it declared successfully + Assert.True(((IRabbitMQProvisionable)queue.Resource).ProvisionedTask.IsCompletedSuccessfully); + // Vhost itself succeeded + Assert.True(((IRabbitMQProvisionable)vhost.Resource).ProvisionedTask.IsCompletedSuccessfully); + } +} diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/Snapshots/ConnectionPropertiesTests.VerifyManifestWithConnectionProperties.verified.json b/tests/Aspire.Hosting.RabbitMQ.Tests/Snapshots/ConnectionPropertiesTests.VerifyManifestWithConnectionProperties.verified.json index 385cb1d2126..ab10b9f4b4d 100644 --- a/tests/Aspire.Hosting.RabbitMQ.Tests/Snapshots/ConnectionPropertiesTests.VerifyManifestWithConnectionProperties.verified.json +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/Snapshots/ConnectionPropertiesTests.VerifyManifestWithConnectionProperties.verified.json @@ -14,6 +14,29 @@ "SERVERWITHPARAMETERS_PORT": "{serverWithParameters.bindings.tcp.port}", "SERVERWITHPARAMETERS_USERNAME": "{username.value}", "SERVERWITHPARAMETERS_PASSWORD": "{password.value}", - "SERVERWITHPARAMETERS_URI": "amqp://{username-uri-encoded.value}:{password-uri-encoded.value}@{serverWithParameters.bindings.tcp.host}:{serverWithParameters.bindings.tcp.port}" + "SERVERWITHPARAMETERS_URI": "amqp://{username-uri-encoded.value}:{password-uri-encoded.value}@{serverWithParameters.bindings.tcp.host}:{serverWithParameters.bindings.tcp.port}", + "ConnectionStrings__myvhost": "{myvhost.connectionString}", + "MYVHOST_HOST": "{server.bindings.tcp.host}", + "MYVHOST_PORT": "{server.bindings.tcp.port}", + "MYVHOST_USERNAME": "guest", + "MYVHOST_PASSWORD": "{server-password.value}", + "MYVHOST_URI": "amqp://guest:{server-password.value}@{server.bindings.tcp.host}:{server.bindings.tcp.port}/myvhost", + "MYVHOST_VIRTUALHOST": "myvhost", + "ConnectionStrings__myqueue": "{myqueue.connectionString}", + "MYQUEUE_HOST": "{server.bindings.tcp.host}", + "MYQUEUE_PORT": "{server.bindings.tcp.port}", + "MYQUEUE_USERNAME": "guest", + "MYQUEUE_PASSWORD": "{server-password.value}", + "MYQUEUE_URI": "amqp://guest:{server-password.value}@{server.bindings.tcp.host}:{server.bindings.tcp.port}/myvhost", + "MYQUEUE_VIRTUALHOST": "myvhost", + "MYQUEUE_QUEUENAME": "myqueue", + "ConnectionStrings__myexchange": "{myexchange.connectionString}", + "MYEXCHANGE_HOST": "{server.bindings.tcp.host}", + "MYEXCHANGE_PORT": "{server.bindings.tcp.port}", + "MYEXCHANGE_USERNAME": "guest", + "MYEXCHANGE_PASSWORD": "{server-password.value}", + "MYEXCHANGE_URI": "amqp://guest:{server-password.value}@{server.bindings.tcp.host}:{server.bindings.tcp.port}/myvhost", + "MYEXCHANGE_VIRTUALHOST": "myvhost", + "MYEXCHANGE_EXCHANGENAME": "myexchange" } } \ No newline at end of file diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FailingFakeRabbitMQProvisioningClient.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FailingFakeRabbitMQProvisioningClient.cs new file mode 100644 index 00000000000..75c77be61cb --- /dev/null +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FailingFakeRabbitMQProvisioningClient.cs @@ -0,0 +1,27 @@ +// 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.RabbitMQ.Tests.TestServices; + +/// +/// A provisioning client that always throws on , +/// simulating a vhost-level failure that should cascade to all children. +/// +internal sealed class FailingFakeRabbitMQProvisioningClient : IRabbitMQProvisioningClient +{ + 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) => throw new NotImplementedException(); + public Task DeclareQueueAsync(string vhost, string name, bool durable, bool exclusive, bool autoDelete, IDictionary? args, CancellationToken ct) => throw new NotImplementedException(); + public Task BindQueueAsync(string vhost, string sourceExchange, string queue, string routingKey, IDictionary? args, CancellationToken ct) => throw new NotImplementedException(); + public Task BindExchangeAsync(string vhost, string sourceExchange, string destExchange, string routingKey, IDictionary? args, CancellationToken ct) => throw new NotImplementedException(); + public Task QueueExistsAsync(string vhost, string name, CancellationToken ct) => throw new NotImplementedException(); + public Task ExchangeExistsAsync(string vhost, string name, CancellationToken ct) => throw new NotImplementedException(); + public Task CreateVirtualHostAsync(string vhost, CancellationToken ct) => throw new DistributedApplicationException("Failed to create virtual host"); + public Task PutShovelAsync(string vhost, string name, RabbitMQShovelDefinition def, CancellationToken ct) => throw new NotImplementedException(); + public Task GetShovelStateAsync(string vhost, string name, CancellationToken ct) => throw new NotImplementedException(); + public Task PutPolicyAsync(string vhost, string name, RabbitMQPolicyDefinition def, CancellationToken ct) => throw new NotImplementedException(); + public Task PolicyExistsAsync(string vhost, string name, CancellationToken ct) => throw new NotImplementedException(); + public ValueTask DisposeAsync() => default; +} diff --git a/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FakeRabbitMQProvisioningClient.cs b/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FakeRabbitMQProvisioningClient.cs new file mode 100644 index 00000000000..58ee9e2fced --- /dev/null +++ b/tests/Aspire.Hosting.RabbitMQ.Tests/TestServices/FakeRabbitMQProvisioningClient.cs @@ -0,0 +1,151 @@ +// 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.RabbitMQ.Tests.TestServices; + +internal sealed class FakeRabbitMQProvisioningClient : IRabbitMQProvisioningClient +{ + public List Calls { get; } = new(); + + /// + /// When set, throws for queues whose name is in this set. + /// Used to simulate per-entity failures without affecting siblings. + /// + public HashSet FailQueueNames { get; } = new(StringComparer.Ordinal); + + /// + /// When set, throws for virtual hosts whose name is in this set. + /// + public HashSet FailVirtualHostNames { get; } = new(StringComparer.Ordinal); + + /// + /// When set, throws for exchanges whose name is in this set. + /// + public HashSet FailExchangeNames { get; } = new(StringComparer.Ordinal); + + /// + /// When set, and throw for + /// source exchanges whose name is in this set. + /// + public HashSet FailBindingSourceExchangeNames { get; } = new(StringComparer.Ordinal); + + /// + /// When set, throws for policies whose name is in this set. + /// + public HashSet FailPolicyNames { get; } = new(StringComparer.Ordinal); + + /// + /// Controls the return value of . Defaults to . + /// + public bool CanConnect { get; set; } = true; + + public Task CanConnectAsync(string vhost, CancellationToken ct) + { + Calls.Add($"CanConnectAsync({vhost})"); + return Task.FromResult(CanConnect); + } + + public Task DeclareExchangeAsync(string vhost, string name, string type, bool durable, bool autoDelete, IDictionary? args, CancellationToken ct) + { + Calls.Add($"DeclareExchangeAsync({vhost}, {name}, {type}, {durable}, {autoDelete})"); + if (FailExchangeNames.Contains(name)) + { + throw new DistributedApplicationException($"Simulated failure declaring exchange '{name}'."); + } + + return Task.CompletedTask; + } + + public Task DeclareQueueAsync(string vhost, string name, bool durable, bool exclusive, bool autoDelete, IDictionary? args, CancellationToken ct) + { + Calls.Add($"DeclareQueueAsync({vhost}, {name}, {durable}, {exclusive}, {autoDelete})"); + if (FailQueueNames.Contains(name)) + { + throw new DistributedApplicationException($"Simulated failure declaring queue '{name}'."); + } + + return Task.CompletedTask; + } + + public Task BindQueueAsync(string vhost, string sourceExchange, string queue, string routingKey, IDictionary? args, CancellationToken ct) + { + Calls.Add($"BindQueueAsync({vhost}, {sourceExchange}, {queue}, {routingKey})"); + if (FailBindingSourceExchangeNames.Contains(sourceExchange)) + { + throw new DistributedApplicationException($"Simulated failure binding queue '{queue}' to exchange '{sourceExchange}'."); + } + + return Task.CompletedTask; + } + + public Task BindExchangeAsync(string vhost, string sourceExchange, string destExchange, string routingKey, IDictionary? args, CancellationToken ct) + { + Calls.Add($"BindExchangeAsync({vhost}, {sourceExchange}, {destExchange}, {routingKey})"); + if (FailBindingSourceExchangeNames.Contains(sourceExchange)) + { + throw new DistributedApplicationException($"Simulated failure binding exchange '{destExchange}' to exchange '{sourceExchange}'."); + } + + return Task.CompletedTask; + } + + public Task QueueExistsAsync(string vhost, string name, CancellationToken ct) + { + Calls.Add($"QueueExistsAsync({vhost}, {name})"); + return Task.FromResult(!FailQueueNames.Contains(name)); + } + + public Task ExchangeExistsAsync(string vhost, string name, CancellationToken ct) + { + Calls.Add($"ExchangeExistsAsync({vhost}, {name})"); + return Task.FromResult(!FailExchangeNames.Contains(name)); + } + + public Task CreateVirtualHostAsync(string vhost, CancellationToken ct) + { + Calls.Add($"CreateVirtualHostAsync({vhost})"); + if (FailVirtualHostNames.Contains(vhost)) + { + throw new DistributedApplicationException($"boom"); + } + + return Task.CompletedTask; + } + + public Task PutShovelAsync(string vhost, string name, RabbitMQShovelDefinition def, CancellationToken ct) + { + Calls.Add($"PutShovelAsync({vhost}, {name}, {def.Value.SrcUri}, {def.Value.DestUri})"); + return Task.CompletedTask; + } + + public Task GetShovelStateAsync(string vhost, string name, CancellationToken ct) + { + Calls.Add($"GetShovelStateAsync({vhost}, {name})"); + return Task.FromResult("running"); + } + + public Task PutPolicyAsync(string vhost, string name, RabbitMQPolicyDefinition def, CancellationToken ct) + { + Calls.Add($"PutPolicyAsync({vhost}, {name}, {def.Pattern}, {def.ApplyTo})"); + if (FailPolicyNames.Contains(name)) + { + throw new DistributedApplicationException($"Simulated failure applying policy '{name}'."); + } + + return Task.CompletedTask; + } + + public Task PolicyExistsAsync(string vhost, string name, CancellationToken ct) + { + Calls.Add($"PolicyExistsAsync({vhost}, {name})"); + return Task.FromResult(!FailPolicyNames.Contains(name)); + } + + public ValueTask DisposeAsync() + { + Calls.Add("DisposeAsync()"); + return default; + } +}