File: ImageBuilder.cs
Web Access
Project: ..\..\..\src\Containers\Microsoft.NET.Build.Containers\Microsoft.NET.Build.Containers.csproj (Microsoft.NET.Build.Containers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.NET.Build.Containers.Resources;
 
namespace Microsoft.NET.Build.Containers;
 
/// <summary>
/// The class builds new image based on the base image.
/// </summary>
internal sealed class ImageBuilder
{
    // a snapshot of the manifest that this builder is based on
    private readonly ManifestV2 _baseImageManifest;
 
    // the mutable internal manifest that we're building by modifying the base and applying customizations
    private readonly ManifestV2 _manifest;
    private readonly ImageConfig _baseImageConfig;
    private readonly ILogger _logger;
 
    /// <summary>
    /// This is a parser for ASPNETCORE_URLS based on https://github.com/dotnet/aspnetcore/blob/main/src/Http/Http/src/BindingAddress.cs
    /// We can cut corners a bit here because we really only care about ports, if they exist.
    /// </summary>
    internal static Regex aspnetPortRegex = new(@"(?<scheme>\w+)://(?<domain>([*+]|).+):(?<port>\d+)");
 
    public ImageConfig BaseImageConfig => _baseImageConfig;
 
    /// <summary>
    /// MediaType of the output manifest. By default, this will be the same as the base image manifest.
    /// </summary>
    public string ManifestMediaType { get; set; }
 
    internal ImageBuilder(ManifestV2 manifest, string manifestMediaType, ImageConfig baseImageConfig, ILogger logger)
    {
        _baseImageManifest = manifest;
        _manifest = new ManifestV2() { SchemaVersion = manifest.SchemaVersion, Config = manifest.Config, Layers = new(manifest.Layers), MediaType = manifest.MediaType };
        ManifestMediaType = manifestMediaType;
        _baseImageConfig = baseImageConfig;
        _logger = logger;
    }
 
    /// <summary>
    /// Gets a value indicating whether the base image is has a Windows operating system.
    /// </summary>
    public bool IsWindows => _baseImageConfig.IsWindows;
 
    // For tests
    internal string ManifestConfigDigest => _manifest.Config.digest;
 
    /// <summary>
    /// Builds the image configuration <see cref="BuiltImage"/> ready for further processing.
    /// </summary>
    internal BuiltImage Build()
    {
        // before we build, we need to make sure that any image customizations occur
        AssignUserFromEnvironment();
        AssignPortsFromEnvironment();
 
        string imageJsonStr = _baseImageConfig.BuildConfig();
        string imageSha = DigestUtils.GetSha(imageJsonStr);
        string imageDigest = DigestUtils.GetDigestFromSha(imageSha);
        long imageSize = Encoding.UTF8.GetBytes(imageJsonStr).Length;
 
        ManifestConfig newManifestConfig = _manifest.Config with
        {
            digest = imageDigest,
            size = imageSize,
            mediaType = ManifestMediaType switch
            {
                SchemaTypes.OciManifestV1 => SchemaTypes.OciImageConfigV1,
                SchemaTypes.DockerManifestV2 => SchemaTypes.DockerContainerV1,
                _ => SchemaTypes.OciImageConfigV1 // opinion - defaulting to modern here, but really this should never happen
            }
        };
 
        ManifestV2 newManifest = new ManifestV2()
        {
            Config = newManifestConfig,
            SchemaVersion = _manifest.SchemaVersion,
            MediaType = ManifestMediaType,
            Layers = _manifest.Layers
        };
 
        return new BuiltImage()
        {
            Config = imageJsonStr,
            ImageDigest = imageDigest,
            ImageSha = imageSha,
            Manifest = JsonSerializer.SerializeToNode(newManifest)?.ToJsonString() ?? "",
            ManifestDigest = newManifest.GetDigest(),
            ManifestMediaType = ManifestMediaType,
            Layers = _manifest.Layers
        };
    }
 
    /// <summary>
    /// Adds a <see cref="Layer"/> to a base image.
    /// </summary>
    internal void AddLayer(Layer l)
    {
        _manifest.Layers.Add(new(l.Descriptor.MediaType, l.Descriptor.Size, l.Descriptor.Digest, l.Descriptor.Urls));
        _baseImageConfig.AddLayer(l);
    }
 
    internal (string name, string value) AddBaseImageDigestLabel()
    {
        var label = ("org.opencontainers.image.base.digest", _baseImageManifest.GetDigest());
        AddLabel(label.Item1, label.Item2);
        return label;
    }
 
    /// <summary>
    /// Adds a label to a base image.
    /// </summary>
    internal void AddLabel(string name, string value) => _baseImageConfig.AddLabel(name, value);
 
    /// <summary>
    /// Adds environment variables to a base image.
    /// </summary>
    internal void AddEnvironmentVariable(string envVarName, string value) => _baseImageConfig.AddEnvironmentVariable(envVarName, value);
 
    /// <summary>
    /// Exposes additional port.
    /// </summary>
    internal void ExposePort(int number, PortType type) => _baseImageConfig.ExposePort(number, type);
 
    /// <summary>
    /// Sets working directory for the image.
    /// </summary>
    internal void SetWorkingDirectory(string workingDirectory) => _baseImageConfig.SetWorkingDirectory(workingDirectory);
 
    /// <summary>
    /// Sets the ENTRYPOINT and CMD for the image.
    /// </summary>
    internal void SetEntrypointAndCmd(string[] entrypoint, string[] cmd) => _baseImageConfig.SetEntrypointAndCmd(entrypoint, cmd);
 
    /// <summary>
    /// Sets the USER for the image.
    /// </summary>
    internal void SetUser(string user, bool isExplicitUserInteraction = true) => _baseImageConfig.SetUser(user, isExplicitUserInteraction);
 
    internal static (string[] entrypoint, string[] cmd) DetermineEntrypointAndCmd(
        string[] entrypoint,
        string[] entrypointArgs,
        string[] cmd,
        string[] appCommand,
        string[] appCommandArgs,
        string appCommandInstruction,
        string[]? baseImageEntrypoint,
        Action<string> logWarning,
        Action<string, string?> logError)
    {
        bool setsEntrypoint = entrypoint.Length > 0 || entrypointArgs.Length > 0;
        bool setsCmd = cmd.Length > 0;
 
        baseImageEntrypoint ??= Array.Empty<string>();
        // Some (Microsoft) base images set 'dotnet' as the ENTRYPOINT. We mustn't use it.
        if (baseImageEntrypoint.Length == 1 && (baseImageEntrypoint[0] == "dotnet" || baseImageEntrypoint[0] == "/usr/bin/dotnet"))
        {
            baseImageEntrypoint = Array.Empty<string>();
        }
 
        if (string.IsNullOrEmpty(appCommandInstruction))
        {
            if (setsEntrypoint)
            {
                // Backwards-compatibility: before 'AppCommand'/'Cmd' was added, only 'Entrypoint' was available.
                if (!setsCmd && appCommandArgs.Length == 0 && entrypoint.Length == 0)
                {
                    // Copy over the values for starting the application from AppCommand.
                    entrypoint = appCommand;
                    appCommand = Array.Empty<string>();
 
                    // Use EntrypointArgs as cmd.
                    cmd = entrypointArgs;
                    entrypointArgs = Array.Empty<string>();
 
                    if (entrypointArgs.Length > 0)
                    {
                        // Log warning: Instead of ContainerEntrypointArgs, use ContainerAppCommandArgs for arguments that must always be set, or ContainerDefaultArgs for default arguments that the user override when creating the container.
                        logWarning(nameof(Strings.EntrypointArgsSetPreferAppCommandArgs));
                    }
 
                    appCommandInstruction = KnownAppCommandInstructions.None;
                }
                else
                {
                    // There's an Entrypoint. Use DefaultArgs for the AppCommand.
                    appCommandInstruction = KnownAppCommandInstructions.DefaultArgs;
                }
            }
            else
            {
                // Default to use an Entrypoint.
                // If the base image defines an ENTRYPOINT, print a warning.
                if (baseImageEntrypoint.Length > 0)
                {
                    logWarning(nameof(Strings.BaseEntrypointOverwritten));
                }
                appCommandInstruction = KnownAppCommandInstructions.Entrypoint;
            }
        }
 
        if (entrypointArgs.Length > 0 && entrypoint.Length == 0)
        {
            logError(nameof(Strings.EntrypointArgsSetNoEntrypoint), null);
            return (Array.Empty<string>(), Array.Empty<string>());
        }
 
        if (appCommandArgs.Length > 0 && appCommand.Length == 0)
        {
            logError(nameof(Strings.AppCommandArgsSetNoAppCommand), null);
            return (Array.Empty<string>(), Array.Empty<string>());
        }
 
        switch (appCommandInstruction)
        {
            case KnownAppCommandInstructions.None:
                if (appCommand.Length > 0 || appCommandArgs.Length > 0)
                {
                    logError(nameof(Strings.AppCommandSetNotUsed), appCommandInstruction);
                    return (Array.Empty<string>(), Array.Empty<string>());
                }
                break;
            case KnownAppCommandInstructions.DefaultArgs:
                cmd = appCommand.Concat(appCommandArgs).Concat(cmd).ToArray();
                break;
            case KnownAppCommandInstructions.Entrypoint:
                if (setsEntrypoint)
                {
                    logError(nameof(Strings.EntrypointConflictAppCommand), appCommandInstruction);
                    return (Array.Empty<string>(), Array.Empty<string>());
                }
                entrypoint = appCommand;
                entrypointArgs = appCommandArgs;
                break;
            default:
                throw new NotSupportedException(
                    Resource.FormatString(
                        nameof(Strings.UnknownAppCommandInstruction),
                        appCommandInstruction,
                        string.Join(",", KnownAppCommandInstructions.SupportedAppCommandInstructions)));
        }
 
        return (entrypoint.Length > 0 ? entrypoint.Concat(entrypointArgs).ToArray() : baseImageEntrypoint, cmd);
    }
 
    /// <summary>
    /// The APP_UID environment variable is a convention used to set the user in a data-driven manner. we should respect it if it's present.
    /// </summary>
    internal void AssignUserFromEnvironment()
    {
        // it's a common convention to apply custom users with the APP_UID convention - we check and apply that here
        if (_baseImageConfig.EnvironmentVariables.TryGetValue(EnvironmentVariables.APP_UID, out string? appUid))
        {
            _logger.LogTrace("Setting user from APP_UID environment variable");
            SetUser(appUid, isExplicitUserInteraction: false);
        }
    }
 
    /// <summary>
    /// ASP.NET can have urls/ports set via three environment variables - if we see any of them we should create ExposedPorts for them
    /// to ensure tooling can automatically create port mappings.
    /// </summary>
    internal void AssignPortsFromEnvironment()
    {
        // asp.net images control port bindings via three environment variables. we should check for those variables and ensure that ports are created for them.
        // precendence is captured at https://github.com/dotnet/aspnetcore/blob/f49c1c7f7467c184ffb630086afac447772096c6/src/Hosting/Hosting/src/GenericHost/GenericWebHostService.cs#L68-L119
        // ASPNETCORE_URLS is the most specific and is the only one used if present, followed by ASPNETCORE_HTTPS_PORT and ASPNETCORE_HTTP_PORT together
 
        // https://learn.microsoft.com//aspnet/core/fundamentals/host/web-host?view=aspnetcore-8.0#server-urls - the format of ASPNETCORE_URLS has been stable for many years now
        if (_baseImageConfig.EnvironmentVariables.TryGetValue(EnvironmentVariables.ASPNETCORE_URLS, out string? urls))
        {
            foreach (var url in Split(urls))
            {
                _logger.LogTrace("Setting ports from ASPNETCORE_URLS environment variable");
                var match = aspnetPortRegex.Match(url);
                if (match.Success && int.TryParse(match.Groups["port"].Value, out int port))
                {
                    _logger.LogTrace("Added port {port}", port);
                    ExposePort(port, PortType.tcp);
                }
            }
            return; // we're done here - ASPNETCORE_URLS is the most specific and overrides the other two
        }
 
        // port-specific
        // https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-8.0#specify-ports-only - new for .NET 8 - allows just changing port(s) easily
        if (_baseImageConfig.EnvironmentVariables.TryGetValue(EnvironmentVariables.ASPNETCORE_HTTP_PORTS, out string? httpPorts))
        {
            _logger.LogTrace("Setting ports from ASPNETCORE_HTTP_PORTS environment variable");
            foreach (var port in Split(httpPorts))
            {
                if (int.TryParse(port, out int parsedPort))
                {
                    _logger.LogTrace("Added port {port}", parsedPort);
                    ExposePort(parsedPort, PortType.tcp);
                }
                else
                {
                    _logger.LogTrace("Skipped port {port} because it could not be parsed as an integer", port);
                }
            }
        }
 
        if (_baseImageConfig.EnvironmentVariables.TryGetValue(EnvironmentVariables.ASPNETCORE_HTTPS_PORTS, out string? httpsPorts))
        {
            _logger.LogTrace("Setting ports from ASPNETCORE_HTTPS_PORTS environment variable");
            foreach (var port in Split(httpsPorts))
            {
                if (int.TryParse(port, out int parsedPort))
                {
                    _logger.LogTrace("Added port {port}", parsedPort);
                    ExposePort(parsedPort, PortType.tcp);
                }
                else
                {
                    _logger.LogTrace("Skipped port {port} because it could not be parsed as an integer", port);
                }
            }
        }
 
        static string[] Split(string input)
        {
            return input.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        }
    }
 
    internal static class EnvironmentVariables
    {
        public static readonly string APP_UID = nameof(APP_UID);
        public static readonly string ASPNETCORE_URLS = nameof(ASPNETCORE_URLS);
        public static readonly string ASPNETCORE_HTTP_PORTS = nameof(ASPNETCORE_HTTP_PORTS);
        public static readonly string ASPNETCORE_HTTPS_PORTS = nameof(ASPNETCORE_HTTPS_PORTS);
    }
 
}