// 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 ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher.
#pragma warning disable ASPIRECOMPUTE002 // GetEndpointPropertyExpression/GetHostAddressExpression are experimental compute-environment APIs the publisher relies on.
#pragma warning disable ASPIRERADIUS006 // Secret-store model types (RadiusSecretStoreResource, etc.) are experimental; consumed internally by the publisher.
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing.Constructs;
using Aspire.Hosting.Radius.ResourceMapping;
using Aspire.Hosting.Radius.Secrets;
using Azure.Provisioning;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.Radius.Publishing;
/// <summary>
/// Builds an Azure.Provisioning Infrastructure AST from a <see cref="DistributedApplicationModel"/>
/// for a specific Radius environment. Generates typed <c>ProvisionableResource</c> constructs
/// (environments, applications, recipe packs, resource type instances, containers) that are
/// compiled to Bicep via <c>Infrastructure.Build().Compile()</c>.
/// </summary>
internal sealed class RadiusInfrastructureBuilder
{
private readonly RadiusEnvironmentResource _environment;
private readonly DistributedApplicationModel _model;
private readonly ResourceTypeMapper _typeMapper;
private readonly ILogger _logger;
/// <summary>
/// Publish-mode execution context used to resolve container environment variables and
/// service-discovery values. Set at the start of <see cref="BuildAsync"/>.
/// </summary>
private DistributedApplicationExecutionContext _executionContext = null!;
private CancellationToken _cancellationToken;
// Bicep parameters allocated for secret/parameter values referenced by container env vars.
// Keyed by the Aspire parameter name so repeated references reuse a single param declaration.
// These are emitted as top-level Bicep `param`s (secure when the source parameter is secret)
// instead of inlining values, so no literal secret is written to the published artifact.
private readonly Dictionary<string, ProvisioningParameter> _envParametersByName = new(StringComparer.Ordinal);
// Maps the emitted Bicep parameter identifier to its originating Aspire ParameterResource, so
// the deploy step can resolve each value at deploy time and pass it via `rad deploy --parameters`.
private readonly Dictionary<string, ParameterResource> _deployParametersByIdentifier = new(StringComparer.Ordinal);
// Bicep `param`s allocated for recipe-parameter and inline-secret values that bind an Aspire
// ParameterResource. Keyed by the Aspire parameter name so repeated references reuse a single
// declaration; secure when the source parameter is secret so no value is written to the artifact.
private readonly Dictionary<string, ProvisioningParameter> _recipeParameters = new(StringComparer.Ordinal);
// Maps the emitted recipe/inline-secret Bicep parameter identifier to its originating Aspire
// ParameterResource, unioned into RadiusDeployParametersAnnotation so the deploy step resolves a
// value for every valueless `param` at deploy time.
private readonly Dictionary<string, ParameterResource> _recipeParameterBindings = new(StringComparer.Ordinal);
// Guards against two distinct Aspire parameter names sanitizing to the same Bicep identifier,
// which would emit duplicate `param` declarations (ASPIRERADIUS028). Keyed by Bicep identifier.
private readonly Dictionary<string, string> _recipeParameterIdentifiers = new(StringComparer.Ordinal);
// Recipe parameters are user-supplied object graphs, so bound traversal to avoid
// unbounded recursion from accidental cycles or pathological nesting.
private const int MaxRecipeParameterNestingDepth = 32;
/// <summary>
/// Default recipe template paths per resource type.
/// </summary>
private static readonly Dictionary<string, string> s_defaultRecipeTemplates = new(StringComparer.Ordinal)
{
[RadiusResourceTypes.RedisCaches] = "ghcr.io/radius-project/recipes/local-dev/rediscaches:latest",
[RadiusResourceTypes.SqlDatabases] = "ghcr.io/radius-project/recipes/local-dev/sqldatabases:latest",
[RadiusResourceTypes.PostgreSqlDatabases] = "ghcr.io/radius-project/recipes/local-dev/postgresqldatabases:latest",
[RadiusResourceTypes.MongoDatabases] = "ghcr.io/radius-project/recipes/local-dev/mongodatabases:latest",
[RadiusResourceTypes.RabbitMQQueues] = "ghcr.io/radius-project/recipes/local-dev/rabbitmqqueues:latest",
// The Radius.Compute/containers UDT needs a recipe registered in the env's recipe pack;
// shipped Radius does not include one by default, so register the published container
// recipe so native containers deploy without a manually-authored recipe.
[RadiusResourceTypes.Containers] = "ghcr.io/radius-project/kube-recipes/containers:latest",
// Legacy fallback types also get default recipes
[RadiusResourceTypes.LegacyRedisCaches] = "ghcr.io/radius-project/recipes/local-dev/rediscaches:latest",
[RadiusResourceTypes.LegacyMongoDatabases] = "ghcr.io/radius-project/recipes/local-dev/mongodatabases:latest",
[RadiusResourceTypes.LegacyRabbitMQQueues] = "ghcr.io/radius-project/recipes/local-dev/rabbitmqqueues:latest",
[RadiusResourceTypes.LegacyDaprStateStores] = "ghcr.io/radius-project/recipes/local-dev/daprstatestores:latest",
[RadiusResourceTypes.LegacyDaprPubSubBrokers] = "ghcr.io/radius-project/recipes/local-dev/daprpubsubbrokers:latest",
};
internal RadiusInfrastructureBuilder(
RadiusEnvironmentResource environment,
DistributedApplicationModel model,
ResourceTypeMapper typeMapper,
ILogger logger)
{
_environment = environment;
_model = model;
_typeMapper = typeMapper;
_logger = logger;
}
/// <summary>
/// Builds the Bicep AST and populates a <see cref="RadiusInfrastructureOptions"/> with
/// typed constructs. Runs <c>ConfigureRadiusInfrastructure</c> callbacks last (last-write-wins).
/// </summary>
/// <param name="executionContext">
/// Publish-mode execution context used to resolve container environment variables and
/// service-discovery values from the application model.
/// </param>
/// <param name="cancellationToken">A token to cancel the build.</param>
internal async Task<RadiusInfrastructureOptions> BuildAsync(
DistributedApplicationExecutionContext executionContext,
CancellationToken cancellationToken)
{
_executionContext = executionContext;
_cancellationToken = cancellationToken;
var options = new RadiusInfrastructureOptions();
var envIdentifier = BicepPostProcessor.SanitizeIdentifier(_environment.Name);
// Classify resources for this environment. ResolveResourceType is computed once per
// resource here and reused below — calling it repeatedly would re-emit the
// ResourceTypeMapper Info/Warning logs (legacy fallback / unmapped type) for every
// resource, producing duplicate noise on every publish.
var (radiusResources, computeResources, resolvedTypes) = ClassifyResources();
// 1. UDT recipe pack (created first so environment can reference its ID)
var recipePackIdentifier = "recipepack";
var udtRecipeEntries = new Dictionary<string, RecipeEntry>(StringComparer.Ordinal);
var legacyRecipeEntries = new Dictionary<string, Dictionary<string, RecipeEntry>>(StringComparer.Ordinal);
var typeInstancesByResourceName = new Dictionary<string, RadiusResourceTypeConstruct>(StringComparer.Ordinal);
// Radius binds one recipe per resource type per environment. Each type gets its default
// in-cluster recipe: UDT (Radius.*) types via the shared recipe pack, legacy
// Applications.* types via inline named recipes on the legacy environment. Per-instance
// and custom recipe overrides are not part of this PR — they arrive with the follow-up
// that reintroduces the recipe customization API.
foreach (var resource in radiusResources)
{
var (resourceType, _) = resolvedTypes[resource];
if (IsLegacyResourceType(resourceType))
{
AddLegacyRecipeEntry(legacyRecipeEntries, resourceType);
}
else
{
AddRecipeEntry(udtRecipeEntries, resourceType);
}
}
// Partition flags.
var hasUdtResources = radiusResources.Any(r =>
!IsLegacyResourceType(resolvedTypes[r].ResourceType));
var hasLegacyResources = radiusResources.Any(r =>
IsLegacyResourceType(resolvedTypes[r].ResourceType));
var hasComputeResources = computeResources.Any();
// Radius secret stores routed to this environment. Applications.Core/secretStores is a
// legacy Applications.Core resource, so its presence forces the legacy environment/
// application chain (which it references for scope). No-op when no store is declared,
// keeping the default path byte-for-byte unchanged.
var secretStoresForScope = GetSecretStoresForScope().ToList();
var hasSecretStores = secretStoresForScope.Count > 0;
// Secret-store consumers (recipeConfig auth / envSecrets) also require the legacy
// Applications.Core/environments chain, since recipeConfig lives on that resource.
var secretStoresAnnotation = _environment.Annotations
.OfType<Annotations.RadiusSecretStoresAnnotation>()
.FirstOrDefault();
var hasSecretStoreConsumers = secretStoresAnnotation is { Consumers.Count: > 0 };
// Compute workloads always route to the UDT compute container type
// (Radius.Compute/containers), which forces the UDT environment/application chain.
var computeForcesUdtChain = hasComputeResources;
// 2. UDT environment + application — emitted only when we have UDT
// radius resources or any UDT-bound compute workload. Pure-legacy
// publishes (Redis-only) skip the UDT chain entirely so older Radius
// installs aren't forced to understand `Radius.Core/*`.
RadiusRecipePackConstruct? recipePackConstruct = null;
RadiusEnvironmentConstruct? envConstruct = null;
RadiusApplicationConstruct? appConstruct = null;
var appIdentifier = "app";
if (hasUdtResources || computeForcesUdtChain)
{
// UDT containers route to Radius.Compute/containers, which the control plane
// provisions through a recipe. Register the default container recipe in the
// pack so native containers deploy on shipped Radius without a hand-authored
// recipe — mirroring how backing resources get their default recipes.
if (computeForcesUdtChain)
{
AddRecipeEntry(udtRecipeEntries, RadiusResourceTypes.Containers);
}
recipePackConstruct = CreateRecipePackConstruct(recipePackIdentifier, udtRecipeEntries);
options.RecipePacks.Add(recipePackConstruct);
envConstruct = CreateEnvironmentConstruct(envIdentifier, recipePackConstruct);
options.Environments.Add(envConstruct);
appConstruct = CreateApplicationConstruct(appIdentifier, envConstruct);
options.Applications.Add(appConstruct);
}
// 3. Legacy parents are emitted lazily — only if any legacy backing
// resource, secret store, or secret-store consumer is present. Legacy
// env/app share the *resource name* with the UDT pair so Radius still
// sees them as the same logical app/environment; only the Bicep
// identifiers differ.
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct = null;
LegacyApplicationConstruct? legacyAppConstruct = null;
if (hasLegacyResources || hasSecretStores || hasSecretStoreConsumers)
{
// If the UDT chain is also emitted we suffix legacy identifiers with
// `_legacy`; otherwise (pure-legacy publish) legacy can claim the
// unsuffixed identifiers.
var legacyEnvIdentifier = (hasUdtResources || computeForcesUdtChain)
? envIdentifier + "_legacy" : envIdentifier;
var legacyAppIdentifier = (hasUdtResources || computeForcesUdtChain)
? appIdentifier + "_legacy" : appIdentifier;
legacyEnvConstruct = CreateLegacyEnvironmentConstruct(
legacyEnvIdentifier, legacyRecipeEntries);
options.LegacyEnvironments.Add(legacyEnvConstruct);
legacyAppConstruct = CreateLegacyApplicationConstruct(
legacyAppIdentifier, appIdentifier, BuildIdExpression(legacyEnvConstruct));
options.LegacyApplications.Add(legacyAppConstruct);
}
// Secret stores (Applications.Core/secretStores) — emitted after the legacy chain they
// reference for scope. No-op when no store is declared.
var secretStoreConstructs = EmitSecretStores(options, secretStoresForScope, legacyEnvConstruct, legacyAppConstruct);
// 4. Resource type instances — parent wiring depends on legacy vs UDT.
// Track each builder-created instance's parent pair so RewireIdReferences
// can re-resolve `.id` after callbacks without clobbering resources that
// a callback added itself.
var instanceParents = new Dictionary<RadiusResourceTypeConstruct, (ProvisionableResource? Env, ProvisionableResource App)>();
foreach (var resource in radiusResources)
{
var (resourceType, apiVersion) = resolvedTypes[resource];
var identifier = BicepPostProcessor.SanitizeIdentifier(resource.Name);
var isLegacy = IsLegacyResourceType(resourceType);
ProvisionableResource? parentEnv = isLegacy ? legacyEnvConstruct : envConstruct;
ProvisionableResource parentApp = isLegacy ? legacyAppConstruct! : appConstruct!;
var typeInstance = CreateResourceTypeConstruct(
identifier, resource.Name, resourceType, apiVersion,
parentApp, parentEnv);
options.ResourceTypeInstances.Add(typeInstance);
typeInstancesByResourceName[resource.Name] = typeInstance;
instanceParents[typeInstance] = (parentEnv, parentApp);
}
// 5. Container workloads always route to the UDT compute container type
// (Radius.Compute/containers) parented to the UDT application.
var containerConnectionTargets = new Dictionary<RadiusContainerConstruct, Dictionary<string, RadiusResourceTypeConstruct>>();
// Records the literal container ports (endpoint name -> port + protocol) that service
// discovery was derived from, keyed by the immutable container map key (the resource name),
// so a ConfigureRadiusInfrastructure callback that later changes/removes a port — or replaces
// or drops the whole container — can be rejected after callbacks run. Keying by the stable
// map key (not the construct instance) means a callback that swaps in a new construct for the
// same workload is still validated. See ValidatePostCallbackContainerInvariants.
var containerPortSnapshots = new Dictionary<string, Dictionary<string, (int Port, string Protocol)>>(StringComparer.Ordinal);
foreach (var resource in computeResources)
{
var identifier = BicepPostProcessor.SanitizeIdentifier(resource.Name);
var image = GetContainerImage(resource);
var connectionTargets = GetConnectionTargets(resource, radiusResources, typeInstancesByResourceName);
WarnIfImageMayNotPull(resource.Name, image);
// Resolve the resource's environment variables (config, connection strings, OTEL_*,
// WithEnvironment, and `services__*` service discovery) and its endpoint ports the
// same way the Kubernetes publisher does, so the deployed container behaves like the
// local run. Secret/parameter values are routed to Bicep `param`s (never literals).
var env = await ResolveEnvironmentAsync(resource).ConfigureAwait(false);
var ports = ResolvePorts(resource);
var containerConstruct = CreateContainerConstruct(
identifier, resource.Name, image, appConstruct!, envConstruct, connectionTargets, env, ports);
options.Containers.Add(containerConstruct);
containerConnectionTargets[containerConstruct] = connectionTargets;
containerPortSnapshots[resource.Name] = ports.ToDictionary(
kv => kv.Key,
kv => (
((IBicepValue)kv.Value.ContainerPort).LiteralValue is int literalPort ? literalPort : -1,
((IBicepValue)kv.Value.Protocol).LiteralValue is string literalProtocol ? literalProtocol : string.Empty),
StringComparer.Ordinal);
}
// Emit the Bicep parameters allocated for secret/parameter-backed container env vars as
// top-level `param`s, before ConfigureRadiusInfrastructure runs so callbacks can see them.
options.Parameters.AddRange(_envParametersByName.Values);
// 6. Snapshot every identifier that rewiring depends on, then run
// ConfigureRadiusInfrastructure callbacks (last-write-wins). We only
// re-resolve a `.id` reference below if its target was *renamed* by a
// callback; references the callback set explicitly are preserved.
var identifierSnapshot = new IdentifierSnapshot(
envConstruct?.BicepIdentifier,
appConstruct?.BicepIdentifier,
legacyEnvConstruct?.BicepIdentifier,
legacyAppConstruct?.BicepIdentifier,
options.RecipePacks.ToDictionary(p => p, p => p.BicepIdentifier),
instanceParents.ToDictionary(
kv => kv.Key,
kv => (EnvId: kv.Value.Env?.BicepIdentifier,
AppId: kv.Value.App.BicepIdentifier)),
containerConnectionTargets.ToDictionary(
kv => kv.Key,
kv => kv.Value.ToDictionary(
tkv => tkv.Key, tkv => tkv.Value.BicepIdentifier)),
secretStoreConstructs.Values.ToDictionary(c => c, c => c.BicepIdentifier));
RunConfigureCallbacks(options);
// Validate the post-callback container set. A ConfigureRadiusInfrastructure callback can
// rename containers, mutate/remove ports, add ports to a previously portless container, or
// replace/drop a workload entirely. Service discovery (`services__*` URLs and the recipe
// Service name/port) was derived from the pre-callback model, so all of these can silently
// break cross-container calls or emit an invalid manifest. Validate the final state and fail
// fast on any detectable divergence.
ValidatePostCallbackContainerInvariants(options, containerPortSnapshots);
// 7. Rewire `.id` cross-references for targets whose BicepIdentifier
// was changed by a callback; leave everything else (including callback
// edits to references) alone.
RewireIdReferences(options, appConstruct, envConstruct,
legacyAppConstruct, legacyEnvConstruct, instanceParents,
containerConnectionTargets,
identifierSnapshot);
// Secret stores participate in the same escape-hatch surface, so their consumer references
// (recipeConfig `<store>.id`) and parent scope IDs must be rewired too when a callback
// renames a store construct or the legacy application/environment it is scoped to.
RewireSecretStoreReferences(secretStoresForScope, secretStoreConstructs,
legacyAppConstruct, legacyEnvConstruct, identifierSnapshot);
// Surface recipe-parameter scopes that target a resource type with no emitted recipe
// entry, and register any ParameterResource-backed recipe/inline-secret Bicep params.
WarnUnmatchedResourceTypeScopes(udtRecipeEntries.Keys.Concat(legacyRecipeEntries.Keys));
foreach (var (name, parameter) in _recipeParameters)
{
options.RecipeParameters[name] = parameter;
}
// Surface the param-identifier -> ParameterResource bindings so the deploy step can
// resolve a value for every valueless `param` at deploy time (rad deploy --parameters).
foreach (var (identifier, parameter) in _recipeParameterBindings)
{
options.RecipeParameterBindings[identifier] = parameter;
}
RecordDeployParameters();
return options;
}
// Records the emitted Bicep parameter identifier → ParameterResource mapping on the
// environment resource so the deploy step can resolve each value at deploy time and pass it
// via `rad deploy --parameters`. Replaces any prior annotation so a re-publish (e.g. repeated
// BuildAsync calls) stays idempotent rather than accumulating stale mappings.
private void RecordDeployParameters()
{
foreach (var existing in _environment.Annotations.OfType<RadiusDeployParametersAnnotation>().ToList())
{
_environment.Annotations.Remove(existing);
}
// Persist the union of PR1 container-env parameters and PR2 recipe/inline-secret
// parameter bindings. A parameter referenced by both a container env var and a recipe/
// secret value must resolve to exactly one deploy binding, so merge rather than replace.
var deployParameters = new Dictionary<string, ParameterResource>(_deployParametersByIdentifier, StringComparer.Ordinal);
foreach (var (identifier, parameter) in _recipeParameterBindings)
{
deployParameters[identifier] = parameter;
}
if (deployParameters.Count > 0)
{
_environment.Annotations.Add(new RadiusDeployParametersAnnotation(deployParameters));
}
}
/// <summary>
/// Pre-callback snapshot of every construct identifier the builder wired
/// references against. After callbacks run, <see cref="RewireIdReferences"/>
/// compares each target's current identifier against the snapshot and only
/// rewires the ones that changed — preserving any direct reference edits a
/// callback performed.
/// </summary>
private sealed record IdentifierSnapshot(
string? EnvId,
string? AppId,
string? LegacyEnvId,
string? LegacyAppId,
Dictionary<RadiusRecipePackConstruct, string> RecipePackIds,
Dictionary<RadiusResourceTypeConstruct, (string? EnvId, string AppId)> InstanceParentIds,
Dictionary<RadiusContainerConstruct, Dictionary<string, string>> ContainerConnectionTargetIds,
Dictionary<RadiusSecretStoreConstruct, string> SecretStoreIds);
/// <summary>
/// Returns <c>true</c> when <paramref name="resourceType"/> is a legacy
/// <c>Applications.*</c> type that should be parented to
/// <c>Applications.Core/environments</c> rather than <c>Radius.Core/environments</c>.
/// </summary>
private static bool IsLegacyResourceType(string resourceType) =>
resourceType.StartsWith("Applications.", StringComparison.Ordinal);
/// <summary>
/// After callbacks run, re-resolve each builder-created <c>.id</c>
/// cross-reference only when its target's <c>BicepIdentifier</c> was
/// actually changed by a callback. References the callback edited directly
/// (without renaming the target) are preserved — honouring the public
/// "last-write-wins" contract on <c>ConfigureRadiusInfrastructure</c>.
/// </summary>
private static void RewireIdReferences(
RadiusInfrastructureOptions options,
RadiusApplicationConstruct? appConstruct,
RadiusEnvironmentConstruct? envConstruct,
LegacyApplicationConstruct? legacyAppConstruct,
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
Dictionary<RadiusResourceTypeConstruct, (ProvisionableResource? Env, ProvisionableResource App)> instanceParents,
Dictionary<RadiusContainerConstruct, Dictionary<string, RadiusResourceTypeConstruct>> containerConnectionTargets,
IdentifierSnapshot snapshot)
{
// UDT env → recipe packs. Rebuild only if any builder-created pack was
// renamed. (New packs added by a callback and removed packs are left to
// the callback to wire up — this method only fixes broken refs.)
if (envConstruct is not null)
{
var anyPackRenamed = false;
foreach (var (pack, snapId) in snapshot.RecipePackIds)
{
if (!string.Equals(pack.BicepIdentifier, snapId, StringComparison.Ordinal))
{
anyPackRenamed = true;
break;
}
}
if (anyPackRenamed)
{
envConstruct.RecipePacks.Clear();
foreach (var pack in options.RecipePacks)
{
envConstruct.RecipePacks.Add(BuildIdExpression(pack));
}
}
}
// UDT app → UDT env.
if (appConstruct is not null && envConstruct is not null &&
IdentifierChanged(envConstruct, snapshot.EnvId))
{
appConstruct.EnvironmentId = BuildIdExpression(envConstruct);
}
// Legacy app → legacy env.
if (legacyAppConstruct is not null && legacyEnvConstruct is not null &&
IdentifierChanged(legacyEnvConstruct, snapshot.LegacyEnvId))
{
legacyAppConstruct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
}
// Resource type instances: rewire each parent ref only if *that*
// parent's identifier was renamed.
foreach (var instance in options.ResourceTypeInstances)
{
if (!instanceParents.TryGetValue(instance, out var parents))
{
continue;
}
if (!snapshot.InstanceParentIds.TryGetValue(instance, out var snapIds))
{
continue;
}
if (!string.Equals(parents.App.BicepIdentifier, snapIds.AppId, StringComparison.Ordinal))
{
instance.ApplicationId = BuildIdExpression(parents.App);
}
if (parents.Env is not null &&
!string.Equals(parents.Env.BicepIdentifier, snapIds.EnvId, StringComparison.Ordinal))
{
instance.EnvironmentId = BuildIdExpression(parents.Env);
}
}
// Containers — rewire ApplicationId only if the UDT app was renamed;
// rewire each connection source only if its target was renamed.
foreach (var container in options.Containers)
{
if (!containerConnectionTargets.TryGetValue(container, out var targets))
{
// Callback-added container; leave its refs alone.
continue;
}
if (appConstruct is not null && IdentifierChanged(appConstruct, snapshot.AppId))
{
container.ApplicationId = BuildIdExpression(appConstruct);
}
if (envConstruct is not null && IdentifierChanged(envConstruct, snapshot.EnvId))
{
container.EnvironmentId = BuildIdExpression(envConstruct);
}
if (targets.Count == 0 ||
!snapshot.ContainerConnectionTargetIds.TryGetValue(container, out var targetSnapIds))
{
continue;
}
foreach (var (connectionName, targetConstruct) in targets)
{
if (!targetSnapIds.TryGetValue(connectionName, out var snapTargetId))
{
continue;
}
if (string.Equals(targetConstruct.BicepIdentifier, snapTargetId, StringComparison.Ordinal))
{
continue;
}
// Target was renamed — replace the stale connection entry.
container.Connections[connectionName] = new ConnectionConstruct
{
Source = BuildIdExpression(targetConstruct),
};
}
}
}
private static bool IdentifierChanged(ProvisionableResource resource, string? snapshotId)
=> !string.Equals(resource.BicepIdentifier, snapshotId, StringComparison.Ordinal);
/// <summary>
/// After callbacks run, rewire secret-store cross-references whose target was renamed:
/// <list type="bullet">
/// <item>a store's <c>ApplicationId</c>/<c>EnvironmentId</c> parent scope, if the legacy
/// application/environment it points at was renamed; and</item>
/// <item>the environment's <c>recipeConfig</c>, which references consumed stores by
/// <c><identifier>.id</c>, if any store construct was renamed.</item>
/// </list>
/// Mirrors <see cref="RewireIdReferences"/> for the secret-store surface exposed via
/// <see cref="RadiusInfrastructureOptions.SecretStores"/>.
/// </summary>
private void RewireSecretStoreReferences(
IReadOnlyList<RadiusSecretStoreResource> stores,
IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs,
LegacyApplicationConstruct? legacyAppConstruct,
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
IdentifierSnapshot snapshot)
{
if (storeConstructs.Count == 0)
{
return;
}
// Parent scope IDs: an application-scoped store references the legacy application, an
// environment-scoped store the legacy environment. If a callback renamed that parent
// construct, the store's ApplicationId/EnvironmentId still points at the old symbol.
var legacyAppRenamed = legacyAppConstruct is not null && IdentifierChanged(legacyAppConstruct, snapshot.LegacyAppId);
var legacyEnvRenamed = legacyEnvConstruct is not null && IdentifierChanged(legacyEnvConstruct, snapshot.LegacyEnvId);
if (legacyAppRenamed || legacyEnvRenamed)
{
foreach (var store in stores)
{
if (!storeConstructs.TryGetValue(store.Name, out var construct))
{
continue;
}
// Mirror the scope selection used when the store was emitted (see EmitSecretStores).
if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null)
{
if (legacyAppRenamed)
{
construct.ApplicationId = BuildIdExpression(legacyAppConstruct);
}
}
else if (legacyEnvConstruct is not null && legacyEnvRenamed)
{
construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
}
}
}
// recipeConfig references each consumed store by `<identifier>.id`. It is a single serialized
// object (not individually addressable per store), so — unlike the per-reference constructs
// above — the consistent way to honor a store rename is to rebuild the whole recipeConfig from
// the current constructs. Only do so when a store was actually renamed, preserving direct
// callback edits in every other case.
var anyStoreRenamed = false;
foreach (var (construct, snapId) in snapshot.SecretStoreIds)
{
if (!string.Equals(construct.BicepIdentifier, snapId, StringComparison.Ordinal))
{
anyStoreRenamed = true;
break;
}
}
if (anyStoreRenamed && legacyEnvConstruct is not null)
{
ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs);
}
}
private (string ResourceType, string ApiVersion) ResolveResourceType(IResource resource)
{
return _typeMapper.MapResource(resource);
}
private (List<IResource> radiusResources, List<IResource> computeResources, Dictionary<IResource, (string ResourceType, string ApiVersion)> resolvedTypes) ClassifyResources()
{
var radiusTypes = new List<IResource>();
var compute = new List<IResource>();
var resolved = new Dictionary<IResource, (string ResourceType, string ApiVersion)>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var resource in _model.Resources)
{
// Skip the Radius environment itself
if (resource is RadiusEnvironmentResource)
{
continue;
}
// Check deployment target: only include resources targeted to this environment
// or resources with no explicit target (default to this environment)
if (!IsTargetedToThisEnvironment(resource))
{
continue;
}
// Resolve child resources to parent
var resolvedResource = ResolveToParent(resource);
if (resolvedResource != resource)
{
// Child resources (e.g., SqlServerDatabaseResource) are represented
// via their parent; skip the child itself
continue;
}
// Avoid duplicates
if (!seen.Add(resource.Name))
{
continue;
}
// Use ResourceTypeMapper to determine classification:
// - Explicit container/project resources with Containers mapping → compute workloads
// - Resources with a specific resource type mapping → resource type instances
// - Unmapped resources (ParameterResource, etc.) → skip
var resolvedType = ResolveResourceType(resource);
resolved[resource] = resolvedType;
var resourceType = resolvedType.ResourceType;
if (resource is ProjectResource ||
(resource is ContainerResource && resourceType == RadiusResourceTypes.Containers))
{
compute.Add(resource);
}
else if (resourceType != RadiusResourceTypes.Containers)
{
radiusTypes.Add(resource);
}
// else: unmapped resource (e.g., ParameterResource) — skip
}
return (radiusTypes, compute, resolved);
}
private bool IsTargetedToThisEnvironment(IResource resource)
{
// The PrepareDeploymentTargets pipeline step (RadiusInfrastructure.PrepareDeploymentTargetsAsync)
// attaches a DeploymentTargetAnnotation to every compute resource that belongs to this
// environment, with ComputeEnvironment set to OwningComputeEnvironment ?? this. With multiple
// compute environments in the model, untargeted resources are rejected upstream by
// ValidateComputeEnvironments before this code runs.
//
// Use the framework's canonical lookup (Aspire.Hosting.ApplicationModel.ResourceExtensions
// .GetDeploymentTargetAnnotation) so behaviour stays in sync with manifest/publish paths
// and so the lookup honours ComputeEnvironmentAnnotation overrides set via WithComputeEnvironment.
var targetComputeEnvironment = _environment.OwningComputeEnvironment ?? _environment;
return resource.GetDeploymentTargetAnnotation(targetComputeEnvironment) is not null;
}
/// <summary>
/// Resolves a child resource (e.g., SqlServerDatabaseResource) to its parent.
/// Returns the resource itself if it has no parent.
/// </summary>
private static IResource ResolveToParent(IResource resource)
{
if (resource is IResourceWithParent childResource)
{
return childResource.Parent;
}
return resource;
}
/// <summary>
/// Builds a <c>.id</c> expression for a resource, e.g., <c>envIdentifier.id</c>.
/// </summary>
private static BicepExpression BuildIdExpression(Azure.Provisioning.Primitives.ProvisionableResource resource)
{
return new MemberExpression(new IdentifierExpression(resource.BicepIdentifier), "id");
}
private RadiusEnvironmentConstruct CreateEnvironmentConstruct(
string identifier, RadiusRecipePackConstruct recipePackConstruct)
{
var construct = new RadiusEnvironmentConstruct(identifier);
construct.EnvironmentName = _environment.Name;
construct.KubernetesNamespace = _environment.Namespace;
construct.RecipePacks.Add(BuildIdExpression(recipePackConstruct));
ApplyCloudProviders(construct);
return construct;
}
private void ApplyCloudProviders(RadiusEnvironmentConstruct construct)
{
var annotation = _environment.Annotations
.OfType<Annotations.RadiusCloudProvidersAnnotation>()
.FirstOrDefault();
if (annotation is null)
{
return;
}
if (annotation.Azure is { } azure)
{
construct.AzureSubscriptionId = azure.SubscriptionId;
construct.AzureResourceGroupName = azure.ResourceGroup;
}
if (annotation.Aws is { } aws)
{
construct.AwsAccountId = aws.AccountId;
construct.AwsRegion = aws.Region;
}
}
// The legacy Applications.Core/environments schema carries cloud providers under the
// same properties.providers.{azure,aws}.scope paths as the UDT environment. Apply them
// here too so a pure-legacy publish (e.g. a managed Redis with no UDT compute) still
// emits the provider configuration that the publish-time ASPIRERADIUS020 check requires.
private void ApplyCloudProviders(LegacyApplicationEnvironmentConstruct construct)
{
var annotation = _environment.Annotations
.OfType<Annotations.RadiusCloudProvidersAnnotation>()
.FirstOrDefault();
if (annotation is null)
{
return;
}
if (annotation.Azure is { } azure)
{
construct.AzureScope = BuildAzureScope(azure);
}
if (annotation.Aws is { } aws)
{
construct.AwsScope = BuildAwsScope(aws);
}
}
private static string BuildAzureScope(CloudProviders.AzureRadiusProviderConfig azure)
=> $"/subscriptions/{azure.SubscriptionId}/resourceGroups/{azure.ResourceGroup}";
private static string BuildAwsScope(CloudProviders.AwsRadiusProviderConfig aws)
=> $"/planes/aws/aws/accounts/{aws.AccountId}/regions/{aws.Region}";
private static RadiusApplicationConstruct CreateApplicationConstruct(
string identifier, RadiusEnvironmentConstruct? envConstruct)
{
var construct = new RadiusApplicationConstruct(identifier);
construct.ApplicationName = identifier;
construct.EnvironmentId = BuildIdExpression(envConstruct!);
return construct;
}
private static RadiusResourceTypeConstruct CreateResourceTypeConstruct(
string identifier, string resourceName, string resourceType, string apiVersion,
ProvisionableResource appConstruct, ProvisionableResource? envConstruct)
{
var construct = new RadiusResourceTypeConstruct(identifier, resourceType, apiVersion);
construct.ResourceName = resourceName;
construct.ApplicationId = BuildIdExpression(appConstruct);
construct.EnvironmentId = BuildIdExpression(envConstruct!);
// Every instance binds its resource type's single default recipe (UDT types via the
// shared recipe pack, legacy types via the "default" entry on the legacy environment),
// so no per-instance recipe name is emitted here. Per-instance / named recipe overrides
// are deferred to the follow-up that reintroduces the recipe customization API.
return construct;
}
private void AddRecipeEntry(
Dictionary<string, RecipeEntry> entries,
string resourceType)
{
if (s_defaultRecipeTemplates.TryGetValue(resourceType, out var defaultTemplate))
{
// Don't overwrite a custom entry a ConfigureRadiusInfrastructure callback may add.
entries.TryAdd(resourceType, new RecipeEntry("bicep", defaultTemplate));
}
else
{
_logger.LogWarning(
"No default recipe template found for resource type '{ResourceType}'. " +
"Register a recipe for this type via ConfigureRadiusInfrastructure().",
resourceType);
}
}
private RadiusRecipePackConstruct CreateRecipePackConstruct(
string identifier, Dictionary<string, RecipeEntry> recipeEntries)
{
var construct = new RadiusRecipePackConstruct(identifier);
construct.PackName = "default";
foreach (var (type, entry) in recipeEntries)
{
var recipeEntry = new RecipeEntryConstruct
{
RecipeKind = entry.RecipeKind,
RecipeLocation = entry.RecipeLocation,
};
// Apply environment-level WithRecipeParameters for this resource type (environment-wide
// merged with any resource-type-scoped overrides). No-op when none are declared.
var parameters = GetEffectiveRecipeParameters(type);
if (parameters is not null)
{
ApplyRecipeParameters(recipeEntry.Parameters, parameters);
}
construct.Recipes[type] = recipeEntry;
}
return construct;
}
private void AddLegacyRecipeEntry(
Dictionary<string, Dictionary<string, RecipeEntry>> entries,
string resourceType)
{
// Legacy Applications.* types register their recipe under the "default" name on the
// legacy environment. The outer map is keyed by recipe name so a future PR can register
// multiple named recipes per type; this PR only emits the single default recipe.
const string recipeName = "default";
if (!entries.TryGetValue(resourceType, out var byName))
{
byName = new Dictionary<string, RecipeEntry>(StringComparer.Ordinal);
entries[resourceType] = byName;
}
if (s_defaultRecipeTemplates.TryGetValue(resourceType, out var defaultTemplate))
{
byName.TryAdd(recipeName, new RecipeEntry("bicep", defaultTemplate));
}
else
{
_logger.LogWarning(
"No default recipe template found for legacy resource type '{ResourceType}'. " +
"Register a recipe for this type via ConfigureRadiusInfrastructure().",
resourceType);
}
}
private LegacyApplicationEnvironmentConstruct CreateLegacyEnvironmentConstruct(
string identifier,
Dictionary<string, Dictionary<string, RecipeEntry>> legacyRecipeEntries)
{
var construct = new LegacyApplicationEnvironmentConstruct(identifier);
// Resource name intentionally matches the UDT environment so Radius
// treats both parents as the same logical environment scope.
construct.EnvironmentName = _environment.Name;
construct.ComputeKind = "kubernetes";
construct.ComputeNamespace = _environment.Namespace;
ApplyCloudProviders(construct);
foreach (var (resourceType, byName) in legacyRecipeEntries)
{
var inner = new BicepDictionary<LegacyRecipeEntryConstruct>();
var parameters = GetEffectiveRecipeParameters(resourceType);
foreach (var (recipeName, entry) in byName)
{
var legacyEntry = new LegacyRecipeEntryConstruct
{
TemplateKind = entry.RecipeKind,
TemplatePath = entry.RecipeLocation,
};
// Apply environment-level WithRecipeParameters for this legacy resource type.
// No-op when none are declared.
if (parameters is not null)
{
ApplyRecipeParameters(legacyEntry.Parameters, parameters);
}
inner[recipeName] = legacyEntry;
}
construct.Recipes[resourceType] = inner;
}
return construct;
}
private static LegacyApplicationConstruct CreateLegacyApplicationConstruct(
string identifier, string applicationName,
BicepValue<string> environmentId)
{
var construct = new LegacyApplicationConstruct(identifier);
// Share the UDT application's `name:` — rubber-duck feedback: only the
// Bicep identifier is suffixed with `_legacy`.
construct.ApplicationName = applicationName;
construct.EnvironmentId = environmentId;
return construct;
}
private static string GetContainerImage(IResource resource)
{
var imageAnnotation = resource.Annotations.OfType<ContainerImageAnnotation>().FirstOrDefault();
if (imageAnnotation is not null)
{
var image = imageAnnotation.Image;
if (!string.IsNullOrEmpty(imageAnnotation.Tag))
{
image = $"{image}:{imageAnnotation.Tag}";
}
if (!string.IsNullOrEmpty(imageAnnotation.Registry))
{
image = $"{imageAnnotation.Registry}/{image}";
}
return image;
}
// ProjectResource has no ContainerImageAnnotation by default — the integration does
// not (yet) build and push project images. Failing fast at publish time with a clear
// remediation prevents the silent `aspire publish && aspire deploy` → in-cluster
// ImagePullBackOff failure mode, which is opaque to the user (Radius/Kubernetes
// surface it, not Aspire). Mirrors the CLI behaviour guideline that errors should
// name the specific action the user must take.
if (resource is ProjectResource)
{
throw new InvalidOperationException(
$"Project resource '{resource.Name}' cannot be published to Radius because no container image " +
"has been associated with it. The Aspire.Hosting.Radius integration does not yet build or push " +
"project images. As a workaround, build and push an image to a registry the target cluster can " +
"pull from, then attach it via WithContainerImage(\"<registry>/<image>:<tag>\") on the project " +
"resource. Tracking issue: https://github.com/microsoft/aspire/issues/16844.");
}
// Non-project, non-container resources reach this path only in misconfiguration
// (the resource type mapping would normally skip them). Fall back to a placeholder
// image with a logged warning via WarnIfImageMayNotPull so the publish still
// produces inspectable output.
return $"{resource.Name}:latest";
}
private static Dictionary<string, RadiusResourceTypeConstruct> GetConnectionTargets(
IResource resource,
List<IResource> radiusResources,
Dictionary<string, RadiusResourceTypeConstruct> typeInstancesByResourceName)
{
var connections = new Dictionary<string, RadiusResourceTypeConstruct>(StringComparer.Ordinal);
// Find all ResourceRelationshipAnnotation with type "Reference"
var references = resource.Annotations
.OfType<ResourceRelationshipAnnotation>()
.Where(r => r.Type == "Reference");
foreach (var reference in references)
{
var referencedResource = reference.Resource;
// Resolve child resources (e.g., SqlServerDatabaseResource) to parent
if (referencedResource is IResourceWithParent childResource)
{
referencedResource = childResource.Parent;
}
// Only create connections for Radius resource type instances (non-compute)
if (radiusResources.Any(p => p.Name == referencedResource.Name)
&& typeInstancesByResourceName.TryGetValue(referencedResource.Name, out var targetConstruct))
{
connections[referencedResource.Name] = targetConstruct;
}
}
return connections;
}
private static RadiusContainerConstruct CreateContainerConstruct(
string identifier, string resourceName, string image,
RadiusApplicationConstruct appConstruct,
RadiusEnvironmentConstruct? envConstruct,
Dictionary<string, RadiusResourceTypeConstruct> connectionTargets,
IReadOnlyDictionary<string, ContainerEnvVarConstruct> env,
IReadOnlyDictionary<string, ContainerPortConstruct> ports)
{
var construct = new RadiusContainerConstruct(identifier, resourceName);
construct.ContainerName = resourceName;
construct.Image = image;
construct.ApplicationId = BuildIdExpression(appConstruct);
construct.EnvironmentId = BuildIdExpression(envConstruct!);
if (connectionTargets.Count > 0)
{
foreach (var (name, targetConstruct) in connectionTargets)
{
var connectionConstruct = new ConnectionConstruct();
connectionConstruct.Source = BuildIdExpression(targetConstruct);
construct.Connections[name] = connectionConstruct;
}
}
foreach (var (name, envVar) in env)
{
construct.Env[name] = envVar;
}
foreach (var (name, port) in ports)
{
construct.Ports[name] = port;
}
return construct;
}
/// <summary>
/// Maps a compute resource's <see cref="EndpointAnnotation"/>s to Radius container ports,
/// keyed by endpoint name. Uses the target (container) port when specified, otherwise the
/// allocated/declared port. Endpoints with no resolvable port are skipped.
/// </summary>
private static Dictionary<string, ContainerPortConstruct> ResolvePorts(IResource resource)
{
var ports = new Dictionary<string, ContainerPortConstruct>(StringComparer.Ordinal);
if (!resource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
{
return ports;
}
var seenPorts = new HashSet<(int ContainerPort, string Protocol)>();
foreach (var endpoint in endpoints)
{
// Use the shared service-port resolver so the container port emitted here matches the
// Service port the recipe exposes and the port the environment puts in service-discovery
// URLs (RadiusServiceDiscovery). A null result means this endpoint contributes no port
// (e.g. the synthetic default HTTPS endpoint), so the recipe creates no Service for it.
if (RadiusServiceDiscovery.ResolveServicePort(resource, endpoint.Name) is not int containerPort)
{
continue;
}
var protocol = endpoint.Protocol == ProtocolType.Udp ? "UDP" : "TCP";
// Deduplicate by (container port, protocol), matching the Kubernetes publisher's ToService
// dedup. Multiple endpoints can resolve to the same container port (e.g. an explicit
// portless HTTP and HTTPS endpoint on a project both default to 8080), and the recipe would
// otherwise emit two Kubernetes Service ports with the same (port, protocol), which the
// provider rejects. The first endpoint wins; the others still resolve to the same port in
// their service-discovery URLs, so nothing is lost. See: https://github.com/microsoft/aspire/issues/14029
if (!seenPorts.Add((containerPort, protocol)))
{
continue;
}
var port = new ContainerPortConstruct
{
ContainerPort = containerPort,
Protocol = protocol,
};
ports[endpoint.Name] = port;
}
return ports;
}
/// <summary>
/// Resolves a compute resource's environment variables into Radius container <c>env</c>
/// entries. Mirrors the Kubernetes publisher: HTTPS service-discovery variables are dropped
/// (no in-cluster TLS), endpoint references become cluster-FQDN URLs via the environment's
/// <see cref="RadiusEnvironmentResource.GetHostAddressExpression"/>, and secret/parameter
/// values are routed to Bicep <c>param</c>s so no literal secret is written to the artifact.
/// </summary>
private async Task<Dictionary<string, ContainerEnvVarConstruct>> ResolveEnvironmentAsync(IResource resource)
{
var result = new Dictionary<string, ContainerEnvVarConstruct>(StringComparer.Ordinal);
if (resource is not IResourceWithEnvironment)
{
return result;
}
var context = new EnvironmentCallbackContext(_executionContext, resource, cancellationToken: _cancellationToken)
{
Logger = _logger,
};
if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var callbacks))
{
foreach (var callback in callbacks)
{
await callback.Callback(context).ConfigureAwait(false);
}
}
// Drop HTTPS service-discovery variables: containers in the cluster don't terminate TLS
// (ingress/service mesh does), so an https `services__*` URL would be unreachable. This
// matches RemoveHttpsServiceDiscoveryVariables in the Kubernetes/Docker Compose publishers.
var httpsServiceKeys = context.EnvironmentVariables
.Where(kvp => kvp.Value is EndpointReference epRef
&& epRef.Scheme == "https"
&& kvp.Key.StartsWith("services__", StringComparison.Ordinal))
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in httpsServiceKeys)
{
context.EnvironmentVariables.Remove(key);
}
foreach (var (key, rawValue) in context.EnvironmentVariables)
{
var parts = new List<EnvPart>();
try
{
await ResolveEnvPartsAsync(rawValue, resource, parts).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
// Some endpoint references cannot be resolved at publish time — e.g. a non-HTTP
// scheme (redis, tcp, ...) whose port isn't allocated yet. Those backing resources
// are surfaced through Radius `connection`s instead of literal container env vars,
// so skip the variable rather than failing the whole build.
_logger.LogDebug(ex, "Skipping environment variable '{Key}' on resource '{Resource}': value could not be resolved at publish time.", key, resource.Name);
continue;
}
result[key] = new ContainerEnvVarConstruct { Value = BuildEnvBicepValue(parts) };
}
return result;
}
// An ordered fragment of a container env-var value: either a literal string or a reference to
// a Bicep parameter (used for secret/parameter values so the literal is never emitted).
private readonly record struct EnvPart(string? Literal, ProvisioningParameter? Parameter)
{
public static EnvPart FromLiteral(string literal) => new(literal, null);
public static EnvPart FromParameter(ProvisioningParameter parameter) => new(null, parameter);
}
/// <summary>
/// Recursively flattens an environment-variable value into ordered <see cref="EnvPart"/>s.
/// Endpoint references resolve to cluster-FQDN URLs, parameter resources resolve to Bicep
/// <c>param</c> references, and composite reference expressions are spliced together so a
/// mixed literal/secret value is preserved precisely.
/// </summary>
private async Task ResolveEnvPartsAsync(object? value, IResource owner, List<EnvPart> parts)
{
switch (value)
{
case null:
return;
case string s:
parts.Add(EnvPart.FromLiteral(s));
return;
case bool b:
parts.Add(EnvPart.FromLiteral(b ? "true" : "false"));
return;
case ParameterResource param:
parts.Add(EnvPart.FromParameter(GetOrAddEnvParameter(param)));
return;
case IResourceBuilder<ParameterResource> paramBuilder:
parts.Add(EnvPart.FromParameter(GetOrAddEnvParameter(paramBuilder.Resource)));
return;
case EndpointReference endpointReference:
parts.Add(EnvPart.FromLiteral(ResolveEndpointUrl(endpointReference)));
return;
case EndpointReferenceExpression endpointReferenceExpression:
parts.Add(EnvPart.FromLiteral(ResolveEndpointProperty(endpointReferenceExpression)));
return;
case ConnectionStringReference connectionStringReference:
await ResolveEnvPartsAsync(connectionStringReference.Resource.ConnectionStringExpression, owner, parts).ConfigureAwait(false);
return;
case IResourceWithConnectionString resourceWithConnectionString:
await ResolveEnvPartsAsync(resourceWithConnectionString.ConnectionStringExpression, owner, parts).ConfigureAwait(false);
return;
case ReferenceExpression referenceExpression:
await ResolveReferenceExpressionPartsAsync(referenceExpression, owner, parts).ConfigureAwait(false);
return;
case IFormattable formattable:
parts.Add(EnvPart.FromLiteral(formattable.ToString(null, CultureInfo.InvariantCulture)));
return;
default:
// Fall back to publish-mode resolution (e.g. manifest expression providers) and
// capture whatever literal string the framework produces.
if (value is IValueProvider valueProvider)
{
var context = new ValueProviderContext { ExecutionContext = _executionContext, Caller = owner };
var resolved = await valueProvider.GetValueAsync(context, _cancellationToken).ConfigureAwait(false);
parts.Add(EnvPart.FromLiteral(resolved ?? string.Empty));
return;
}
parts.Add(EnvPart.FromLiteral(value.ToString() ?? string.Empty));
return;
}
}
/// <summary>
/// Splices a composite <see cref="ReferenceExpression"/> into ordered parts by interleaving
/// its literal <see cref="ReferenceExpression.Format"/> chunks with the recursively-resolved
/// parts of each value provider (matching the <c>{0}</c>, <c>{1}</c>, ... placeholders).
/// </summary>
private async Task ResolveReferenceExpressionPartsAsync(ReferenceExpression expression, IResource owner, List<EnvPart> parts)
{
// No providers: the format string is already the literal value (after un-escaping braces).
if (expression.ValueProviders.Count == 0)
{
parts.Add(EnvPart.FromLiteral(UnescapeBraces(expression.Format)));
return;
}
// Pre-resolve each provider's parts so the placeholder splice is a simple lookup.
var providerParts = new List<EnvPart>[expression.ValueProviders.Count];
for (var i = 0; i < expression.ValueProviders.Count; i++)
{
var inner = new List<EnvPart>();
await ResolveEnvPartsAsync(expression.ValueProviders[i], owner, inner).ConfigureAwait(false);
providerParts[i] = inner;
}
// Walk the format string, emitting literal text and substituting `{i}` placeholders.
// Braces are escaped as `{{`/`}}` in composite expression formats.
var format = expression.Format;
var literal = new StringBuilder();
for (var i = 0; i < format.Length; i++)
{
var c = format[i];
if (c == '{')
{
if (i + 1 < format.Length && format[i + 1] == '{')
{
literal.Append('{');
i++;
continue;
}
var close = format.IndexOf('}', i + 1);
var indexText = format.Substring(i + 1, close - i - 1);
var index = int.Parse(indexText, CultureInfo.InvariantCulture);
if (literal.Length > 0)
{
parts.Add(EnvPart.FromLiteral(literal.ToString()));
literal.Clear();
}
parts.AddRange(providerParts[index]);
i = close;
continue;
}
if (c == '}' && i + 1 < format.Length && format[i + 1] == '}')
{
literal.Append('}');
i++;
continue;
}
literal.Append(c);
}
if (literal.Length > 0)
{
parts.Add(EnvPart.FromLiteral(literal.ToString()));
}
}
private static string UnescapeBraces(string format) =>
format.Replace("{{", "{", StringComparison.Ordinal).Replace("}}", "}", StringComparison.Ordinal);
// Allocates (or reuses) the Bicep parameter that carries this Aspire parameter's value. The
// parameter is declared `@secure()` when the source is a secret so its value is neither printed
// in deploy logs nor written to the artifact. The identifier→resource mapping is recorded for
// the deploy step, which supplies the actual value via `rad deploy --parameters`.
private ProvisioningParameter GetOrAddEnvParameter(ParameterResource parameter)
{
if (_envParametersByName.TryGetValue(parameter.Name, out var existing))
{
return existing;
}
var identifier = Infrastructure.NormalizeBicepIdentifier(parameter.Name);
// A recipe parameter / inline secret may already have allocated a secure `param` for this
// same Aspire parameter — recipe-pack and secret-store emission both run before container
// env-var resolution. Reuse that declaration (it is emitted via options.RecipeParameters)
// so the shared value produces a single Bicep `param` and one deploy binding rather than a
// duplicate declaration. Keyed on the exact Aspire parameter name (unique in the app model)
// so two *distinct* parameters whose names normalize to the same identifier are NOT merged
// here — they fall through and surface as a genuine identifier collision (ASPIRERADIUS056).
// Not cached in _envParametersByName so it is not emitted twice.
if (_recipeParameters.TryGetValue(parameter.Name, out var recipeParameter))
{
return recipeParameter;
}
var provisioningParameter = new ProvisioningParameter(identifier, typeof(string))
{
IsSecure = parameter.Secret,
};
_envParametersByName[parameter.Name] = provisioningParameter;
_deployParametersByIdentifier[identifier] = parameter;
return provisioningParameter;
}
private static BicepValue<string> BuildEnvBicepValue(List<EnvPart> parts)
{
if (parts.Count == 0)
{
return string.Empty;
}
// All-literal value: concatenate directly (also covers the common single-literal case).
if (parts.All(static p => p.Parameter is null))
{
return string.Concat(parts.Select(static p => p.Literal));
}
// A single parameter with no surrounding literals maps straight to the `param` reference,
// emitting `value: paramName` rather than an interpolated string.
if (parts is [{ Literal: null, Parameter: { } soleParameter }])
{
return soleParameter;
}
// Mixed literal/parameter value: build an interpolated Bicep string ('...${param}...').
// Literals are passed as interpolation arguments (not spliced into the format) so any '{'
// or '}' they contain can't be misread as a placeholder.
var format = new StringBuilder();
var args = new object[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
format.Append('{').Append(i.ToString(CultureInfo.InvariantCulture)).Append('}');
args[i] = parts[i].Parameter is { } parameter ? parameter : parts[i].Literal!;
}
return BicepFunction.Interpolate(FormattableStringFactory.Create(format.ToString(), args));
}
/// <summary>
/// Resolves an <see cref="EndpointReference"/> to a cluster-FQDN URL (<c>scheme://host:port</c>)
/// using the environment's <see cref="RadiusEnvironmentResource.GetHostAddressExpression"/> so
/// the namespace-qualified service name is used.
/// </summary>
private string ResolveEndpointUrl(EndpointReference endpointReference) =>
ResolveHostExpression(((IComputeEnvironmentResource)_environment).GetEndpointPropertyExpression(endpointReference.Property(EndpointProperty.Url)));
private string ResolveEndpointProperty(EndpointReferenceExpression endpointReferenceExpression) =>
ResolveHostExpression(((IComputeEnvironmentResource)_environment).GetEndpointPropertyExpression(endpointReferenceExpression));
/// <summary>
/// Resolves a <see cref="ReferenceExpression"/> produced by the environment's endpoint
/// helpers to a literal string. The host address is a literal cluster FQDN, so the whole
/// expression resolves synchronously without needing the run-mode value pipeline.
/// </summary>
private static string ResolveHostExpression(ReferenceExpression expression) =>
expression.GetValueAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult() ?? string.Empty;
/// <summary>
/// Warns when a container image may not pull correctly without <c>imagePullPolicy</c>.
/// The container v2 schema removes <c>imagePullPolicy</c>, so users of kind clusters or
/// local images need to ensure images are pre-loaded and use explicit tags.
/// </summary>
private void WarnIfImageMayNotPull(string resourceName, string image)
{
if (image.EndsWith(":latest", StringComparison.Ordinal) || !image.Contains(':'))
{
_logger.LogWarning(
"Resource '{ResourceName}' uses image '{Image}' which may default to 'Always' pull policy " +
"in Kubernetes. The Radius container v2 schema no longer supports imagePullPolicy. " +
"For kind clusters, pre-load images with 'kind load docker-image' and use explicit tags.",
resourceName, image);
}
if (!image.Contains('/'))
{
_logger.LogWarning(
"Resource '{ResourceName}' uses image '{Image}' without a registry prefix. " +
"Ensure the image is available in the target cluster (e.g., pre-loaded via 'kind load docker-image').",
resourceName, image);
}
}
private void RunConfigureCallbacks(RadiusInfrastructureOptions options)
{
var callbacks = _environment.Annotations
.OfType<RadiusInfrastructureConfigureAnnotation>()
.ToArray();
foreach (var callback in callbacks)
{
callback.Configure(options);
}
}
// A Kubernetes Service name must be a valid RFC 1123 DNS label of at most 63 characters:
// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
// The Radius recipe names the Service `{resource}-{resource}` (RadiusServiceDiscovery), so a
// resource name longer than 31 characters overflows the limit even though Aspire itself allows
// names up to ModelName.DefaultMaxLength (64).
private const int MaxKubernetesServiceNameLength = 63;
// Validates the final (post-callback) container set. Aspire emits service discovery
// (`services__*` URLs and the recipe Service name/port) from the pre-callback model, so a
// ConfigureRadiusInfrastructure callback that renames a container, changes/removes a port,
// adds a port to a previously portless container, or replaces/drops a workload can silently
// break cross-container calls or emit an invalid manifest. Fail fast on any detectable
// divergence. Only literal values are validated; a non-literal (Bicep-expression) name or port
// cannot be reconciled with the fixed literal service-discovery value, so it is rejected too.
private static void ValidatePostCallbackContainerInvariants(
RadiusInfrastructureOptions options,
IReadOnlyDictionary<string, Dictionary<string, (int Port, string Protocol)>> portSnapshots)
{
// Index the final containers by their immutable map key (the resource name, fixed at
// construction). Keying by the map key rather than the construct instance means a callback
// that swapped in a new construct for the same workload is still matched to its baseline.
var containersByMapKey = new Dictionary<string, RadiusContainerConstruct>(StringComparer.Ordinal);
foreach (var container in options.Containers)
{
containersByMapKey[container.ContainerMapKey] = container;
}
foreach (var (mapKey, snapshot) in portSnapshots)
{
// A portless container has no Service and no `services__*` value can address it, so
// removing it in a callback is harmless — skip the preservation check for empty
// snapshots so the invariant does not needlessly reject valid customization callbacks.
if (snapshot.Count == 0)
{
continue;
}
// The workload service discovery was emitted for must still be present under the same
// map key. A callback that removed it — or replaced it with a differently keyed
// container — leaves consumers pointing at a Service that is no longer produced.
if (!containersByMapKey.TryGetValue(mapKey, out var container))
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback removed or replaced container '{mapKey}'. Aspire " +
$"service discovery already emitted 'services__*' variables that address it, so dropping the " +
$"workload would break cross-container calls. Keep the container to keep service discovery consistent.");
}
// Only containers that had service ports pre-callback have a Service (`{name}-{name}`)
// that `services__*` addresses, so the name/map-key equality is only required for them.
// A portless baseline container or one added entirely by the callback has no service-
// discovery contract — Radius permits its top-level name to differ from the map key — so
// gating this check on a non-empty snapshot keeps the customization escape hatch open.
ValidateContainerNameMatchesMapKey(container);
foreach (var (portName, expected) in snapshot)
{
if (!container.Ports.TryGetValue(portName, out var portValue) || portValue.Value is not { } port)
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback removed port '{portName}' from container " +
$"'{mapKey}'. Aspire service discovery already emitted this port ({expected.Port}) into " +
$"consumer 'services__*' variables, so removing it would break cross-container calls. " +
$"Remove the port change to keep service discovery consistent.");
}
// Reject a non-literal port/protocol: service discovery is a fixed literal, so a
// callback that swaps in a Bicep expression could evaluate to a different value at
// deploy time, reintroducing exactly the mismatch this guard prevents. An
// expression-backed BicepValue<int> reports a default LiteralValue of 0 (not null),
// so a non-null Expression is the reliable "non-literal" signal, not the LiteralValue.
var portValueBicep = (IBicepValue)port.ContainerPort;
if (portValueBicep.Expression is not null || portValueBicep.LiteralValue is not int literalPort)
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback replaced port '{portName}' on container " +
$"'{mapKey}' with a non-literal Bicep expression. Aspire service discovery already emitted " +
$"the literal port {expected.Port} into consumer 'services__*' variables and cannot follow a " +
$"computed port, so a computed containerPort is not supported. Remove the port change.");
}
var protocolValueBicep = (IBicepValue)port.Protocol;
if (protocolValueBicep.Expression is not null || protocolValueBicep.LiteralValue is not string literalProtocol)
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback replaced the protocol of port '{portName}' on " +
$"container '{mapKey}' with a non-literal Bicep expression. Aspire service discovery assumes " +
$"the literal protocol '{expected.Protocol}', so a computed protocol is not supported. Remove " +
$"the protocol change.");
}
if (literalPort != expected.Port || !string.Equals(literalProtocol, expected.Protocol, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback changed port '{portName}' on container " +
$"'{mapKey}' from {expected.Port}/{expected.Protocol} to {literalPort}/{literalProtocol}. " +
$"Aspire service discovery already emitted {expected.Port}/{expected.Protocol} into consumer " +
$"'services__*' variables, so this would break cross-container calls. Remove the port change.");
}
}
}
// Validate the FINAL container set. A callback can add the first port to a previously
// portless container, add a new container, or add a second endpoint. Once a container
// declares ports the recipe creates a Service, so re-check the two things the recipe cares
// about on the post-callback state: the Service name fits the Kubernetes limit, and the
// container's ports are unique by (containerPort, protocol).
foreach (var container in options.Containers)
{
if (container.Ports.Count == 0)
{
continue;
}
ValidateServiceNameWithinKubernetesLimit(container);
// The pre-callback `seenPorts` dedup in ResolvePorts only covers the baseline ports. A
// callback can add a second endpoint (e.g. `http2`) that resolves to the same
// (containerPort, protocol) as a preserved one, which would make the recipe emit
// duplicate Kubernetes Service ports — exactly what the baseline dedup prevents. Re-run
// the dedup on the FINAL literal ports so a callback can't reintroduce the collision.
var seenPorts = new HashSet<(int ContainerPort, string Protocol)>();
foreach (var (portName, portValue) in container.Ports)
{
if (portValue.Value is not { } port)
{
continue;
}
// Only literal ports can collide deterministically; non-literal (expression-backed)
// ports on callback-added containers are the customization's own responsibility and
// can't be compared here.
if (((IBicepValue)port.ContainerPort).LiteralValue is not int literalPort ||
((IBicepValue)port.Protocol).LiteralValue is not string literalProtocol)
{
continue;
}
if (!seenPorts.Add((literalPort, literalProtocol)))
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback left container '{container.ContainerMapKey}' with " +
$"more than one port on {literalPort}/{literalProtocol} (for example port '{portName}'). The " +
$"Radius container recipe creates one Kubernetes Service port per declared port, so duplicate " +
$"(containerPort, protocol) pairs would emit conflicting Service ports. Remove the duplicate port.");
}
}
}
}
// Ensures a container's top-level `name:` still equals its `properties.containers` map key. The
// default name is a literal (the resource name); a callback that changes it to a mismatched
// literal, or replaces it with a non-literal Bicep expression we cannot compare, throws.
//
// NOTE: this is an *Aspire* service-discovery limitation, not a Radius v2 schema requirement.
// Radius itself permits a container resource whose map keys (e.g. `frontend`, `sidecar`) differ
// from the top-level name; Aspire derives `services__*` values from the original resource name,
// so a rename would make the emitted address diverge from the deployed Service.
private static void ValidateContainerNameMatchesMapKey(RadiusContainerConstruct container)
{
var name = (IBicepValue)container.ContainerName;
// An expression-backed BicepValue reports a default LiteralValue (null for string), but to
// stay consistent with the port guard we treat any non-null Expression as the non-literal
// signal.
if (name.Expression is not null || name.LiteralValue is not string literalName)
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback replaced container '{container.ContainerMapKey}' name " +
$"with a non-literal Bicep expression. The Aspire Radius publisher derives service discovery from " +
$"the original container name, so it must stay the literal resource name '{container.ContainerMapKey}' " +
$"(this is an Aspire limitation, not a Radius schema requirement). Remove the rename to keep the " +
$"emitted 'services__*' values addressing the deployed Service.");
}
if (!string.Equals(literalName, container.ContainerMapKey, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"A ConfigureRadiusInfrastructure callback renamed container '{container.ContainerMapKey}' to " +
$"'{literalName}'. The Aspire Radius publisher derives service discovery from the original container " +
$"name, so renaming it makes the emitted 'services__*' values point at a Service that is no longer " +
$"produced (this is an Aspire limitation, not a Radius schema requirement). Remove the rename to keep " +
$"cross-container calls working.");
}
}
private static void ValidateServiceNameWithinKubernetesLimit(RadiusContainerConstruct container)
{
// The recipe names the Service `${normalizedName}-${containerName}` = `{top-level name}-
// {map key}`. For a baseline container the name-equality guard forces name == map key, so
// this is `{name}-{name}`; for a callback-added/portless container the name may legitimately
// differ, so compute the actual Service name from the literal top-level name when available.
var mapKey = container.ContainerMapKey;
var topLevelName = ((IBicepValue)container.ContainerName).LiteralValue is string literalName ? literalName : mapKey;
var serviceName = RadiusServiceDiscovery.GetServiceName(topLevelName, mapKey);
if (serviceName.Length > MaxKubernetesServiceNameLength)
{
throw new InvalidOperationException(
$"The Radius container recipe creates a Kubernetes Service named '{serviceName}' for resource " +
$"'{mapKey}', but that is {serviceName.Length} characters — longer than the " +
$"{MaxKubernetesServiceNameLength}-character limit for a Kubernetes Service name (an RFC 1123 DNS " +
$"label). Shorten the resource name to at most {(MaxKubernetesServiceNameLength - 1) / 2} characters " +
$"so the doubled '{{name}}-{{name}}' Service name stays within the limit.");
}
}
internal readonly record struct RecipeEntry(string RecipeKind, string RecipeLocation);
// ---------------------------------------------------------------------------------------------
// Recipe parameters (WithRecipeParameters) — environment-wide + resource-type-scoped values
// flowed onto the shared recipe pack. ParameterResource-backed values are emitted as valueless
// (secure when the source is secret) Bicep `param`s so no literal secret lands in the artifact.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Computes the effective recipe parameter set for a resource type by merging the
/// environment-wide parameters with any parameters scoped to that resource type.
/// Resource-type-scoped values win on key collision. Returns <see langword="null"/> when no
/// parameters apply.
/// </summary>
private IReadOnlyDictionary<string, object>? GetEffectiveRecipeParameters(string resourceType)
{
var annotation = _environment.Annotations
.OfType<Annotations.RadiusRecipeParametersAnnotation>()
.FirstOrDefault();
if (annotation is null)
{
return null;
}
var effective = new Dictionary<string, object>(annotation.EnvironmentWide, StringComparer.Ordinal);
if (annotation.ByResourceType.TryGetValue(resourceType, out var scoped))
{
foreach (var (key, value) in scoped)
{
if (effective.ContainsKey(key))
{
_logger.LogDebug(
"Recipe parameter '{Key}' scoped to resource type '{ResourceType}' overrides the environment-wide value.",
key, resourceType);
}
effective[key] = value;
}
}
return effective.Count == 0 ? null : effective;
}
/// <summary>
/// Serializes each effective recipe parameter into <paramref name="target"/>, preserving Bicep
/// type fidelity and emitting parameter references for bound <see cref="ParameterResource"/>
/// values and provider references.
/// </summary>
private void ApplyRecipeParameters(BicepDictionary<object> target, IReadOnlyDictionary<string, object> parameters)
{
foreach (var (key, value) in parameters)
{
target[key] = ConvertRecipeParameterValue(value);
}
}
/// <summary>
/// Converts a single recipe parameter value to a Bicep value. Handles
/// <see cref="ParameterResource"/> bindings (emitted as a Bicep <c>param</c> reference, never a
/// resolved secret), provider-scope references, and literal/array/object values.
/// </summary>
private BicepValue<object> ConvertRecipeParameterValue(object? value) =>
ConvertRecipeParameterValue(value, new HashSet<object>(ReferenceEqualityComparer.Instance), depth: 0);
private BicepValue<object> ConvertRecipeParameterValue(object? value, HashSet<object> visited, int depth)
{
if (depth > MaxRecipeParameterNestingDepth)
{
throw new NotSupportedException(
$"Recipe parameter values cannot be nested deeper than {MaxRecipeParameterNestingDepth} levels.");
}
switch (value)
{
case null:
return new BicepValue<object>(new NullLiteralExpression());
case BicepValue<object> bicepValue:
return bicepValue;
case IBicepValue alreadyBicep:
return new BicepValue<object>(alreadyBicep);
case BicepExpression expression:
return new BicepValue<object>(expression);
case IResourceBuilder<ParameterResource> parameterBuilder:
return ParameterReference(GetOrAddRecipeParameter(parameterBuilder.Resource));
case ParameterResource parameterResource:
return ParameterReference(GetOrAddRecipeParameter(parameterResource));
case RadiusProviderReference providerReference:
return ToRecipeBicepValue(ResolveProviderReference(providerReference));
case System.Collections.IDictionary dictionary:
return ConvertRecipeParameterObject(dictionary, visited, depth);
case string or int or long or bool or double or float or decimal:
return ToRecipeBicepValue(value);
case System.Collections.IEnumerable sequence:
return ConvertRecipeParameterArray(sequence, visited, depth);
default:
return ToRecipeBicepValue(value);
}
}
private BicepValue<object> ConvertRecipeParameterObject(
System.Collections.IDictionary dictionary,
HashSet<object> visited,
int depth)
{
if (!visited.Add(dictionary))
{
throw new NotSupportedException("Recipe parameter values cannot contain cycles.");
}
try
{
var result = new BicepDictionary<object>();
foreach (System.Collections.DictionaryEntry entry in dictionary)
{
if (entry.Key is not string key)
{
throw new NotSupportedException(
$"Recipe parameter object keys must be strings, but found '{entry.Key?.GetType().Name ?? "null"}'.");
}
result[key] = ConvertRecipeParameterValue(entry.Value, visited, depth + 1);
}
return new BicepValue<object>(result);
}
finally
{
visited.Remove(dictionary);
}
}
private BicepValue<object> ConvertRecipeParameterArray(
System.Collections.IEnumerable sequence,
HashSet<object> visited,
int depth)
{
if (!visited.Add(sequence))
{
throw new NotSupportedException("Recipe parameter values cannot contain cycles.");
}
try
{
var result = new BicepList<object>();
foreach (var element in sequence)
{
result.Add(ConvertRecipeParameterValue(element, visited, depth + 1));
}
return new BicepValue<object>(result);
}
finally
{
visited.Remove(sequence);
}
}
private static BicepValue<object> ToRecipeBicepValue(object value)
{
return BicepPostProcessor.ToBicepValue(value) switch
{
BicepValue<object> bicepValue => bicepValue,
var nestedValue => new BicepValue<object>(nestedValue)
};
}
/// <summary>
/// Wraps a Bicep <c>param</c> declaration as a value usable inside a recipe <c>parameters</c>
/// object (a reference to the parameter identifier).
/// </summary>
private static BicepValue<object> ParameterReference(ProvisioningParameter parameter)
{
BicepValue<object> reference = parameter;
return reference;
}
/// <summary>
/// Returns (creating once) the Bicep <c>param</c> declaration for an Aspire
/// <see cref="ParameterResource"/>. Secret parameters are declared secure so no value is
/// written to the published artifact.
/// </summary>
private ProvisioningParameter GetOrAddRecipeParameter(ParameterResource parameter)
{
if (!_recipeParameters.TryGetValue(parameter.Name, out var provisioningParameter))
{
var identifier = BicepPostProcessor.SanitizeIdentifier(parameter.Name);
// Two distinct parameter names can sanitize to the same Bicep identifier (e.g.
// "my-key" and "my.key" both become "my_key"). Emitting two `param my_key`
// declarations produces invalid Bicep, so fail with an actionable diagnostic
// (ASPIRERADIUS028) instead.
if (_recipeParameterIdentifiers.TryGetValue(identifier, out var existingName))
{
throw new InvalidOperationException(
$"Recipe parameters bound to Aspire parameters '{existingName}' and '{parameter.Name}' both " +
$"map to the Bicep identifier '{identifier}'. Rename one of the parameters so they produce " +
"distinct Bicep identifiers. Diagnostic: ASPIRERADIUS028.");
}
provisioningParameter = new ProvisioningParameter(identifier, typeof(string))
{
IsSecure = parameter.Secret,
};
_recipeParameters[parameter.Name] = provisioningParameter;
_recipeParameterIdentifiers[identifier] = parameter.Name;
// Remember the originating ParameterResource keyed by the Bicep identifier so the
// deploy step can pass `--parameters <identifier>=<value>` for this valueless param.
_recipeParameterBindings[identifier] = parameter;
}
return provisioningParameter;
}
/// <summary>
/// Resolves a <see cref="RadiusProviderReference"/> to the corresponding scope value from the
/// cloud provider configured on this environment. Throws when the referenced provider is not
/// configured.
/// </summary>
private string ResolveProviderReference(RadiusProviderReference reference)
{
var providers = _environment.Annotations
.OfType<Annotations.RadiusCloudProvidersAnnotation>()
.FirstOrDefault();
return reference.Field switch
{
RadiusProviderScopeField.Region =>
providers?.Aws?.Region ?? throw MissingProviderReference("AWS", "WithAwsProvider"),
RadiusProviderScopeField.AccountId =>
providers?.Aws?.AccountId ?? throw MissingProviderReference("AWS", "WithAwsProvider"),
RadiusProviderScopeField.SubscriptionId =>
providers?.Azure?.SubscriptionId ?? throw MissingProviderReference("Azure", "WithAzureProvider"),
RadiusProviderScopeField.ResourceGroup =>
providers?.Azure?.ResourceGroup ?? throw MissingProviderReference("Azure", "WithAzureProvider"),
_ => throw new NotSupportedException($"Unknown provider scope field '{reference.Field}'."),
};
}
private InvalidOperationException MissingProviderReference(string cloud, string configureMethod) =>
new($"A recipe parameter on Radius environment '{_environment.Name}' references {cloud} provider " +
$"configuration, but no {cloud} provider is configured. Call {configureMethod}(...) on the environment.");
/// <summary>
/// Emits a non-fatal warning for each resource-type-scoped parameter set whose resource type
/// has no recipe entry in the emitted recipe pack.
/// </summary>
private void WarnUnmatchedResourceTypeScopes(IEnumerable<string> emittedResourceTypes)
{
var annotation = _environment.Annotations
.OfType<Annotations.RadiusRecipeParametersAnnotation>()
.FirstOrDefault();
if (annotation is null)
{
return;
}
var emitted = new HashSet<string>(emittedResourceTypes, StringComparer.Ordinal);
foreach (var resourceType in annotation.ByResourceType.Keys)
{
if (!emitted.Contains(resourceType))
{
_logger.LogWarning(
"Recipe parameters were scoped to resource type '{ResourceType}' on Radius environment " +
"'{Environment}', but no recipe entry of that type exists in the emitted recipe pack; " +
"those parameters were ignored.",
resourceType, _environment.Name);
}
}
}
// ---------------------------------------------------------------------------------------------
// Secret stores (AddRadiusSecretStore / WithSecretStore) — emitted as Applications.Core/
// secretStores scoped to the legacy environment/application, plus recipeConfig consumers.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Returns the Radius secret stores routed to this environment: environment-scoped stores owned
/// by this environment, plus all application-scoped stores.
/// </summary>
private IEnumerable<RadiusSecretStoreResource> GetSecretStoresForScope()
{
return _model.Resources.OfType<RadiusSecretStoreResource>().Where(s =>
(s.Scope == RadiusSecretStoreScope.Environment && ReferenceEquals(s.OwningEnvironment, _environment))
|| s.Scope == RadiusSecretStoreScope.Application);
}
/// <summary>
/// Emits one <see cref="RadiusSecretStoreConstruct"/> per declared store, scoped to the legacy
/// Applications.Core environment/application (secret stores are Applications.Core resources) and
/// populated per mode (inline / existing / sealed).
/// </summary>
private Dictionary<string, RadiusSecretStoreConstruct> EmitSecretStores(
RadiusInfrastructureOptions options,
IReadOnlyList<RadiusSecretStoreResource> stores,
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
LegacyApplicationConstruct? legacyAppConstruct)
{
var storeConstructs = new Dictionary<string, RadiusSecretStoreConstruct>(StringComparer.Ordinal);
foreach (var store in stores)
{
var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name);
var construct = new RadiusSecretStoreConstruct(identifier)
{
StoreName = store.Name,
StoreType = store.Type.ToRadiusTypeString(),
};
// Scope is implied by the declaring API form: application-scoped stores reference the
// application; environment-scoped stores reference the environment.
if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null)
{
construct.ApplicationId = BuildIdExpression(legacyAppConstruct);
}
else if (legacyEnvConstruct is not null)
{
construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
}
PopulateInlineSecretStoreData(store, construct);
PopulateSecretReferenceData(store, construct, options);
storeConstructs[store.Name] = construct;
options.SecretStores.Add(construct);
}
ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs);
return storeConstructs;
}
/// <summary>
/// Emits the environment's <c>recipeConfig</c> from the recorded secret-store consumers
/// (private Bicep-registry auth, Terraform Git PAT auth, and <c>envSecrets</c>), referencing
/// each store by its <c>.id</c>.
/// </summary>
private void ApplySecretStoreConsumers(
LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs)
{
var annotation = _environment.Annotations
.OfType<Annotations.RadiusSecretStoresAnnotation>()
.FirstOrDefault();
if (legacyEnvConstruct is null || annotation is null || annotation.Consumers.Count == 0)
{
return;
}
var bicepAuth = new Dictionary<string, object>(StringComparer.Ordinal);
var gitPat = new Dictionary<string, object>(StringComparer.Ordinal);
var envSecrets = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var consumer in annotation.Consumers)
{
var secretRef = ResolveSecretStoreReference(consumer.Store, storeConstructs);
switch (consumer.Kind)
{
case RadiusSecretStoreConsumerKind.BicepRegistryAuth:
bicepAuth[consumer.Selector!] = new Dictionary<string, object> { ["secret"] = secretRef };
break;
case RadiusSecretStoreConsumerKind.TerraformGitPat:
gitPat[consumer.Selector!] = new Dictionary<string, object> { ["secret"] = secretRef };
break;
case RadiusSecretStoreConsumerKind.EnvSecret:
envSecrets[consumer.Selector!] = new Dictionary<string, object>
{
["source"] = secretRef,
["key"] = consumer.Key!,
};
break;
default:
throw new InvalidOperationException(
$"Unknown secret-store consumer kind '{consumer.Kind}' for store '{consumer.Store.Name}'.");
}
}
var recipeConfig = new Dictionary<string, object>(StringComparer.Ordinal);
if (bicepAuth.Count > 0)
{
recipeConfig["bicep"] = new Dictionary<string, object> { ["authentication"] = bicepAuth };
}
if (gitPat.Count > 0)
{
recipeConfig["terraform"] = new Dictionary<string, object>
{
["authentication"] = new Dictionary<string, object>
{
["git"] = new Dictionary<string, object> { ["pat"] = gitPat },
},
};
}
if (envSecrets.Count > 0)
{
recipeConfig["envSecrets"] = envSecrets;
}
if (recipeConfig.Count > 0)
{
legacyEnvConstruct.RecipeConfig = BicepPostProcessor.ToBicepObject(recipeConfig);
}
}
/// <summary>
/// Resolves the value emitted for a secret-store reference in <c>recipeConfig</c>: the store's
/// <c>.id</c> expression.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The store is not emitted for this environment (<c>ASPIRERADIUS050</c>).
/// </exception>
private object ResolveSecretStoreReference(
RadiusSecretStoreResource store,
IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs)
{
if (storeConstructs.TryGetValue(store.Name, out var construct))
{
return BuildIdExpression(construct);
}
// Never fall back to the bare store name: that emits a plain string where a secret-store
// `.id` is expected, producing a reference Radius rejects only at deploy (or, worse, that
// silently resolves to nothing). Fail fast with an actionable diagnostic naming the
// consuming environment and the unresolved store.
throw new InvalidOperationException(
$"Environment '{_environment.Name}' references secret store '{store.Name}', but that store is not " +
"emitted for this environment. Ensure the store is declared on this environment. " +
"Diagnostic: ASPIRERADIUS050.");
}
/// <summary>
/// Populates a secret-store construct's <c>data</c> for the inline (Radius-created) mode: each
/// key's value is a reference to a valueless <c>@secure()</c> Bicep <c>param</c> (reusing
/// <see cref="GetOrAddRecipeParameter"/>), with <c>encoding</c> emitted when the author set it
/// explicitly or the type default is not <c>raw</c>.
/// </summary>
private void PopulateInlineSecretStoreData(RadiusSecretStoreResource store, RadiusSecretStoreConstruct construct)
{
if (!store.Population.HasInlineData)
{
return;
}
foreach (var (key, binding) in store.Population.Data)
{
var parameter = GetOrAddRecipeParameter(binding.Parameter);
var entry = new RadiusSecretStoreDataEntryConstruct
{
Value = new IdentifierExpression(parameter.BicepIdentifier),
};
var encoding = binding.Encoding ?? store.Type.DefaultEncoding();
if (binding.Encoding is not null || !string.Equals(encoding, "raw", StringComparison.Ordinal))
{
entry.Encoding = encoding;
}
construct.Data[key] = entry;
}
}
/// <summary>
/// Populates a secret-store construct for the existing-secret / sealed-secret modes: emits
/// <c>properties.resource: '<namespace>/<name>'</c> and each declared key as an
/// empty object (<c>{}</c>). A bare <c><name></c> defaults its namespace to the owning
/// environment's <see cref="RadiusEnvironmentResource.Namespace"/>.
/// </summary>
private void PopulateSecretReferenceData(
RadiusSecretStoreResource store,
RadiusSecretStoreConstruct construct,
RadiusInfrastructureOptions options)
{
if (!store.Population.IsSecretReference)
{
return;
}
construct.ResourceReference = ResolveSecretResourceReference(store, options);
foreach (var key in store.Population.Keys)
{
// An entry with no assigned properties emits as an empty object, naming a key to
// expose from the referenced Secret without passing any value through Aspire.
construct.Data[key] = new RadiusSecretStoreDataEntryConstruct();
}
}
/// <summary>
/// Resolves a secret store's <c>resource</c> reference: a fully-qualified
/// <c><namespace>/<name></c> is emitted verbatim; a bare <c><name></c> is
/// prefixed with the owning environment's namespace.
/// </summary>
private string ResolveSecretResourceReference(RadiusSecretStoreResource store, RadiusInfrastructureOptions options)
{
var population = store.Population;
var defaultNamespace = store.OwningEnvironment?.Namespace ?? _environment.Namespace;
// For a sealed store the underlying Secret's namespace/name come from the SealedSecret
// manifest metadata (also the deploy-time materialization poll target); a missing or
// unreadable manifest fails publish with ASPIRERADIUS044.
if (population.HasSealedSecret)
{
var manifestPath = store.Population.SealedManifestPath!;
if (!options.SealedSecretManifests.TryGetValue(store.Name, out var manifest))
{
manifest = SealedSecretManifest.ReadValidated(store.Name, manifestPath, defaultNamespace);
options.SealedSecretManifests[store.Name] = manifest;
}
var metadata = manifest.Metadata;
RadiusSecretStoreValidation.ValidateSealedSecretNamespace(store, metadata, manifest.SourcePath);
return $"{metadata.Namespace}/{metadata.Name}";
}
var reference = population.ResourceReference!;
if (reference.Contains('/', StringComparison.Ordinal))
{
return reference;
}
return $"{defaultNamespace}/{reference}";
}
}