// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPROJECTS001
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Radius.ResourceMapping;
/// <summary>
/// Maps Aspire resource types to Radius user-defined resource type (UDT) strings.
/// </summary>
/// <remarks>
/// <para>
/// Radius is migrating from built-in portable resource types (<c>Applications.*</c>) to
/// user-defined resource types (UDTs) under the <c>Radius.*</c> namespace. During the transition,
/// types that have a <see cref="RadiusTypeMapping.LegacyFallbackType"/> are emitted using the
/// legacy <c>Applications.*</c> type and API version. Once a Radius release promotes the UDT
/// to stable, the legacy fallback should be removed from the mapping entry.
/// </para>
/// </remarks>
internal sealed class ResourceTypeMapper
{
private readonly ILogger _logger;
/// <summary>
/// Mapping entry containing the new Radius.* type, optional legacy fallback, and API version.
/// </summary>
internal readonly record struct RadiusTypeMapping(
string RadiusType,
string ApiVersion,
string? LegacyFallbackType = null,
string? LegacyApiVersion = null);
/// <summary>
/// Maps Aspire resource CLR type information keyed by the resource's <em>simple</em> type
/// name (e.g. <c>"RedisResource"</c>), not its fully-qualified name. This is an intentional
/// compatibility tradeoff: the mapped resource types live in optional hosting packages that
/// this project deliberately does not reference, and keying by simple name tolerates a type
/// moving assemblies or namespaces across Aspire versions. Fully-qualified names would also
/// work as plain string keys, but would break silently on such a move.
/// </summary>
/// <remarks>
/// Caveat: because lookup (see <see cref="GetMappingKey"/>) walks the inheritance chain
/// comparing <c>Type.Name</c>, a third-party resource type that happens to share
/// a simple name with an entry here (e.g. an unrelated <c>RedisResource</c> in another
/// namespace/assembly) would be mapped to the corresponding Radius type. This is an accepted
/// low-risk tradeoff for the no-assembly-reference design.
/// </remarks>
private static readonly Dictionary<string, RadiusTypeMapping> s_typeMappings = new(StringComparer.Ordinal)
{
// Resource types from optional hosting packages - referenced by string name
// since those packages are not referenced by this project.
// Redis and RabbitMQ moved onto their Radius.* UDTs in Radius 0.60: both
// kube-recipes/rediscaches and kube-recipes/rabbitmq are published, so the UDT is
// deployable and the legacy fallback is gone. Neither recipe is in a stable release yet,
// so both are pinned by commit SHA rather than :latest — see s_defaultRecipeTemplates in
// RadiusInfrastructureBuilder.
["RedisResource"] = new(
RadiusResourceTypes.RedisCaches,
RadiusResourceTypes.RadiusApiVersion),
// SQL Server stays on the legacy portable type. The contrib UDT is
// Radius.Data/sqlServerDatabases (not sqlDatabases), and its Kubernetes recipe
// (kube-recipes/sqlserverdatabases) is not published yet, so emitting the UDT would fail
// recipe resolution at deploy time. The legacy type has a published recipe
// (recipes/local-dev/sqldatabases) and a listSecrets() action, so the credentials the recipe
// generates can still be projected to consumers. Drop the fallback once the UDT recipe ships.
["SqlServerServerResource"] = new(
RadiusResourceTypes.SqlServerDatabases,
RadiusResourceTypes.RadiusApiVersion,
RadiusResourceTypes.LegacySqlDatabases,
RadiusResourceTypes.LegacyApiVersion),
["PostgresServerResource"] = new(
RadiusResourceTypes.PostgreSqlDatabases,
RadiusResourceTypes.RadiusApiVersion),
["MongoDBServerResource"] = new(
RadiusResourceTypes.MongoDatabases,
RadiusResourceTypes.RadiusApiVersion,
RadiusResourceTypes.LegacyMongoDatabases,
RadiusResourceTypes.LegacyApiVersion),
["RabbitMQServerResource"] = new(
RadiusResourceTypes.RabbitMQ,
RadiusResourceTypes.RadiusApiVersion),
// Core hosting types - these are in the Aspire.Hosting package
["ContainerResource"] = new(
RadiusResourceTypes.Containers,
RadiusResourceTypes.RadiusApiVersion),
["ProjectResource"] = new(
RadiusResourceTypes.Containers,
RadiusResourceTypes.RadiusApiVersion),
// Dapr types. Radius 0.60 has no Radius.Dapr/* namespace, so unlike the entries above these
// are not a UDT-with-legacy-fallback pair — the legacy Applications.Dapr/* portable type is
// the only type that exists, and it is mapped directly as the primary type.
//
// These entries must not be deleted: MapResource falls back to Radius.Compute/containers for
// unmapped resources, so removing them would silently publish Dapr building blocks as
// containers rather than failing.
["DaprStateStoreResource"] = new(
RadiusResourceTypes.LegacyDaprStateStores,
RadiusResourceTypes.LegacyApiVersion),
["DaprPubSubResource"] = new(
RadiusResourceTypes.LegacyDaprPubSubBrokers,
RadiusResourceTypes.LegacyApiVersion),
};
private static readonly RadiusTypeMapping s_containerFallback = new(
RadiusResourceTypes.Containers,
RadiusResourceTypes.RadiusApiVersion);
public ResourceTypeMapper(ILogger<ResourceTypeMapper> logger)
: this((ILogger)logger)
{
}
// Overload for callers that already have a non-generic ILogger (e.g., a pipeline-step
// logger or a test FakeLogger). Allows mapper diagnostics to flow into the same logger
// the caller uses, instead of forcing each call site to allocate a LoggerFactory.
internal ResourceTypeMapper(ILogger logger)
{
_logger = logger;
}
/// <summary>
/// Maps an Aspire resource to its Radius resource type string and API version.
/// </summary>
/// <remarks>
/// Uses the new <c>Radius.*</c> namespace when available, falling back to legacy
/// <c>Applications.*</c> types during the UDT migration. Unmapped resource types
/// fall back to <c>Radius.Compute/containers</c>.
/// </remarks>
/// <param name="resource">The Aspire resource to map.</param>
/// <returns>A tuple of (resourceType, apiVersion) for the Radius resource.</returns>
public (string ResourceType, string ApiVersion) MapResource(IResource resource)
{
var resourceTypeName = GetMappingKey(resource);
if (s_typeMappings.TryGetValue(resourceTypeName, out var mapping))
{
// If a legacy fallback exists, it means the Radius.* type is not yet fully migrated.
// Use the legacy type. This is the prototype's expected v1 mapping for several types
// (e.g. MongoDB, SQL Server), so we log at Information rather than Warning
// to avoid flooding the dashboard / CI logs with per-resource yellow noise. The README
// documents which Aspire resources are mapped to legacy vs UDT.
if (mapping.LegacyFallbackType is not null)
{
_logger.LogInformation(
"Resource '{ResourceName}' mapped to legacy type '{LegacyType}' (API {LegacyApiVersion}). " +
"New type '{RadiusType}' is pending migration.",
resource.Name,
mapping.LegacyFallbackType,
mapping.LegacyApiVersion,
mapping.RadiusType);
return (mapping.LegacyFallbackType, mapping.LegacyApiVersion!);
}
return (mapping.RadiusType, mapping.ApiVersion);
}
// Unmapped type — fallback to Radius.Compute/containers
_logger.LogWarning(
"Resource '{ResourceName}' of type '{ResourceType}' has no Radius mapping; " +
"falling back to '{FallbackType}'.",
resource.Name,
resource.GetType().Name,
s_containerFallback.RadiusType);
return (s_containerFallback.RadiusType, s_containerFallback.ApiVersion);
}
/// <summary>
/// Returns <see langword="true"/> when <paramref name="resource"/> maps to a known
/// non-compute Radius backing resource type (e.g. a cache, database, or queue).
/// Returns <see langword="false"/> for compute workloads (project/container) and for
/// resources with no Radius mapping (which would otherwise fall back to
/// <see cref="RadiusResourceTypes.Containers"/>).
/// </summary>
/// <remarks>
/// This is a side-effect-free probe; unlike <see cref="MapResource"/> it never logs, so
/// callers do not emit the per-resource mapping diagnostics that belong to the publish path.
/// </remarks>
internal static bool IsBackingResource(IResource resource)
{
var key = GetMappingKey(resource);
return s_typeMappings.TryGetValue(key, out var mapping)
&& !string.Equals(mapping.RadiusType, RadiusResourceTypes.Containers, StringComparison.Ordinal);
}
/// <summary>
/// The Radius type a backing resource is emitted as — the legacy fallback when one is
/// configured, otherwise the <c>Radius.*</c> type — or <see langword="null"/> when
/// <paramref name="resource"/> is not a backing resource.
/// </summary>
/// <remarks>
/// Side-effect-free like <see cref="IsBackingResource"/>, so a diagnostic can name the type
/// without emitting the per-resource mapping log entries that belong to the publish path.
/// </remarks>
internal static string? TryGetEmittedBackingType(IResource resource)
{
var key = GetMappingKey(resource);
if (!s_typeMappings.TryGetValue(key, out var mapping) ||
string.Equals(mapping.RadiusType, RadiusResourceTypes.Containers, StringComparison.Ordinal))
{
return null;
}
return mapping.LegacyFallbackType ?? mapping.RadiusType;
}
/// <summary>
/// The Radius type each backing-resource mapping actually emits — the legacy fallback when one
/// is configured, otherwise the <c>Radius.*</c> type — paired with the mapping key it came from.
/// </summary>
/// <remarks>
/// Exists so <c>RadiusBackingConnections</c>'s schema table can be asserted total over this
/// classification. Without that assertion, adding a mapping (or dropping a
/// <see cref="RadiusTypeMapping.LegacyFallbackType"/>) would leave the new emitted type without
/// a schema, and the publisher would fall back to routing Aspire's locally-generated password
/// into the deployed connection string — the defect
/// <see href="https://github.com/microsoft/aspire/issues/18935"/> describes.
/// </remarks>
internal static IEnumerable<(string MappingKey, string EmittedType)> GetEmittedBackingTypes() =>
s_typeMappings
.Where(static kvp => !string.Equals(kvp.Value.RadiusType, RadiusResourceTypes.Containers, StringComparison.Ordinal))
.Select(static kvp => (kvp.Key, kvp.Value.LegacyFallbackType ?? kvp.Value.RadiusType));
/// <summary>
/// Gets the key used to look up the type mapping. Walks the type hierarchy to find the
/// most specific match (e.g., <c>RedisResource</c> inherits from <c>ContainerResource</c>
/// but should match as Redis).
/// </summary>
private static string GetMappingKey(IResource resource)
{
var type = resource.GetType();
// Walk the inheritance chain to find the most specific mapped type
while (type is not null)
{
if (s_typeMappings.ContainsKey(type.Name))
{
return type.Name;
}
type = type.BaseType;
}
if (resource is IDotnetProgramResource)
{
return nameof(ProjectResource);
}
return resource.GetType().Name;
}
}