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

Fix issues pushing images to Amazon ECR #279

Merged
merged 3 commits into from
Jan 10, 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
37 changes: 37 additions & 0 deletions Microsoft.NET.Build.Containers/AmazonECRMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Microsoft.NET.Build.Containers;

/// <summary>
/// A delegating handler that handles the special error handling needed for Amazon ECR.
///
/// When pushing images to ECR if the target container repository does not exist ECR ends
/// the connection causing an IOException with a generic "The response ended prematurely."
/// error message. The handler catches the generic error and provides a more informed error
/// message to let the user know they need to create the repository.
/// </summary>
public class AmazonECRMessageHandler : DelegatingHandler
{
public AmazonECRMessageHandler(HttpMessageHandler innerHandler) : base(innerHandler) { }

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException e) when (e.InnerException is IOException ioe && ioe.Message.Equals("The response ended prematurely.", StringComparison.OrdinalIgnoreCase))
baronfel marked this conversation as resolved.
Show resolved Hide resolved
{
var message = "Request to Amazon Elastic Container Registry failed prematurely. This is often caused when the target repository does not exist in the registry.";
throw new ContainerHttpException(message, request.RequestUri?.ToString(), null);
}
catch
{
throw;
}
}
}
90 changes: 77 additions & 13 deletions Microsoft.NET.Build.Containers/Registry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,61 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Reflection.Metadata.Ecma335;
using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Xml.Linq;

namespace Microsoft.NET.Build.Containers;

public record struct Registry(Uri BaseUri)
public struct Registry
{
private const string DockerManifestV2 = "application/vnd.docker.distribution.manifest.v2+json";
private const string DockerContainerV1 = "application/vnd.docker.container.image.v1+json";
private const int MaxChunkSizeBytes = 1024 * 64;

private string RegistryName { get; } = BaseUri.Host;
private readonly Uri BaseUri { get; init; }
private readonly string RegistryName => BaseUri.Host;

public Registry(Uri baseUri)
{
BaseUri = baseUri;
_client = CreateClient();
}

/// <summary>
/// The max chunk size for patch blob uploads. By default the size is 5 MB.
/// </summary>
/// <remarks>
/// 5 MB is chosen because it's the limit that works with all registries we tested -
/// notably Amazon Elastic Container Registry requires 5MB chunks for all but the last chunk.
/// </remarks>
public readonly int MaxChunkSizeBytes => 5 * 1024 * 1024;

/// <summary>
/// Check to see if the registry is for Amazon Elastic Container Registry (ECR).
/// </summary>
public readonly bool IsAmazonECRRegistry
{
get
{
// If this the registry is to public ECR the name will contain "public.ecr.aws".
if (RegistryName.Contains("public.ecr.aws"))
{
return true;
}

// If the registry is to a private ECR the registry will start with an account id which is a 12 digit number and will container either
// ".ecr." or ".ecr-" if pushed to a FIPS endpoint.
var accountId = RegistryName.Split('.')[0];
if ((RegistryName.Contains(".ecr.") || RegistryName.Contains(".ecr-")) && accountId.Length == 12 && long.TryParse(accountId, out _))
{
return true;
}

return false;
}
}

public async Task<Image> GetImageManifest(string name, string reference)
{
Expand Down Expand Up @@ -118,7 +159,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten

if (pushResponse.StatusCode != HttpStatusCode.Accepted)
{
string errorMessage = $"Failed to upload blob to {pushUri}; recieved {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
string errorMessage = $"Failed to upload blob to {pushUri}; received {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
throw new ApplicationException(errorMessage);
}

Expand Down Expand Up @@ -162,9 +203,10 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten

HttpResponseMessage patchResponse = await client.PatchAsync(patchUri, content);

if (patchResponse.StatusCode != HttpStatusCode.Accepted)
// Fail the upload if the response code is not Accepted (202) or if uploading to Amazon ECR which returns back Created (201).
if (!(patchResponse.StatusCode == HttpStatusCode.Accepted || (IsAmazonECRRegistry && patchResponse.StatusCode == HttpStatusCode.Created)))
{
string errorMessage = $"Failed to upload blob to {patchUri}; recieved {patchResponse.StatusCode} with detail {await patchResponse.Content.ReadAsStringAsync()}";
string errorMessage = $"Failed to upload blob to {patchUri}; received {patchResponse.StatusCode} with detail {await patchResponse.Content.ReadAsStringAsync()}";
throw new ApplicationException(errorMessage);
}

Expand Down Expand Up @@ -194,7 +236,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten

if (finalizeResponse.StatusCode != HttpStatusCode.Created)
{
string errorMessage = $"Failed to finalize upload to {putUri}; recieved {finalizeResponse.StatusCode} with detail {await finalizeResponse.Content.ReadAsStringAsync()}";
string errorMessage = $"Failed to finalize upload to {putUri}; received {finalizeResponse.StatusCode} with detail {await finalizeResponse.Content.ReadAsStringAsync()}";
throw new ApplicationException(errorMessage);
}
}
Expand All @@ -211,16 +253,22 @@ private readonly async Task<bool> BlobAlreadyUploaded(string name, string digest
return false;
}

private static HttpClient _client = CreateClient();
private readonly HttpClient _client;

private static HttpClient GetClient()
private readonly HttpClient GetClient()
{
return _client;
}

private static HttpClient CreateClient()
private HttpClient CreateClient()
{
var clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });
HttpMessageHandler clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });

if(IsAmazonECRRegistry)
{
clientHandler = new AmazonECRMessageHandler(clientHandler);
}

HttpClient client = new(clientHandler);

client.DefaultRequestHeaders.Accept.Clear();
Expand All @@ -242,7 +290,9 @@ public async Task Push(Image x, string name, string? tag, string baseName, Actio

HttpClient client = GetClient();
var reg = this;
await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {

Func<Descriptor, Task> uploadLayerFunc = async (descriptor) =>
{
string digest = descriptor.Digest;
logProgressMessage($"Uploading layer {digest} to {reg.RegistryName}");
if (await reg.BlobAlreadyUploaded(name, digest, client))
Expand All @@ -269,7 +319,21 @@ await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
await reg.Push(Layer.FromDescriptor(descriptor), name, logProgressMessage);
logProgressMessage($"Finished uploading layer {digest} to {reg.RegistryName}");
}
}));
};

// Pushing to ECR uses a much larger chunk size. To avoid getting too many socket disconnects trying to do too many
baronfel marked this conversation as resolved.
Show resolved Hide resolved
// parallel uploads be more conservative and upload one layer at a time.
if(IsAmazonECRRegistry)
{
foreach(var descriptor in x.LayerDescriptors)
{
await uploadLayerFunc(descriptor);
}
}
else
{
await Task.WhenAll(x.LayerDescriptors.Select(descriptor => uploadLayerFunc(descriptor)));
}

using (MemoryStream stringStream = new MemoryStream(Encoding.UTF8.GetBytes(x.config.ToJsonString())))
{
Expand Down
15 changes: 15 additions & 0 deletions Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,19 @@ public async Task GetFromRegistry()

Assert.IsNotNull(downloadedImage);
}

[DataRow("public.ecr.aws", true)]
[DataRow("123412341234.dkr.ecr.us-west-2.amazonaws.com", true)]
[DataRow("123412341234.dkr.ecr-fips.us-west-2.amazonaws.com", true)]
[DataRow("notvalid.dkr.ecr.us-west-2.amazonaws.com", false)]
[DataRow("1111.dkr.ecr.us-west-2.amazonaws.com", false)]
[DataRow("mcr.microsoft.com", false)]
[DataRow("localhost", false)]
[DataRow("hub", false)]
[TestMethod]
public void CheckIfAmazonECR(string registryName, bool isECR)
{
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(registryName));
Assert.AreEqual(isECR, registry.IsAmazonECRRegistry);
}
}