diff --git a/Microsoft.NET.Build.Containers.IntegrationTests/CapturingLogger.cs b/Microsoft.NET.Build.Containers.IntegrationTests/CapturingLogger.cs index 2cd5a842..fbd26d78 100644 --- a/Microsoft.NET.Build.Containers.IntegrationTests/CapturingLogger.cs +++ b/Microsoft.NET.Build.Containers.IntegrationTests/CapturingLogger.cs @@ -19,6 +19,8 @@ public class CapturingLogger : ILogger private List _errors = new(); public IReadOnlyList Errors {get { return _errors; } } + public List AllMessages => Errors.Select(e => e.Message!).Concat(Warnings.Select(w => w.Message!)).Concat(Messages.Select(m => m.Message!)).ToList(); + public void Initialize(IEventSource eventSource) { eventSource.MessageRaised += (o, e) => _messages.Add(e); diff --git a/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs b/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs index 27af4f26..3e681c8c 100644 --- a/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs +++ b/Microsoft.NET.Build.Containers.IntegrationTests/ParseContainerPropertiesTests.cs @@ -16,12 +16,13 @@ public class ParseContainerPropertiesTests [DockerDaemonAvailableFact] public void Baseline() { - var (project, _) = ProjectInitializer.InitProject(new () { + var (project, _, d) = ProjectInitializer.InitProject(new () { [ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0", [ContainerRegistry] = "localhost:5010", [ContainerImageName] = "dotnet/testimage", [ContainerImageTags] = "7.0;latest" }); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); Assert.True(instance.Build(new[]{ComputeContainerConfig}, null, null, out var outputs)); @@ -37,11 +38,11 @@ public void Baseline() [DockerDaemonAvailableFact] public void SpacesGetReplacedWithDashes() { - var (project, _) = ProjectInitializer.InitProject(new () { + var (project, _, d) = ProjectInitializer.InitProject(new () { [ContainerBaseImage] = "mcr microsoft com/dotnet runtime:7.0", [ContainerRegistry] = "localhost:5010" }); - + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); Assert.True(instance.Build(new[]{ComputeContainerConfig}, null, null, out var outputs)); @@ -53,13 +54,13 @@ public void SpacesGetReplacedWithDashes() [DockerDaemonAvailableFact] public void RegexCatchesInvalidContainerNames() { - var (project, logs) = ProjectInitializer.InitProject(new () { + var (project, logs, d) = ProjectInitializer.InitProject(new () { [ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0", [ContainerRegistry] = "localhost:5010", [ContainerImageName] = "dotnet testimage", [ContainerImageTag] = "5.0" }); - + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); Assert.True(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs)); Assert.Contains(logs.Messages, m => m.Message?.Contains("'ContainerImageName' was not a valid container image name, it was normalized to 'dotnet-testimage'") == true); @@ -68,13 +69,13 @@ public void RegexCatchesInvalidContainerNames() [DockerDaemonAvailableFact] public void RegexCatchesInvalidContainerTags() { - var (project, logs) = ProjectInitializer.InitProject(new () { + var (project, logs, d) = ProjectInitializer.InitProject(new () { [ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0", [ContainerRegistry] = "localhost:5010", [ContainerImageName] = "dotnet/testimage", [ContainerImageTag] = "5 0" }); - + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); Assert.False(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs)); @@ -85,14 +86,14 @@ public void RegexCatchesInvalidContainerTags() [DockerDaemonAvailableFact] public void CanOnlySupplyOneOfTagAndTags() { - var (project, logs) = ProjectInitializer.InitProject(new () { + var (project, logs, d) = ProjectInitializer.InitProject(new () { [ContainerBaseImage] = "mcr.microsoft.com/dotnet/runtime:7.0", [ContainerRegistry] = "localhost:5010", [ContainerImageName] = "dotnet/testimage", [ContainerImageTag] = "5.0", [ContainerImageTags] = "latest;oldest" }); - + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); Assert.False(instance.Build(new[]{ComputeContainerConfig}, new [] { logs }, null, out var outputs)); diff --git a/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs b/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs index a472810c..38f694ae 100644 --- a/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs +++ b/Microsoft.NET.Build.Containers.IntegrationTests/ProjectInitializer.cs @@ -35,17 +35,17 @@ private static string CombineFiles(string propsFile, string targetsFile) return tempTargetLocation; } - public static (Project, CapturingLogger) InitProject(Dictionary bonusProps, [CallerMemberName]string projectName = "") + public static (Project, CapturingLogger, IDisposable) InitProject(Dictionary bonusProps, [CallerMemberName]string projectName = "") { var props = new Dictionary(); // required parameters props["TargetFileName"] = "foo.dll"; props["AssemblyName"] = "foo"; - props["_TargetFrameworkVersionWithoutV"] = "7.0"; + props["TargetFrameworkVersion"] = "v7.0"; props["_NativeExecutableExtension"] = ".exe"; //TODO: windows/unix split here props["Version"] = "1.0.0"; // TODO: need to test non-compliant version strings here props["NetCoreSdkVersion"] = "7.0.100"; // TODO: float this to current SDK? - // test setup parameters so that we can load the props/targets/tasks + // test setup parameters so that we can load the props/targets/tasks props["ContainerCustomTasksAssembly"] = Path.GetFullPath(Path.Combine(".", "Microsoft.NET.Build.Containers.dll")); props["_IsTest"] = "true"; @@ -63,6 +63,8 @@ public static (Project, CapturingLogger) InitProject(Dictionary { props[kvp.Key] = kvp.Value; } - return (collection.LoadProject(_combinedTargetsLocation, props, null), logs); + // derived properties, since these might be set by bonusProps + props["_TargetFrameworkVersionWithoutV"] = props["TargetFrameworkVersion"].TrimStart('v'); + return (collection.LoadProject(_combinedTargetsLocation, props, null), logs, collection); } } diff --git a/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs b/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs index fbdae218..f8c0a8b2 100644 --- a/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs +++ b/Microsoft.NET.Build.Containers.IntegrationTests/TargetsTests.cs @@ -7,6 +7,7 @@ using Xunit; using Microsoft.NET.Build.Containers.IntegrationTests; using Microsoft.NET.Build.Containers.UnitTests; +using System.Linq; namespace Microsoft.NET.Build.Containers.Targets.IntegrationTests; @@ -18,10 +19,11 @@ public class TargetsTests [DockerDaemonAvailableTheory] public void CanSetEntrypointArgsToUseAppHost(bool useAppHost, params string[] entrypointArgs) { - var (project, _) = ProjectInitializer.InitProject(new() + var (project, _, d) = ProjectInitializer.InitProject(new() { [UseAppHost] = useAppHost.ToString() }, projectName: $"{nameof(CanSetEntrypointArgsToUseAppHost)}_{useAppHost}_{String.Join("_", entrypointArgs)}"); + using var _ = d; Assert.True(project.Build(ComputeContainerConfig)); var computedEntrypointArgs = project.GetItems(ContainerEntrypoint).Select(i => i.EvaluatedInclude).ToArray(); foreach (var (First, Second) in entrypointArgs.Zip(computedEntrypointArgs)) @@ -38,10 +40,11 @@ public void CanSetEntrypointArgsToUseAppHost(bool useAppHost, params string[] en [DockerDaemonAvailableTheory] public void CanNormalizeInputContainerNames(string projectName, string expectedContainerImageName, bool shouldPass) { - var (project, _) = ProjectInitializer.InitProject(new() + var (project, _, d) = ProjectInitializer.InitProject(new() { [AssemblyName] = projectName }, projectName: $"{nameof(CanNormalizeInputContainerNames)}_{projectName}_{expectedContainerImageName}_{shouldPass}"); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().Be(shouldPass, "Build should have succeeded"); Assert.Equal(expectedContainerImageName, instance.GetPropertyValue(ContainerImageName)); @@ -56,11 +59,12 @@ public void CanNormalizeInputContainerNames(string projectName, string expectedC [DockerDaemonAvailableTheory] public void CanWarnOnInvalidSDKVersions(string sdkVersion, bool isAllowed) { - var (project, _) = ProjectInitializer.InitProject(new() + var (project, _, d) = ProjectInitializer.InitProject(new() { ["NETCoreSdkVersion"] = sdkVersion, ["PublishProfile"] = "DefaultContainer" }, projectName: $"{nameof(CanWarnOnInvalidSDKVersions)}_{sdkVersion}_{isAllowed}"); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); var derivedIsAllowed = Boolean.Parse(project.GetProperty("_IsSDKContainerAllowedVersion").EvaluatedValue); // var buildResult = instance.Build(new[]{"_ContainerVerifySDKVersion"}, null, null, out var outputs); @@ -72,10 +76,11 @@ public void CanWarnOnInvalidSDKVersions(string sdkVersion, bool isAllowed) [DockerDaemonAvailableTheory] public void GetsConventionalLabelsByDefault(bool shouldEvaluateLabels) { - var (project, _) = ProjectInitializer.InitProject(new() + var (project, _, d) = ProjectInitializer.InitProject(new() { [ContainerGenerateLabels] = shouldEvaluateLabels.ToString() }, projectName: $"{nameof(GetsConventionalLabelsByDefault)}_{shouldEvaluateLabels}"); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().BeTrue("Build should have succeeded"); if (shouldEvaluateLabels) @@ -98,14 +103,15 @@ public void ShouldNotIncludeSourceControlLabelsUnlessUserOptsIn(bool includeSour var commitHash = "abcdef"; var repoUrl = "https://git.cosmere.com/shard/whimsy.git"; - var (project, _) = ProjectInitializer.InitProject(new() + var (project, logger, d) = ProjectInitializer.InitProject(new() { ["PublishRepositoryUrl"] = includeSourceControl.ToString(), ["PrivateRepositoryUrl"] = repoUrl, ["SourceRevisionId"] = commitHash }, projectName: $"{nameof(ShouldNotIncludeSourceControlLabelsUnlessUserOptsIn)}_{includeSourceControl}"); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); - instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().BeTrue("Build should have succeeded"); + instance.Build(new[] { ComputeContainerConfig }, null, null, out var outputs).Should().BeTrue("Build should have succeeded but failed due to {0}", String.Join("\n", logger.AllMessages)); var labels = instance.GetItems(ContainerLabel); if (includeSourceControl) { @@ -121,32 +127,54 @@ public void ShouldNotIncludeSourceControlLabelsUnlessUserOptsIn(bool includeSour }; } - [InlineData("7.0.100", "7.0", "7.0")] - [InlineData("7.0.100-preview.7", "7.0", "7.0")] - [InlineData("7.0.100-rc.1", "7.0", "7.0")] - [InlineData("8.0.100", "8.0", "8.0")] - [InlineData("8.0.100", "7.0", "7.0")] - [InlineData("8.0.100-preview.7", "8.0", "8.0-preview.7")] - [InlineData("8.0.100-rc.1", "8.0", "8.0-rc.1")] - [InlineData("8.0.100-rc.1", "7.0", "7.0")] - [InlineData("8.0.200", "8.0", "8.0")] - [InlineData("8.0.200", "7.0", "7.0")] - [InlineData("8.0.200-preview3", "7.0", "7.0")] - [InlineData("8.0.200-preview3", "8.0", "8.0")] - [InlineData("6.0.100", "6.0", "6.0")] - [InlineData("6.0.100-preview.1", "6.0", "6.0")] + [InlineData("7.0.100", "v7.0", "7.0")] + [InlineData("7.0.100-preview.7", "v7.0", "7.0")] + [InlineData("7.0.100-rc.1", "v7.0", "7.0")] + [InlineData("8.0.100", "v8.0", "8.0")] + [InlineData("8.0.100", "v7.0", "7.0")] + [InlineData("8.0.100-preview.7", "v8.0", "8.0-preview.7")] + [InlineData("8.0.100-rc.1", "v8.0", "8.0-rc.1")] + [InlineData("8.0.100-rc.1", "v7.0", "7.0")] + [InlineData("8.0.200", "v8.0", "8.0")] + [InlineData("8.0.200", "v7.0", "7.0")] + [InlineData("8.0.200-preview3", "v7.0", "7.0")] + [InlineData("8.0.200-preview3", "v8.0", "8.0")] + [InlineData("6.0.100", "v6.0", "6.0")] + [InlineData("6.0.100-preview.1", "v6.0", "6.0")] [Theory] public void CanComputeTagsForSupportedSDKVersions(string sdkVersion, string tfm, string expectedTag) { - var (project, logger) = ProjectInitializer.InitProject(new() + var (project, logger, d) = ProjectInitializer.InitProject(new() { ["NETCoreSdkVersion"] = sdkVersion, - ["_TargetFrameworkVersionWithoutV"] = tfm, + ["TargetFrameworkVersion"] = tfm, ["PublishProfile"] = "DefaultContainer" }, projectName: $"{nameof(CanComputeTagsForSupportedSDKVersions)}_{sdkVersion}_{tfm}_{expectedTag}"); + using var _ = d; var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); instance.Build(new[]{"_ComputeContainerBaseImageTag"}, null, null, out var outputs).Should().BeTrue(String.Join(Environment.NewLine, logger.Errors)); var computedTag = instance.GetProperty("_ContainerBaseImageTag").EvaluatedValue; computedTag.Should().Be(expectedTag); } + + [InlineData("v8.0", "linux-x64", "64198")] + [InlineData("v8.0", "win-x64", "ContainerUser")] + [InlineData("v7.0", "linux-x64", null)] + [InlineData("v7.0", "win-x64", null)] + [InlineData("v9.0", "linux-x64", "64198")] + [InlineData("v9.0", "win-x64", "ContainerUser")] + [Theory] + public void CanComputeContainerUser(string tfm, string rid, string expectedUser) + { + var (project, logger, d) = ProjectInitializer.InitProject(new() + { + ["TargetFrameworkVersion"] = tfm, + ["ContainerRuntimeIdentifier"] = rid + }, projectName: $"{nameof(CanComputeTagsForSupportedSDKVersions)}_{tfm}_{rid}_{expectedUser}"); + using var _ = d; + var instance = project.CreateProjectInstance(global::Microsoft.Build.Execution.ProjectInstanceSettings.None); + instance.Build(new[]{ComputeContainerConfig}, null, null, out var outputs).Should().BeTrue(String.Join(Environment.NewLine, logger.Errors)); + var computedTag = instance.GetProperty("ContainerUser")?.EvaluatedValue; + computedTag.Should().Be(expectedUser); + } } diff --git a/docs/ContainerCustomization.md b/docs/ContainerCustomization.md index 32b3e1ac..a7df052d 100644 --- a/docs/ContainerCustomization.md +++ b/docs/ContainerCustomization.md @@ -207,6 +207,27 @@ ContainerEntrypointArg items have one property: ``` +## ContainerUser + +This item controls the default user that the container will run as. This is often used to run the container as a non-root user, which is a best practice for security. There are a few constraints to know about this field: + +* It can take a variety of forms - user name, linux user ids, group name, linux group id, `username:groupname`, id variants of the above +* There is no verification that the user or group specified exists on the image +* Changing the user can alter the behavior of the application, especially in regards to things like File System permissions + +The default value of this field varies by project TFM and target operating system: + +* if you are targeting .NET 8 or higher and using the Microsoft runtime images, then + * on Linux the rootless user `app` will be used (though it will be referenced by its user id) + * on Windows the rootless user `ContainerUser` will be used +* otherwise no default `ContainerUser` will be used + +```xml + + my-existing-app-user + +``` + ## Default container labels Labels are often used to provide consistent metadata on container images. This package provides some default labels to encourage better maintainability of the generated images, drawn from the set defined as part of the [OCI Image specification](https://github.com/opencontainers/image-spec/blob/main/annotations.md). Where possible, we use the values of common [NuGet Project Properties](https://learn.microsoft.com/en-us/nuget/reference/msbuild-targets#pack-target) as defaults for these annotations, though we also provide more specific properties for each of these labels. diff --git a/packaging/build/Microsoft.NET.Build.Containers.targets b/packaging/build/Microsoft.NET.Build.Containers.targets index fc38da9c..0e9db3b3 100644 --- a/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/packaging/build/Microsoft.NET.Build.Containers.targets @@ -11,6 +11,7 @@ OR $(NETCoreSdkVersion.Contains('-')) == false ) )">true + <_ContainerIsTargetingNet8TFM>$([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '8.0')) @@ -50,7 +51,10 @@ <_ContainerBaseImageName Condition="'$(_ContainerBaseImageName)' == ''">dotnet/runtime - $(_ContainerBaseRegistry)/$(_ContainerBaseImageName):$(_ContainerBaseImageTag) + <_ContainerIsUsingMicrosoftDefaultImages Condition="'$(ContainerBaseImage)' != ''">false + <_ContainerIsUsingMicrosoftDefaultImages Condition="'$(ContainerBaseImage)' == ''">true + + $(_ContainerBaseRegistry)/$(_ContainerBaseImageName):$(_ContainerBaseImageTag) @@ -83,20 +87,29 @@ For builds that have a RID, we default to that RID. Otherwise, we default to the RID of the currently-executing SDK. --> $(RuntimeIdentifier) $(NETCoreSdkPortableRuntimeIdentifier) + <_ContainerIsTargetingWindows>false + <_ContainerIsTargetingWindows Condition="$(ContainerRuntimeIdentifier.StartsWith('win'))">true - /app - C:\app + /app + C:\app - - + + + + + ContainerUser + + 64198 + +