Skip to content

Commit a6223e9

Browse files
authored
Merge branch 'main' into darc-main-476ee6a5-de68-485b-811e-e4d207ff5a3e
2 parents 7713c41 + 71f34e0 commit a6223e9

7 files changed

Lines changed: 254 additions & 2 deletions

File tree

.github/instructions/csharp.instructions.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ For all new C# code:
1010

1111
- Use file-scoped namespaces.
1212
- Use collection expresions - write `[1, 2, 3]` and not `new List<int> { 1, 2, 3 }`.
13-
- Use `var` for local variable declarations.
13+
- Use explicit type declarations instead of `var` for local variables.
1414
- Use switch expressions and pattern matching.
1515
- Use string interpolation (`$"Hello, {name}!"`) instead of `string.Format` or concatenation.
1616
- Use `"""triple-quoted strings"""` for multi-line string literals. These can be interpolated as well.
1717
- Use expression-bodied members for simple getters and setters.
1818

19+
## Naming
20+
21+
- Avoid single-letter variable names, except for simple loop counters (e.g. `i`, `j`).
22+
- Avoid abbreviations or acronyms in names, except for widely known and accepted abbreviations (e.g. `Id`, `Url`, `Http`).
23+
- Prefer clarity over brevity - a longer descriptive name is better than a short ambiguous one.
24+
1925
## Code Design Rules
2026

2127
- Use immutable records instead of classes for DTOs.
@@ -24,6 +30,7 @@ For all new C# code:
2430
- Reuse existing methods or services as much as possible.
2531
- Use composition over inheritance.
2632
- Use LINQ instead of for loops when working with collections.
33+
- Place private nested types at the end of the containing class.
2734

2835
## Error Handling & Edge Cases
2936

eng/docker-tools/templates/variables/docker-images.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
variables:
2-
imageNames.imageBuilderName: mcr.microsoft.com/dotnet-buildtools/image-builder:2917799
2+
imageNames.imageBuilderName: mcr.microsoft.com/dotnet-buildtools/image-builder:2919324
33
imageNames.imageBuilder: $(imageNames.imageBuilderName)
44
imageNames.imageBuilder.withrepo: imagebuilder-withrepo:$(Build.BuildId)-$(System.JobId)
55
imageNames.testRunner: mcr.microsoft.com/dotnet-buildtools/prereqs:azurelinux3.0-docker-testrunner

src/ImageBuilder.Tests/CopyImageServiceTests.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Collections.Generic;
6+
using System.Threading;
57
using System.Threading.Tasks;
68
using Azure.Core;
79
using Azure.ResourceManager;
810
using Azure.ResourceManager.ContainerRegistry.Models;
911
using Microsoft.DotNet.ImageBuilder.Configuration;
12+
using Microsoft.DotNet.ImageBuilder.Oras;
1013
using Microsoft.DotNet.ImageBuilder.Tests.Helpers;
1114
using Microsoft.Extensions.Logging;
1215
using Moq;
@@ -29,6 +32,7 @@ public async Task ImportImageAsync_DryRun_DoesNotRequirePublishConfiguration()
2932
var service = new CopyImageService(
3033
Mock.Of<ILogger<CopyImageService>>(),
3134
Mock.Of<IAcrImageImporter>(),
35+
Mock.Of<IOrasService>(),
3236
ConfigurationHelper.CreateOptionsMock(emptyConfig));
3337

3438
await Should.NotThrowAsync(() =>
@@ -69,10 +73,15 @@ public async Task ImportImageAsync_ExternalSourceRegistry_DoesNotRequireSourceRe
6973
};
7074

7175
var mockImporter = new Mock<IAcrImageImporter>();
76+
var mockOras = new Mock<IOrasService>();
77+
mockOras
78+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
79+
.ReturnsAsync(Array.Empty<string>());
7280

7381
var service = new CopyImageService(
7482
Mock.Of<ILogger<CopyImageService>>(),
7583
mockImporter.Object,
84+
mockOras.Object,
7685
ConfigurationHelper.CreateOptionsMock(publishConfig));
7786

7887
await service.ImportImageAsync(
@@ -92,4 +101,156 @@ await service.ImportImageAsync(
92101
c.Source.RegistryAddress == "docker.io" && c.Source.ResourceId == null!)),
93102
Times.Once);
94103
}
104+
105+
/// <summary>
106+
/// When referrers exist for the source image, ImportImageAsync should import each referrer
107+
/// as an untagged artifact in addition to the main image.
108+
/// </summary>
109+
[Fact]
110+
public async Task ImportImageAsync_CopiesReferrersAlongWithSourceImage()
111+
{
112+
PublishConfiguration publishConfig = CreateAcrPublishConfig("myacr.azurecr.io");
113+
114+
var mockImporter = new Mock<IAcrImageImporter>();
115+
var mockOras = new Mock<IOrasService>();
116+
mockOras
117+
.Setup(o => o.GetReferrersAsync("myacr.azurecr.io/repo:tag", It.IsAny<CancellationToken>()))
118+
.ReturnsAsync(["myacr.azurecr.io/repo@sha256:ref1", "myacr.azurecr.io/repo@sha256:ref2"]);
119+
120+
var service = new CopyImageService(
121+
Mock.Of<ILogger<CopyImageService>>(),
122+
mockImporter.Object,
123+
mockOras.Object,
124+
ConfigurationHelper.CreateOptionsMock(publishConfig));
125+
126+
await service.ImportImageAsync(
127+
destTagNames: ["mirror/repo:tag"],
128+
destAcrName: "myacr.azurecr.io",
129+
srcTagName: "repo:tag",
130+
srcRegistryName: "myacr.azurecr.io",
131+
isDryRun: false);
132+
133+
// Main image import with TargetTags
134+
mockImporter.Verify(
135+
x => x.ImportImageAsync(
136+
"myacr.azurecr.io",
137+
It.IsAny<ResourceIdentifier>(),
138+
It.Is<ContainerRegistryImportImageContent>(c =>
139+
c.TargetTags.Count == 1 && c.TargetTags[0] == "mirror/repo:tag")),
140+
Times.Once);
141+
142+
// Two referrer imports with UntaggedTargetRepositories
143+
mockImporter.Verify(
144+
x => x.ImportImageAsync(
145+
"myacr.azurecr.io",
146+
It.IsAny<ResourceIdentifier>(),
147+
It.Is<ContainerRegistryImportImageContent>(c =>
148+
c.UntaggedTargetRepositories.Count == 1
149+
&& c.UntaggedTargetRepositories[0] == "mirror/repo"
150+
&& c.Source.SourceImage == "repo@sha256:ref1")),
151+
Times.Once);
152+
153+
mockImporter.Verify(
154+
x => x.ImportImageAsync(
155+
"myacr.azurecr.io",
156+
It.IsAny<ResourceIdentifier>(),
157+
It.Is<ContainerRegistryImportImageContent>(c =>
158+
c.UntaggedTargetRepositories.Count == 1
159+
&& c.UntaggedTargetRepositories[0] == "mirror/repo"
160+
&& c.Source.SourceImage == "repo@sha256:ref2")),
161+
Times.Once);
162+
163+
// Total: 1 main + 2 referrers = 3
164+
mockImporter.Verify(
165+
x => x.ImportImageAsync(
166+
It.IsAny<string>(),
167+
It.IsAny<ResourceIdentifier>(),
168+
It.IsAny<ContainerRegistryImportImageContent>()),
169+
Times.Exactly(3));
170+
}
171+
172+
/// <summary>
173+
/// When no referrers exist, ImportImageAsync should import only the main image.
174+
/// </summary>
175+
[Fact]
176+
public async Task ImportImageAsync_NoReferrers_ImportsOnlySourceImage()
177+
{
178+
PublishConfiguration publishConfig = CreateAcrPublishConfig("myacr.azurecr.io");
179+
180+
var mockImporter = new Mock<IAcrImageImporter>();
181+
var mockOras = new Mock<IOrasService>();
182+
mockOras
183+
.Setup(o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
184+
.ReturnsAsync(Array.Empty<string>());
185+
186+
var service = new CopyImageService(
187+
Mock.Of<ILogger<CopyImageService>>(),
188+
mockImporter.Object,
189+
mockOras.Object,
190+
ConfigurationHelper.CreateOptionsMock(publishConfig));
191+
192+
await service.ImportImageAsync(
193+
destTagNames: ["mirror/repo:tag"],
194+
destAcrName: "myacr.azurecr.io",
195+
srcTagName: "repo:tag",
196+
srcRegistryName: "myacr.azurecr.io",
197+
isDryRun: false);
198+
199+
mockImporter.Verify(
200+
x => x.ImportImageAsync(
201+
It.IsAny<string>(),
202+
It.IsAny<ResourceIdentifier>(),
203+
It.IsAny<ContainerRegistryImportImageContent>()),
204+
Times.Once);
205+
}
206+
207+
/// <summary>
208+
/// In dry-run mode, referrer discovery should be skipped entirely since it
209+
/// requires registry connectivity.
210+
/// </summary>
211+
[Fact]
212+
public async Task ImportImageAsync_DryRun_SkipsReferrerDiscovery()
213+
{
214+
var mockOras = new Mock<IOrasService>();
215+
216+
var service = new CopyImageService(
217+
Mock.Of<ILogger<CopyImageService>>(),
218+
Mock.Of<IAcrImageImporter>(),
219+
mockOras.Object,
220+
ConfigurationHelper.CreateOptionsMock(new PublishConfiguration()));
221+
222+
await service.ImportImageAsync(
223+
destTagNames: ["myacr.azurecr.io/repo:tag"],
224+
destAcrName: "myacr.azurecr.io",
225+
srcTagName: "repo:tag",
226+
srcRegistryName: "myacr.azurecr.io",
227+
isDryRun: true);
228+
229+
mockOras.Verify(
230+
o => o.GetReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
231+
Times.Never);
232+
}
233+
234+
private static PublishConfiguration CreateAcrPublishConfig(string acrServer)
235+
{
236+
return new PublishConfiguration
237+
{
238+
RegistryAuthentication =
239+
[
240+
new RegistryAuthentication
241+
{
242+
Server = acrServer,
243+
ResourceGroup = "my-rg",
244+
Subscription = Guid.NewGuid().ToString(),
245+
ServiceConnection = new ServiceConnection
246+
{
247+
Name = "test",
248+
Id = Guid.NewGuid().ToString(),
249+
TenantId = Guid.NewGuid().ToString(),
250+
ClientId = Guid.NewGuid().ToString()
251+
}
252+
}
253+
]
254+
};
255+
}
95256
}

src/ImageBuilder.Tests/Oras/OrasDotNetServiceTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ namespace Microsoft.DotNet.ImageBuilder.Tests.Oras;
2020

2121
public class OrasDotNetServiceTests
2222
{
23+
[Theory]
24+
[InlineData(null)]
25+
[InlineData("")]
26+
[InlineData(" ")]
27+
public async Task GetReferrersAsync_NullOrWhitespaceReference_ThrowsArgumentException(string? reference)
28+
{
29+
var service = CreateService();
30+
31+
var exception = await Should.ThrowAsync<ArgumentException>(async () =>
32+
await service.GetReferrersAsync(reference!));
33+
34+
exception.ShouldNotBeNull();
35+
}
36+
2337
[Fact]
2438
public async Task PushSignatureAsync_ReadsPayloadFile()
2539
{

src/ImageBuilder/CopyImageService.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Collections.Generic;
56
using System.Linq;
67
using System.Threading.Tasks;
78
using Azure.Core;
89
using Azure.ResourceManager.ContainerRegistry.Models;
910
using Microsoft.DotNet.ImageBuilder.Configuration;
11+
using Microsoft.DotNet.ImageBuilder.Oras;
1012
using Microsoft.Extensions.Options;
1113
using Microsoft.VisualStudio.Services.Common;
1214

@@ -27,15 +29,18 @@ public class CopyImageService : ICopyImageService
2729
{
2830
private readonly ILogger<CopyImageService> _logger;
2931
private readonly IAcrImageImporter _acrRegistryImporter;
32+
private readonly IOrasService _orasService;
3033
private readonly PublishConfiguration _publishConfig;
3134

3235
public CopyImageService(
3336
ILogger<CopyImageService> logger,
3437
IAcrImageImporter acrRegistryImporter,
38+
IOrasService orasService,
3539
IOptions<PublishConfiguration> publishConfigOptions)
3640
{
3741
_logger = logger;
3842
_acrRegistryImporter = acrRegistryImporter;
43+
_orasService = orasService;
3944
_publishConfig = publishConfigOptions.Value;
4045
}
4146

@@ -92,5 +97,31 @@ public async Task ImportImageAsync(
9297
importImageContent.TargetTags.AddRange(destTagNames);
9398

9499
await _acrRegistryImporter.ImportImageAsync(destAcrName, destResourceId, importImageContent);
100+
101+
// Discover and import all OCI referrers (signatures, SBOMs, etc.) for the source image.
102+
string sourceImageReference = DockerHelper.GetImageName(srcRegistryName, srcTagName);
103+
IReadOnlyList<string> referrers = await _orasService.GetReferrersAsync(sourceImageReference);
104+
105+
string destRepo = destTagNames.First().Split(':')[0].Split('@')[0];
106+
foreach (string referrer in referrers)
107+
{
108+
_logger.LogInformation("Importing referrer '{Referrer}' to '{DestAcr}/{DestRepo}'", referrer, destAcrName, destRepo);
109+
110+
string referrerDigestReference = DockerHelper.TrimRegistry(referrer, srcRegistryName);
111+
ContainerRegistryImportSource referrerImportSrc = new(referrerDigestReference)
112+
{
113+
ResourceId = srcResourceId,
114+
RegistryAddress = srcResourceId is null ? srcRegistryName : null,
115+
Credentials = sourceCredentials
116+
};
117+
118+
ContainerRegistryImportImageContent referrerImportContent = new(referrerImportSrc)
119+
{
120+
Mode = ContainerRegistryImportMode.Force,
121+
};
122+
referrerImportContent.UntaggedTargetRepositories.Add(destRepo);
123+
124+
await _acrRegistryImporter.ImportImageAsync(destAcrName, destResourceId, referrerImportContent);
125+
}
95126
}
96127
}

src/ImageBuilder/Oras/IOrasService.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System.Collections.Generic;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.DotNet.ImageBuilder.Signing;
@@ -33,4 +34,15 @@ Task<string> PushSignatureAsync(
3334
Descriptor subjectDescriptor,
3435
PayloadSigningResult result,
3536
CancellationToken cancellationToken = default);
37+
38+
/// <summary>
39+
/// Returns the fully-qualified references of all OCI referrers for the given image.
40+
/// </summary>
41+
/// <param name="reference">Full registry reference (e.g., "registry.io/repo:tag" or "registry.io/repo@sha256:...").</param>
42+
/// <param name="cancellationToken">Cancellation token.</param>
43+
/// <returns>
44+
/// A list of digest-based image references (e.g., "registry.io/repo@sha256:abc...")
45+
/// for every referrer associated with the image.
46+
/// </returns>
47+
Task<IReadOnlyList<string>> GetReferrersAsync(string reference, CancellationToken cancellationToken = default);
3648
}

src/ImageBuilder/Oras/OrasDotNetService.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,33 @@ await Packer.PackManifestAsync(
118118
return signatureDescriptor.Digest;
119119
}
120120

121+
/// <inheritdoc/>
122+
public async Task<IReadOnlyList<string>> GetReferrersAsync(string reference, CancellationToken cancellationToken = default)
123+
{
124+
ArgumentException.ThrowIfNullOrWhiteSpace(reference);
125+
126+
_logger.LogDebug("Fetching referrers for reference: {Reference}", reference);
127+
128+
long startTime = Stopwatch.GetTimestamp();
129+
Repository repository = CreateRepository(reference);
130+
Descriptor subjectDescriptor = await repository.ResolveAsync(reference, cancellationToken);
131+
132+
List<string> referrers = [];
133+
Reference parsedRef = Reference.Parse(reference);
134+
135+
await foreach (Descriptor referrer in repository.FetchReferrersAsync(subjectDescriptor, cancellationToken))
136+
{
137+
string referrerReference = $"{parsedRef.Registry}/{parsedRef.Repository}@{referrer.Digest}";
138+
referrers.Add(referrerReference);
139+
_logger.LogDebug("Found referrer: {Referrer} (artifactType={ArtifactType})", referrerReference, referrer.ArtifactType);
140+
}
141+
142+
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
143+
_logger.LogDebug("Found {Count} referrer(s) for {Reference} in {Elapsed}", referrers.Count, reference, elapsed);
144+
145+
return referrers;
146+
}
147+
121148
/// <summary>
122149
/// Creates an authenticated ORAS repository client for the given reference.
123150
/// </summary>

0 commit comments

Comments
 (0)