|
// 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);
}
}
|