Skip to content

Commit

Permalink
Match sdk-container-builds properties.
Browse files Browse the repository at this point in the history
  • Loading branch information
tmds committed Oct 28, 2022
1 parent 11eb369 commit 2d05390
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 65 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,20 @@ 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
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
...
<ImageTag>myapp</ImageTag>
<ImageBase>ubi</ImageBase>
<ContainerImageName>myapp</ContainerImageName>
<ContainerImageTag>latest</ContainerImageTag>
<ContainerBaseImage>ubi</ContainerBaseImage>
</PropertyGroup>

</Project>
```

The following properties are supported: `ContainerImageTag`, `ContainerImageTags`, `ContainerImageName`, `ContainerRegistry`, `ContainerBaseImage`, `ContainerWorkingDirectory`, `ContainerImageArchitecture`, `ContainerSdkImage`.
88 changes: 71 additions & 17 deletions src/build-image/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(new[] { "--base", "-b" }, "Flavor of the base image");
var tagOption = new Option<string>(new[] { "--tag", "-t" }, $"Name for the built image [default: {DefaultTag}]");
var tagOption = new Option<string>(new[] { "--tag", "-t" }, $"Name for the built image");
var asfileOption = new Option<string>( "--as-file", "Generates a Containerfile with the specified name");
var printOption = new Option<bool>("--print", "Print the Containerfile") { Arity = ArgumentArity.Zero };
var pushOption = new Option<bool>("--push", "After the build, push the image to the repository") { Arity = ArgumentArity.Zero };
Expand Down Expand Up @@ -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}.");
Expand All @@ -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<string> 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
{
Expand All @@ -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)
{
Expand All @@ -160,35 +199,50 @@ 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)
{
console.Error.WriteLine($"Failed to build image.");
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;
}

Expand Down
11 changes: 11 additions & 0 deletions src/build-image/ContainerEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down
12 changes: 7 additions & 5 deletions src/build-image/DotnetContainerfileBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}");
Expand All @@ -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");
Expand All @@ -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();
}
}
109 changes: 77 additions & 32 deletions src/build-image/ImageFlavorDatabase.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
}
}
Loading

0 comments on commit 2d05390

Please sign in to comment.