diff --git a/Microsoft.NET.Build.Containers/AmazonECRMessageHandler.cs b/Microsoft.NET.Build.Containers/AmazonECRMessageHandler.cs
new file mode 100644
index 00000000..7764de30
--- /dev/null
+++ b/Microsoft.NET.Build.Containers/AmazonECRMessageHandler.cs
@@ -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;
+
+///
+/// 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.
+///
+public class AmazonECRMessageHandler : DelegatingHandler
+{
+ public AmazonECRMessageHandler(HttpMessageHandler innerHandler) : base(innerHandler) { }
+
+ protected override async Task 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))
+ {
+ 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;
+ }
+ }
+}
diff --git a/Microsoft.NET.Build.Containers/Registry.cs b/Microsoft.NET.Build.Containers/Registry.cs
index 2d8462e6..ad996651 100644
--- a/Microsoft.NET.Build.Containers/Registry.cs
+++ b/Microsoft.NET.Build.Containers/Registry.cs
@@ -6,6 +6,7 @@
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;
@@ -13,13 +14,53 @@
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();
+ }
+
+ ///
+ /// The max chunk size for patch blob uploads. By default the size is 5 MB.
+ ///
+ ///
+ /// 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.
+ ///
+ public readonly int MaxChunkSizeBytes => 5 * 1024 * 1024;
+
+ ///
+ /// Check to see if the registry is for Amazon Elastic Container Registry (ECR).
+ ///
+ 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 GetImageManifest(string name, string reference)
{
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -211,16 +253,22 @@ private readonly async Task 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();
@@ -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 uploadLayerFunc = async (descriptor) =>
+ {
string digest = descriptor.Digest;
logProgressMessage($"Uploading layer {digest} to {reg.RegistryName}");
if (await reg.BlobAlreadyUploaded(name, digest, client))
@@ -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
+ // 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())))
{
diff --git a/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs b/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs
index adc8fb35..b14a1853 100644
--- a/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs
+++ b/Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs
@@ -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);
+ }
}