From a57655db03c05afc3811b7dc3e91332fe8869c9c Mon Sep 17 00:00:00 2001 From: Joseph Petersen Date: Sun, 9 Aug 2026 08:21:28 +0200 Subject: [PATCH 1/2] Skip container blob uploads for existing manifests --- .../ImagePublisher.cs | 8 +- .../PublicAPI/net11.0/PublicAPI.Unshipped.txt | 2 + .../Registry/DefaultManifestOperations.cs | 13 ++ .../Registry/IManifestOperations.cs | 2 + .../Registry/Registry.cs | 50 ++++--- .../Resources/Strings.Designer.cs | 9 ++ .../Resources/Strings.resx | 4 + .../Resources/xlf/Strings.cs.xlf | 7 +- .../Resources/xlf/Strings.de.xlf | 7 +- .../Resources/xlf/Strings.es.xlf | 7 +- .../Resources/xlf/Strings.fr.xlf | 7 +- .../Resources/xlf/Strings.it.xlf | 7 +- .../Resources/xlf/Strings.ja.xlf | 7 +- .../Resources/xlf/Strings.ko.xlf | 7 +- .../Resources/xlf/Strings.pl.xlf | 7 +- .../Resources/xlf/Strings.pt-BR.xlf | 7 +- .../Resources/xlf/Strings.ru.xlf | 7 +- .../Resources/xlf/Strings.tr.xlf | 7 +- .../Resources/xlf/Strings.zh-Hans.xlf | 7 +- .../Resources/xlf/Strings.zh-Hant.xlf | 7 +- .../Tasks/CreateNewImage.Interface.cs | 5 + .../Tasks/CreateNewImage.cs | 2 +- .../Microsoft.NET.Build.Containers.targets | 3 + .../EndToEndTests.cs | 4 +- .../RegistryTests.cs | 124 ++++++++++++++++++ 25 files changed, 282 insertions(+), 35 deletions(-) diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs b/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs index d6a9c56850ec..7776d96f6018 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs @@ -12,6 +12,7 @@ public static async Task PublishImageAsync( BuiltImage singleArchImage, SourceImageReference sourceImageReference, DestinationImageReference destinationImageReference, + bool skipPushIfAlreadyPresent, Microsoft.Build.Utilities.TaskLoggingHelper Log, Telemetry telemetry, CancellationToken cancellationToken) @@ -37,7 +38,12 @@ await PushToRemoteRegistryAsync( destinationImageReference, Log, cancellationToken, - destinationImageReference.RemoteRegistry!.PushAsync, + (image, source, destination, token) => destinationImageReference.RemoteRegistry!.PushAsync( + image, + source, + destination, + skipPushIfAlreadyPresent, + token), Strings.ContainerBuilder_ImageUploadedToRegistry).ConfigureAwait(false); break; default: diff --git a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt index 5db830dd4f46..64714e230c34 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt +++ b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt @@ -253,6 +253,8 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.get -> b Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPushIfAlreadyPresent.get -> bool +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPushIfAlreadyPresent.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageFormat.get -> string? diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs index 363df11b416d..1fccccd5e1b0 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultManifestOperations.cs @@ -24,6 +24,19 @@ internal DefaultManifestOperations(Uri baseUri, string registryName, HttpClient _registryName = registryName; } + public async Task ExistsAsync(string repositoryName, string reference, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Head, new Uri(_baseUri, $"/v2/{repositoryName}/manifests/{reference}")).AcceptManifestFormats(); + using HttpResponseMessage response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.StatusCode switch + { + HttpStatusCode.OK => true, + _ when (int)response.StatusCode >= 500 => await LogAndThrowContainerHttpException(response, cancellationToken).ConfigureAwait(false), + _ => false, + }; + } + public async Task GetAsync(string repositoryName, string reference, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs index 221e2fe594a9..d7f50fd6b195 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/IManifestOperations.cs @@ -11,6 +11,8 @@ namespace Microsoft.NET.Build.Containers; /// internal interface IManifestOperations { + public Task ExistsAsync(string repositoryName, string reference, CancellationToken cancellationToken); + public Task GetAsync(string repositoryName, string reference, CancellationToken cancellationToken); public Task PutAsync(string repositoryName, string reference, string manifestListJson, string mediaType, CancellationToken cancellationToken); diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs index 1b359e4d7886..743774be6c2c 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs @@ -595,13 +595,24 @@ public async Task PushManifestListAsync( } public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, CancellationToken cancellationToken) - => PushAsync(builtImage, source, destination, pushTags: true, cancellationToken); + => PushAsync(builtImage, source, destination, skipIfManifestExists: false, cancellationToken); - private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool pushTags, CancellationToken cancellationToken) + public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool skipIfManifestExists, CancellationToken cancellationToken) + => PushAsync(builtImage, source, destination, pushTags: true, skipIfManifestExists, cancellationToken); + + private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool pushTags, bool skipIfManifestExists, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Registry destinationRegistry = destination.RemoteRegistry!; + bool manifestExists = skipIfManifestExists && + await _registryAPI.Manifest.ExistsAsync(destination.Repository, builtImage.ManifestDigest, cancellationToken).ConfigureAwait(false); + + if (manifestExists) + { + _logger.LogInformation(Strings.Registry_ManifestExists, builtImage.ManifestDigest, destination.Repository); + } + Func uploadLayerFunc = async (descriptor) => { cancellationToken.ThrowIfCancellationRequested(); @@ -634,25 +645,28 @@ private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, } }; - if (SupportsParallelUploads) + if (!manifestExists) { - await Task.WhenAll(builtImage.LayerDescriptors.Select(descriptor => uploadLayerFunc(descriptor))).ConfigureAwait(false); - } - else - { - foreach (var descriptor in builtImage.LayerDescriptors) + if (SupportsParallelUploads) { - await uploadLayerFunc(descriptor).ConfigureAwait(false); + await Task.WhenAll(builtImage.LayerDescriptors.Select(descriptor => uploadLayerFunc(descriptor))).ConfigureAwait(false); + } + else + { + foreach (var descriptor in builtImage.LayerDescriptors) + { + await uploadLayerFunc(descriptor).ConfigureAwait(false); + } } - } - cancellationToken.ThrowIfCancellationRequested(); - using (MemoryStream stringStream = new(Encoding.UTF8.GetBytes(builtImage.Config))) - { - var configDigest = builtImage.ImageDigest!; - _logger.LogInformation(Strings.Registry_ConfigUploadStarted, configDigest); - await UploadBlobAsync(destination.Repository, configDigest, stringStream, cancellationToken).ConfigureAwait(false); - _logger.LogInformation(Strings.Registry_ConfigUploaded); + cancellationToken.ThrowIfCancellationRequested(); + using (MemoryStream stringStream = new(Encoding.UTF8.GetBytes(builtImage.Config))) + { + var configDigest = builtImage.ImageDigest!; + _logger.LogInformation(Strings.Registry_ConfigUploadStarted, configDigest); + await UploadBlobAsync(destination.Repository, configDigest, stringStream, cancellationToken).ConfigureAwait(false); + _logger.LogInformation(Strings.Registry_ConfigUploaded); + } } // Tags can refer to an image manifest or an image manifest list. @@ -668,7 +682,7 @@ private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, _logger.LogInformation(Strings.Registry_TagUploaded, tag, RegistryName); } } - else + else if (!manifestExists) { _logger.LogInformation(Strings.Registry_ManifestUploadStarted, RegistryName, builtImage.ManifestDigest); await _registryAPI.Manifest.PutAsync(destination.Repository, builtImage.ManifestDigest, builtImage.Manifest, builtImage.ManifestMediaType, cancellationToken).ConfigureAwait(false); diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.Designer.cs b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.Designer.cs index c2320bc5174d..2bae6c608622 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.Designer.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.Designer.cs @@ -762,6 +762,15 @@ internal static string Registry_ManifestUploaded { } } + /// + /// Looks up a localized string similar to Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads.. + /// + internal static string Registry_ManifestExists { + get { + return ResourceManager.GetString("Registry_ManifestExists", resourceCulture); + } + } + /// /// Looks up a localized string similar to Uploading manifest to registry '{0}' as blob '{1}'.. /// diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx index 7a9cce993870..0637ecd19f0c 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/Strings.resx @@ -453,6 +453,10 @@ Uploaded manifest to '{0}'. {0} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. {0} is the registry name diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf index ff1f27d3cf6e..af67e46b8e8b 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.cs.xlf @@ -399,6 +399,11 @@ Nahrávání vrstvy {0} do {1} bylo dokončeno. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Manifest se nahrává do registru {0} jako objekt blob {1}. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf index 88ad45053418..fd601564236a 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.de.xlf @@ -399,6 +399,11 @@ Das Hochladen der Ebene „{0}“ nach „{1}“ wurde abgeschlossen. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Das Manifest wird in die Registrierung „{0}“ als Blob „{1}“ hochgeladen. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf index 0273d84cdfef..bed53ed7c8fa 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.es.xlf @@ -399,6 +399,11 @@ Finalizó la carga de la capa "{0}" en "{1}". {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Cargando manifiesto en el Registro "{0}" como blob "{1}". @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf index db5e696eb703..f293367e8a47 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.fr.xlf @@ -399,6 +399,11 @@ Fin du chargement de la couche «{0}» vers «{1}». {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Chargement du manifeste dans le Registre '{0}' en tant qu’objet blob '{1}'. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf index 2e2a3c1b7e0d..6df14ca3cb17 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.it.xlf @@ -399,6 +399,11 @@ Caricamento del livello '{0}' in '{1}' completato. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Caricamento del manifesto nel Registro di sistema '{0}' come BLOB '{1}'. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf index 7f089486310a..79c947dc81df 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ja.xlf @@ -399,6 +399,11 @@ レイヤー '{0}' の '{1}' へのアップロードが完了しました。 {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. BLOB '{0}' としてレジストリ '{1}' にマニフェストをアップロードしています。 @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf index 0ed455d0ce4d..68a801b63feb 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ko.xlf @@ -399,6 +399,11 @@ '{1}'에 '{0}' 계층을 업로드했습니다. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. '{0}' 레지스트리에 매니페스트를 '{1}' Blob으로 업로드하는 중입니다. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf index ad454aff8dff..8bcf7aa61b58 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pl.xlf @@ -399,6 +399,11 @@ Zakończono przekazywanie warstwy „{0}” do rejestru „{1}”. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Przekazywanie manifestu do rejestru „{0}” jako obiektu blob „{1}”. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf index b59b874d1e68..0f63c0fb8559 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.pt-BR.xlf @@ -399,6 +399,11 @@ Camada de carregamento concluída '{0}' para '{1}'. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Carregamento do manifesto para o registro '{0}' como blob '{1}'. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf index 855b66d3cbe0..24ec12d180ef 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.ru.xlf @@ -399,6 +399,11 @@ Завершена отправка слоя "{0}" в "{1}". {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Отправка манифеста в реестр "{0}" как BLOB-объект "{1}". @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf index 6094698e8978..aecd13fd48fe 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.tr.xlf @@ -399,6 +399,11 @@ '{0}' katmanının, '{1}' kayıt defterine karşıya yüklenmesi tamamlandı. {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. Bildirim, '{0}' kayıt defterine '{1}' blob olarak karşıya yükleniyor. @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf index 2a542be24d20..c11c391d7cdd 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hans.xlf @@ -399,6 +399,11 @@ 已完成将层“{0}”上传到“{1}”。 {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. 正在将清单作为 blob“{1}”上传到注册表“{0}”。 @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf index 76cd198a6152..7d15e4b79b4c 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf +++ b/src/Containers/Microsoft.NET.Build.Containers/Resources/xlf/Strings.zh-Hant.xlf @@ -399,6 +399,11 @@ 已完成上傳圖層 '{0}' 至 '{1}'。 {0} is the layer digest, {1} is the registry name + + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + Manifest '{0}' already exists in repository '{1}'. Skipping layer and configuration uploads. + {0} is the manifest digest, {1} is the repository name + Uploading manifest to registry '{0}' as blob '{1}'. 上傳資訊清單至登錄 '{0}' 做為 blob '{1}'。 @@ -491,4 +496,4 @@ - \ No newline at end of file + diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs index d33273ee309f..2611accac81d 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs @@ -166,6 +166,11 @@ partial class CreateNewImage /// public bool SkipPublishing { get; set; } + /// + /// If true, the tooling will skip layer and configuration uploads when the generated manifest already exists in the destination registry. + /// + public bool SkipPushIfAlreadyPresent { get; set; } + [Output] public string GeneratedContainerManifest { get; set; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs index 64506e88b352..69fe3e3c4e77 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs @@ -227,7 +227,7 @@ private async Task ExecuteAsyncCore(ILogger logger, ILoggerFactory msbuild if (!SkipPublishing) { - await ImagePublisher.PublishImageAsync(builtImage, sourceImageReference, destinationImageReference, Log, telemetry, cancellationToken) + await ImagePublisher.PublishImageAsync(builtImage, sourceImageReference, destinationImageReference, SkipPushIfAlreadyPresent, Log, telemetry, cancellationToken) .ConfigureAwait(false); } diff --git a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets index 07f09c6aa9b8..5451cf169802 100644 --- a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets @@ -135,6 +135,7 @@ true true true + false @@ -277,6 +278,7 @@ ContainerUser="$(ContainerUser)" RuntimeIdentifierGraphPath="$(RuntimeIdentifierGraphPath)" SkipPublishing="$(_SkipContainerPublishing)" + SkipPushIfAlreadyPresent="$(ContainerSkipPushIfAlreadyPresent)" GenerateLabels="$(ContainerGenerateLabels)" GenerateDigestLabel="$(ContainerGenerateLabelsImageBaseDigest)"> @@ -333,6 +335,7 @@ _ContainerEnvironmentVariables=@(ContainerEnvironmentVariable->'%(Identity):%(Value)'); ContainerGenerateLabels=$(ContainerGenerateLabels); ContainerGenerateLabelsImageBaseDigest=$(ContainerGenerateLabelsImageBaseDigest); + ContainerSkipPushIfAlreadyPresent=$(ContainerSkipPushIfAlreadyPresent); _SkipContainerPublishing=$(_SkipContainerPublishing); ContainerImageFormat=$(_SingleImageContainerFormat); _IsMultiRIDBuild=false; diff --git a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs index 6eb58542ad46..340a2e08ed20 100644 --- a/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs +++ b/test/Microsoft.NET.Build.Containers.IntegrationTests/EndToEndTests.cs @@ -1628,8 +1628,8 @@ public async Task CheckDownloadErrorMessageWhenSourceRepositoryThrows() (var taskLog, var errors) = SetupTaskLog(); var telemetry = new Telemetry(sourceReference, destinationReference, taskLog); - await ImagePublisher.PublishImageAsync(builtImage, sourceReference, destinationReference, taskLog, telemetry, CancellationToken.None) - .ConfigureAwait(false); + await ImagePublisher.PublishImageAsync(builtImage, sourceReference, destinationReference, false, taskLog, telemetry, CancellationToken.None) + .ConfigureAwait(false); // Assert the error message Assert.IsTrue(taskLog.HasLoggedErrors); diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs index ed6535a9b64b..51460071aac9 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs @@ -52,6 +52,130 @@ public void DockerIoAlias() Assert.AreEqual("registry-1.docker.io", registry.BaseUri.Host); } + [DataRow(HttpStatusCode.OK, true)] + [DataRow(HttpStatusCode.NotFound, false)] + [DataRow(HttpStatusCode.Unauthorized, false)] + [DataRow(HttpStatusCode.Forbidden, false)] + [DataRow(HttpStatusCode.MethodNotAllowed, false)] + [TestMethod] + public async Task ManifestExistsAsync_ReturnsExpectedResult(HttpStatusCode statusCode, bool expected) + { + ILogger logger = _loggerFactory.CreateLogger(nameof(ManifestExistsAsync_ReturnsExpectedResult)); + Mock client = new(MockBehavior.Loose); + HttpRequestMessage? request = null; + client.Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => request = message) + .ReturnsAsync(new HttpResponseMessage(statusCode)); + DefaultManifestOperations operations = new(new Uri("https://example.com"), "example.com", client.Object, logger); + + bool actual = await operations.ExistsAsync("test/repository", "sha256:1234", CancellationToken.None); + + Assert.AreEqual(expected, actual); + Assert.IsNotNull(request); + Assert.AreEqual(HttpMethod.Head, request.Method); + Assert.AreEqual("https://example.com/v2/test/repository/manifests/sha256:1234", request.RequestUri!.AbsoluteUri); + Assert.IsNotEmpty(request.Headers.Accept); + } + + [TestMethod] + public async Task ManifestExistsAsync_PropagatesServerErrors() + { + ILogger logger = _loggerFactory.CreateLogger(nameof(ManifestExistsAsync_PropagatesServerErrors)); + Mock client = new(MockBehavior.Loose); + client.Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + DefaultManifestOperations operations = new(new Uri("https://example.com"), "example.com", client.Object, logger); + + await Assert.ThrowsAsync(() => operations.ExistsAsync("test/repository", "sha256:1234", CancellationToken.None)); + } + + [TestMethod] + public async Task PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists() + { + ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists)); + const string repository = "test/repository"; + const string manifestDigest = "sha256:manifest"; + string[] tags = ["latest", "stable"]; + + Mock manifestOperations = new(MockBehavior.Strict); + manifestOperations + .Setup(m => m.ExistsAsync(repository, manifestDigest, It.IsAny())) + .ReturnsAsync(true); + foreach (string tag in tags) + { + manifestOperations + .Setup(m => m.PutAsync(repository, tag, "{}", SchemaTypes.OciManifestV1, It.IsAny())) + .Returns(Task.CompletedTask); + } + + Mock api = new(MockBehavior.Strict); + api.SetupGet(a => a.Manifest).Returns(manifestOperations.Object); + Registry registry = new("example.com", logger, api.Object); + BuiltImage image = new() + { + Config = "{}", + ImageDigest = "sha256:config", + Manifest = "{}", + ManifestDigest = manifestDigest, + ManifestMediaType = SchemaTypes.OciManifestV1, + Layers = [new ManifestLayer(SchemaTypes.OciLayerGzipV1, 123, "sha256:layer", null)], + OS = "linux", + Architecture = "amd64", + }; + SourceImageReference source = new(registry, "base/image", "latest", null); + DestinationImageReference destination = new(registry, repository, tags); + + await registry.PushAsync(image, source, destination, skipIfManifestExists: true, CancellationToken.None); + + manifestOperations.Verify(m => m.ExistsAsync(repository, manifestDigest, It.IsAny()), Times.Once); + foreach (string tag in tags) + { + manifestOperations.Verify(m => m.PutAsync(repository, tag, "{}", SchemaTypes.OciManifestV1, It.IsAny()), Times.Once); + } + api.VerifyGet(a => a.Blob, Times.Never); + } + + [TestMethod] + public async Task PushAsync_DoesNotCheckManifestUnlessEnabled() + { + ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_DoesNotCheckManifestUnlessEnabled)); + const string repository = "test/repository"; + const string configDigest = "sha256:config"; + + Mock blobOperations = new(MockBehavior.Strict); + blobOperations + .Setup(b => b.ExistsAsync(repository, configDigest, It.IsAny())) + .ReturnsAsync(true); + Mock manifestOperations = new(MockBehavior.Strict); + manifestOperations + .Setup(m => m.PutAsync(repository, "latest", "{}", SchemaTypes.OciManifestV1, It.IsAny())) + .Returns(Task.CompletedTask); + Mock api = new(MockBehavior.Strict); + api.SetupGet(a => a.Blob).Returns(blobOperations.Object); + api.SetupGet(a => a.Manifest).Returns(manifestOperations.Object); + + Registry registry = new("example.com", logger, api.Object); + BuiltImage image = new() + { + Config = "{}", + ImageDigest = configDigest, + Manifest = "{}", + ManifestDigest = "sha256:manifest", + ManifestMediaType = SchemaTypes.OciManifestV1, + Layers = [], + OS = "linux", + Architecture = "amd64", + }; + SourceImageReference source = new(registry, "base/image", "latest", null); + DestinationImageReference destination = new(registry, repository, ["latest"]); + + await registry.PushAsync(image, source, destination, CancellationToken.None); + + manifestOperations.Verify(m => m.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + blobOperations.Verify(b => b.ExistsAsync(repository, configDigest, It.IsAny()), Times.Once); + manifestOperations.Verify(m => m.PutAsync(repository, "latest", "{}", SchemaTypes.OciManifestV1, It.IsAny()), Times.Once); + } + [TestMethod] public async Task RegistriesThatProvideNoUploadSizeAttemptFullUpload() { From a80fc558d8b1d661ab7d4c542a91c7e780e08b60 Mon Sep 17 00:00:00 2001 From: Joseph Petersen Date: Tue, 11 Aug 2026 21:01:40 +0200 Subject: [PATCH 2/2] Enable container manifest reuse by default --- .../Microsoft.NET.Build.Containers/ImagePublisher.cs | 4 ++-- .../PublicAPI/net11.0/PublicAPI.Unshipped.txt | 4 ++-- .../Registry/Registry.cs | 10 +++++----- .../Tasks/CreateNewImage.Interface.cs | 4 ++-- .../Tasks/CreateNewImage.cs | 2 +- .../build/Microsoft.NET.Build.Containers.targets | 6 +++--- .../RegistryTests.cs | 12 ++++++------ 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs b/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs index 7776d96f6018..bb1cbf494412 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ImagePublisher.cs @@ -12,7 +12,7 @@ public static async Task PublishImageAsync( BuiltImage singleArchImage, SourceImageReference sourceImageReference, DestinationImageReference destinationImageReference, - bool skipPushIfAlreadyPresent, + bool noCache, Microsoft.Build.Utilities.TaskLoggingHelper Log, Telemetry telemetry, CancellationToken cancellationToken) @@ -42,7 +42,7 @@ await PushToRemoteRegistryAsync( image, source, destination, - skipPushIfAlreadyPresent, + noCache, token), Strings.ContainerBuilder_ImageUploadedToRegistry).ConfigureAwait(false); break; diff --git a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt index 64714e230c34..d67c55dbbe76 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt +++ b/src/Containers/Microsoft.NET.Build.Containers/PublicAPI/net11.0/PublicAPI.Unshipped.txt @@ -253,8 +253,8 @@ Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.get -> b Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GenerateDigestLabel.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.get -> bool Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPublishing.set -> void -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPushIfAlreadyPresent.get -> bool -Microsoft.NET.Build.Containers.Tasks.CreateNewImage.SkipPushIfAlreadyPresent.set -> void +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.NoCache.get -> bool +Microsoft.NET.Build.Containers.Tasks.CreateNewImage.NoCache.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.get -> Microsoft.Build.Framework.ITaskItem![]! Microsoft.NET.Build.Containers.Tasks.CreateNewImage.GeneratedContainerNames.set -> void Microsoft.NET.Build.Containers.Tasks.CreateNewImage.ImageFormat.get -> string? diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs index 743774be6c2c..2c4fb4eaacd6 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs @@ -595,17 +595,17 @@ public async Task PushManifestListAsync( } public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, CancellationToken cancellationToken) - => PushAsync(builtImage, source, destination, skipIfManifestExists: false, cancellationToken); + => PushAsync(builtImage, source, destination, noCache: false, cancellationToken); - public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool skipIfManifestExists, CancellationToken cancellationToken) - => PushAsync(builtImage, source, destination, pushTags: true, skipIfManifestExists, cancellationToken); + public Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool noCache, CancellationToken cancellationToken) + => PushAsync(builtImage, source, destination, pushTags: true, noCache, cancellationToken); - private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool pushTags, bool skipIfManifestExists, CancellationToken cancellationToken) + private async Task PushAsync(BuiltImage builtImage, SourceImageReference source, DestinationImageReference destination, bool pushTags, bool noCache, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Registry destinationRegistry = destination.RemoteRegistry!; - bool manifestExists = skipIfManifestExists && + bool manifestExists = !noCache && await _registryAPI.Manifest.ExistsAsync(destination.Repository, builtImage.ManifestDigest, cancellationToken).ConfigureAwait(false); if (manifestExists) diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs index 2611accac81d..29eeacbe5f49 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.Interface.cs @@ -167,9 +167,9 @@ partial class CreateNewImage public bool SkipPublishing { get; set; } /// - /// If true, the tooling will skip layer and configuration uploads when the generated manifest already exists in the destination registry. + /// If true, the tooling will upload the image without checking whether its manifest already exists in the destination registry. /// - public bool SkipPushIfAlreadyPresent { get; set; } + public bool NoCache { get; set; } [Output] public string GeneratedContainerManifest { get; set; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs index 69fe3e3c4e77..109fd9480393 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Tasks/CreateNewImage.cs @@ -227,7 +227,7 @@ private async Task ExecuteAsyncCore(ILogger logger, ILoggerFactory msbuild if (!SkipPublishing) { - await ImagePublisher.PublishImageAsync(builtImage, sourceImageReference, destinationImageReference, SkipPushIfAlreadyPresent, Log, telemetry, cancellationToken) + await ImagePublisher.PublishImageAsync(builtImage, sourceImageReference, destinationImageReference, NoCache, Log, telemetry, cancellationToken) .ConfigureAwait(false); } diff --git a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets index 5451cf169802..85bc0f746fe4 100644 --- a/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets +++ b/src/Containers/packaging/build/Microsoft.NET.Build.Containers.targets @@ -100,6 +100,7 @@ latest $([System.DateTime]::UtcNow.ToString('yyyyMMddhhmmss')) + false true true true - false @@ -278,7 +278,7 @@ ContainerUser="$(ContainerUser)" RuntimeIdentifierGraphPath="$(RuntimeIdentifierGraphPath)" SkipPublishing="$(_SkipContainerPublishing)" - SkipPushIfAlreadyPresent="$(ContainerSkipPushIfAlreadyPresent)" + NoCache="$(ContainerPushNoCache)" GenerateLabels="$(ContainerGenerateLabels)" GenerateDigestLabel="$(ContainerGenerateLabelsImageBaseDigest)"> @@ -335,7 +335,7 @@ _ContainerEnvironmentVariables=@(ContainerEnvironmentVariable->'%(Identity):%(Value)'); ContainerGenerateLabels=$(ContainerGenerateLabels); ContainerGenerateLabelsImageBaseDigest=$(ContainerGenerateLabelsImageBaseDigest); - ContainerSkipPushIfAlreadyPresent=$(ContainerSkipPushIfAlreadyPresent); + ContainerPushNoCache=$(ContainerPushNoCache); _SkipContainerPublishing=$(_SkipContainerPublishing); ContainerImageFormat=$(_SingleImageContainerFormat); _IsMultiRIDBuild=false; diff --git a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs index 51460071aac9..f945ca5ac5ca 100644 --- a/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs +++ b/test/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs @@ -90,9 +90,9 @@ public async Task ManifestExistsAsync_PropagatesServerErrors() } [TestMethod] - public async Task PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists() + public async Task PushAsync_SkipsBlobUploadsByDefaultWhenManifestAlreadyExists() { - ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists)); + ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_SkipsBlobUploadsByDefaultWhenManifestAlreadyExists)); const string repository = "test/repository"; const string manifestDigest = "sha256:manifest"; string[] tags = ["latest", "stable"]; @@ -125,7 +125,7 @@ public async Task PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists() SourceImageReference source = new(registry, "base/image", "latest", null); DestinationImageReference destination = new(registry, repository, tags); - await registry.PushAsync(image, source, destination, skipIfManifestExists: true, CancellationToken.None); + await registry.PushAsync(image, source, destination, CancellationToken.None); manifestOperations.Verify(m => m.ExistsAsync(repository, manifestDigest, It.IsAny()), Times.Once); foreach (string tag in tags) @@ -136,9 +136,9 @@ public async Task PushAsync_SkipsBlobUploadsWhenManifestAlreadyExists() } [TestMethod] - public async Task PushAsync_DoesNotCheckManifestUnlessEnabled() + public async Task PushAsync_DoesNotCheckManifestWhenNoCacheIsEnabled() { - ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_DoesNotCheckManifestUnlessEnabled)); + ILogger logger = _loggerFactory.CreateLogger(nameof(PushAsync_DoesNotCheckManifestWhenNoCacheIsEnabled)); const string repository = "test/repository"; const string configDigest = "sha256:config"; @@ -169,7 +169,7 @@ public async Task PushAsync_DoesNotCheckManifestUnlessEnabled() SourceImageReference source = new(registry, "base/image", "latest", null); DestinationImageReference destination = new(registry, repository, ["latest"]); - await registry.PushAsync(image, source, destination, CancellationToken.None); + await registry.PushAsync(image, source, destination, noCache: true, CancellationToken.None); manifestOperations.Verify(m => m.ExistsAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); blobOperations.Verify(b => b.ExistsAsync(repository, configDigest, It.IsAny()), Times.Once);