diff --git a/README.md b/README.md index 0dca4be..50e945a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ The `--base` option can be used to select the .NET base image. When not specified, Microsoft images from `mcr.microsoft.com` are used. When set to `ubi`, Red Hat images from `registry.access.redhat.com` are used. When set to another value, like `alpine`, the corresponding Microsoft images are used. +You can also use the full image repository names, like `mcr.microsoft.com/dotnet/runtime:6.0-alpine`. The options can also be specified in the .NET project file. ```xml @@ -101,9 +102,12 @@ The options can also be specified in the .NET project file. ... - myapp - ubi + myapp + latest + ubi ``` + +The following properties are supported: `ContainerImageTag`, `ContainerImageTags`, `ContainerImageName`, `ContainerRegistry`, `ContainerBaseImage`, `ContainerWorkingDirectory`, `ContainerImageArchitecture`, `ContainerSdkImage`. diff --git a/src/build-image/BuildCommand.cs b/src/build-image/BuildCommand.cs index 65af9c9..8ec5ca2 100644 --- a/src/build-image/BuildCommand.cs +++ b/src/build-image/BuildCommand.cs @@ -3,13 +3,11 @@ class BuildCommand : RootCommand { - private const string DefaultTag = "dotnet-app"; - public BuildCommand() : base("Build a container image from a .NET project.") { var baseOption = new Option(new[] { "--base", "-b" }, "Flavor of the base image"); - var tagOption = new Option(new[] { "--tag", "-t" }, $"Name for the built image [default: {DefaultTag}]"); + var tagOption = new Option(new[] { "--tag", "-t" }, $"Name for the built image"); var asfileOption = new Option( "--as-file", "Generates a Containerfile with the specified name"); var printOption = new Option("--print", "Print the Containerfile") { Arity = ArgumentArity.Zero }; var pushOption = new Option("--push", "After the build, push the image to the repository") { Arity = ArgumentArity.Zero }; @@ -105,7 +103,7 @@ public static int Handle(IConsole console, string? baseFlavor, string? tag, stri return 1; } - arch ??= projectInformation.ImageArchitecture; + arch ??= projectInformation.ContainerImageArchitecture; if (!TryGetTargetPlatform(arch, out string? targetPlatform)) { console.Error.WriteLine($"Unknown target architecture: {arch}."); @@ -120,10 +118,50 @@ public static int Handle(IConsole console, string? baseFlavor, string? tag, stri sdkVersion = $"{globalJson.SdkVersion.Major}.{globalJson.SdkVersion.Minor}"; } - tag ??= projectInformation.ImageTag ?? DefaultTag; + List tags = new(); + if (tag is not null) + { + tags.Add(tag); + } + else + { + string? name = projectInformation.ContainerImageName; + if (name is null) + { + name = projectInformation.AssemblyName; + if (name.EndsWith(".dll")) + { + name = name.Substring(0, name.Length - 4); + } + } + + if (projectInformation.ContainerRegistry is not null) + { + name = $"{projectInformation.ContainerRegistry}/{name}"; + } + + if (projectInformation.ContainerImageTag is not null && projectInformation.ContainerImageTags is not null) + { + console.Error.WriteLine($"Both ContainerImageTag and ContainerImageTags are specified."); + return 1; + } + if (projectInformation.ContainerImageTags is not null) + { + foreach (var t in projectInformation.ContainerImageTags.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + tags.Add($"{name}:{t}"); + } + } + else + { + string t = projectInformation.ContainerImageTag ?? projectInformation.Version ?? "latest"; + tags.Add($"{name}:{t}"); + } + } + if (asfile is null) { - console.WriteLine($"Building image '{tag}' from project '{projectFile}'."); + console.WriteLine($"Building image {string.Join(", ", tags.Select(t => $"'{t}'"))} from project '{projectFile}'."); } else { @@ -135,14 +173,15 @@ public static int Handle(IConsole console, string? baseFlavor, string? tag, stri { ProjectPath = project, AssemblyName = projectInformation.AssemblyName, - TargetPlatform = targetPlatform + TargetPlatform = targetPlatform, + WorkingDirectory = projectInformation.ContainerWorkingDirectory }; // Build the image. - baseFlavor ??= projectInformation.ImageBase ?? ""; + baseFlavor ??= projectInformation.ContainerBaseImage ?? ""; // TODO: detect is ASP.NET. - FlavorInfo flavorInfo = ImageFlavorDatabase.GetFlavorInfo(baseFlavor, dotnetVersion, sdkVersion); - buildOptions.RuntimeImage = flavorInfo.RuntimeImage; + FlavorInfo flavorInfo = ImageFlavorDatabase.GetFlavorInfo(baseFlavor, projectInformation.ContainerSdkImage, dotnetVersion, sdkVersion); + buildOptions.RuntimeImage = flavorInfo.BaseImage; buildOptions.SdkImage = flavorInfo.SdkImage; if (containerEngine is not null) { @@ -160,17 +199,18 @@ public static int Handle(IConsole console, string? baseFlavor, string? tag, stri string containerfileName = asfile ?? "Containerfile." + Path.GetRandomFileName(); File.WriteAllText(containerfileName, containerfileContent); + string firstTag = tags.First(); if (asfile is not null) { if (containerEngine is not null) { console.WriteLine("To build the image, run:"); - console.WriteLine(containerEngine.GetBuildCommandLine(containerfileName, tag, contextDir)); + console.WriteLine(containerEngine.GetBuildCommandLine(containerfileName, firstTag, contextDir)); } return 0; } - bool buildSuccessful = containerEngine!.TryBuild(console, containerfileName, tag, contextDir); + bool buildSuccessful = containerEngine!.TryBuild(console, containerfileName, firstTag, contextDir); File.Delete(containerfileName); if (!buildSuccessful) { @@ -178,17 +218,31 @@ public static int Handle(IConsole console, string? baseFlavor, string? tag, stri return 1; } - if (push) + for (int i = 1; i < tags.Count; i++) { - console.WriteLine($"Pushing image '{tag}' to repository."); - bool pushSuccesful = containerEngine!.TryPush(console, tag); - if (!pushSuccesful) + string t = tags[i]; + bool tagSuccesful = containerEngine!.TryTag(console, firstTag, t); + if (!tagSuccesful) { - console.Error.WriteLine($"Failed to push image."); + console.Error.WriteLine($"Failed to tag image."); return 1; } } + if (push) + { + foreach (var t in tags) + { + console.WriteLine($"Pushing image '{t}' to repository."); + bool pushSuccesful = containerEngine!.TryPush(console, t); + if (!pushSuccesful) + { + console.Error.WriteLine($"Failed to push image."); + return 1; + } + } + } + return 0; } diff --git a/src/build-image/ContainerEngine.cs b/src/build-image/ContainerEngine.cs index f327a35..0180e5d 100644 --- a/src/build-image/ContainerEngine.cs +++ b/src/build-image/ContainerEngine.cs @@ -126,6 +126,17 @@ public bool TryPush(IConsole console, string tag) return command.Run(console) == 0; } + public bool TryTag(IConsole console, string src, string target) + { + ProcessCommand command = new() + { + FileName = Command, + Arguments = { "tag", src, target } + }; + + return command.Run(console) == 0; + } + class ProcessCommand { public string FileName { get; set; } = null!; diff --git a/src/build-image/DotnetContainerfileBuilder.cs b/src/build-image/DotnetContainerfileBuilder.cs index 776181e..05e4b9a 100644 --- a/src/build-image/DotnetContainerfileBuilder.cs +++ b/src/build-image/DotnetContainerfileBuilder.cs @@ -9,6 +9,7 @@ public class DotnetContainerfileBuilderOptions public bool SupportsCacheMount { get; set; } public bool SupportsCacheMountSELinuxRelabling { get; set; } public string? TargetPlatform { get; set; } + public string? WorkingDirectory { get; set; } } class DotnetContainerfileBuilder @@ -19,13 +20,14 @@ public static string BuildFile(DotnetContainerfileBuilderOptions options) const int ContainerGid = 0; const string BuildHomeDir = "/home/build"; const string HomeDir = "/home/app"; - const string AppDir = "/app"; const string TargetRoot = "/rootfs"; string fromImage = options.RuntimeImage ?? throw new ArgumentNullException(nameof(options.RuntimeImage)); string buildImage = options.SdkImage ?? throw new ArgumentNullException(nameof(options.SdkImage)); string projectPath = options.ProjectPath ?? throw new ArgumentNullException(nameof(options.ProjectPath)); string assemblyName = options.AssemblyName ?? throw new ArgumentNullException(nameof(options.AssemblyName)); + string workingDirectory = options.WorkingDirectory ?? "/app"; + string appDir = workingDirectory; var sb = new StringBuilder(); sb.AppendLine($"ARG UID={ContainerUid}"); @@ -52,10 +54,10 @@ public static string BuildFile(DotnetContainerfileBuilderOptions options) string relabel = options.SupportsCacheMountSELinuxRelabling ? ",Z" : ""; string cacheMount = options.SupportsCacheMount ? $"--mount=type=cache,id=nuget,target={BuildHomeDir}/.nuget/packages{relabel} " : ""; sb.AppendLine($"RUN {cacheMount}dotnet restore {projectPath}"); - sb.AppendLine($"RUN {cacheMount}dotnet publish --no-restore -c Release -o {TargetRoot}{AppDir} {projectPath}"); + sb.AppendLine($"RUN {cacheMount}dotnet publish --no-restore -c Release -o {TargetRoot}{appDir} {projectPath}"); // Ensure the application and home directory are owned by uid:gid. - sb.AppendLine($"RUN chgrp -R $GID {TargetRoot}{AppDir} {TargetRoot}{HomeDir} && chmod -R g=u {TargetRoot}{AppDir} {TargetRoot}{HomeDir} && chown -R $UID:$GID {TargetRoot}{AppDir} {TargetRoot}{HomeDir}"); + sb.AppendLine($"RUN chgrp -R $GID {TargetRoot}{appDir} {TargetRoot}{HomeDir} && chmod -R g=u {TargetRoot}{appDir} {TargetRoot}{HomeDir} && chown -R $UID:$GID {TargetRoot}{appDir} {TargetRoot}{HomeDir}"); sb.AppendLine($""); sb.AppendLine($"# Build application image"); @@ -67,8 +69,8 @@ public static string BuildFile(DotnetContainerfileBuilderOptions options) sb.AppendLine($"USER $UID:$GID"); sb.AppendLine("ENV ASPNETCORE_URLS=http://*:8080"); sb.AppendLine($"ENV HOME={HomeDir}"); - sb.AppendLine($"WORKDIR {AppDir}"); - sb.AppendLine($"ENTRYPOINT [\"dotnet\", \"{AppDir}/{assemblyName}\"]"); + sb.AppendLine($"WORKDIR {workingDirectory}"); + sb.AppendLine($"ENTRYPOINT [\"dotnet\", \"{appDir}/{assemblyName}\"]"); return sb.ToString(); } } \ No newline at end of file diff --git a/src/build-image/ImageFlavorDatabase.cs b/src/build-image/ImageFlavorDatabase.cs index 45bc9fe..61df066 100644 --- a/src/build-image/ImageFlavorDatabase.cs +++ b/src/build-image/ImageFlavorDatabase.cs @@ -1,54 +1,99 @@ class FlavorInfo { public string Flavor { get; init; } = null!; - public string RuntimeImage { get; init; } = null!; + public string BaseImage { get; init; } = null!; public string SdkImage { get; init; } = null!; } class ImageFlavorDatabase { - public static FlavorInfo GetFlavorInfo(string flavor, string runtimeVersion, string sdkVersion) + public static FlavorInfo GetFlavorInfo(string baseFlavor, string? sdkFlavor, string runtimeVersion, string sdkVersion) { - string fromImage, buildImage; - if (flavor.StartsWith("ubi")) + return new FlavorInfo() { - string runtimeVersionNoDot = runtimeVersion.Replace(".", ""); - string sdkVersionNoDot = sdkVersion.Replace(".", ""); - string baseOs = flavor; - if (baseOs == "ubi") + Flavor = baseFlavor, + BaseImage = ResolveImage(baseFlavor, runtimeVersion, isSdk: false, out string resolvedFlavor), + SdkImage = ResolveImage(sdkFlavor ?? resolvedFlavor, sdkVersion, isSdk: true, out _) + }; + } + + private static bool IsRepositoryName(string flavor) => flavor.Contains("/"); + private static bool IsShortName(string flavor) => !IsRepositoryName(flavor); + + private static string ResolveImage(string flavor, string version, bool isSdk, out string resolvedFlavor) + { + if (IsRepositoryName(flavor)) + { + if (flavor.Contains("redhat.com")) { - baseOs = "ubi8"; // TODO: switch based on dotnetVersion + // Fall through to short name. + flavor = "ubi"; } - fromImage = $"registry.access.redhat.com/{baseOs}/dotnet-{runtimeVersionNoDot}-runtime"; - buildImage = $"registry.access.redhat.com/{baseOs}/dotnet-{sdkVersionNoDot}"; - } - else - { - string imageTag = runtimeVersion; - string sdkImageTag = sdkVersion; - if (!string.IsNullOrEmpty(flavor)) + else { - imageTag += $"-{flavor}"; - sdkImageTag += $"-{flavor}"; + int colonPos = flavor.IndexOf(':'); - const string ChiseledSuffix = "-chiseled"; - if (sdkImageTag.EndsWith(ChiseledSuffix)) + if (colonPos == -1) // example: flavor = mcr.microsoft.com/dotnet/runtime { - // There are no chiseled SDK images. - sdkImageTag = sdkImageTag.Substring(0, sdkImageTag.Length - ChiseledSuffix.Length); + resolvedFlavor = ""; + return $"{flavor}:{version}"; + } + else // example: flavor = mcr.microsoft.com/dotnet/runtime:6.0-alpine + { + string registryName = flavor.Substring(0, colonPos); + string tag = flavor.Substring(colonPos + 1); + + // strip version from the tag. + while (tag.Length > 0 && (char.IsDigit(tag[0]) || tag[0] == '.')) + { + tag = tag.Substring(1); + } + + resolvedFlavor = tag.StartsWith('-') ? tag.Substring(1) : tag; + + return $"{registryName}:{version}{tag}"; } } - bool isPreview = flavor == "jammy-chiseled" && runtimeVersion == "6.0"; - string fromRegistry = isPreview ? "mcr.microsoft.com/dotnet/nightly/aspnet" : "mcr.microsoft.com/dotnet/aspnet"; - fromImage = $"{fromRegistry}:{imageTag}"; - buildImage = $"mcr.microsoft.com/dotnet/sdk:{sdkImageTag}"; } - return new FlavorInfo() + // flavor is a short name + resolvedFlavor = flavor; + + if (flavor.StartsWith("ubi")) // Red Hat image. { - Flavor = flavor, - RuntimeImage = fromImage, - SdkImage = buildImage - }; + string versionNoDot = version.Replace(".", ""); + + return isSdk ? $"registry.access.redhat.com/{DotNetVersionToRedHatBaseImage(version)}/dotnet-{versionNoDot}" + : $"registry.access.redhat.com/{DotNetVersionToRedHatBaseImage(version)}/dotnet-{versionNoDot}-runtime"; + + static string DotNetVersionToRedHatBaseImage(string version) => version switch + { + _ => "ubi8" + }; + } + else // Microsoft image. + { + const string ChiseledSuffix = "-chiseled"; + if (flavor.EndsWith(ChiseledSuffix) && isSdk) // There are no chiseled SDK images. + { + flavor = flavor.Substring(0, flavor.Length - ChiseledSuffix.Length); + } + + string registryName = isSdk ? "mcr.microsoft.com/dotnet/sdk" : "mcr.microsoft.com/dotnet/aspnet"; + + // jammy-chiseled is preview for .NET 6.0. + if (flavor == "jammy-chiseled" && version == "6.0") + { + registryName = "mcr.microsoft.com/dotnet/nightly/aspnet"; + } + + string tag = version; + if (flavor.Length > 0) + { + tag += $"-{flavor}"; + } + + return $"{registryName}:{tag}"; + } } } \ No newline at end of file diff --git a/src/build-image/ProjectReader.cs b/src/build-image/ProjectReader.cs index 20ee593..6a26618 100644 --- a/src/build-image/ProjectReader.cs +++ b/src/build-image/ProjectReader.cs @@ -4,9 +4,21 @@ class ProjectInformation { public string? DotnetVersion { get; set; } public string? AssemblyName { get; set; } - public string? ImageTag { get; set; } - public string? ImageBase { get; set; } - public string? ImageArchitecture { get; set; } + + public string? Version { get; set; } + + // Match properties of built-in sdk support + // See https://github.com/dotnet/sdk-container-builds/blob/main/docs/ContainerCustomization.md. + public string? ContainerImageTag { get; set; } + public string? ContainerImageName { get; set; } + public string? ContainerRegistry { get; set; } + public string? ContainerBaseImage { get; set; } + public string? ContainerWorkingDirectory { get; set; } + public string? ContainerImageTags { get; set; } + + // Additional properties + public string? ContainerImageArchitecture { get; set; } + public string? ContainerSdkImage { get; set; } } class ProjectReader @@ -32,9 +44,16 @@ public static ProjectInformation ReadProjectInfo(string path) { DotnetVersion = dotnetVersion, AssemblyName = assemblyName, - ImageTag = GetProperty(project, "ImageTag"), - ImageBase = GetProperty(project, "ImageBase"), - ImageArchitecture = GetProperty(project, "ImageArchitecture"), + ContainerImageTag = GetProperty(project, "ContainerImageTag"), + ContainerImageTags = GetProperty(project, "ContainerImageTags"), + ContainerImageName = GetProperty(project, "ContainerImageName"), + ContainerRegistry = GetProperty(project, "ContainerRegistry"), + ContainerBaseImage = GetProperty(project, "ContainerBaseImage"), + ContainerWorkingDirectory = GetProperty(project, "ContainerWorkingDirectory"), + Version = GetProperty(project, "Version"), + + ContainerImageArchitecture = GetProperty(project, "ContainerImageArchitecture"), + ContainerSdkImage = GetProperty(project, "ContainerSdkImage"), }; project.ProjectCollection.UnloadProject(project); diff --git a/tests/BuildImage.Tests/ImageFlavors.cs b/tests/BuildImage.Tests/ImageFlavors.cs index 41127bc..704fc95 100644 --- a/tests/BuildImage.Tests/ImageFlavors.cs +++ b/tests/BuildImage.Tests/ImageFlavors.cs @@ -18,11 +18,13 @@ public class ImageFlavors [InlineData("alpine", "6.0", "mcr.microsoft.com/dotnet/aspnet:6.0-alpine", "6.0", "mcr.microsoft.com/dotnet/sdk:6.0-alpine")] // Microsoft jammy-chiseled [InlineData("jammy-chiseled", "6.0", "mcr.microsoft.com/dotnet/nightly/aspnet:6.0-jammy-chiseled", "6.0", "mcr.microsoft.com/dotnet/sdk:6.0-jammy")] + // Name with repository + [InlineData("some.repository.com/repo/runtime:1.0-alpine", "6.0", "some.repository.com/repo/runtime:6.0-alpine", "6.0", "mcr.microsoft.com/dotnet/sdk:6.0-alpine")] [Theory] public void Flavors(string flavor, string runtimeVersion, string runtimeImage, string sdkVersion, string sdkImage) { - var flavorInfo = ImageFlavorDatabase.GetFlavorInfo(flavor, runtimeVersion, sdkVersion); - Assert.Equal(flavorInfo.RuntimeImage, runtimeImage); - Assert.Equal(flavorInfo.SdkImage, sdkImage); + var flavorInfo = ImageFlavorDatabase.GetFlavorInfo(flavor, null, runtimeVersion, sdkVersion); + Assert.Equal(runtimeImage, flavorInfo.BaseImage); + Assert.Equal(sdkImage, flavorInfo.SdkImage); } } \ No newline at end of file