File: ApplicationModel\ContainerImagePushOptions.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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;
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Represents options for pushing container images to a registry.
/// </summary>
/// <remarks>
/// This class allows customization of how container images are named and tagged when pushed to a container registry.
/// The <see cref="RemoteImageName"/> specifies the repository path (without registry endpoint or tag),
/// and <see cref="RemoteImageTag"/> specifies the tag to apply. Use <see cref="GetFullRemoteImageNameAsync"/>
/// to construct the complete image reference including registry endpoint and tag.
/// </remarks>
[Experimental("ASPIRECOMPUTE002", UrlFormat = "https://aka.ms/aspire/diagnostics#{0}")]
public sealed class ContainerImagePushOptions
{
    /// <summary>
    /// Gets or sets the remote image name (repository path without registry endpoint or tag).
    /// </summary>
    /// <value>
    /// <para>
    /// The repository path for the image. This can be specified in several formats:
    /// </para>
    /// <list type="bullet">
    /// <item><description>Simple image name: <c>myapp</c> - Uses the registry endpoint and repository from the associated <see cref="IContainerRegistry"/>.</description></item>
    /// <item><description>Repository and image: <c>myorg/myapp</c> - Uses the registry endpoint but overrides the repository.</description></item>
    /// <item><description>Full path with host: <c>docker.io/captainsafia/myapp</c> - Overrides both the registry endpoint and repository.</description></item>
    /// </list>
    /// <para>
    /// This should not include the tag (e.g., <c>:latest</c>). Use <see cref="RemoteImageTag"/> for the tag.
    /// If not set explicitly, defaults to the resource name in lowercase.
    /// </para>
    /// </value>
    public string? RemoteImageName { get; set; }
 
    /// <summary>
    /// Gets or sets the remote image tag.
    /// </summary>
    /// <value>
    /// The tag to apply to the image when pushed to the registry, such as "latest", "v1.0.0", or "dev".
    /// If not set explicitly, defaults to "latest".
    /// </value>
    public string? RemoteImageTag { get; set; }
 
    /// <summary>
    /// Gets the full remote image name including registry endpoint and tag.
    /// </summary>
    /// <param name="registry">The container registry to use for constructing the full image name.</param>
    /// <param name="cancellationToken">The cancellation token.</param>
    /// <returns>
    /// The fully qualified image name in the format "{registryEndpoint}/{repository}/{imageName}:{tag}"
    /// or "{registryEndpoint}/{imageName}:{tag}" if no repository is specified.
    /// For example: "myregistry.azurecr.io/myapp:v1.0.0" or "docker.io/captainsafia/myapp:latest".
    /// </returns>
    /// <exception cref="InvalidOperationException">Thrown when <see cref="RemoteImageName"/> is <c>null</c> or empty.</exception>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="registry"/> is <c>null</c>.</exception>
    /// <remarks>
    /// <para>
    /// This method retrieves the registry endpoint asynchronously and combines it with the remote image name and tag.
    /// If <see cref="RemoteImageTag"/> is <c>null</c> or empty, "latest" is used as the default tag.
    /// </para>
    /// <para>
    /// The <see cref="RemoteImageName"/> value is parsed to determine if it contains an override for the registry
    /// host or repository. If the <see cref="RemoteImageName"/> contains a host component (detected by the presence
    /// of a dot in the first segment), that host will be used instead of the registry endpoint. Otherwise, the
    /// registry endpoint is used and the <see cref="IContainerRegistry.Repository"/> (if set) is prepended to the image name.
    /// </para>
    /// </remarks>
    public async Task<string> GetFullRemoteImageNameAsync(
        IContainerRegistry registry,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrEmpty(RemoteImageName))
        {
            throw new InvalidOperationException("RemoteImageName must be set.");
        }
 
        ArgumentNullException.ThrowIfNull(registry);
 
        var tag = string.IsNullOrEmpty(RemoteImageTag) ? "latest" : RemoteImageTag;
 
        // Parse the RemoteImageName to check if it contains a host override
        var (host, imagePath) = ParseImageReference(RemoteImageName);
 
        if (host is not null)
        {
            // RemoteImageName contains a host, use it directly instead of the registry endpoint
            return $"{host}/{imagePath}:{tag}";
        }
 
        // Use the registry endpoint
        var registryEndpoint = await registry.Endpoint
            .GetValueAsync(cancellationToken)
            .ConfigureAwait(false);
 
        // Check if the registry has a repository configured
        var repository = registry.Repository is not null
            ? await registry.Repository.GetValueAsync(cancellationToken).ConfigureAwait(false)
            : null;
 
        if (!string.IsNullOrEmpty(repository))
        {
            // Combine registry endpoint, repository, and image path
            return $"{registryEndpoint}/{repository}/{imagePath}:{tag}";
        }
 
        // No repository, just use registry endpoint and image path
        return $"{registryEndpoint}/{imagePath}:{tag}";
    }
 
    /// <summary>
    /// Parses an image reference to extract the host (if present) and image path.
    /// </summary>
    /// <param name="imageReference">The image reference to parse (e.g., "docker.io/library/nginx" or "myapp").</param>
    /// <returns>
    /// A tuple containing the host (or <see langword="null"/> if not present) and the image path.
    /// </returns>
    /// <remarks>
    /// <para>
    /// The host is detected by checking if the first segment of the path contains a dot (<c>.</c>) or colon (<c>:</c>),
    /// or if it equals <c>localhost</c>. This follows the Docker image reference specification.
    /// </para>
    /// <para>
    /// Examples:
    /// <list type="bullet">
    /// <item><description><c>docker.io/library/nginx</c> → host: <c>docker.io</c>, path: <c>library/nginx</c></description></item>
    /// <item><description><c>ghcr.io/user/repo</c> → host: <c>ghcr.io</c>, path: <c>user/repo</c></description></item>
    /// <item><description><c>localhost:5000/myapp</c> → host: <c>localhost:5000</c>, path: <c>myapp</c></description></item>
    /// <item><description><c>myorg/myapp</c> → host: <c>null</c>, path: <c>myorg/myapp</c></description></item>
    /// <item><description><c>myapp</c> → host: <c>null</c>, path: <c>myapp</c></description></item>
    /// </list>
    /// </para>
    /// </remarks>
    internal static (string? Host, string ImagePath) ParseImageReference(string imageReference)
    {
        var firstSlashIndex = imageReference.IndexOf('/');
 
        if (firstSlashIndex == -1)
        {
            // No slash, so it's just an image name with no host
            return (null, imageReference);
        }
 
        var firstSegment = imageReference[..firstSlashIndex];
        var remainder = imageReference[(firstSlashIndex + 1)..];
 
        // Check if the first segment looks like a host:
        // - Contains a dot (e.g., docker.io, ghcr.io, myregistry.azurecr.io)
        // - Contains a colon (e.g., localhost:5000)
        // - Is "localhost"
        if (firstSegment.Contains('.') || firstSegment.Contains(':') || firstSegment.Equals("localhost", StringComparison.OrdinalIgnoreCase))
        {
            return (firstSegment, remainder);
        }
 
        // First segment doesn't look like a host, treat the whole thing as the image path
        return (null, imageReference);
    }
}