diff --git a/Microsoft.NET.Build.Containers/ContainerHelpers.cs b/Microsoft.NET.Build.Containers/ContainerHelpers.cs index fc593200..046e02fa 100644 --- a/Microsoft.NET.Build.Containers/ContainerHelpers.cs +++ b/Microsoft.NET.Build.Containers/ContainerHelpers.cs @@ -9,6 +9,11 @@ public static class ContainerHelpers private static Regex imageNameRegex = new Regex("^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*$"); + /// + /// Matches if the string is not lowercase or numeric, or ., _, or -. + /// + private static Regex imageNameCharacters = new Regex("[^a-zA-Z0-9._-]"); + /// /// Given some "fully qualified" image name (e.g. mcr.microsoft.com/dotnet/runtime), return /// a valid UriBuilder. This means appending 'https' if the URI is not absolute, otherwise UriBuilder will throw. @@ -39,8 +44,8 @@ public static bool IsValidRegistry(string registryName) { // No scheme prefixed onto the registry if (string.IsNullOrEmpty(registryName) || - (!registryName.StartsWith("http://") && - !registryName.StartsWith("https://") && + (!registryName.StartsWith("http://") && + !registryName.StartsWith("https://") && !registryName.StartsWith("docker://"))) { return false; @@ -89,9 +94,9 @@ public static bool IsValidImageTag(string imageTag) /// /// /// True if the parse was successful. When false is returned, all out vars are set to empty strings. - public static bool TryParseFullyQualifiedContainerName(string fullyQualifiedContainerName, - [NotNullWhen(true)] out string? containerRegistry, - [NotNullWhen(true)] out string? containerName, + public static bool TryParseFullyQualifiedContainerName(string fullyQualifiedContainerName, + [NotNullWhen(true)] out string? containerRegistry, + [NotNullWhen(true)] out string? containerName, [NotNullWhen(true)] out string? containerTag) { Uri? uri = ContainerImageToUri(fullyQualifiedContainerName); @@ -115,4 +120,27 @@ public static bool TryParseFullyQualifiedContainerName(string fullyQualifiedCont containerTag = indexOfColon == -1 ? "" : image.Substring(indexOfColon + 1); return true; } + + /// + /// Checks if a given container image name adheres to the image name spec. If not, and recoverable, then normalizes invalid characters. + /// + public static bool NormalizeImageName(string containerImageName, [NotNullWhen(false)] out string? normalizedImageName) + { + if (IsValidImageName(containerImageName)) + { + normalizedImageName = null; + return true; + } + else + { + if (Char.IsUpper(containerImageName, 0)) + { + containerImageName = Char.ToLowerInvariant(containerImageName[0]) + containerImageName[1..]; + } else if (!Char.IsLetterOrDigit(containerImageName, 0)) { + throw new ArgumentException("The first character of the image name must be a lowercase letter or a digit."); + } + normalizedImageName = imageNameCharacters.Replace(containerImageName, "-"); + return false; + } + } } diff --git a/Microsoft.NET.Build.Containers/CreateNewImage.cs b/Microsoft.NET.Build.Containers/CreateNewImage.cs index 42c625bf..718238b8 100644 --- a/Microsoft.NET.Build.Containers/CreateNewImage.cs +++ b/Microsoft.NET.Build.Containers/CreateNewImage.cs @@ -109,11 +109,20 @@ public override bool Execute() Layer newLayer = Layer.FromDirectory(PublishDirectory, WorkingDirectory); image.AddLayer(newLayer); + image.WorkingDirectory = WorkingDirectory; image.SetEntrypoint(Entrypoint.Select(i => i.ItemSpec).ToArray(), EntrypointArgs.Select(i => i.ItemSpec).ToArray()); if (OutputRegistry.StartsWith("docker://")) { - LocalDocker.Load(image, ImageName, ImageTag, BaseImageName).Wait(); + try + { + LocalDocker.Load(image, ImageName, ImageTag, BaseImageName).Wait(); + } + catch (AggregateException ex) when (ex.InnerException is DockerLoadException dle) + { + Log.LogErrorFromException(dle, showStackTrace: false); + return !Log.HasLoggedErrors; + } } else { diff --git a/Microsoft.NET.Build.Containers/DockerLoadException.cs b/Microsoft.NET.Build.Containers/DockerLoadException.cs new file mode 100644 index 00000000..fb08518a --- /dev/null +++ b/Microsoft.NET.Build.Containers/DockerLoadException.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace Microsoft.NET.Build.Containers +{ + public class DockerLoadException : Exception + { + public DockerLoadException() + { + } + + public DockerLoadException(string? message) : base(message) + { + } + + public DockerLoadException(string? message, Exception? innerException) : base(message, innerException) + { + } + + protected DockerLoadException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } +} \ No newline at end of file diff --git a/Microsoft.NET.Build.Containers/Layer.cs b/Microsoft.NET.Build.Containers/Layer.cs index 62cfe08c..3d35cb68 100644 --- a/Microsoft.NET.Build.Containers/Layer.cs +++ b/Microsoft.NET.Build.Containers/Layer.cs @@ -11,20 +11,14 @@ public record struct Layer public static Layer FromDirectory(string directory, string containerPath) { - DirectoryInfo di = new(directory); - - IEnumerable<(string path, string containerPath)> fileList = - di.GetFileSystemInfos() - .Where(fsi => fsi is FileInfo).Select( - fsi => - { - string destinationPath = - Path.Join(containerPath, - Path.GetRelativePath(directory, fsi.FullName)) - .Replace(Path.DirectorySeparatorChar, '/'); - return (fsi.FullName, destinationPath); - }); - + var fileList = + new DirectoryInfo(directory) + .EnumerateFiles("*", SearchOption.AllDirectories) + .Select(fsi => + { + string destinationPath = Path.Join(containerPath, Path.GetRelativePath(directory, fsi.FullName)).Replace(Path.DirectorySeparatorChar, '/'); + return (fsi.FullName, destinationPath); + }); return FromFiles(fileList); } diff --git a/Microsoft.NET.Build.Containers/LocalDocker.cs b/Microsoft.NET.Build.Containers/LocalDocker.cs index a4d0c0fd..d2a4d47b 100644 --- a/Microsoft.NET.Build.Containers/LocalDocker.cs +++ b/Microsoft.NET.Build.Containers/LocalDocker.cs @@ -14,6 +14,7 @@ public static async Task Load(Image x, string name, string tag, string baseName) ProcessStartInfo loadInfo = new("docker", $"load"); loadInfo.RedirectStandardInput = true; loadInfo.RedirectStandardOutput = true; + loadInfo.RedirectStandardError = true; using Process? loadProcess = Process.Start(loadInfo); @@ -29,6 +30,11 @@ public static async Task Load(Image x, string name, string tag, string baseName) loadProcess.StandardInput.Close(); await loadProcess.WaitForExitAsync(); + + if (loadProcess.ExitCode != 0) + { + throw new DockerLoadException($"Failed to load image to local Docker daemon. stdout: {await loadProcess.StandardError.ReadToEndAsync()}"); + } } public static async Task WriteImageToStream(Image x, string name, string tag, Stream imageStream) diff --git a/Microsoft.NET.Build.Containers/ParseContainerProperties.cs b/Microsoft.NET.Build.Containers/ParseContainerProperties.cs index 49677333..47487e54 100644 --- a/Microsoft.NET.Build.Containers/ParseContainerProperties.cs +++ b/Microsoft.NET.Build.Containers/ParseContainerProperties.cs @@ -62,11 +62,6 @@ public ParseContainerProperties() public override bool Execute() { - if (!ContainerHelpers.IsValidImageName(ContainerImageName)) - { - Log.LogError($"Invalid {nameof(ContainerImageName)}: {0}", ContainerImageName); - return !Log.HasLoggedErrors; - } if (!string.IsNullOrEmpty(ContainerImageTag) && !ContainerHelpers.IsValidImageTag(ContainerImageTag)) { @@ -106,11 +101,29 @@ public override bool Execute() return !Log.HasLoggedErrors; } + try + { + if (!ContainerHelpers.NormalizeImageName(ContainerImageName, out string? normalizedImageName)) + { + Log.LogWarning(null, "CONTAINER001", null, null, 0, 0, 0, 0, $"{nameof(ContainerImageName)} was not a valid container image name, it was normalized to {normalizedImageName}"); + NewContainerImageName = normalizedImageName; + } + else + { + // name was valid already + NewContainerImageName = ContainerImageName; + } + } + catch (ArgumentException) + { + Log.LogError($"Invalid {nameof(ContainerImageName)}: {{0}}", ContainerImageName); + return !Log.HasLoggedErrors; + } + ParsedContainerRegistry = outputReg; ParsedContainerImage = outputImage; ParsedContainerTag = outputTag; NewContainerRegistry = registryToUse; - NewContainerImageName = ContainerImageName; NewContainerTag = ContainerImageTag; if (BuildEngine != null) diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/TargetsTests.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/TargetsTests.cs index 455688e7..89412aab 100644 --- a/Test.Microsoft.NET.Build.Containers.Filesystem/TargetsTests.cs +++ b/Test.Microsoft.NET.Build.Containers.Filesystem/TargetsTests.cs @@ -76,12 +76,26 @@ public void CanSetEntrypointArgsToUseAppHost(bool useAppHost, params string[] en ["UseAppHost"] = useAppHost.ToString() }); Assert.IsTrue(project.Build("ComputeContainerConfig")); + var computedEntrypointArgs = project.GetItems("ContainerEntrypoint").Select(i => i.EvaluatedInclude).ToArray(); + foreach (var (First, Second) in entrypointArgs.Zip(computedEntrypointArgs)) { - var computedEntrypointArgs = project.GetItems("ContainerEntrypoint").Select(i => i.EvaluatedInclude).ToArray(); - foreach (var (First, Second) in entrypointArgs.Zip(computedEntrypointArgs)) - { - Assert.AreEqual(First, Second); - } + Assert.AreEqual(First, Second); } } + + [DataRow("WebApplication44", "webApplication44", true)] + [DataRow("friendly-suspicious-alligator", "friendly-suspicious-alligator", true)] + [DataRow("*friendly-suspicious-alligator", "", false)] + [DataRow("web/app2+7", "web-app2-7", true)] + [TestMethod] + public void CanNormalizeInputContainerNames(string projectName, string expectedContainerImageName, bool shouldPass) + { + var project = InitProject(new() + { + ["AssemblyName"] = projectName + }); + var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); + Assert.AreEqual(shouldPass, instance.Build(new[]{"ComputeContainerConfig"}, null, null, out var outputs), "Build should have succeeded"); + Assert.AreEqual(expectedContainerImageName, instance.GetPropertyValue("ContainerImageName")); + } } \ No newline at end of file diff --git a/version.json b/version.json index db709d94..6b9c7322 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.1-alpha.{height}", + "version": "0.2-alpha.{height}", + "versionHeightOffset": -1, "nugetPackageVersion": { "semVer": 2 },