Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Match sdk-container-builds properties. #32

Merged
merged 1 commit into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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