Skip to content

Commit 173d806

Browse files
committed
Fix issues pushing images to Amazon ECR
1 parent e35d96a commit 173d806

File tree

3 files changed

+126
-13
lines changed

3 files changed

+126
-13
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.NET.Build.Containers;
8+
9+
/// <summary>
10+
/// A delegating handler that handles the special error handling needed for Amazon ECR.
11+
///
12+
/// When pushing images to ECR if the target container repository does not exist ECR ends
13+
/// the connection causing an IOException with a generic "The response ended prematurely."
14+
/// error message. The handler catches the generic error and provides a more informed error
15+
/// message to let the user know they need to create the repository.
16+
/// </summary>
17+
public class AmazonECRMessageHandler : DelegatingHandler
18+
{
19+
public AmazonECRMessageHandler(HttpMessageHandler innerHandler) : base(innerHandler) { }
20+
21+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
22+
{
23+
try
24+
{
25+
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
26+
}
27+
catch (HttpRequestException e) when (e.InnerException is IOException ioe && ioe.Message.Equals("The response ended prematurely.", StringComparison.OrdinalIgnoreCase))
28+
{
29+
var message = "Request to Amazon Elastic Container Registry failed prematurely. This is often caused when the target repository does not exist in the registry.";
30+
throw new ContainerHttpException(message, request.RequestUri?.ToString(), null);
31+
}
32+
catch
33+
{
34+
throw;
35+
}
36+
}
37+
}

Microsoft.NET.Build.Containers/Registry.cs

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,58 @@
66
using System.Net.Http;
77
using System.Net.Http.Headers;
88
using System.Net.Http.Json;
9+
using System.Reflection.Metadata.Ecma335;
910
using System.Runtime.ExceptionServices;
1011
using System.Text;
1112
using System.Text.Json.Nodes;
1213
using System.Xml.Linq;
1314

1415
namespace Microsoft.NET.Build.Containers;
1516

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

22-
private string RegistryName { get; } = BaseUri.Host;
22+
private Uri BaseUri { get; init; }
23+
private string RegistryName => BaseUri.Host;
24+
25+
public Registry(Uri baseUri)
26+
{
27+
BaseUri = baseUri;
28+
_client = CreateClient();
29+
}
30+
31+
/// <summary>
32+
/// The max chunk size for patch blob uploads. By default the size is 64 KB.
33+
/// Amazon Elasic Container Registry (ECR) requires patch chunk size to be 5 MB except for the last chunk.
34+
/// </summary>
35+
public readonly int MaxChunkSizeBytes => IsAmazonECRRegistry ? 5248080 : 1024 * 64;
36+
37+
/// <summary>
38+
/// Check to see if the registry is for Amazon Elastic Container Registry (ECR).
39+
/// </summary>
40+
public readonly bool IsAmazonECRRegistry
41+
{
42+
get
43+
{
44+
// If this the registry is to public ECR the name will contain "public.ecr.aws".
45+
if (RegistryName.Contains("public.ecr.aws"))
46+
{
47+
return true;
48+
}
49+
50+
// 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
51+
// ".ecr." or ".ecr-" if pushed to a FIPS endpoint.
52+
var accountId = RegistryName.Split('.')[0];
53+
if ((RegistryName.Contains(".ecr.") || RegistryName.Contains(".ecr-")) && accountId.Length == 12 && long.TryParse(accountId, out _))
54+
{
55+
return true;
56+
}
57+
58+
return false;
59+
}
60+
}
2361

2462
public async Task<Image> GetImageManifest(string name, string reference)
2563
{
@@ -118,7 +156,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten
118156

119157
if (pushResponse.StatusCode != HttpStatusCode.Accepted)
120158
{
121-
string errorMessage = $"Failed to upload blob to {pushUri}; recieved {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
159+
string errorMessage = $"Failed to upload blob to {pushUri}; received {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
122160
throw new ApplicationException(errorMessage);
123161
}
124162

@@ -162,9 +200,10 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten
162200

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

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

@@ -194,7 +233,7 @@ private readonly async Task UploadBlob(string name, string digest, Stream conten
194233

195234
if (finalizeResponse.StatusCode != HttpStatusCode.Created)
196235
{
197-
string errorMessage = $"Failed to finalize upload to {putUri}; recieved {finalizeResponse.StatusCode} with detail {await finalizeResponse.Content.ReadAsStringAsync()}";
236+
string errorMessage = $"Failed to finalize upload to {putUri}; received {finalizeResponse.StatusCode} with detail {await finalizeResponse.Content.ReadAsStringAsync()}";
198237
throw new ApplicationException(errorMessage);
199238
}
200239
}
@@ -211,16 +250,22 @@ private readonly async Task<bool> BlobAlreadyUploaded(string name, string digest
211250
return false;
212251
}
213252

214-
private static HttpClient _client = CreateClient();
253+
private HttpClient _client;
215254

216-
private static HttpClient GetClient()
255+
private HttpClient GetClient()
217256
{
218257
return _client;
219258
}
220259

221-
private static HttpClient CreateClient()
260+
private HttpClient CreateClient()
222261
{
223-
var clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });
262+
HttpMessageHandler clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });
263+
264+
if(IsAmazonECRRegistry)
265+
{
266+
clientHandler = new AmazonECRMessageHandler(clientHandler);
267+
}
268+
224269
HttpClient client = new(clientHandler);
225270

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

243288
HttpClient client = GetClient();
244289
var reg = this;
245-
await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
290+
291+
Func<Descriptor, Task> uploadLayerFunc = async (descriptor) =>
292+
{
246293
string digest = descriptor.Digest;
247294
logProgressMessage($"Uploading layer {digest} to {reg.RegistryName}");
248295
if (await reg.BlobAlreadyUploaded(name, digest, client))
@@ -269,7 +316,21 @@ await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
269316
await reg.Push(Layer.FromDescriptor(descriptor), name, logProgressMessage);
270317
logProgressMessage($"Finished uploading layer {digest} to {reg.RegistryName}");
271318
}
272-
}));
319+
};
320+
321+
// Pushing to ECR uses a much larger chunk size. To avoid getting too many socket disconnects trying to do too many
322+
// parallel uploads be more conservative and upload one layer at a time.
323+
if(IsAmazonECRRegistry)
324+
{
325+
foreach(var descriptor in x.LayerDescriptors)
326+
{
327+
await uploadLayerFunc(descriptor);
328+
}
329+
}
330+
else
331+
{
332+
await Task.WhenAll(x.LayerDescriptors.Select(descriptor => uploadLayerFunc(descriptor)));
333+
}
273334

274335
using (MemoryStream stringStream = new MemoryStream(Encoding.UTF8.GetBytes(x.config.ToJsonString())))
275336
{

Test.Microsoft.NET.Build.Containers.Filesystem/RegistryTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,19 @@ public async Task GetFromRegistry()
1919

2020
Assert.IsNotNull(downloadedImage);
2121
}
22+
23+
[DataRow("public.ecr.aws", true)]
24+
[DataRow("123412341234.dkr.ecr.us-west-2.amazonaws.com", true)]
25+
[DataRow("123412341234.dkr.ecr-fips.us-west-2.amazonaws.com", true)]
26+
[DataRow("notvalid.dkr.ecr.us-west-2.amazonaws.com", false)]
27+
[DataRow("1111.dkr.ecr.us-west-2.amazonaws.com", false)]
28+
[DataRow("mcr.microsoft.com", false)]
29+
[DataRow("localhost", false)]
30+
[DataRow("hub", false)]
31+
[TestMethod]
32+
public void CheckIfAmazonECR(string registryName, bool isECR)
33+
{
34+
Registry registry = new Registry(ContainerHelpers.TryExpandRegistryToUri(registryName));
35+
Assert.AreEqual(isECR, registry.IsAmazonECRRegistry);
36+
}
2237
}

0 commit comments

Comments
 (0)