-
Notifications
You must be signed in to change notification settings - Fork 67
Expand file tree
/
Copy pathBuildCommand.cs
More file actions
863 lines (738 loc) · 41.8 KB
/
Copy pathBuildCommand.cs
File metadata and controls
863 lines (738 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.DotNet.ImageBuilder.Commands.Build;
using Microsoft.DotNet.ImageBuilder.Models.Image;
using Microsoft.DotNet.ImageBuilder.ViewModel;
namespace Microsoft.DotNet.ImageBuilder.Commands
{
public class BuildCommand : ManifestCommand<BuildOptions>
{
private readonly IDockerService _dockerService;
private readonly ILogger<BuildCommand> _logger;
private readonly IGitService _gitService;
private readonly IProcessService _processService;
private readonly ICopyImageService _copyImageService;
private readonly Lazy<IManifestService> _manifestService;
private readonly IRegistryCredentialsProvider _registryCredentialsProvider;
private readonly IAzureTokenCredentialProvider _tokenCredentialProvider;
private readonly IImageCacheService _imageCacheService;
private readonly IImageInfoService _imageInfoService;
private readonly Lazy<ImageNameResolverForBuild> _imageNameResolver;
private readonly Lazy<string?> _storageAccountToken;
public BuildCommand(
IManifestJsonService manifestJsonService,
IDockerService dockerService,
ILogger<BuildCommand> logger,
IGitService gitService,
IProcessService processService,
ICopyImageService copyImageService,
IManifestServiceFactory manifestServiceFactory,
IRegistryCredentialsProvider registryCredentialsProvider,
IAzureTokenCredentialProvider tokenCredentialProvider,
IImageCacheService imageCacheService,
IImageInfoService imageInfoService) : base(manifestJsonService)
{
_dockerService = new DockerServiceCache(dockerService ?? throw new ArgumentNullException(nameof(dockerService)));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_gitService = gitService ?? throw new ArgumentNullException(nameof(gitService));
_processService = processService ?? throw new ArgumentNullException(nameof(processService));
_copyImageService = copyImageService ?? throw new ArgumentNullException(nameof(copyImageService));
_registryCredentialsProvider = registryCredentialsProvider ?? throw new ArgumentNullException(nameof(registryCredentialsProvider));
_tokenCredentialProvider = tokenCredentialProvider ?? throw new ArgumentNullException(nameof(tokenCredentialProvider));
_imageCacheService = imageCacheService ?? throw new ArgumentNullException(nameof(imageCacheService));
_imageInfoService = imageInfoService ?? throw new ArgumentNullException(nameof(imageInfoService));
// Lazily create services which need access to options
ArgumentNullException.ThrowIfNull(manifestServiceFactory);
_manifestService = new Lazy<IManifestService>(() =>
manifestServiceFactory.Create(Options.CredentialsOptions));
_imageNameResolver = new Lazy<ImageNameResolverForBuild>(() =>
new ImageNameResolverForBuild(
Options.BaseImageOverrideOptions,
Manifest,
Options.RepoPrefix,
Options.SourceRepoPrefix));
_storageAccountToken = new Lazy<string?>(() =>
{
if (!Options.Internal)
{
return null;
}
var tokenObject = _tokenCredentialProvider.GetToken(
Options.StorageServiceConnection,
AzureScopes.StorageAccount);
return tokenObject.Token;
});
}
protected override string Description => "Builds Dockerfiles";
public override async Task ExecuteAsync()
{
Options.BaseImageOverrideOptions.Validate();
bool isImageInfoOutputEnabled = !string.IsNullOrEmpty(Options.ImageInfoOutputPath);
if (isImageInfoOutputEnabled && string.IsNullOrEmpty(Options.SourceRepoUrl))
{
throw new InvalidOperationException("Source repo URL must be provided when outputting to an image info file.");
}
ImageDigestCache imageDigestCache = new ImageDigestCache(_manifestService);
await ExecuteWithDockerCredentialsAsync(() => PullBaseImagesAsync(imageDigestCache));
_logger.LogInformation("BUILDING IMAGES");
List<TagInfo> builtTags = [];
HashSet<string> builtTagNames = [];
HashSet<PlatformData> builtPlatforms = [];
// Maps source image-info digests to copied locations so shared Dockerfile cache hits
// can resolve per-repo digests.
Dictionary<string, string> sourceDigestCopyLocationMapping = [];
Dictionary<string, PlatformData> platformDataByTag = [];
List<PlatformData> platformsWithNoPushTags = [];
ImageArtifactDetails? imageArtifactDetails =
isImageInfoOutputEnabled ? new ImageArtifactDetails() : null;
ImageArtifactDetails? srcImageArtifactDetails = null;
if (!string.IsNullOrWhiteSpace(Options.ImageInfoSourcePath))
{
srcImageArtifactDetails = ImageInfoHelper.LoadFromFile(
Options.ImageInfoSourcePath,
Manifest,
skipManifestValidation: true);
}
else if (Manifest.Model.ImageInfo is not null)
{
string imageInfoContent = await _imageInfoService.PullImageInfoArtifactAsync(
Manifest,
string.IsNullOrWhiteSpace(Options.RegistryOverride) ? Manifest.Model.Registry : Options.RegistryOverride,
Options.RepoPrefix);
srcImageArtifactDetails = ImageInfoHelper.LoadFromContent(
imageInfoContent,
Manifest,
skipManifestValidation: true);
}
foreach (RepoInfo repoInfo in Manifest.FilteredRepos)
{
RepoData repoData = CreateRepoData(repoInfo);
RepoData? srcRepoData = srcImageArtifactDetails?.Repos.FirstOrDefault(srcRepo => srcRepo.Repo == repoInfo.Name);
foreach (ImageInfo image in repoInfo.FilteredImages)
{
ImageData imageData = CreateImageData(image);
repoData.Images.Add(imageData);
ImageData? srcImageData = srcRepoData?.Images.FirstOrDefault(srcImage => srcImage.ManifestImage == image);
foreach (PlatformInfo platform in image.FilteredPlatforms)
{
// Tag the built images with the shared tags as well as the platform tags.
// Some tests and image FROM instructions depend on these tags.
List<TagInfo> allTagInfos = platform.Tags
.Concat(image.SharedTags)
.ToList();
List<string> allTags = allTagInfos
.Select(tag => tag.FullyQualifiedName)
.ToList();
List<TagInfo> concreteTags = platform.Tags.ToList();
PlatformData platformData = CreatePlatformData(image, platform);
imageData.Platforms.Add(platformData);
if (platformData.PlatformInfo is not null)
{
foreach (TagInfo tag in platformData.PlatformInfo.Tags)
{
platformDataByTag.Add(tag.FullyQualifiedName, platformData);
}
}
bool isCachedImage = false;
bool shouldCheckCache = !Options.NoCache;
if (shouldCheckCache && platform.FinalStageFromImage is not null)
{
string finalStageLocalTag =
_imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage);
shouldCheckCache = !builtTagNames.Contains(finalStageLocalTag);
}
if (shouldCheckCache)
{
ImageCacheResult cacheResult = await _imageCacheService.CheckForCachedImageAsync(
srcImageData,
platformData,
imageDigestCache,
_imageNameResolver.Value,
sourceRepoUrl: Options.SourceRepoUrl,
isLocalBaseImageExpected: true,
isDryRun: Options.IsDryRun);
if (cacheResult.State.HasFlag(ImageCacheState.Cached))
{
isCachedImage = true;
CopyPlatformDataFromCachedPlatform(platformData, cacheResult.Platform!);
platformData.IsUnchanged = cacheResult.State != ImageCacheState.CachedWithMissingTags;
await OnCacheHitAsync(
repoInfo,
allTagInfos,
pullImage: cacheResult.IsNewCacheHit,
sourceDigest: cacheResult.Platform!.Digest,
imageDigestCache,
sourceDigestCopyLocationMapping);
}
}
Dictionary<string, string> pushedDigestByTag = [];
if (!isCachedImage)
{
builtTags.AddRange(allTagInfos);
builtTagNames.UnionWith(allTags);
Dictionary<string, string> labels = [];
if (!string.IsNullOrEmpty(Options.SourceRepoUrl))
{
labels[OciAnnotations.Source] = Options.SourceRepoUrl;
labels[OciAnnotations.Revision] = _gitService.GetCommitSha(platform.DockerfilePath, useFullHash: true);
// Record which Dockerfile the image was built from, relative to the repo root.
// The repo root must be discovered via Git rather than assuming it's the manifest's directory.
string repoRoot = _gitService.GetRepoRoot(platform.DockerfilePath);
labels[ImageBuilderLabels.Dockerfile] =
PathHelper.NormalizePath(Path.GetRelativePath(repoRoot, platform.DockerfilePath));
}
if (platform.FinalStageFromImage is not null)
{
labels[OciAnnotations.BaseName] =
_imageNameResolver.Value.GetFromImagePublicTag(platform.FinalStageFromImage);
string? baseImageDigest = await imageDigestCache.GetLocalImageDigestAsync(
_imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun);
if (!string.IsNullOrEmpty(baseImageDigest))
{
labels[OciAnnotations.BaseDigest] = DockerHelper.GetDigestSha(baseImageDigest);
}
}
BuildImage(platform, allTags, labels);
builtPlatforms.Add(platformData);
if (Options.IsPushEnabled)
{
IEnumerable<TagInfo> tagsForDigest = imageArtifactDetails is null ? [] : concreteTags;
// Log in again to refresh token as it may have expired from a long build
await ExecuteWithDockerCredentialsAsync(
async () =>
{
pushedDigestByTag = await PushTagsAsync(allTagInfos, tagsForDigest, imageDigestCache);
});
if (platform.FinalStageFromImage is not null)
{
platformData.BaseImageDigest =
await imageDigestCache.GetLocalImageDigestAsync(
_imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage), Options.IsDryRun);
}
}
}
if (imageArtifactDetails is not null)
{
// Multiple concrete tags for the same platform should all resolve to the same
// digest and created date; validate each tag before preserving the shared values.
foreach (TagInfo tag in concreteTags)
{
if (Options.IsPushEnabled)
{
string? digest =
isCachedImage
? await imageDigestCache.GetLocalImageDigestAsync(tag.FullyQualifiedName, Options.IsDryRun)
: pushedDigestByTag[tag.FullyQualifiedName];
SetPlatformDataDigest(platformData, tag.FullyQualifiedName, digest);
SetPlatformDataBaseDigest(platformData, platformDataByTag);
await SetPlatformDataLayersAsync(platformData, tag.FullyQualifiedName);
}
SetPlatformDataCreatedDate(platformData, tag.FullyQualifiedName);
}
if (!concreteTags.Any())
{
platformsWithNoPushTags.Add(platformData);
}
if (!string.IsNullOrEmpty(Options.SourceRepoUrl))
{
platformData.CommitUrl = _gitService.GetDockerfileCommitUrl(platformData.PlatformInfo, Options.SourceRepoUrl);
}
}
}
}
if (repoData.Images.Any())
{
imageArtifactDetails?.Repos.Add(repoData);
}
}
if ((builtTags.Count > 0 || _imageCacheService.HasAnyCachedPlatforms)
&& !string.IsNullOrEmpty(Options.ImageInfoOutputPath)
&& imageArtifactDetails is not null)
{
List<PlatformData> allPlatforms = imageArtifactDetails.EnumeratePlatforms().ToList();
// Some platforms do not have concrete tags. In such cases, they must be duplicates of a platform in a different
// image which does have a concrete tag. For these platforms that do not have concrete tags, we are unable to
// lookup digest/created info based on their tag. Instead, we find the matching platform which does have that info
// set (as a result of having a concrete tag) and copy its values.
foreach (PlatformData platform in platformsWithNoPushTags)
{
PlatformData matchingBuiltPlatform = allPlatforms.First(builtPlatform =>
(builtPlatform.PlatformInfo?.Tags ?? []).Any() &&
platform.ImageInfo is not null &&
platform.PlatformInfo is not null &&
builtPlatform.ImageInfo is not null &&
builtPlatform.PlatformInfo is not null &&
PlatformInfo.AreMatchingPlatforms(platform.ImageInfo, platform.PlatformInfo, builtPlatform.ImageInfo, builtPlatform.PlatformInfo));
platform.Digest = matchingBuiltPlatform.Digest;
platform.Created = matchingBuiltPlatform.Created;
}
string imageInfoContent = JsonHelper.SerializeObject(imageArtifactDetails);
File.WriteAllText(Options.ImageInfoOutputPath, imageInfoContent);
}
WriteBuildSummary(builtTags);
if (!string.IsNullOrEmpty(Options.OutputVariableName))
{
WriteBuiltImagesToOutputVar(Options.OutputVariableName, builtPlatforms);
}
}
private async Task ExecuteWithDockerCredentialsAsync(Func<Task> action)
{
await _registryCredentialsProvider.ExecuteWithCredentialsAsync(
isDryRun: Options.IsDryRun,
action: action,
credentialsOptions: Options.CredentialsOptions,
registryName: Manifest.Registry);
}
private async Task ExecuteWithDockerCredentialsAsync(Action action)
{
await _registryCredentialsProvider.ExecuteWithCredentialsAsync(
isDryRun: Options.IsDryRun,
action: action,
credentialsOptions: Options.CredentialsOptions,
registryName: Manifest.Registry);
}
private void WriteBuiltImagesToOutputVar(string outputVariableName, IEnumerable<PlatformData> builtPlatforms)
{
IEnumerable<string> builtDigests = builtPlatforms
.Select(platform => DockerHelper.GetDigestString(platform.PlatformInfo!.RepoName, DockerHelper.GetDigestSha(platform.Digest)))
.Distinct();
_logger.LogInformation(
PipelineHelper.FormatOutputVariable(
outputVariableName,
string.Join(',', builtDigests)));
}
private void SetPlatformDataCreatedDate(PlatformData platform, string tag)
{
DateTime createdDate = _dockerService.GetCreatedDate(tag, Options.IsDryRun).ToUniversalTime();
if (platform.Created != default && platform.Created != createdDate)
{
// All of the tags associated with the platform should have the same Created date
throw new InvalidOperationException(
$"Tag '{tag}' has a Created date that differs from the corresponding image's Created date value of '{platform.Created}'.");
}
platform.Created = createdDate;
}
private void SetPlatformDataBaseDigest(PlatformData platform, Dictionary<string, PlatformData> platformDataByTag)
{
string? baseImageDigest = platform.BaseImageDigest;
if (platform.BaseImageDigest is null && platform.PlatformInfo?.FinalStageFromImage is not null)
{
if (!platformDataByTag.TryGetValue(platform.PlatformInfo.FinalStageFromImage, out PlatformData? basePlatformData))
{
throw new InvalidOperationException(
$"Unable to find platform data for tag '{platform.PlatformInfo.FinalStageFromImage}'. " +
"It's likely that the platforms are not ordered according to dependency.");
}
if (basePlatformData.Digest == null)
{
throw new InvalidOperationException($"Digest for platform '{basePlatformData.GetIdentifier()}' has not been calculated yet.");
}
baseImageDigest = basePlatformData.Digest;
}
if (platform.PlatformInfo?.FinalStageFromImage is not null && baseImageDigest is not null)
{
baseImageDigest = DockerHelper.GetDigestString(
DockerHelper.GetRepo(_imageNameResolver.Value.GetFromImagePublicTag(platform.PlatformInfo.FinalStageFromImage)),
DockerHelper.GetDigestSha(baseImageDigest));
}
platform.BaseImageDigest = baseImageDigest;
}
private async Task SetPlatformDataLayersAsync(PlatformData platform, string tag)
{
if (platform.Layers == null || !platform.Layers.Any())
{
platform.Layers = (await _manifestService.Value.GetImageLayersAsync(tag, Options.IsDryRun)).ToList();
}
}
private void SetPlatformDataDigest(PlatformData platform, string tag, string? digest)
{
// The digest of an image that is pushed to ACR is guaranteed to be the same when transferred to MCR.
if (digest is not null && platform.PlatformInfo is not null)
{
digest = DockerHelper.GetDigestString(platform.PlatformInfo.FullRepoModelName, DockerHelper.GetDigestSha(digest));
}
if (!string.IsNullOrEmpty(platform.Digest) && platform.Digest != digest)
{
// Pushing the same image with different tags should result in the same digest being output
throw new InvalidOperationException(
$"Tag '{tag}' was pushed with a resulting digest value that differs from the corresponding image's digest value." +
Environment.NewLine +
$"\tDigest value from image info: {platform.Digest}{Environment.NewLine}" +
$"\tDigest value retrieved from query: {digest}");
}
if (digest is null)
{
throw new InvalidOperationException($"Unable to retrieve digest for Dockerfile '{platform.Dockerfile}'.");
}
platform.Digest = digest;
}
private void CopyPlatformDataFromCachedPlatform(PlatformData dstPlatform, PlatformData srcPlatform)
{
// When a cache hit occurs for a Dockerfile, we want to transfer some of the metadata about the previously
// published image so we don't need to recalculate it again.
dstPlatform.BaseImageDigest = srcPlatform.BaseImageDigest;
dstPlatform.Layers = new List<Layer>(srcPlatform.Layers);
}
private RepoData CreateRepoData(RepoInfo repoInfo) =>
new RepoData
{
Repo = repoInfo.Name
};
private PlatformData CreatePlatformData(ImageInfo image, PlatformInfo platform)
{
PlatformData platformData = PlatformData.FromPlatformInfo(platform, image);
platformData.SimpleTags = platform.Tags
.Select(tag => tag.Name)
.OrderBy(name => name)
.ToList();
return platformData;
}
private ImageData CreateImageData(ImageInfo image)
{
ImageData imageData =
new ImageData
{
ProductVersion = image.ProductVersion
};
if (image.SharedTags.Any())
{
imageData.Manifest = new ManifestData
{
SharedTags = image.SharedTags
.Select(tag => tag.Name)
.ToList()
};
}
return imageData;
}
private void ValidatePlatformIsCompatibleWithBaseImage(PlatformInfo platform)
{
if (platform.FinalStageFromImage is null || Options.SkipPlatformCheck)
{
return;
}
string baseImageTag = _imageNameResolver.Value.GetFromImageLocalTag(platform.FinalStageFromImage);
// Base image should already be pulled or built so it's ok to inspect it
(Models.Manifest.Architecture baseImageArch, string? baseImageVariant) =
_dockerService.GetImageArch(baseImageTag, Options.IsDryRun);
// The containerd/platforms library treats default variants as implicit. Its normalizeArch
// function maps arm/"" to arm/v7, arm64/v8 to arm64/"", and amd64/v1 to amd64/"". Its
// Parse function then strips v7 back off for arm (the canonical form omits the default).
// The matcher.Match normalizes both sides before comparing, so arm/"" and arm/v7 always
// match, as do arm64/"" and arm64/v8.
// See https://github.com/containerd/containerd/blob/d1d9d07f1960f7f3648298e44963a263eac87fa5/vendor/github.com/containerd/platforms/platforms.go
//
// Image producers (buildkit, umoci/rockcraft) may omit the default variant from the OCI
// image config, which is valid per the OCI spec (variant is OPTIONAL). We mirror
// containerd's equivalence here so that our validation doesn't reject these images.
// See https://github.com/moby/buildkit/issues/4039
bool skipVariantCheck =
(platform.Model.Architecture == Models.Manifest.Architecture.ARM64
&& baseImageArch == Models.Manifest.Architecture.ARM64
&& ((platform.Model.Variant == "v8" || string.IsNullOrEmpty(platform.Model.Variant))
&& (baseImageVariant == "v8" || string.IsNullOrEmpty(baseImageVariant))))
|| (platform.Model.Architecture == Models.Manifest.Architecture.ARM
&& baseImageArch == Models.Manifest.Architecture.ARM
&& ((platform.Model.Variant == "v7" || string.IsNullOrEmpty(platform.Model.Variant))
&& (baseImageVariant == "v7" || string.IsNullOrEmpty(baseImageVariant))));
if (platform.Model.Architecture != baseImageArch || (!skipVariantCheck && platform.Model.Variant != baseImageVariant))
{
throw new InvalidOperationException(
$"Platform '{platform.DockerfilePathRelativeToManifest}' is configured with an architecture that is not compatible with " +
$"the base image '{baseImageTag}':" + Environment.NewLine +
"Manifest platform:" + Environment.NewLine +
$"\tArchitecture: {platform.Model.Architecture}" + Environment.NewLine +
$"\tVariant: {platform.Model.Variant}" + Environment.NewLine +
"Base image:" + Environment.NewLine +
$"\tArchitecture: {baseImageArch}" + Environment.NewLine +
$"\tVariant: {baseImageVariant}");
}
}
private void BuildImage(PlatformInfo platform, IEnumerable<string> allTags, IDictionary<string, string> labels)
{
ValidatePlatformIsCompatibleWithBaseImage(platform);
bool createdPrivateDockerfile = UpdateDockerfileFromCommands(platform, out string dockerfilePath);
try
{
string? buildOutput = _dockerService.BuildImage(
dockerfilePath,
platform.BuildContextPath,
platform.PlatformLabel,
allTags,
GetBuildArgs(platform),
labels,
GetDockerBuildOptions(),
Options.IsRetryEnabled,
Options.IsDryRun);
// Print image size
string? firstTag = allTags.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s));
if (firstTag is not null)
{
long size = _dockerService.GetImageSize(firstTag, Options.IsDryRun);
_logger.LogInformation($"Image size (on disk): {size} bytes");
}
if (!Options.IsSkipPullingEnabled && !Options.IsDryRun && buildOutput?.Contains("Pulling from") == true)
{
throw new InvalidOperationException(
"Build resulted in a base image being pulled. All image pulls should be done as a pre-build step. " +
"Any other image that's not accounted for means there's some kind of mistake in how things are " +
"configured or a bug in the code.");
}
}
finally
{
if (createdPrivateDockerfile)
{
File.Delete(dockerfilePath);
}
}
}
/// <summary>
/// Gets all the necessary Docker build-args for the specified platform. When building internal images, this
/// also includes the access token for the storage account service connection's access token. This refers to
/// arguments passed via the <c>--build-arg</c> option to the <c>docker build</c> command, not the arguments
/// passed directly to <c>docker build</c>.
/// </summary>
/// <remarks>
/// Platform build args (from the manifest) take precedence over any build args specified via the command line.
/// </remarks>
/// <param name="platform">The platform to get build args for.</param>
/// <returns>Dictionary of all build args to use. Translates to <c>--build-arg key=value</c></returns>
private Dictionary<string, string?> GetBuildArgs(PlatformInfo platform)
{
Dictionary<string, string?> buildArgs = [];
if (Options.Internal)
{
buildArgs["ACCESSTOKEN"] = _storageAccountToken.Value;
}
foreach (var kvp in Options.BuildArgs)
{
buildArgs[kvp.Key] = kvp.Value;
}
foreach (var kvp in platform.BuildArgs)
{
buildArgs[kvp.Key] = kvp.Value;
}
return buildArgs;
}
private IEnumerable<string> GetDockerBuildOptions() =>
Options.DockerBuildOptions.Where(option => !string.IsNullOrWhiteSpace(option));
private async Task OnCacheHitAsync(
RepoInfo repo,
IEnumerable<TagInfo> allTags,
bool pullImage,
string sourceDigest,
ImageDigestCache imageDigestCache,
Dictionary<string, string> sourceDigestCopyLocationMapping)
{
_logger.LogInformation(string.Empty);
_logger.LogInformation("CACHE HIT");
_logger.LogInformation(string.Empty);
// When a cache hit occurs on an image, we copy the image from its source location (e.g. mcr.microsoft.com) to its
// destination location (e.g. staging repo in ACR). Copying only occurs if push is enabled since it will result in
// a write operation on the registry and is essentially a push. We then pull the image by its digest. If push is enabled
// and the image was copied, we pull from the destination of the copy; otherwise, we pull directly from the source.
// The pulled image is then tagged with the same tags it would be tagged with had it been built locally. This allows
// dependent Dockerfiles that reference those tags to seamlessly consume the pulled image.
string copiedSourceDigest = sourceDigest;
if (Options.IsPushEnabled)
{
copiedSourceDigest = await CopyCachedImage(allTags, sourceDigest);
}
// Pull the image instead of building it
if (pullImage)
{
await ExecuteWithDockerCredentialsAsync(() =>
{
// Don't need to provide the platform because we're pulling by digest. No need to worry about multi-arch tags.
_dockerService.PullImage(copiedSourceDigest, null, Options.IsDryRun);
sourceDigestCopyLocationMapping[sourceDigest] = copiedSourceDigest;
});
}
// Tag the image as if it were locally built so that subsequent built images can reference it
foreach (TagInfo tag in allTags)
{
if (!sourceDigestCopyLocationMapping.TryGetValue(sourceDigest, out string? resolvedSourceDigest))
{
throw new InvalidOperationException("Digest should be mapped by this point");
}
_dockerService.CreateTag(resolvedSourceDigest, tag.FullyQualifiedName, Options.IsDryRun);
// Rewrite the digest to match the repo of the tags being associated with it. This is necessary
// in order to handle scenarios where shared Dockerfiles are being used across different repositories.
// In that scenario, the digest that is retrieved will be based on the repo of the first repository
// encountered. For subsequent cache hits on different repositories, we need to prepopulate the digest
// cache with a digest value that would correspond to that repository, not the original repository.
string newDigest = DockerHelper.GetImageName(
Manifest.Model.Registry, repo.Model.Name, digest: DockerHelper.GetDigestSha(resolvedSourceDigest));
// Populate the digest cache with the known digest value for the tags assigned to the image.
// This is needed in order to prevent a call to the manifest tool to get the digest for these tags
// because they haven't yet been pushed to staging by that time.
imageDigestCache.AddDigest(tag.FullyQualifiedName, newDigest);
}
}
private async Task<string> CopyCachedImage(IEnumerable<TagInfo> allTags, string sourceDigest)
{
string[] destTags = allTags
.Select(tagInfo => DockerHelper.TrimRegistry(tagInfo.FullyQualifiedName))
.ToArray();
string? srcRegistry = DockerHelper.GetRegistry(sourceDigest);
await _copyImageService.ImportImageAsync(
destTagNames: destTags,
destAcrName: Manifest.Registry,
srcTagName: DockerHelper.TrimRegistry(sourceDigest, srcRegistry),
copyReferrers: true,
srcRegistryName: srcRegistry);
// Redefine the source digest to be from the destination of the copy, not the source. The canonical scenario
// here is to copy the cached image from MCR to the staging location in an ACR. This allows test jobs to always pull
// from that staging location, not knowing whether it ended up there as a built image or a cached image.
string destRepo = DockerHelper.GetRepo(DockerHelper.GetRepo(destTags.First()));
sourceDigest = DockerHelper.GetImageName(Manifest.Registry, destRepo, digest: DockerHelper.GetDigestSha(sourceDigest));
return sourceDigest;
}
private async Task PullBaseImagesAsync(ImageDigestCache imageDigestCache)
{
_logger.LogInformation("PULLING LATEST BASE IMAGES");
if (Options.IsSkipPullingEnabled)
{
_logger.LogInformation("No external base images to pull");
return;
}
HashSet<string> pulledTags = [];
HashSet<string> externalFromImages = [];
foreach (PlatformInfo platform in Manifest.GetFilteredPlatforms())
{
IEnumerable<string> platformExternalFromImages = platform.ExternalFromImages.Distinct();
externalFromImages.UnionWith(platformExternalFromImages);
IEnumerable<string> tagsToPull =
platformExternalFromImages.Select(_imageNameResolver.Value.GetFromImagePullTag);
foreach (string pullTag in tagsToPull)
{
if (pulledTags.Add(pullTag))
{
// Pull the image, specifying its platform to ensure we get the necessary image in the case of
// a multi-arch tag.
_dockerService.PullImage(pullTag, platform.PlatformLabel, Options.IsDryRun);
}
}
}
if (pulledTags.Count <= 0)
{
_logger.LogInformation("No external base images to pull");
return;
}
IEnumerable<string> finalStageExternalFromImages =
Manifest.GetFilteredPlatforms()
.Where(platform =>
platform.FinalStageFromImage is not null &&
!platform.IsInternalFromImage(platform.FinalStageFromImage))
.Select(platform =>
_imageNameResolver.Value.GetFromImagePullTag(platform.FinalStageFromImage!))
.Distinct();
if (!finalStageExternalFromImages.IsSubsetOf(pulledTags))
{
throw new InvalidOperationException(
"The following tags are identified as final stage tags but were not pulled:" +
Environment.NewLine +
string.Join(", ", finalStageExternalFromImages.Except(pulledTags).ToArray()));
}
await Parallel.ForEachAsync(finalStageExternalFromImages, async (fromImage, cancellationToken) =>
{
// Ensure the digest of the pulled image is retrieved right away after pulling so it's available in
// the DockerServiceCache for later use. The longer we wait to get the digest after pulling, the
// greater chance the tag could be updated resulting in a different digest returned than what was
// originally pulled.
await imageDigestCache.GetLocalImageDigestAsync(fromImage, Options.IsDryRun);
});
// Tag the images that were pulled from the mirror as they are referenced in the Dockerfiles
Parallel.ForEach(externalFromImages, fromImage =>
{
string pullTag = _imageNameResolver.Value.GetFromImagePullTag(fromImage);
if (pullTag != fromImage)
{
_dockerService.CreateTag(pullTag, fromImage, Options.IsDryRun);
}
});
}
private async Task<Dictionary<string, string>> PushTagsAsync(
IEnumerable<TagInfo> tagsToPush,
IEnumerable<TagInfo> tagsForDigest,
ImageDigestCache imageDigestCache)
{
_logger.LogInformation("PUSHING BUILT IMAGES");
HashSet<string> digestTagNames = tagsForDigest
.Select(tag => tag.FullyQualifiedName)
.ToHashSet();
Dictionary<string, string> pushedDigestByTag = [];
foreach (TagInfo tag in tagsToPush)
{
_dockerService.PushImage(tag.FullyQualifiedName, Options.IsDryRun);
if (digestTagNames.Contains(tag.FullyQualifiedName))
{
string? digest = null;
for (int attempt = 0; attempt <= RetryHelper.MaxRetries && digest is null; attempt++)
{
digest = await imageDigestCache.GetLocalImageDigestAsync(tag.FullyQualifiedName, Options.IsDryRun);
}
if (digest is null)
{
throw new InvalidOperationException($"Unable to retrieve digest for pushed tag '{tag.FullyQualifiedName}'.");
}
pushedDigestByTag.Add(tag.FullyQualifiedName, digest);
}
}
return pushedDigestByTag;
}
private bool UpdateDockerfileFromCommands(PlatformInfo platform, out string dockerfilePath)
{
bool updateDockerfile = false;
dockerfilePath = platform.DockerfilePath;
// If a repo override has been specified, update the FROM commands.
if (platform.OverriddenFromImages.Any())
{
string dockerfileContents = File.ReadAllText(dockerfilePath);
foreach (string fromImage in platform.OverriddenFromImages)
{
string fromRepo = DockerHelper.GetRepo(fromImage);
RepoInfo repo = Manifest.FilteredRepos.First(r => r.FullModelName == fromRepo);
string newFromImage = DockerHelper.ReplaceRepo(fromImage, repo.QualifiedName);
_logger.LogInformation($"Replacing FROM `{fromImage}` with `{newFromImage}`");
Regex fromRegex = new Regex($@"FROM\s+{Regex.Escape(fromImage)}[^\s\r\n]*");
dockerfileContents = fromRegex.Replace(dockerfileContents, $"FROM {newFromImage}");
updateDockerfile = true;
}
if (updateDockerfile)
{
// Don't overwrite the original dockerfile - write it to a new path.
dockerfilePath += ".temp";
_logger.LogInformation($"Writing updated Dockerfile: {dockerfilePath}");
_logger.LogInformation(dockerfileContents);
File.WriteAllText(dockerfilePath, dockerfileContents);
}
}
return updateDockerfile;
}
private void WriteBuildSummary(IReadOnlyCollection<TagInfo> builtTags)
{
_logger.LogInformation("IMAGES BUILT");
if (builtTags.Any())
{
foreach (TagInfo tag in builtTags)
{
_logger.LogInformation(tag.FullyQualifiedName);
}
}
else
{
_logger.LogInformation("No images built");
}
_logger.LogInformation(string.Empty);
}
}
}