File: Publishing\RadiusBackingConnections.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// 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.Radius.ResourceMapping;
using Azure.Provisioning.Expressions;
 
namespace Aspire.Hosting.Radius.Publishing;
 
/// <summary>
/// Projects the values a consumer needs for a Radius <em>backing</em> resource (a cache, database,
/// or queue) out of that resource's own Radius declaration, rather than deriving them from the
/// Aspire endpoints of the container Aspire would have run locally.
/// </summary>
/// <remarks>
/// <para>
/// A backing resource is not deployed as a <c>Radius.Compute/containers</c>; it is provisioned by a
/// Radius <em>recipe</em>. The recipe owns the Kubernetes objects and the credentials, and neither
/// is derivable from the Aspire model:
/// </para>
/// <list type="bullet">
/// <item>The Service name varies per recipe — the contrib Kubernetes recipes name it after
/// <c>context.resource.name</c>, while the legacy <c>local-dev/rediscaches</c> recipe uses a
/// <c>uniqueString</c>-suffixed name. So the container rule <c>{name}-{name}</c> used by
/// <see cref="RadiusServiceDiscovery"/> never addresses a backing resource's Service. The
/// authoritative address is the recipe's own <c>properties.host</c> / <c>properties.port</c>
/// output. See <see href="https://github.com/microsoft/aspire/issues/18935"/>.</item>
/// <item>The recipe generates its own credentials, so the <c>ParameterResource</c> password Aspire
/// generates for local run mode is not the deployed password. For the legacy
/// <c>Applications.*</c> types the deployed value is read back with the type's <c>listSecrets()</c>
/// action. For the <c>Radius.*</c> UDTs there is no <c>listSecrets()</c>; instead username and
/// password are <em>required schema properties</em> on the resource itself (the recipe reads them as
/// <c>context.resource.properties.&lt;name&gt;</c>), so Aspire writes its own parameter there and
/// both sides then agree by construction (see
/// <c>RadiusInfrastructureBuilder.ApplyRecipeInputPropertyCredentialsAsync</c>).</item>
/// </list>
/// <para>
/// Projections are keyed by the <em>emitted</em> Radius type string rather than the Aspire CLR
/// type, because the legacy and UDT schemas for the same Aspire resource expose different property
/// names and different secret mechanisms.
/// </para>
/// </remarks>
internal static class RadiusBackingConnections
{
    /// <summary>
    /// How a Radius type's credentials reach the consumer. Deliberately a closed hierarchy rather
    /// than a pair of loosely-related fields: the previous shape
    /// (<c>string? PasswordSecret</c> plus <c>bool TakesCredentialRecipeParameters</c>) could
    /// express "handled by neither mechanism", and a row in that state fell through to normal
    /// parameter routing and re-emitted the local password Aspire generated for run mode — the very
    /// defect <see href="https://github.com/microsoft/aspire/issues/18935"/> reports. Making that
    /// state unrepresentable means a new row cannot silently regress.
    /// </summary>
    internal abstract record RadiusCredentialMode
    {
        // Private constructor closes the hierarchy to the nested cases below, so `switch` over them
        // is exhaustive and a future contributor must pick one deliberately.
        private RadiusCredentialMode()
        {
        }
 
        /// <summary>The recipe generates the credential and exposes it through <c>listSecrets()</c>.</summary>
        internal sealed record ListSecrets(string PasswordSecretName) : RadiusCredentialMode;
 
        /// <summary>
        /// <c>username</c>/<c>password</c> are required schema properties on the resource, so Aspire
        /// writes its own parameters there and both sides agree by construction. They are not
        /// <c>properties.recipe.parameters</c>: the manifests mark them <c>required</c> and the
        /// recipes read <c>context.resource.properties.&lt;name&gt;</c>, so a resource carrying them
        /// only as recipe parameters fails schema validation before any recipe runs.
        /// </summary>
        internal sealed record RecipeInputProperties : RadiusCredentialMode;
 
        /// <summary>
        /// The credential is passed to the recipe as the <em>resource ID</em> of a separate
        /// <c>Radius.Security/secrets</c> resource, rather than as a literal on the consuming
        /// resource. Aspire emits the secret holding its own <c>@secure()</c> parameter and assigns
        /// <c><paramref name="PropertyName"/>: &lt;secret&gt;.id</c>, so — as with
        /// <see cref="RecipeInputProperties"/> — the deployed credential is the one Aspire already
        /// composed into the connection string.
        /// </summary>
        /// <remarks>
        /// Distinct from <see cref="RecipeInputProperties"/> because that mode writes the value
        /// straight onto the property. A property that expects a secret <em>resource ID</em> would
        /// then receive a password string, which Radius rejects at deploy time.
        /// </remarks>
        /// <param name="PropertyName">The resource property that takes the secret's ID.</param>
        /// <param name="SecretKey">The key under the secret's <c>data</c> map holding the value.</param>
        internal sealed record SecretResourceReference(string PropertyName, string SecretKey) : RadiusCredentialMode;
 
        /// <summary>
        /// The recipe deploys the workload with <em>no</em> authentication, so there is no
        /// credential to project. Any password Aspire generated for run mode resolves to an empty
        /// value, and <paramref name="Reason"/> is written into the publish-time warning that
        /// reports it.
        /// </summary>
        /// <remarks>
        /// Distinct from <see cref="NotProjected"/>: the address <em>is</em> projected, only the
        /// credential is absent. Emitting <c>listSecrets().password</c> for such a type is worse
        /// than emitting nothing — the recipe records no secrets, so the accessor fails the
        /// deployment when ARM evaluates a property the returned object does not have.
        /// </remarks>
        internal sealed record NoCredential(string Reason) : RadiusCredentialMode;
 
        /// <summary>
        /// The type carries no credential Aspire can project. <paramref name="Reason"/> is written
        /// into the publish-time error so the omission is explained rather than merely observed.
        /// </summary>
        internal sealed record NotProjected(string Reason) : RadiusCredentialMode;
    }
 
    /// <summary>
    /// Describes how one emitted Radius type surfaces the values a consumer needs.
    /// </summary>
    /// <param name="HostProperty">Name of the non-secret property carrying the host/FQDN, or
    /// <see langword="null"/> when the type does not expose one.</param>
    /// <param name="PortProperty">Name of the non-secret property carrying the port, or
    /// <see langword="null"/> when the type does not expose one.</param>
    /// <param name="UserNameProperty">Name of the non-secret property carrying the user name, or
    /// <see langword="null"/> when the type does not expose one. This is a plain property rather
    /// than a <c>listSecrets()</c> key: the legacy <c>Applications.Datastores/mongoDatabases</c> and
    /// <c>Applications.Messaging/rabbitMQQueues</c> types return only <c>connectionString</c> and
    /// <c>password</c> from <c>listSecrets()</c>, and expose <c>username</c> at
    /// <c>properties.username</c>.</param>
    /// <param name="Credentials">How the credential reaches the consumer.</param>
    internal sealed record RadiusConnectionSchema(
        string? HostProperty,
        string? PortProperty,
        string? UserNameProperty,
        RadiusCredentialMode Credentials);
 
    // Declared before s_schemas: static field initializers run in textual order, so a shared
    // instance referenced from the table must already be assigned.
    private static readonly RadiusConnectionSchema s_daprNotProjected = new(
        HostProperty: null,
        PortProperty: null,
        UserNameProperty: null,
        new RadiusCredentialMode.NotProjected(
            "Dapr building blocks are reached through the Dapr sidecar's component configuration " +
            "rather than a host, port, or credential Aspire can compose into a connection string."));
 
    // Schema shapes verified against the Radius TypeSpec definitions for the legacy portable types
    // (radius-project/radius, typespec/Applications.Datastores/*.tsp and
    // typespec/Applications.Messaging/rabbitMQQueues.tsp) and the UDT manifests in
    // radius-project/resource-types-contrib (Data/*, Messaging/*).
    //
    // Keyed by the *emitted* type, i.e. what ResourceTypeMapper.MapResource returns. Every backing
    // type that mapper can emit must appear here; BackingResourceContractTests enforces that,
    // so dropping a LegacyFallbackType without adding the corresponding UDT row fails at test time
    // rather than silently emitting Aspire's local password at deploy time.
    private static readonly Dictionary<string, RadiusConnectionSchema> s_schemas = new(StringComparer.Ordinal)
    {
        // Legacy portable types expose host/port as plain properties and their credentials through
        // a first-class listSecrets() action. Mongo also exposes a user name.
        [RadiusResourceTypes.LegacyMongoDatabases] =
            new("host", "port", "username", new RadiusCredentialMode.ListSecrets("password")),
 
        // Applications.Datastores/sqlDatabases names the address property `server`, not `host`
        // (typespec/Applications.Datastores/sqlDatabases.tsp), and returns `password` from
        // listSecrets(). `username` is a plain property the recipe writes.
        [RadiusResourceTypes.LegacySqlDatabases] =
            new("server", "port", "username", new RadiusCredentialMode.ListSecrets("password")),
 
        // UDTs expose readOnly host/port but no listSecrets(); username/password are required
        // schema properties and the password is redacted on read (x-radius-sensitive), so the only
        // consistent value is the parameter Aspire itself writes onto the resource. The user name is
        // an *input* here, not a readable output, so there is no UserNameProperty.
        [RadiusResourceTypes.PostgreSqlDatabases] =
            new("host", "port", null, new RadiusCredentialMode.RecipeInputProperties()),
 
        // Radius 0.60's kube-recipes/rediscaches still deploys Redis with no `--requirepass`, so
        // there is no deployed credential to project. The type exposes readOnly host/port and no
        // listSecrets() action; emitting a password accessor would fail the deployment on a
        // property the recipe never records. Modelling it as unauthenticated is what keeps the
        // projected values honest — see ASPIRERADIUS075.
        [RadiusResourceTypes.RedisCaches] =
            new("host", "port", null, new RadiusCredentialMode.NoCredential(
                "the 'kube-recipes/rediscaches' recipe deploys Redis without authentication and publishes no password " +
                "secret, so there is no deployed credential to hand to consumers")),
 
        // RabbitMQ's UDT takes `password` as the *resource ID* of a Radius.Security/secrets
        // resource, not as a literal, so it cannot use RecipeInputProperties. `username` is a
        // plain required input (an input, not a readable output, so there is no UserNameProperty —
        // the same reasoning as postgreSqlDatabases above).
        [RadiusResourceTypes.RabbitMQ] =
            new("host", "port", null, new RadiusCredentialMode.SecretResourceReference("password", "password")),
 
        // Deliberately absent: Radius.Data/sqlServerDatabases. The contrib manifest exists, but its
        // Kubernetes recipe is not published (ghcr.io/radius-project/kube-recipes/sqlserverdatabases
        // does not exist), so ResourceTypeMapper still emits the legacy type above. Adding the row here
        // without a deployable recipe would only make the unreachable path look supported.
 
        // Dapr types are backing resources by classification but are consumed through the Dapr
        // sidecar's component configuration, not through an address or credential Aspire composes.
        // They are listed explicitly so the schema table stays total over everything
        // ResourceTypeMapper classifies as a backing resource. Only the legacy types appear:
        // Radius 0.60 has no Radius.Dapr/* namespace, so there is no UDT counterpart to key on.
        [RadiusResourceTypes.LegacyDaprStateStores] = s_daprNotProjected,
        [RadiusResourceTypes.LegacyDaprPubSubBrokers] = s_daprNotProjected,
 
        // Deliberately absent: Radius.Data/mongoDatabases. That UDT manifest ships in the 0.60
        // Bicep extension, but no Kubernetes recipe is published for it, so ResourceTypeMapper
        // still emits the legacy type for MongoDB. Leaving it out means a future migration that
        // drops the legacy fallback fails loudly here instead of quietly reverting to the
        // pre-fix behaviour.
    };
 
    /// <summary>
    /// Gets the connection schema for an emitted Radius type, or <see langword="null"/> when the
    /// type is not a known backing resource (e.g. <c>Radius.Compute/containers</c>).
    /// </summary>
    public static RadiusConnectionSchema? GetSchema(string radiusType) =>
        s_schemas.TryGetValue(radiusType, out var schema) ? schema : null;
 
    /// <summary>
    /// The emitted Radius types this table describes. Used by the schema guard test to assert the
    /// table stays total over <see cref="ResourceTypeMapper"/>'s backing-resource classification.
    /// </summary>
    public static IReadOnlyCollection<string> KnownTypes => s_schemas.Keys;
 
    /// <summary>
    /// Builds <c>{identifier}.properties.{propertyName}</c>.
    /// </summary>
    public static BicepExpression Property(string bicepIdentifier, string propertyName) =>
        new MemberExpression(
            new MemberExpression(new IdentifierExpression(bicepIdentifier), "properties"),
            propertyName);
 
    /// <summary>
    /// Builds <c>{identifier}.listSecrets().{secretName}</c>.
    /// </summary>
    /// <remarks>
    /// <c>listSecrets()</c> is a first-class action on the legacy <c>Applications.*</c> portable
    /// types (see the generated Bicep types under
    /// <c>hack/bicep-types-radius/generated/applications/applications.datastores</c>) and is the
    /// documented way to read recipe-generated credentials at deploy time. The <c>Radius.*</c> UDTs
    /// deliberately do not have it, which is why they use
    /// <see cref="RadiusCredentialMode.RecipeInputProperties"/> instead.
    /// </remarks>
    public static BicepExpression Secret(string bicepIdentifier, string secretName) =>
        new MemberExpression(
            new FunctionCallExpression(
                new MemberExpression(new IdentifierExpression(bicepIdentifier), "listSecrets")),
            secretName);
 
    /// <summary>
    /// Wraps an expression in Bicep's <c>uriComponent(...)</c> percent-encoding.
    /// </summary>
    /// <remarks>
    /// Used for values a <c>ReferenceExpression</c> declared with the <c>uri</c> string
    /// format. Aspire applies <c>Uri.EscapeDataString</c> when it resolves such a value itself, but
    /// the publisher emits a Bicep expression whose value is only known at deploy time, so the
    /// escaping has to be emitted as a call. This matters most for a recipe-generated password: it
    /// is drawn from an alphabet the recipe chooses, not Aspire's URL-safe generated one, so an
    /// unescaped <c>@</c>, <c>:</c>, or <c>/</c> would truncate or corrupt the URI it lands in.
    /// See <see href="https://learn.microsoft.com/azure/azure-resource-manager/bicep/bicep-functions-string#uricomponent"/>.
    /// </remarks>
    public static BicepExpression UriComponent(BicepExpression expression) =>
        new FunctionCallExpression(new IdentifierExpression("uriComponent"), expression);
 
    /// <summary>
    /// Wraps an expression in Bicep's <c>string(...)</c> conversion.
    /// </summary>
    /// <remarks>
    /// Container environment variable values are typed <c>string</c>, but a Radius type's
    /// <c>port</c> output is an <c>int</c>. Assigning it bare (<c>value: cache.properties.port</c>)
    /// is a Bicep type error. Inside a string interpolation the conversion is implicit, so this is
    /// only needed when the projected value stands alone.
    /// </remarks>
    public static BicepExpression ToStringExpression(BicepExpression expression) =>
        new FunctionCallExpression(new IdentifierExpression("string"), expression);
}