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