File: RadiusEnvironmentResource.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRECOMPUTE002
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIRERADIUS006 // Secret-store validation/apply steps are experimental; consumed internally by the integration.
 
using System.Diagnostics.CodeAnalysis;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Radius.Publishing;
 
namespace Aspire.Hosting.Radius;
 
/// <summary>
/// Represents a Radius compute environment in the Aspire app model.
/// </summary>
[AspireExport(ExposeProperties = true)]
public sealed class RadiusEnvironmentResource : Resource, IComputeEnvironmentResource
{
    /// <summary>
    /// Initializes a new instance of the <see cref="RadiusEnvironmentResource"/> class.
    /// </summary>
    /// <param name="name">The name of the Radius environment resource.</param>
    /// <remarks>
    /// Registers the publish/deploy pipeline steps as annotations on the resource so any
    /// caller that adds this resource to the application model gets a working environment.
    /// In Run mode the resource is normally not added to the model (see
    /// <c>AddRadiusEnvironment</c>) and the annotations are inert. Mirrors
    /// <c>KubernetesEnvironmentResource</c> / <c>DockerComposeEnvironmentResource</c>, which
    /// also keep their step factories on the resource itself rather than the extension method.
    /// </remarks>
    public RadiusEnvironmentResource(string name) : base(name)
    {
        // Single multi-step annotation matches KubernetesEnvironmentResource so a wrapper
        // integration (or any caller that constructs the resource directly) gets a complete,
        // self-contained publish pipeline. Run-mode safety comes from the resource not being
        // registered with the application builder in Run mode, not from a guard here.
        Annotations.Add(new PipelineStepAnnotation(_ =>
        {
            // Per-environment prepare step: materializes DeploymentTargetAnnotations on
            // compute resources scoped to this environment. ValidateComputeEnvironments
            // (a DependsOn) fails-fast on multi-env ambiguity before this step runs, and
            // RequiredBy(BeforeStart) makes the prepared targets observable to downstream
            // publishing code.
            var prepareStep = new PipelineStep
            {
                Name = $"prepare-deployment-targets-{Name}",
                Description = $"Prepares Radius deployment targets for {Name}.",
                Action = stepContext => RadiusInfrastructure.PrepareDeploymentTargetsAsync(this, stepContext),
                DependsOnSteps = [WellKnownPipelineSteps.ValidateComputeEnvironments],
                RequiredBySteps = [WellKnownPipelineSteps.BeforeStart],
            };
 
            var publishStep = new RadiusBicepPublishingContext(this).CreatePipelineStep();
            var deployStep = new RadiusDeploymentPipelineStep(this).CreatePipelineStep();
 
            // Fail-fast Radius secret-store validation gate: RequiredBy this environment's
            // publish and deploy steps so type/mode/key/encoding/duplicate-name failures
            // surface before any Bicep is emitted or kubectl/rad is contacted. It is a no-op
            // when the model declares no secret stores, keeping the default path unchanged.
            var validateSecretStoresStep = new PipelineStep
            {
                Name = $"validate-radius-secret-stores-{Name}",
                Description = $"Validates Radius secret stores for {Name}.",
                Action = Secrets.RadiusSecretStoreValidation.ValidateAsync,
                RequiredBySteps = [publishStep.Name, deployStep.Name],
            };
 
            // Sealed-secrets apply/wait gate: applies each SealedSecret manifest to the workspace's
            // cluster and waits for the underlying Secret to materialize before rad deploy. Scheduled
            // after publish and RequiredBy deploy; a no-op when no sealed store is declared.
            var applySealedSecretsStep = new SealedSecretApplyStep(this).CreatePipelineStep();
 
            // Only schedule the credential-register step when the environment
            // has cloud-provider configuration attached. Apps without the new
            // WithAzure/WithAws extensions emit byte-identical pipelines.
            var hasCloudProviders = Annotations
                .OfType<Annotations.RadiusCloudProvidersAnnotation>()
                .Any();
            if (hasCloudProviders)
            {
                var registerStep = new RadCredentialRegisterStep(this).CreatePipelineStep();
                return [validateSecretStoresStep, prepareStep, publishStep, registerStep, applySealedSecretsStep, deployStep];
            }
 
            return [validateSecretStoresStep, prepareStep, publishStep, applySealedSecretsStep, deployStep];
        }));
    }
 
    /// <summary>
    /// Gets or sets the Kubernetes namespace for resource deployment.
    /// </summary>
    public string Namespace { get; set; } = "default";
 
    /// <summary>
    /// Gets or sets the parent compute environment this Radius environment is hosted by, when
    /// the Radius env is itself a child of a higher-level compute environment (e.g. an Azure
    /// AKS environment that wraps both Kubernetes and Radius). When set, resources that target
    /// the parent environment are also adopted by this Radius environment during the prepare
    /// step. Defaults to <see langword="null"/> (no parent).
    /// </summary>
    /// <remarks>
    /// Mirrors <c>KubernetesEnvironmentResource.OwningComputeEnvironment</c>. Today this is
    /// always <see langword="null"/> for vanilla Radius; the property exists so an Azure
    /// hosting integration can wrap Radius the same way Azure Kubernetes wraps the K8s
    /// integration without needing a breaking change to this type.
    /// </remarks>
    public IComputeEnvironmentResource? OwningComputeEnvironment { get; set; }
 
    /// <inheritdoc/>
    [Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    public ReferenceExpression GetHostAddressExpression(EndpointReference endpointReference)
    {
        var resource = endpointReference.Resource;
        // Kubernetes service DNS for a resource deployed to a namespace is
        // `<service>.<namespace>.svc.cluster.local`. The namespace segment is required: without
        // it the name only resolves for callers already inside the same namespace, so cross-
        // namespace (and fully-qualified) service discovery breaks.
        //
        // Resolve the namespace of the environment the *target* resource deploys into, not this
        // environment's. With multiple Radius environments in one model (each with its own
        // WithNamespace), a WithReference from a resource in environment A to a resource in
        // environment B must emit B's namespace, otherwise B's service name would be qualified
        // with A's namespace and never resolve. `WithComputeEnvironment` is mandatory in
        // multi-environment models (enforced by the ValidateComputeEnvironments pipeline step),
        // so the target carries a ComputeEnvironmentAnnotation for this reachable cross-env case.
        // Fall back to this environment's namespace when the target resolves to no Radius
        // environment: the single-environment and AKS-wrap cases share this environment's
        // namespace, so the fallback is correct there.
        var targetNamespace = (resource.GetComputeEnvironment() as RadiusEnvironmentResource)?.Namespace ?? Namespace;
 
        // The Radius Kubernetes container recipe names the ClusterIP Service `{name}-{name}` (see
        // RadiusServiceDiscovery), not the bare resource name, so service discovery must address
        // that Service — otherwise the FQDN never resolves and cross-container calls fail.
        var serviceName = RadiusServiceDiscovery.GetServiceName(resource);
        return ReferenceExpression.Create($"{serviceName}.{targetNamespace}.svc.cluster.local");
    }
 
    // Explicit interface implementation: this override customizes only the *port source* for Radius
    // peers (Service/container port instead of the proxy/host port) and is reached solely through
    // IComputeEnvironmentResource. Keeping it off the public RadiusEnvironmentResource surface
    // matches the other publishers (Kubernetes/Docker), which don't expose this member at all.
    ReferenceExpression IComputeEnvironmentResource.GetEndpointPropertyExpression(EndpointReferenceExpression endpointReferenceExpression)
    {
        ArgumentNullException.ThrowIfNull(endpointReferenceExpression);
 
        var endpointReference = endpointReferenceExpression.Endpoint;
        var property = endpointReferenceExpression.Property;
        var endpoint = endpointReference.EndpointAnnotation;
        var scheme = endpoint.UriScheme;
 
        // Unlike the default IComputeEnvironmentResource implementation (which uses the proxy/host
        // port), a Radius peer is reachable on the recipe Service's port, which equals the container
        // port (port == targetPort == containerPort). Resolve the service port from the same helper
        // the Bicep container-port emission uses so the emitted URL and the generated Service agree.
        var resolvedServicePort = RadiusServiceDiscovery.ResolveServicePort(endpointReference.Resource, endpoint.Name);
        var port = resolvedServicePort ?? GetDefaultPort(scheme, endpoint);
        var host = new Lazy<ReferenceExpression>(() => GetHostAddressExpression(endpointReference));
 
        return property switch
        {
            EndpointProperty.Url => IsDefaultPort(scheme, port)
                ? ReferenceExpression.Create($"{scheme}://{host.Value}")
                : ReferenceExpression.Create($"{scheme}://{host.Value}:{RadiusServiceDiscovery.ToInvariantString(port)}"),
            EndpointProperty.Host or EndpointProperty.IPV4Host => host.Value,
            EndpointProperty.Port => ReferenceExpression.Create($"{RadiusServiceDiscovery.ToInvariantString(port)}"),
            // The Radius recipe Service targets the container port, which equals the Service port
            // (port == targetPort == containerPort). Use the same resolved value as Port/Url so the
            // TargetPort property can't disagree with the container port ResolvePorts emits. Fall back
            // to the container's port reference only when no Service port is resolved (e.g. an
            // unallocated HTTPS endpoint that is dropped from service discovery anyway).
            EndpointProperty.TargetPort => resolvedServicePort is int targetPort
                ? ReferenceExpression.Create($"{RadiusServiceDiscovery.ToInvariantString(targetPort)}")
                : ReferenceExpression.Create($"{new ContainerPortReference(endpointReference.Resource)}"),
            EndpointProperty.Scheme => ReferenceExpression.Create($"{scheme}"),
            EndpointProperty.HostAndPort => ReferenceExpression.Create($"{host.Value}:{RadiusServiceDiscovery.ToInvariantString(port)}"),
            EndpointProperty.TlsEnabled => ReferenceExpression.Create($"{(endpoint.TlsEnabled ? bool.TrueString : bool.FalseString)}"),
            _ => throw new InvalidOperationException($"The property '{property}' is not supported for the endpoint '{endpoint.Name}'.")
        };
    }
 
    // Mirrors the private helpers on IComputeEnvironmentResource so this override reproduces the
    // default port semantics (only the port *source* differs — Radius uses the Service/container
    // port instead of the proxy/host port).
    private static int GetDefaultPort(string scheme, EndpointAnnotation endpoint)
    {
        if (string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase))
        {
            return 80;
        }
 
        if (string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase))
        {
            return 443;
        }
 
        throw new InvalidOperationException($"Endpoint '{endpoint.Name}' must specify a port for scheme '{scheme}'.");
    }
 
    private static bool IsDefaultPort(string scheme, int port) =>
        string.Equals(scheme, "http", StringComparison.OrdinalIgnoreCase) && port == 80 ||
        string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) && port == 443;
}