File: AzureContainerRegistryExtensions.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.ContainerRegistry\Aspire.Hosting.Azure.ContainerRegistry.csproj (Aspire.Hosting.Azure.ContainerRegistry)
// 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 ASPIRECOMPUTE003 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using System.Globalization;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Azure.Provisioning;
using Azure.Provisioning.ContainerRegistry;
using Azure.Provisioning.Expressions;
using NCrontab;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for adding Azure Container Registry resources to the application model.
/// </summary>
public static class AzureContainerRegistryExtensions
{
    /// <summary>
    /// Adds an Azure Container Registry resource to the application model.
    /// </summary>
    /// <param name="builder">The builder for the distributed application.</param>
    /// <param name="name">The name of the resource.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{AzureContainerRegistryResource}"/> builder.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
    /// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is null or empty.</exception>
    [AspireExport("addAzureContainerRegistry", Description = "Adds an Azure Container Registry resource to the distributed application model.")]
    public static IResourceBuilder<AzureContainerRegistryResource> AddAzureContainerRegistry(this IDistributedApplicationBuilder builder, [ResourceName] string name)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        builder.AddAzureProvisioning();
 
        var configureInfrastructure = (AzureResourceInfrastructure infrastructure) =>
        {
            var registry = AzureProvisioningResource.CreateExistingOrNewProvisionableResource(infrastructure,
                (identifier, name) =>
                {
                    var resource = ContainerRegistryService.FromExisting(identifier);
                    resource.Name = name;
                    return resource;
                },
                (infrastructure) => new ContainerRegistryService(infrastructure.AspireResource.GetBicepIdentifier())
                {
                    Sku = new() { Name = ContainerRegistrySkuName.Basic },
                    Tags = { { "aspire-resource-name", infrastructure.AspireResource.Name } }
                });
 
            infrastructure.Add(registry);
 
            infrastructure.Add(new ProvisioningOutput("name", typeof(string)) { Value = registry.Name.ToBicepExpression() });
            infrastructure.Add(new ProvisioningOutput("loginServer", typeof(string)) { Value = registry.LoginServer.ToBicepExpression() });
        };
 
        var resource = new AzureContainerRegistryResource(name, configureInfrastructure);
 
        IResourceBuilder<AzureContainerRegistryResource> resourceBuilder;
 
        // Don't add the resource to the infrastructure if we're in run mode.
        if (builder.ExecutionContext.IsRunMode)
        {
            resourceBuilder = builder.CreateResourceBuilder(resource);
        }
        else
        {
            resourceBuilder = builder.AddResource(resource)
                .WithAnnotation(new DefaultRoleAssignmentsAnnotation(new HashSet<RoleDefinition>()));
        }
 
        SubscribeToAddRegistryTargetAnnotations(builder, resource);
 
        return resourceBuilder;
    }
 
    /// <summary>
    /// Subscribes to BeforeStartEvent to add RegistryTargetAnnotation to all resources in the model.
    /// </summary>
    private static void SubscribeToAddRegistryTargetAnnotations(IDistributedApplicationBuilder builder, AzureContainerRegistryResource registry)
    {
        builder.Eventing.Subscribe<BeforeStartEvent>((beforeStartEvent, cancellationToken) =>
        {
            foreach (var resource in beforeStartEvent.Model.Resources)
            {
                // Add a RegistryTargetAnnotation to indicate this registry is available as a default target
                resource.Annotations.Add(new RegistryTargetAnnotation(registry));
            }
 
            return Task.CompletedTask;
        });
    }
 
    /// <summary>
    /// Configures a resource that implements <see cref="IContainerRegistry"/> to use the specified Azure Container Registry.
    /// </summary>
    /// <typeparam name="T">The resource type that implements <see cref="IContainerRegistry"/>.</typeparam>
    /// <param name="builder">The resource builder for a resource that implements <see cref="IContainerRegistry"/>.</param>
    /// <param name="registryBuilder">The resource builder for the <see cref="AzureContainerRegistryResource"/> to use.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="registryBuilder"/> is null.</exception>
    public static IResourceBuilder<T> WithAzureContainerRegistry<T>(this IResourceBuilder<T> builder, IResourceBuilder<AzureContainerRegistryResource> registryBuilder)
        where T : IResource, IComputeEnvironmentResource
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(registryBuilder);
 
        // Add a ContainerRegistryReferenceAnnotation to indicate that the resource is using a specific registry
        builder.WithAnnotation(new ContainerRegistryReferenceAnnotation(registryBuilder.Resource));
 
        return builder;
    }
 
    /// <summary>
    /// Gets the <see cref="AzureContainerRegistryResource"/> associated with the specified Azure compute environment resource.
    /// </summary>
    /// <typeparam name="T">The resource type that implements <see cref="IAzureComputeEnvironmentResource"/>.</typeparam>
    /// <param name="builder">The resource builder for the compute environment resource.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{AzureContainerRegistryResource}"/> for the associated registry.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <exception cref="InvalidOperationException">Thrown when the resource does not have an associated Azure Container Registry,
    /// or when the associated container registry is not an <see cref="AzureContainerRegistryResource"/>.</exception>
    public static IResourceBuilder<AzureContainerRegistryResource> GetAzureContainerRegistry<T>(this IResourceBuilder<T> builder)
        where T : IResource, IAzureComputeEnvironmentResource
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        var containerRegistry = builder.Resource.ContainerRegistry ?? throw new InvalidOperationException($"The resource '{builder.Resource.Name}' does not have an associated Azure Container Registry.");
        var registry = containerRegistry as AzureContainerRegistryResource ?? throw new InvalidOperationException($"The Container Registry associated with resource '{builder.Resource.Name}' is not an Azure Container Registry.");
 
        return builder.ApplicationBuilder.CreateResourceBuilder(registry);
    }
 
    /// <summary>
    /// Adds a scheduled ACR purge task to remove old or unused container images from the registry.
    /// </summary>
    /// <param name="builder">The resource builder for the <see cref="AzureContainerRegistryResource"/>.</param>
    /// <param name="schedule">The cron schedule for the purge task timer trigger. Must be a five-part cron expression
    /// (<c>minute hour day-of-month month day-of-week</c>); seconds are not supported.</param>
    /// <param name="filter">An optional filter for the <c>acr purge --filter</c> parameter. Only repositories matching this
    /// filter will be purged. Defaults to <c>".*:.*"</c> (all repositories and tags) when <see langword="null"/>.</param>
    /// <param name="ago">The age threshold for <c>acr purge --ago</c>. Images older than this duration will be considered
    /// for removal. Uses Go-style duration format (e.g., <c>2d3h6m</c>). Defaults to <c>0d</c> when <see langword="null"/>.</param>
    /// <param name="keep">The number of most recent images to keep per repository, regardless of age.
    /// Must be greater than zero. Defaults to 3.</param>
    /// <param name="taskName">An optional name for the ACR task resource. If not provided, a name is auto-generated
    /// based on existing purge tasks to avoid conflicts. If a task with the specified name already exists,
    /// an <see cref="ArgumentException"/> is thrown.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{AzureContainerRegistryResource}"/> for chaining.</returns>
    /// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is <see langword="null"/>.</exception>
    /// <exception cref="ArgumentException">Thrown when <paramref name="schedule"/> is <see langword="null"/>, empty, or whitespace,
    /// or when <paramref name="taskName"/> conflicts with an existing task.</exception>
    /// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="keep"/> is less than 1,
    /// or <paramref name="ago"/> is negative or less than 1 minute (when non-zero).</exception>
    /// <example>
    /// This example configures a daily purge task that removes images older than 7 days, keeping the 5 most recent:
    /// <code>
    /// var acr = builder.AddAzureContainerRegistry("myregistry")
    ///     .WithPurgeTask("0 1 * * *", ago: TimeSpan.FromDays(7), keep: 5);
    /// </code>
    /// </example>
    [AspireExport("withPurgeTask", Description = "Configures a purge task for the Azure Container Registry resource.")]
    public static IResourceBuilder<AzureContainerRegistryResource> WithPurgeTask(
        this IResourceBuilder<AzureContainerRegistryResource> builder,
        string schedule,
        string? filter = null,
        TimeSpan? ago = null,
        int keep = 3,
        string? taskName = null)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentException.ThrowIfNullOrWhiteSpace(schedule);
        schedule = schedule.Trim();
 
        try
        {
            _ = CrontabSchedule.Parse(schedule, new CrontabSchedule.ParseOptions { IncludingSeconds = false });
        }
        catch (CrontabException ex)
        {
            throw new ArgumentException(
                $"The schedule '{schedule}' is not a valid five-part cron expression (minute hour day-of-month month day-of-week). {ex.Message}",
                nameof(schedule),
                ex);
        }
 
        if (keep < 1)
        {
            throw new ArgumentOutOfRangeException(nameof(keep), keep, "Keep must be greater than zero.");
        }
 
        var purgeAgo = FormatAgo(ago ?? TimeSpan.Zero);
 
        return builder.ConfigureInfrastructure(infra =>
        {
            var prefix = "purgeOldImages";
 
            var registry = infra.GetProvisionableResources().OfType<ContainerRegistryService>().Single();
            var allTasks = infra.GetProvisionableResources().OfType<ContainerRegistryTask>()
                .Where(t => t.Parent == registry)
                .ToList();
            var autoNamedTasks = allTasks
                .Where(t => t.Name.Value?.StartsWith(prefix, StringComparison.Ordinal) == true)
                .ToList();
            var taskIndex = autoNamedTasks.Count;
 
            if (!string.IsNullOrWhiteSpace(taskName))
            {
                if (allTasks.Any(t => string.Equals(t.Name.Value, taskName, StringComparison.Ordinal)))
                {
                    throw new ArgumentException($"A purge task with the name '{taskName}' already exists.", nameof(taskName));
                }
            }
            else
            {
                taskName = taskIndex == 0 ? prefix : $"{prefix}_{taskIndex}";
            }
 
            var bicepIdentifier = $"{prefix}_{taskIndex}";
 
            var purgeTaskCmdVariable = new ProvisioningVariable($"purgeTaskCmd_{taskIndex}", typeof(string))
            {
                Value = CreatePurgeTaskContent(filter, purgeAgo, keep)
            };
            infra.Add(purgeTaskCmdVariable);
 
            var purgeTask = new ContainerRegistryTask(bicepIdentifier)
            {
                Name = taskName,
                Parent = registry,
                Platform = { OS = ContainerRegistryOS.Linux },
                Step = new ContainerRegistryEncodedTaskStep
                {
                    EncodedTaskContent = new FunctionCallExpression(new IdentifierExpression("base64"), [new IdentifierExpression(purgeTaskCmdVariable.BicepIdentifier)])
                },
                Trigger =
                {
                    TimerTriggers =
                    [
                        new ContainerRegistryTimerTrigger
                        {
                            Name = $"{taskName}_trigger",
                            Schedule = schedule
                        }
                    ]
                }
            };
            infra.Add(purgeTask);
        });
    }
 
    /// <summary>
    /// Adds role assignments to the specified Azure Container Registry resource.
    /// </summary>
    /// <typeparam name="T">The type of the resource being configured.</typeparam>
    /// <param name="builder">The resource builder for the resource that will have role assignments.</param>
    /// <param name="target">The target Azure Container Registry resource.</param>
    /// <param name="roles">The roles to assign to the resource.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
    public static IResourceBuilder<T> WithRoleAssignments<T>(
        this IResourceBuilder<T> builder,
        IResourceBuilder<AzureContainerRegistryResource> target,
        params ContainerRegistryBuiltInRole[] roles)
        where T : IResource
    {
        return builder.WithRoleAssignments(target, ContainerRegistryBuiltInRole.GetBuiltInRoleName, roles);
    }
 
    private static string CreatePurgeTaskContent(string? filter, string ago, int keep)
    {
        return $"""
            version: v1.1.0
            steps:
            - cmd: acr purge --filter '{filter ?? ".*:.*"}' --ago {ago} --keep {keep}
            """.ReplaceLineEndings("\n");
    }
 
    /// <summary>
    /// Formats a <see cref="TimeSpan"/> into a Go-style duration string compatible with <c>acr purge --ago</c>.
    /// Valid units: <c>d</c> (days), <c>h</c> (hours), <c>m</c> (minutes).
    /// </summary>
    /// <remarks>
    /// From the docs: https://learn.microsoft.com/azure/container-registry/container-registry-auto-purge#example-scheduled-purge-of-multiple-repositories-in-a-registry
    ///   A Go-style duration string to indicate a duration beyond which images are deleted. The duration consists of a sequence
    ///   of one or more decimal numbers, each with a unit suffix. Valid time units include "d" for days, "h" for hours, and "m"
    ///   for minutes. For example, --ago 2d3h6m selects all filtered images last modified more than two days, 3 hours, and 6 minutes
    ///   ago, and --ago 1.5h selects images last modified more than 1.5 hours ago.
    /// </remarks>
    private static string FormatAgo(TimeSpan ago)
    {
        if (ago.TotalMinutes < 1 && ago != TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(nameof(ago), ago, "Ago must be at least 1 minute to be compatible with acr purge.");
        }
 
        if (ago == TimeSpan.Zero)
        {
            return "0d";
        }
 
        var sb = new StringBuilder();
 
        if (ago.Days > 0)
        {
            sb.Append(CultureInfo.InvariantCulture, $"{ago.Days}d");
        }
 
        if (ago.Hours > 0)
        {
            sb.Append(CultureInfo.InvariantCulture, $"{ago.Hours}h");
        }
 
        if (ago.Minutes > 0)
        {
            sb.Append(CultureInfo.InvariantCulture, $"{ago.Minutes}m");
        }
 
        return sb.ToString();
    }
}