Skip to content

Commit 3621378

Browse files
authored
Merge pull request #279 from normj/support-amazon-ecr
2 parents f9d6e45 + 9ef2262 commit 3621378

File tree

3 files changed

+129
-13
lines changed

3 files changed

+129
-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: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,61 @@
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 readonly Uri BaseUri { get; init; }
23+
private readonly 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 5 MB.
33+
/// </summary>
34+
/// <remarks>
35+
/// 5 MB is chosen because it's the limit that works with all registries we tested -
36+
/// notably Amazon Elastic Container Registry requires 5MB chunks for all but the last chunk.
37+
/// </remarks>
38+
public readonly int MaxChunkSizeBytes => 5 * 1024 * 1024;
39+
40+
/// <summary>
41+
/// Check to see if the registry is for Amazon Elastic Container Registry (ECR).
42+
/// </summary>
43+
public readonly bool IsAmazonECRRegistry
44+
{
45+
get
46+
{
47+
// If this the registry is to public ECR the name will contain "public.ecr.aws".
48+
if (RegistryName.Contains("public.ecr.aws"))
49+
{
50+
return true;
51+
}
52+
53+
// 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
54+
// ".ecr." or ".ecr-" if pushed to a FIPS endpoint.
55+
var accountId = RegistryName.Split('.')[0];
56+
if ((RegistryName.Contains(".ecr.") || RegistryName.Contains(".ecr-")) && accountId.Length == 12 && long.TryParse(accountId, out _))
57+
{
58+
return true;
59+
}
60+
61+
return false;
62+
}
63+
}
2364

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

119160
if (pushResponse.StatusCode != HttpStatusCode.Accepted)
120161
{
121-
string errorMessage = $"Failed to upload blob to {pushUri}; recieved {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
162+
string errorMessage = $"Failed to upload blob to {pushUri}; received {pushResponse.StatusCode} with detail {await pushResponse.Content.ReadAsStringAsync()}";
122163
throw new ApplicationException(errorMessage);
123164
}
124165

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

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

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

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

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

214-
private static HttpClient _client = CreateClient();
256+
private readonly HttpClient _client;
215257

216-
private static HttpClient GetClient()
258+
private readonly HttpClient GetClient()
217259
{
218260
return _client;
219261
}
220262

221-
private static HttpClient CreateClient()
263+
private HttpClient CreateClient()
222264
{
223-
var clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });
265+
HttpMessageHandler clientHandler = new AuthHandshakeMessageHandler(new SocketsHttpHandler() { PooledConnectionLifetime = TimeSpan.FromMilliseconds(10 /* total guess */) });
266+
267+
if(IsAmazonECRRegistry)
268+
{
269+
clientHandler = new AmazonECRMessageHandler(clientHandler);
270+
}
271+
224272
HttpClient client = new(clientHandler);
225273

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

243291
HttpClient client = GetClient();
244292
var reg = this;
245-
await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
293+
294+
Func<Descriptor, Task> uploadLayerFunc = async (descriptor) =>
295+
{
246296
string digest = descriptor.Digest;
247297
logProgressMessage($"Uploading layer {digest} to {reg.RegistryName}");
248298
if (await reg.BlobAlreadyUploaded(name, digest, client))
@@ -269,7 +319,21 @@ await Task.WhenAll(x.LayerDescriptors.Select(async descriptor => {
269319
await reg.Push(Layer.FromDescriptor(descriptor), name, logProgressMessage);
270320
logProgressMessage($"Finished uploading layer {digest} to {reg.RegistryName}");
271321
}
272-
}));
322+
};
323+
324+
// Pushing to ECR uses a much larger chunk size. To avoid getting too many socket disconnects trying to do too many
325+
// parallel uploads be more conservative and upload one layer at a time.
326+
if(IsAmazonECRRegistry)
327+
{
328+
foreach(var descriptor in x.LayerDescriptors)
329+
{
330+
await uploadLayerFunc(descriptor);
331+
}
332+
}
333+
else
334+
{
335+
await Task.WhenAll(x.LayerDescriptors.Select(descriptor => uploadLayerFunc(descriptor)));
336+
}
273337

274338
using (MemoryStream stringStream = new MemoryStream(Encoding.UTF8.GetBytes(x.config.ToJsonString())))
275339
{

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)