File: RadiusExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius;
using Aspire.Hosting.Radius.Publishing;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for adding and configuring Radius environment resources.
/// </summary>
public static partial class RadiusExtensions
{
    private const int KubernetesNamespaceMaxLength = 63;
 
    /// <summary>
    /// Adds a Radius compute environment to the application model.
    /// </summary>
    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
    /// <param name="name">The name of the Radius environment resource.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{RadiusEnvironmentResource}"/>.</returns>
    /// <remarks>
    /// In <see cref="DistributedApplicationOperation.Run"/> mode this returns an
    /// unregistered builder so the environment does not surface as a resource in
    /// the dashboard and no pipeline steps are wired up — matching
    /// <c>AddKubernetesEnvironment</c> and <c>AddDockerComposeEnvironment</c>.
    /// All deployment-target wiring runs in Publish mode only.
    /// </remarks>
    [AspireExport]
    public static IResourceBuilder<RadiusEnvironmentResource> AddRadiusEnvironment(
        this IDistributedApplicationBuilder builder,
        [ResourceName] string name)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        var resource = new RadiusEnvironmentResource(name);
 
        if (builder.ExecutionContext.IsRunMode)
        {
            // Return a builder that isn't added to the top-level application builder so it
            // doesn't surface as a resource. The Radius integration is publish/deploy-only
            // today; Run mode has nothing to wire up. The pipeline-step annotations on the
            // resource (registered in its constructor) are inert because the resource is
            // not in the application model — matches AddKubernetesEnvironment and
            // AddDockerComposeEnvironment.
            return builder.CreateResourceBuilder(resource);
        }
 
        return builder.AddResource(resource);
    }
 
    /// <summary>
    /// Sets the Kubernetes namespace for the Radius environment.
    /// </summary>
    /// <param name="builder">The Radius environment resource builder.</param>
    /// <param name="kubernetesNamespace">A valid RFC 1123 namespace name.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{RadiusEnvironmentResource}"/>.</returns>
    /// <exception cref="ArgumentException">Thrown when the namespace is not a valid RFC 1123 label.</exception>
    [AspireExport]
    public static IResourceBuilder<RadiusEnvironmentResource> WithNamespace(
        this IResourceBuilder<RadiusEnvironmentResource> builder,
        string kubernetesNamespace)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(kubernetesNamespace);
 
        if (kubernetesNamespace.Length > KubernetesNamespaceMaxLength || !DnsLabelPattern().IsMatch(kubernetesNamespace))
        {
            throw new ArgumentException(
                $"Kubernetes namespace '{kubernetesNamespace}' is invalid. " +
                "Must match RFC 1123: lowercase alphanumeric characters or hyphens, " +
                $"start and end with an alphanumeric character, and be at most {KubernetesNamespaceMaxLength} characters.",
                nameof(kubernetesNamespace));
        }
 
        builder.Resource.Namespace = kubernetesNamespace;
        return builder;
    }
 
    /// <summary>
    /// Registers a callback that can customize the generated Radius infrastructure before Bicep is emitted.
    /// </summary>
    /// <param name="builder">The Radius environment resource builder.</param>
    /// <param name="configure">The callback that mutates the generated infrastructure options.</param>
    /// <returns>The same <see cref="IResourceBuilder{RadiusEnvironmentResource}"/> for chaining.</returns>
    [Experimental("ASPIRERADIUS004", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExportIgnore(Reason = "RadiusInfrastructureOptions customization callbacks are not ATS-compatible.")]
    public static IResourceBuilder<RadiusEnvironmentResource> ConfigureRadiusInfrastructure(
        this IResourceBuilder<RadiusEnvironmentResource> builder,
        Action<RadiusInfrastructureOptions> configure)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(configure);
 
        builder.Resource.Annotations.Add(new RadiusInfrastructureConfigureAnnotation(configure));
        return builder;
    }
 
    [GeneratedRegex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
    private static partial Regex DnsLabelPattern();
 
    /// <summary>
    /// Associates a pre-built container image reference with a project resource so the
    /// Aspire.Hosting.Radius publisher can emit a valid Radius container manifest for it.
    /// </summary>
    /// <param name="builder">The project resource builder.</param>
    /// <param name="image">
    /// A fully-qualified image reference in <c>[registry/]image[:tag]</c> form
    /// (for example <c>localhost:5001/apiservice:latest</c>). When the tag is omitted
    /// <c>latest</c> is used.
    /// </param>
    /// <returns>The same <see cref="IResourceBuilder{ProjectResource}"/> for chaining.</returns>
    /// <remarks>
    /// The Radius publisher does not yet build or push images for <see cref="ProjectResource"/>
    /// (tracked at https://github.com/microsoft/aspire/issues/16844). Until that lands,
    /// callers must build and push the image themselves (for example with
    /// <c>dotnet publish /t:PublishContainer</c>) and then call this method to attach
    /// the resulting registry reference. Without it, <c>aspire publish</c> against a Radius
    /// environment fails with a clear remediation message — and <c>aspire deploy</c> against
    /// Radius would otherwise land pods that hit <c>ImagePullBackOff</c> against the
    /// publisher's <c>&lt;name&gt;:latest</c> fallback.
    ///
    /// The reference is stored as a <see cref="ContainerImageAnnotation"/>, which is also
    /// what container resources use, so the publisher's existing
    /// <c>Registry</c>/<c>Image</c>/<c>Tag</c> assembly path applies uniformly.
    /// </remarks>
    [Experimental("ASPIRERADIUS057", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExport]
    public static IResourceBuilder<ProjectResource> WithContainerImage(
        this IResourceBuilder<ProjectResource> builder,
        string image)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(image);
 
        var (registry, repository, tag) = ParseImageReference(image);
 
        // Replace any previous annotation so callers can override an earlier attachment;
        // matches the LastOrDefault() lookup behaviour the publisher uses.
        var existing = builder.Resource.Annotations.OfType<ContainerImageAnnotation>().LastOrDefault();
        if (existing is not null)
        {
            builder.Resource.Annotations.Remove(existing);
        }
 
        builder.Resource.Annotations.Add(new ContainerImageAnnotation
        {
            Registry = registry,
            Image = repository,
            Tag = tag,
        });
 
        return builder;
    }
 
    // Parse [registry/]image[:tag] into its three components. Examples:
    //   "redis"                       -> (null, "redis",            "latest")
    //   "redis:7"                     -> (null, "redis",            "7")
    //   "library/redis:7"             -> (null, "library/redis",    "7")
    //   "localhost:5001/api:latest"   -> ("localhost:5001", "api",  "latest")
    //   "ghcr.io/owner/repo:v1"       -> ("ghcr.io", "owner/repo",  "v1")
    //
    // A leading path segment is treated as a registry when it contains a '.' or ':' or
    // equals "localhost" — the same heuristic Docker/containerd use to disambiguate a
    // registry hostname from a Docker Hub user namespace. Digests (@sha256:...) are
    // intentionally not supported here; callers needing digest pinning can construct a
    // ContainerImageAnnotation directly.
    private static (string? Registry, string Image, string Tag) ParseImageReference(string reference)
    {
        var registry = (string?)null;
        var remainder = reference;
 
        var firstSlash = reference.IndexOf('/');
        if (firstSlash > 0)
        {
            var firstSegment = reference[..firstSlash];
            if (firstSegment == "localhost" || firstSegment.Contains('.') || firstSegment.Contains(':'))
            {
                registry = firstSegment;
                remainder = reference[(firstSlash + 1)..];
            }
        }
 
        // Tag separator: the last ':' that appears after the last '/' in the remainder
        // (so we don't mistake a port-in-registry for a tag, which is already split off above).
        var tag = "latest";
        var image = remainder;
        var lastSlash = remainder.LastIndexOf('/');
        var lastColon = remainder.LastIndexOf(':');
        if (lastColon > lastSlash)
        {
            tag = remainder[(lastColon + 1)..];
            image = remainder[..lastColon];
        }
 
        return (registry, image, tag);
    }
}