Skip to content

Commit 1ffb326

Browse files
authored
Improve CopyBaseImages and HttpClient logging (#2071)
The `CopyBaseImages` job failed in build#2946397 with a timeout error. Don't really know why. This PR improves logging in `CopyBaseImages`, the HTTP `LoggingHandler`, and `OrasService.GetReferrersAsync` in an attempt to figure out why. Changes in this PR: - **Fix OrasDotnetService auth cache concurrency bug**: Share a single `Cache` instance across concurrent `GetReferrersAsync` calls instead of creating one per call, prevents `InvalidOperationException` from concurrent `Dictionary` mutations (I hit this locally). - **Imporve HTTP logging**: `LoggingHandler` is now a `DelegatingHandler` instead of a `MessageProcessingHandler` which allows it to capture and log elapsed time and transport errors (including timeouts). - **Change HTTP logging to Debug level** and also set the default log level to Debug. - **Fix destination image name formatting** in CopyBaseImages logging. - CopyBaseImages now does more things in dry-run mode. - **Add `isDryRun` to `IOrasService.GetReferrersAsync`**: Skips registry calls in dry-run mode to avoid rate limiting on unauthenticated registries. - **Consolidate import logging**: Single log message per image showing destination, referrer count, source, and dry-run status
1 parent b0e98c2 commit 1ffb326

9 files changed

Lines changed: 103 additions & 58 deletions

File tree

src/ImageBuilder.Tests/CopyImageServiceTests.cs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ public class CopyImageServiceTests
2929
public async Task ImportImageAsync_DryRun_DoesNotRequirePublishConfiguration()
3030
{
3131
var emptyConfig = new PublishConfiguration();
32+
var mockOras = new Mock<IOrasService>();
33+
mockOras
34+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), true, It.IsAny<CancellationToken>()))
35+
.ReturnsAsync([]);
36+
3237
var service = new CopyImageService(
3338
Mock.Of<ILogger<CopyImageService>>(),
3439
Mock.Of<IAcrImageImporter>(),
35-
Mock.Of<IOrasService>(),
40+
mockOras.Object,
3641
ConfigurationHelper.CreateOptionsMock(emptyConfig));
3742

3843
await Should.NotThrowAsync(() =>
@@ -75,7 +80,7 @@ public async Task ImportImageAsync_ExternalSourceRegistry_DoesNotRequireSourceRe
7580
var mockImporter = new Mock<IAcrImageImporter>();
7681
var mockOras = new Mock<IOrasService>();
7782
mockOras
78-
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
83+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
7984
.ReturnsAsync(Array.Empty<ReferrerInfo>());
8085

8186
var service = new CopyImageService(
@@ -114,7 +119,7 @@ public async Task ImportImageAsync_CopiesReferrersAlongWithSourceImage()
114119
var mockImporter = new Mock<IAcrImageImporter>();
115120
var mockOras = new Mock<IOrasService>();
116121
mockOras
117-
.Setup(o => o.GetReferrersAsync("myacr.azurecr.io/repo:tag", It.IsAny<CancellationToken>()))
122+
.Setup(o => o.GetReferrersAsync("myacr.azurecr.io/repo:tag", It.IsAny<bool>(), It.IsAny<CancellationToken>()))
118123
.ReturnsAsync(new List<ReferrerInfo>
119124
{
120125
new("myacr.azurecr.io/repo@sha256:ref1", "application/vnd.cncf.notary.signature"),
@@ -184,7 +189,7 @@ public async Task ImportImageAsync_NoReferrers_ImportsOnlySourceImage()
184189
var mockImporter = new Mock<IAcrImageImporter>();
185190
var mockOras = new Mock<IOrasService>();
186191
mockOras
187-
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
192+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
188193
.ReturnsAsync(Array.Empty<ReferrerInfo>());
189194

190195
var service = new CopyImageService(
@@ -209,17 +214,23 @@ await service.ImportImageAsync(
209214
}
210215

211216
/// <summary>
212-
/// In dry-run mode, referrer discovery should be skipped entirely since it
213-
/// requires registry connectivity.
217+
/// In dry-run mode, referrer discovery and ACR imports are both skipped.
218+
/// The ORAS service receives the dry-run flag and returns an empty list
219+
/// to avoid rate limiting on unauthenticated registries.
214220
/// </summary>
215221
[Fact]
216-
public async Task ImportImageAsync_DryRun_SkipsReferrerDiscovery()
222+
public async Task ImportImageAsync_DryRun_SkipsReferrerDiscoveryAndImport()
217223
{
218224
var mockOras = new Mock<IOrasService>();
225+
mockOras
226+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), true, It.IsAny<CancellationToken>()))
227+
.ReturnsAsync([]);
228+
229+
var mockImporter = new Mock<IAcrImageImporter>();
219230

220231
var service = new CopyImageService(
221232
Mock.Of<ILogger<CopyImageService>>(),
222-
Mock.Of<IAcrImageImporter>(),
233+
mockImporter.Object,
223234
mockOras.Object,
224235
ConfigurationHelper.CreateOptionsMock(new PublishConfiguration()));
225236

@@ -231,7 +242,14 @@ await service.ImportImageAsync(
231242
isDryRun: true);
232243

233244
mockOras.Verify(
234-
o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
245+
o => o.GetReferrersAsync(It.IsAny<string>(), true, It.IsAny<CancellationToken>()),
246+
Times.Once);
247+
248+
mockImporter.Verify(
249+
o => o.ImportImageAsync(
250+
It.IsAny<string>(),
251+
It.IsAny<ResourceIdentifier>(),
252+
It.IsAny<ContainerRegistryImportImageContent>()),
235253
Times.Never);
236254
}
237255

src/ImageBuilder.Tests/Signing/ImageSigningServiceTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ public async Task SignImagesAsync_WritesCorrectPayloadToDisk()
218218
});
219219
mockOras
220220
.Setup(s => s.GetReferrersAsync(
221-
It.IsAny<string>(), It.IsAny<CancellationToken>()))
221+
It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
222222
.ReturnsAsync(new List<ReferrerInfo>());
223223

224224
var fileSystem = new InMemoryFileSystem();
@@ -306,7 +306,7 @@ private static Mock<IOrasService> CreateMockOrasService()
306306
$"sha256:sig-{p.ImageName}");
307307
mock
308308
.Setup(s => s.GetReferrersAsync(
309-
It.IsAny<string>(), It.IsAny<CancellationToken>()))
309+
It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
310310
.ReturnsAsync(new List<ReferrerInfo>());
311311
return mock;
312312
}
@@ -370,7 +370,7 @@ public async Task SignImagesAsync_SkipsAlreadySignedDigests()
370370
// Both digests already have signature referrers
371371
mockOras
372372
.Setup(s => s.GetReferrersAsync(
373-
It.IsAny<string>(), It.IsAny<CancellationToken>()))
373+
It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
374374
.ReturnsAsync(new List<ReferrerInfo>
375375
{
376376
new("registry/repo@sha256:existingsig", "application/vnd.cncf.notary.signature")
@@ -407,10 +407,10 @@ public async Task SignImagesAsync_SkipsAlreadySignedDigests()
407407

408408
// Referrers should be checked for each digest
409409
mockOras.Verify(
410-
s => s.GetReferrersAsync("sha256:abc123", It.IsAny<CancellationToken>()),
410+
s => s.GetReferrersAsync("sha256:abc123", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
411411
Times.Once);
412412
mockOras.Verify(
413-
s => s.GetReferrersAsync("sha256:def456", It.IsAny<CancellationToken>()),
413+
s => s.GetReferrersAsync("sha256:def456", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
414414
Times.Once);
415415

416416
// ESRP should never be called
@@ -429,14 +429,14 @@ public async Task SignImagesAsync_OnlySignsUnsignedDigests()
429429
// First digest already has a signature, second does not
430430
mockOras
431431
.Setup(s => s.GetReferrersAsync(
432-
"sha256:already-signed", It.IsAny<CancellationToken>()))
432+
"sha256:already-signed", It.IsAny<bool>(), It.IsAny<CancellationToken>()))
433433
.ReturnsAsync(new List<ReferrerInfo>
434434
{
435435
new("registry/repo@sha256:existingsig", "application/vnd.cncf.notary.signature")
436436
});
437437
mockOras
438438
.Setup(s => s.GetReferrersAsync(
439-
"sha256:not-yet-signed", It.IsAny<CancellationToken>()))
439+
"sha256:not-yet-signed", It.IsAny<bool>(), It.IsAny<CancellationToken>()))
440440
.ReturnsAsync(new List<ReferrerInfo>());
441441

442442
var service = CreateService(mockOras, mockEsrp: mockEsrp, fileSystem: fileSystem);
@@ -488,7 +488,7 @@ public async Task SignImagesAsync_IgnoresNonSignatureReferrers()
488488
// Referrer exists but is NOT a Notary signature (e.g., an SBOM)
489489
mockOras
490490
.Setup(s => s.GetReferrersAsync(
491-
It.IsAny<string>(), It.IsAny<CancellationToken>()))
491+
It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
492492
.ReturnsAsync(new List<ReferrerInfo>
493493
{
494494
new("registry/repo@sha256:sbom123", "application/spdx+json")

src/ImageBuilder/CopyImageService.cs

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,22 +54,27 @@ public async Task ImportImageAsync(
5454
{
5555
Acr destAcr = Acr.Parse(destAcrName);
5656

57-
string action = isDryRun ? "(Dry run) Would have imported" : "Importing";
5857
string sourceImageName = DockerHelper.GetImageName(srcRegistryName, srcTagName);
59-
var destinationImageNames = destTagNames
60-
.Select(tag => $"'{DockerHelper.GetImageName(destAcr.Name, tag)}'")
61-
.ToList();
58+
string destRepo = destTagNames.First().Split(':')[0].Split('@')[0];
59+
60+
// Discover referrers (signatures, SBOMs, etc.) for the source image.
61+
IReadOnlyList<ReferrerInfo> referrers =
62+
await _orasService.GetReferrersAsync(reference: sourceImageName, isDryRun: isDryRun);
63+
64+
var destinationImageNames =
65+
destTagNames.Select(tag => $"'{DockerHelper.GetImageName(destAcr.Server, tag)}'").ToList();
6266
string formattedDestinationImages = string.Join(", ", destinationImageNames);
63-
_logger.LogInformation("{Action} {DestinationImages} from '{SourceImage}'",
64-
action, formattedDestinationImages, sourceImageName);
67+
68+
_logger.LogInformation(
69+
"Importing {DestinationImages} and {ReferrerCount} referrer(s) from '{SourceImage}' (DryRun={DryRun})",
70+
formattedDestinationImages, referrers.Count, sourceImageName, isDryRun);
6571

6672
if (isDryRun)
6773
{
68-
_logger.LogInformation("Importing skipped due to dry run.");
6974
return;
7075
}
7176

72-
var destResourceId = _publishConfig.GetAcrResource(destAcrName);
77+
ResourceIdentifier destResourceId = _publishConfig.GetAcrResource(destAcrName);
7378

7479
// Only look up the source resource ID for registries in the publish config (i.e. ACRs).
7580
// External registries like docker.io use RegistryAddress + Credentials instead.
@@ -80,8 +85,6 @@ public async Task ImportImageAsync(
8085
? _publishConfig.GetAcrResource(srcRegistryName)
8186
: null;
8287

83-
// Azure ACR import only supports one source identifier. Use ResourceId for ACR-to-ACR
84-
// imports (same tenant), or RegistryAddress for external registries.
8588
ContainerRegistryImportSource importSrc = new(srcTagName)
8689
{
8790
ResourceId = srcResourceId,
@@ -98,15 +101,8 @@ public async Task ImportImageAsync(
98101

99102
await _acrRegistryImporter.ImportImageAsync(destAcrName, destResourceId, importImageContent);
100103

101-
// Discover and import all OCI referrers (signatures, SBOMs, etc.) for the source image.
102-
string sourceImageReference = DockerHelper.GetImageName(srcRegistryName, srcTagName);
103-
IReadOnlyList<ReferrerInfo> referrers = await _orasService.GetReferrersAsync(sourceImageReference);
104-
105-
string destRepo = destTagNames.First().Split(':')[0].Split('@')[0];
106104
foreach (ReferrerInfo referrer in referrers)
107105
{
108-
_logger.LogInformation("Importing referrer '{Referrer}' to '{DestAcr}/{DestRepo}'", referrer.Digest, destAcrName, destRepo);
109-
110106
string referrerDigestReference = DockerHelper.TrimRegistry(referrer.Digest, srcRegistryName);
111107
ContainerRegistryImportSource referrerImportSrc = new(referrerDigestReference)
112108
{

src/ImageBuilder/HttpClientProvider.cs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6+
using System.Diagnostics;
67
using System.Net.Http;
78
using System.Threading;
9+
using System.Threading.Tasks;
810

911
namespace Microsoft.DotNet.ImageBuilder
1012
{
@@ -30,7 +32,12 @@ public HttpClientProvider(ILoggerFactory loggerFactory)
3032

3133
public RegistryHttpClient GetRegistryClient() => _registryHttpClient.Value;
3234

33-
private class LoggingHandler : MessageProcessingHandler
35+
/// <summary>
36+
/// Logs HTTP request/response activity. Successful responses are logged at Debug level.
37+
/// Failures (timeouts, cancellations, transport errors) are logged at Warning level
38+
/// with elapsed time to aid diagnosis of hanging requests.
39+
/// </summary>
40+
private class LoggingHandler : DelegatingHandler
3441
{
3542
private readonly ILogger<LoggingHandler> _logger;
3643

@@ -41,15 +48,31 @@ public LoggingHandler(ILoggerFactory loggerFactory)
4148
InnerHandler = new HttpClientHandler();
4249
}
4350

44-
protected override HttpRequestMessage ProcessRequest(HttpRequestMessage request, CancellationToken cancellationToken)
51+
protected override async Task<HttpResponseMessage> SendAsync(
52+
HttpRequestMessage request,
53+
CancellationToken cancellationToken)
4554
{
46-
_logger.LogInformation($"Sending HTTP request: {request.RequestUri}");
47-
return request;
48-
}
55+
long startTime = Stopwatch.GetTimestamp();
4956

50-
protected override HttpResponseMessage ProcessResponse(HttpResponseMessage response, CancellationToken cancellationToken)
51-
{
52-
return response;
57+
try
58+
{
59+
HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
60+
_logger.LogDebug("HTTP {StatusCode} {Method} {RequestUri} in {Elapsed}",
61+
(int)response.StatusCode, request.Method, request.RequestUri, Stopwatch.GetElapsedTime(startTime));
62+
return response;
63+
}
64+
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
65+
{
66+
_logger.LogWarning(ex, "HTTP timeout {Method} {RequestUri} after {Elapsed}",
67+
request.Method, request.RequestUri, Stopwatch.GetElapsedTime(startTime));
68+
throw;
69+
}
70+
catch (HttpRequestException ex)
71+
{
72+
_logger.LogWarning(ex, "HTTP failure {Method} {RequestUri} after {Elapsed}",
73+
request.Method, request.RequestUri, Stopwatch.GetElapsedTime(startTime));
74+
throw;
75+
}
5376
}
5477
}
5578
}

src/ImageBuilder/ImageBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Microsoft.DotNet.ImageBuilder.Signing;
1212
using Microsoft.Extensions.DependencyInjection;
1313
using Microsoft.Extensions.Hosting;
14+
using Microsoft.Extensions.Logging;
1415
using ICommand = Microsoft.DotNet.ImageBuilder.Commands.ICommand;
1516

1617
namespace Microsoft.DotNet.ImageBuilder;
@@ -29,6 +30,7 @@ public static class ImageBuilder
2930
builder.AddBuildConfiguration();
3031

3132
// Logging
33+
builder.Logging.SetMinimumLevel(LogLevel.Debug);
3234
builder.Logging.AddSimpleConsole(options =>
3335
{
3436
options.IncludeScopes = true;

src/ImageBuilder/LifecycleMetadataService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public LifecycleMetadataService(IOrasService orasService, ILogger<LifecycleMetad
3333

3434
try
3535
{
36-
IReadOnlyList<ReferrerInfo> referrers = await _orasService.GetReferrersAsync(digest, cancellationToken);
36+
IReadOnlyList<ReferrerInfo> referrers =
37+
await _orasService.GetReferrersAsync(digest, isDryRun: false, cancellationToken);
3738

3839
ReferrerInfo? lifecycleReferrer = referrers.FirstOrDefault(
3940
r => r.ArtifactType == OciArtifactType.Lifecycle);

src/ImageBuilder/Oras/IOrasService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@ Task<string> PushSignatureAsync(
3939
/// Returns the OCI referrers for the given image.
4040
/// </summary>
4141
/// <param name="reference">Full registry reference (e.g., "registry.io/repo:tag" or "registry.io/repo@sha256:...").</param>
42+
/// <param name="isDryRun">When true, skips registry calls and returns an empty list.</param>
4243
/// <param name="cancellationToken">Cancellation token.</param>
4344
/// <returns>
4445
/// A list of <see cref="ReferrerInfo"/> containing the digest reference and artifact type
4546
/// for every referrer associated with the image.
4647
/// </returns>
4748
Task<IReadOnlyList<ReferrerInfo>> GetReferrersAsync(
4849
string reference,
50+
bool isDryRun = false,
4951
CancellationToken cancellationToken = default);
5052

5153
/// <summary>

src/ImageBuilder/Oras/OrasDotNetService.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public class OrasDotNetService(
4343
private const string CertificateChainAnnotation = "io.cncf.notary.x509chain.thumbprint#S256";
4444

4545
private readonly IHttpClientProvider _httpClientProvider = httpClientProvider;
46-
private readonly IMemoryCache _cache = cache;
46+
private readonly Cache _orasCache = new(cache);
4747
private readonly IFileSystem _fileSystem = fileSystem;
4848
private readonly ILogger<OrasDotNetService> _logger = logger;
4949
private readonly OrasCredentialProviderAdapter _credentialProvider = new(credentialsProvider, credentialsHost);
@@ -116,13 +116,24 @@ await Packer.PackManifestAsync(
116116
/// <inheritdoc/>
117117
public async Task<IReadOnlyList<ReferrerInfo>> GetReferrersAsync(
118118
string reference,
119+
bool isDryRun = false,
119120
CancellationToken cancellationToken = default)
120121
{
121122
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
123+
IReadOnlyList<ReferrerInfo> referrers = isDryRun ? []
124+
: await GetReferrersImplAsync(reference, cancellationToken);
122125

123-
_logger.LogDebug("Fetching referrers for reference: {Reference}", reference);
126+
_logger.LogInformation(
127+
"{Reference} has {Count} referrer(s) (DryRun={DryRun})",
128+
reference, referrers.Count, isDryRun);
124129

125-
long startTime = Stopwatch.GetTimestamp();
130+
return referrers;
131+
}
132+
133+
private async Task<IReadOnlyList<ReferrerInfo>> GetReferrersImplAsync(
134+
string reference,
135+
CancellationToken cancellationToken)
136+
{
126137
Repository repository = CreateRepository(reference);
127138
Descriptor subjectDescriptor = await repository.ResolveAsync(reference, cancellationToken);
128139

@@ -140,9 +151,6 @@ public async Task<IReadOnlyList<ReferrerInfo>> GetReferrersAsync(
140151
_logger.LogDebug("Found referrer: {Referrer} (artifactType={ArtifactType})", referrerReference, referrer.ArtifactType);
141152
}
142153

143-
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
144-
_logger.LogDebug("Found {Count} referrer(s) for {Reference} in {Elapsed}", referrers.Count, reference, elapsed);
145-
146154
return referrers;
147155
}
148156

@@ -196,8 +204,7 @@ private Repository CreateRepository(string reference)
196204
parsedRef.Registry, parsedRef.Repository, parsedRef.ContentReference);
197205

198206
HttpClient httpClient = _httpClientProvider.GetClient();
199-
Cache cache = new(_cache);
200-
Client authClient = new(httpClient, _credentialProvider, cache);
207+
Client authClient = new(httpClient, _credentialProvider, _orasCache);
201208

202209
RepositoryOptions repositoryOptions = new()
203210
{

0 commit comments

Comments
 (0)