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

Extend Layer Packaging for Windows Containers #343

Merged
merged 2 commits into from
Mar 4, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task ApiEndToEndWithRegistryPushAndPull()

Assert.NotNull(imageBuilder);

Layer l = Layer.FromDirectory(publishDirectory, "/app");
Layer l = Layer.FromDirectory(publishDirectory, "/app", false);

imageBuilder.AddLayer(l);

Expand Down Expand Up @@ -90,7 +90,7 @@ public async Task ApiEndToEndWithLocalLoad()
cancellationToken: default).ConfigureAwait(false);
Assert.NotNull(imageBuilder);

Layer l = Layer.FromDirectory(publishDirectory, "/app");
Layer l = Layer.FromDirectory(publishDirectory, "/app", false);

imageBuilder.AddLayer(l);

Expand Down Expand Up @@ -293,7 +293,7 @@ public async Task CanPackageForAllSupportedContainerRIDs(string dockerPlatform,
cancellationToken: default).ConfigureAwait(false);
Assert.NotNull(imageBuilder);

Layer l = Layer.FromDirectory(publishDirectory, "/app");
Layer l = Layer.FromDirectory(publishDirectory, "/app", false);

imageBuilder.AddLayer(l);
imageBuilder.SetWorkingDirectory(workingDir);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void SingleFileInFolder()

File.WriteAllText(testFilePath, testString);

Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "/app");
Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "/app", false);

Console.WriteLine(l.Descriptor);

Expand All @@ -42,9 +42,37 @@ public void SingleFileInFolder()
VerifyDescriptorInfo(l);

var allEntries = LoadAllTarEntries(l.BackingFile);
Assert.True(allEntries.TryGetValue("app/", out var appEntryType) && appEntryType == TarEntryType.Directory, "Missing app directory entry");
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntryType) && fileEntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
Assert.True(allEntries.TryGetValue("app/", out var appEntry) && appEntry.EntryType == TarEntryType.Directory, "Missing app directory entry");
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntry) && fileEntry.EntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
}

[Fact]
public void SingleFileInFolderWindows()
{
using TransientTestFolder folder = new();

string testFilePath = Path.Join(folder.Path, "TestFile.txt");
string testString = $"Test content for {nameof(SingleFileInFolder)}";

File.WriteAllText(testFilePath, testString);

Layer l = Layer.FromDirectory(directory: folder.Path, containerPath: "C:\\app", true);

var allEntries = LoadAllTarEntries(l.BackingFile);
Assert.True(allEntries.TryGetValue("Files/", out var filesEntry) && filesEntry.EntryType == TarEntryType.Directory, "Missing Files/ directory entry");
Assert.True(allEntries.TryGetValue("Files/app/", out var appEntry) && appEntry.EntryType == TarEntryType.Directory, "Missing Files/app/ directory entry");
Assert.True(allEntries.TryGetValue("Files/app/TestFile.txt", out var fileEntry) && fileEntry.EntryType == TarEntryType.RegularFile, "Missing Files/app/TestFile.txt file entry");
Assert.True(allEntries.TryGetValue("Hives/", out var hivesEntry) && hivesEntry.EntryType == TarEntryType.Directory, "Missing Hives/ directory entry");

// Enable after https://github.com/dotnet/runtime/issues/81699 is resolved
// foreach (var entry in allEntries.Values)
// {
// Assert.IsInstanceOfType(entry, typeof(PaxTarEntry));
// var pax = (PaxTarEntry)entry;
// Assert.IsTrue(pax.ExtendedAttributes.ContainsKey("MSWINDOWS.rawsd"),
// "Missing MSWINDOWS.rawsd definition for " + entry.Name);
// }
}

[Fact]
public void TwoFilesInTwoFolders()
Expand All @@ -64,7 +92,7 @@ public void TwoFilesInTwoFolders()
{
(testFilePath, "/app/TestFile.txt"),
(testFilePath2, "/app/subfolder/TestFile.txt"),
});
}, false);

Console.WriteLine(l.Descriptor);

Expand All @@ -75,10 +103,10 @@ public void TwoFilesInTwoFolders()
VerifyDescriptorInfo(l);

var allEntries = LoadAllTarEntries(l.BackingFile);
Assert.True(allEntries.TryGetValue("app/", out var appEntryType) && appEntryType == TarEntryType.Directory, "Missing app directory entry");
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntryType) && fileEntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
Assert.True(allEntries.TryGetValue("app/subfolder/", out var subfolderType) && subfolderType == TarEntryType.Directory, "Missing subfolder directory entry");
Assert.True(allEntries.TryGetValue("app/subfolder/TestFile.txt", out var subfolderFileEntryType) && subfolderFileEntryType == TarEntryType.RegularFile, "Missing subfolder/TestFile.txt file entry");
Assert.True(allEntries.TryGetValue("app/", out var appEntry) && appEntry.EntryType == TarEntryType.Directory, "Missing app directory entry");
Assert.True(allEntries.TryGetValue("app/TestFile.txt", out var fileEntry) && fileEntry.EntryType == TarEntryType.RegularFile, "Missing TestFile.txt file entry");
Assert.True(allEntries.TryGetValue("app/subfolder/", out var subfolderEntry) && subfolderEntry.EntryType == TarEntryType.Directory, "Missing subfolder directory entry");
Assert.True(allEntries.TryGetValue("app/subfolder/TestFile.txt", out var subfolderFileEntry) && subfolderFileEntry.EntryType == TarEntryType.RegularFile, "Missing subfolder/TestFile.txt file entry");
}

private static void VerifyDescriptorInfo(Layer l)
Expand Down Expand Up @@ -115,18 +143,19 @@ public void Dispose()
ContentStore.ArtifactRoot = priorArtifactRoot;
}
}

private static Dictionary<string, TarEntryType> LoadAllTarEntries(string file)


private static IDictionary<string, TarEntry> LoadAllTarEntries(string file)
{
using var gzip = new GZipStream(File.OpenRead(file), CompressionMode.Decompress);
using var tar = new TarReader(gzip);

var entries = new Dictionary<string, TarEntryType>();

var entries = new Dictionary<string, TarEntry>();
TarEntry? entry;
while ((entry = tar.GetNextEntry()) != null)
{
entries[entry.Name] = entry.EntryType;
entries[entry.Name] = entry;
}

return entries;
Expand Down
2 changes: 1 addition & 1 deletion Microsoft.NET.Build.Containers/ContainerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public static async Task ContainerizeAsync(
WriteIndented = true,
};

Layer l = Layer.FromDirectory(folder.FullName, workingDir);
Layer l = Layer.FromDirectory(folder.FullName, workingDir, imageBuilder.IsWindows);

imageBuilder.AddLayer(l);

Expand Down
5 changes: 5 additions & 0 deletions Microsoft.NET.Build.Containers/ImageBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ internal ImageBuilder(ManifestV2 manifest, ImageConfig baseImageConfig)
_baseImageConfig = baseImageConfig;
}

/// <summary>
/// Gets a value indicating whether the base image is has a Windows operating system.
/// </summary>
public bool IsWindows => _baseImageConfig.IsWindows;

/// <summary>
/// Builds the image configuration <see cref="BuiltImage"/> ready for further processing.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions Microsoft.NET.Build.Containers/ImageConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ internal sealed class ImageConfig
private readonly string _os;
private readonly List<HistoryEntry> _history;

/// <summary>
/// Gets a value indicating whether the base image is has a Windows operating system.
/// </summary>
public bool IsWindows => "windows".Equals(_os, StringComparison.OrdinalIgnoreCase);

internal ImageConfig(string imageConfigJson) : this(JsonNode.Parse(imageConfigJson)!)
{
}
Expand Down
123 changes: 95 additions & 28 deletions Microsoft.NET.Build.Containers/Layer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ namespace Microsoft.NET.Build.Containers;

internal record struct Layer
{
// NOTE: The SID string below was created using the following snippet. As the code is Windows only we keep the constant
// private static string CreateUserOwnerAndGroupSID()
// {
// var descriptor = new RawSecurityDescriptor(
// ControlFlags.SelfRelative,
// new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null),
// new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null),
// null,
// null
// );
//
// var raw = new byte[descriptor.BinaryLength];
// descriptor.GetBinaryForm(raw, 0);
// return Convert.ToBase64String(raw);
// }

private const string BuiltinUsersSecurityDescriptor = "AQAAgBQAAAAkAAAAAAAAAAAAAAABAgAAAAAABSAAAAAhAgAAAQIAAAAAAAUgAAAAIQIAAA==";

public Descriptor Descriptor { get; private set; }

public string BackingFile { get; private set; }
Expand All @@ -25,7 +43,7 @@ public static Layer FromDescriptor(Descriptor descriptor)
};
}

public static Layer FromDirectory(string directory, string containerPath)
public static Layer FromDirectory(string directory, string containerPath, bool isWindowsLayer)
{
var fileList =
new DirectoryInfo(directory)
Expand All @@ -35,16 +53,75 @@ public static Layer FromDirectory(string directory, string containerPath)
string destinationPath = Path.Join(containerPath, Path.GetRelativePath(directory, fsi.FullName)).Replace(Path.DirectorySeparatorChar, '/');
return (fsi.FullName, destinationPath);
});
return FromFiles(fileList);
return FromFiles(fileList, isWindowsLayer);
}

public static Layer FromFiles(IEnumerable<(string path, string containerPath)> fileList)
public static Layer FromFiles(IEnumerable<(string path, string containerPath)> fileList, bool isWindowsLayer)
{
long fileSize;
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
Span<byte> uncompressedHash = stackalloc byte[SHA256.HashSizeInBytes];

// this factory helps us creating the Tar entries with the right attributes
PaxTarEntry CreateTarEntry(TarEntryType entryType, string containerPath)
{
var extendedAttributes = new Dictionary<string, string>();
if (isWindowsLayer)
{
// We grant all users access to the application directory
// https://github.com/buildpacks/rfcs/blob/main/text/0076-windows-security-identifiers.md
extendedAttributes["MSWINDOWS.rawsd"] = BuiltinUsersSecurityDescriptor;
return new PaxTarEntry(entryType, containerPath, extendedAttributes);
}

var entry = new PaxTarEntry(entryType, containerPath, extendedAttributes)
{
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute
};
return entry;
}

string SanitizeContainerPath(string containerPath)
{
// no leading slashes
containerPath = containerPath.TrimStart(PathSeparators);

// For Windows layers we need to put files into a "Files" directory without drive letter.
if (isWindowsLayer)
{
// Cut of drive letter: /* C:\ */
if (containerPath[1] == ':')
{
containerPath = containerPath[3..];
}

containerPath = "Files/" + containerPath;
}

return containerPath;
}

// Ensures that all directory entries for the given segments are created within the tar.
var directoryEntries = new HashSet<string>();
void EnsureDirectoryEntries(TarWriter tar,
IReadOnlyList<string> filePathSegments)
{
var pathBuilder = new StringBuilder();
for (int i = 0; i < filePathSegments.Count - 1; i++)
{
pathBuilder.Append(CultureInfo.InvariantCulture, $"{filePathSegments[i]}/");

string fullPath = pathBuilder.ToString();
if (!directoryEntries.Contains(fullPath))
{
tar.WriteEntry(CreateTarEntry(TarEntryType.Directory, fullPath));
directoryEntries.Add(fullPath);
}
}
}


string tempTarballPath = ContentStore.GetTempFile();
using (FileStream fs = File.Create(tempTarballPath))
Expand All @@ -57,12 +134,24 @@ public static Layer FromFiles(IEnumerable<(string path, string containerPath)> f
{
// Docker treats a COPY instruction that copies to a path like `/app` by
// including `app/` as a directory, with no leading slash. Emulate that here.
string containerPath = item.containerPath.TrimStart(PathSeparators);
string containerPath = SanitizeContainerPath(item.containerPath);

EnsureDirectoryEntries(writer, containerPath.Split(PathSeparators));

EnsureDirectoryEntries(writer, directoryEntries, containerPath.Split(PathSeparators));
using var fileStream = File.OpenRead(item.path);
var entry = CreateTarEntry(TarEntryType.RegularFile, containerPath);
entry.DataStream = fileStream;

writer.WriteEntry(item.path, containerPath);
writer.WriteEntry(entry);
}

// Windows layers need a Hives folder, we do not need to create any Registry Hive deltas inside
if (isWindowsLayer)
{
var entry = CreateTarEntry(TarEntryType.Directory, "Hives/");
writer.WriteEntry(entry);
}

} // Dispose of the TarWriter before getting the hash so the final data get written to the tar stream

int bytesWritten = gz.GetCurrentUncompressedHash(uncompressedHash);
Expand Down Expand Up @@ -104,28 +193,6 @@ public static Layer FromFiles(IEnumerable<(string path, string containerPath)> f

private readonly static char[] PathSeparators = new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar };

/// <summary>
/// Ensures that all directory entries for the given segments are created within the tar.
/// </summary>
/// <param name="tar">The tar into which to add the directory entries.</param>
/// <param name="directoryEntries">The lookup of all known directory entries. </param>
/// <param name="filePathSegments">The segments of the file within the tar for which to create the folders</param>
private static void EnsureDirectoryEntries(TarWriter tar, HashSet<string> directoryEntries, IReadOnlyList<string> filePathSegments)
{
var pathBuilder = new StringBuilder();
for (var i = 0; i < filePathSegments.Count - 1; i++)
{
pathBuilder.Append(CultureInfo.InvariantCulture, $"{filePathSegments[i]}/");

var fullPath = pathBuilder.ToString();
if (!directoryEntries.Contains(fullPath))
{
tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, fullPath));
directoryEntries.Add(fullPath);
}
}
}

/// <summary>
/// A stream capable of computing the hash digest of raw uncompressed data while also compressing it.
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ internal async Task<bool> ExecuteAsync(CancellationToken cancellationToken)

SafeLog("Building image '{0}' with tags {1} on top of base image {2}", ImageName, String.Join(",", ImageTags), sourceImageReference);

Layer newLayer = Layer.FromDirectory(PublishDirectory, WorkingDirectory);
Layer newLayer = Layer.FromDirectory(PublishDirectory, WorkingDirectory, imageBuilder.IsWindows);
imageBuilder.AddLayer(newLayer);
imageBuilder.SetWorkingDirectory(WorkingDirectory);
imageBuilder.SetEntryPoint(Entrypoint.Select(i => i.ItemSpec).ToArray(), EntrypointArgs.Select(i => i.ItemSpec).ToArray());
Expand Down
15 changes: 9 additions & 6 deletions packaging/build/Microsoft.NET.Build.Containers.targets
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,22 @@
<ContainerImageTag Condition="'$(ContainerImageTag)' == '' and '$(ContainerImageTags)' == ''">$(Version)</ContainerImageTag>
<ContainerImageTag Condition="'$(AutoGenerateImageTag)' == 'true' and '$(ContainerImageTags)' == ''">$([System.DateTime]::UtcNow.ToString('yyyyMMddhhmmss'))</ContainerImageTag>

<ContainerWorkingDirectory Condition="'$(ContainerWorkingDirectory)' == ''">/app</ContainerWorkingDirectory>

<!-- The Container RID should default to the RID used for the entire build (to ensure things run on the platform they are built for), but the user knows best and so should be able to set it explicitly.
For builds that have a RID, we default to that RID. Otherwise, we default to the RID of the currently-executing SDK. -->
<ContainerRuntimeIdentifier Condition="'$(ContainerRuntimeIdentifier)' == '' and '$(IsRidAgnostic)' != 'true'">$(RuntimeIdentifier)</ContainerRuntimeIdentifier>
<ContainerRuntimeIdentifier Condition="'$(ContainerRuntimeIdentifier)' == '' ">$(NETCoreSdkPortableRuntimeIdentifier)</ContainerRuntimeIdentifier>

<!-- Set the WorkingDirectory depending on the RID -->
<ContainerWorkingDirectory Condition="'$(ContainerWorkingDirectory)' == '' and !$(ContainerRuntimeIdentifier.StartsWith('win')) ">/app</ContainerWorkingDirectory>
<ContainerWorkingDirectory Condition="'$(ContainerWorkingDirectory)' == '' and $(ContainerRuntimeIdentifier.StartsWith('win')) ">C:\app</ContainerWorkingDirectory>
</PropertyGroup>

<ItemGroup Label="Entrypoint Assignment">
<!-- For non-apphosts, we need to invoke `dotnet` `workingdir/app` as separate args -->
<ContainerEntrypoint Condition="'$(ContainerEntrypoint)' == '' and '$(UseAppHost)' != 'true'" Include="dotnet;$(ContainerWorkingDirectory)/$(TargetFileName)" />
<!-- For apphosts, we need to invoke `workingdir/app` as a single arg -->
<ContainerEntrypoint Condition="'$(ContainerEntrypoint)' == '' and '$(UseAppHost)' == 'true'" Include="$(ContainerWorkingDirectory)/$(AssemblyName)$(_NativeExecutableExtension)" />
<!-- For non-apphosts, we need to invoke `dotnet` `app` as separate args -->
<ContainerEntrypoint Condition="'$(ContainerEntrypoint)' == '' and '$(UseAppHost)' != 'true'" Include="dotnet;$(TargetFileName)" />
<!-- For apphosts, we need to invoke `app` as a single arg -->
<ContainerEntrypoint Condition="'$(ContainerEntrypoint)' == '' and '$(UseAppHost)' == 'true' and !$(ContainerRuntimeIdentifier.StartsWith('win'))" Include="$(ContainerWorkingDirectory)/$(AssemblyName)$(_NativeExecutableExtension)" />
<ContainerEntrypoint Condition="'$(ContainerEntrypoint)' == '' and '$(UseAppHost)' == 'true' and $(ContainerRuntimeIdentifier.StartsWith('win'))" Include="$(AssemblyName)$(_NativeExecutableExtension)" />
</ItemGroup>

<ParseContainerProperties FullyQualifiedBaseImageName="$(ContainerBaseImage)"
Expand Down