File: src\ExecWithRetriesForNuGetPush.cs
Web Access
Project: src\src\Microsoft.DotNet.Build.Tasks.Feed\Microsoft.DotNet.Build.Tasks.Feed.csproj (Microsoft.DotNet.Build.Tasks.Feed)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Microsoft.Arcade.Common;
using Microsoft.Build.Framework;
using Microsoft.Build.Tasks;
using Microsoft.DotNet.VersionTools.Automation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
 
namespace Microsoft.DotNet.Build.Tasks.Feed
{
    /// <summary>
    /// Run a command and retry if the exit code is not 0.
    /// </summary>
    public class ExecWithRetriesForNuGetPush : MSBuildTaskBase
    {
        [Required]
        public string Command { get; set; }
 
        public int MaxAttempts { get; set; } = 5;
 
        /// <summary>
        /// Base, in seconds, raised to the power of the number of retries so far.
        /// </summary>
        public double RetryDelayBase { get; set; } = 6;
 
        /// <summary>
        /// A constant, in seconds, added to (base^retries) to find the delay before retrying.
        /// 
        /// The default is -1 to make the first retry instant, because ((base^0)-1) == 0.
        /// </summary>
        public double RetryDelayConstant { get; set; } = -1;
 
        /// <summary>
        /// The "IgnoredErrorMessagesWithConditional" item collection
        /// allows you to specify error messages which you want to ignore.  If you
        /// specify the "ConditionalErrorMessage" metadata on the Item, then the error message is
        /// only ignored if the "conditional" error message was detected in a previous (or current)
        /// Exec attempt.
        /// 
        /// Example: <IgnoredErrorMessagesWithConditional Include="publish failed" />
        ///            Specifying this item would tell the task to report success, even if "publish failed" is detected
        ///            in the Exec output
        ///
        ///            <IgnoredErrorMessagesWithConditional Include="Overwriting existing packages is forbidden according to the package retention settings for this feed.">
        ///               <ConditionalErrorMessage>Pushing took too long</ConditionalErrorMessage>
        ///            </IgnoredErrorMessagesWithConditional>
        ///            This tells the task to report success if "Overwriting existing packages is forbidden..." is detected
        ///            in the Exec output, but only if a previous Exec attempt failed and reported "Pushing took too long".
        /// </summary>
        public ITaskItem[] IgnoredErrorMessagesWithConditional { get; set; }
 
        /// <summary>
        /// Package file that is pushed by the given command. Required if PassIfIdenticalV2Feed
        /// is set: it is read to compare against the copy of the package on the feed.
        /// </summary>
        public string PackageFile { get; set; }
 
        /// <summary>
        /// If this property specifies a v2 feed endpoint, for example
        /// "https://dotnet.myget.org/F/dotnet-core/api/v2", all errors are ignored if the feed
        /// contains the package and it's byte-for-byte identical to the one being pushed.
        /// </summary>
        public string PassIfIdenticalV2Feed { get; set; }
 
        private CancellationTokenSource _cancelTokenSource = new CancellationTokenSource();
 
        private Exec _runningExec;
 
        private INupkgInfoFactory _nupkgInfoFactory;
 
        public void Cancel()
        {
            _runningExec?.Cancel();
            _cancelTokenSource.Cancel();
        }
 
        public override void ConfigureServices(IServiceCollection collection)
        {
            collection.TryAddSingleton<INupkgInfoFactory, NupkgInfoFactory>();
            collection.TryAddSingleton<IPackageArchiveReaderFactory, PackageArchiveReaderFactory>();
        }
 
        public bool ExecuteTask(INupkgInfoFactory nupkgInfoFactory)
        {
            _nupkgInfoFactory = nupkgInfoFactory;
 
            HashSet<string> activeIgnorableErrorMessages = new HashSet<string>();
            // Add any "Ignore" messages that don't have conditionals to our active list.
            if (IgnoredErrorMessagesWithConditional != null)
            {
                foreach (var message in IgnoredErrorMessagesWithConditional)
                {
                    string conditional = message.GetMetadata("ConditionalErrorMessage");
                    if (string.IsNullOrEmpty(conditional))
                    {
                        activeIgnorableErrorMessages.Add(message.ItemSpec);
                    }
                }
            }
            for (int i = 0; i < MaxAttempts; i++)
            {
                string attemptMessage = $"(attempt {i + 1}/{MaxAttempts})";
                _runningExec = new Exec
                {
                    BuildEngine = BuildEngine,
                    Command = Command,
                    LogStandardErrorAsError = false,
                    IgnoreExitCode = true,
                    ConsoleToMSBuild = true
                };
                if (!_runningExec.Execute())
                {
                    Log.LogError("Child Exec task failed to execute.");
                    break;
                }
 
                int exitCode = _runningExec.ExitCode;
                if (exitCode == 0 || FeedContainsIdenticalPackage())
                {
                    return true;
                }
 
                if (_runningExec.ConsoleOutput != null &&
                    IgnoredErrorMessagesWithConditional != null &&
                    _runningExec.ConsoleOutput.Length > 0)
                {
                    var consoleOutput = _runningExec.ConsoleOutput.Select(c => c.ItemSpec);
                    // If the console output contains a "conditional" message, add the item to the active list.
                    var conditionMessages = IgnoredErrorMessagesWithConditional.Where(m =>
                                               consoleOutput.Any(n =>
                                                  n.Contains(m.GetMetadata("ConditionalErrorMessage"))));
                    foreach (var condition in conditionMessages)
                    {
                        activeIgnorableErrorMessages.Add(condition.ItemSpec);
                    }
                    // If an active "ignore" message is present in the console output, then return true instead of retrying.
                    foreach (var ignoreMessage in activeIgnorableErrorMessages)
                    {
                        if (consoleOutput.Any(c => c.Contains(ignoreMessage)))
                        {
                            Log.LogMessage(MessageImportance.High, $"Error detected, but error condition is valid, ignoring error \"{ignoreMessage}\"");
                            return true;
                        }
                    }
                }
                string message = $"Exec FAILED: exit code {exitCode} {attemptMessage}";
 
                if (i + 1 == MaxAttempts || _cancelTokenSource.IsCancellationRequested)
                {
                    Log.LogError(message);
                    break;
                }
 
                Log.LogMessage(MessageImportance.High, message);
 
                TimeSpan delay = TimeSpan.FromSeconds(
                    Math.Pow(RetryDelayBase, i) + RetryDelayConstant);
 
                Log.LogMessage(MessageImportance.High, $"Retrying after {delay}...");
 
                try
                {
                    Task.Delay(delay, _cancelTokenSource.Token).Wait();
                }
                catch (AggregateException e) when (e.InnerException is TaskCanceledException)
                {
                    break;
                }
            }
            return false;
        }
 
        private bool FeedContainsIdenticalPackage()
        {
            if (string.IsNullOrEmpty(PassIfIdenticalV2Feed) ||
                string.IsNullOrEmpty(PackageFile))
            {
                return false;
            }
 
            var packageInfo = _nupkgInfoFactory.CreateNupkgInfo(PackageFile);
            string packageUrl =
                $"{PassIfIdenticalV2Feed}/package/{packageInfo.Id}/{packageInfo.Version}";
 
            byte[] localBytes = File.ReadAllBytes(PackageFile);
 
            bool identical = false;
 
            try
            {
                Log.LogMessage(
                    MessageImportance.High,
                    $"Downloading package from '{packageUrl}' " +
                    $"to check if identical to '{PackageFile}'");
 
                using (var client = new HttpClient(new HttpClientHandler() { CheckCertificateRevocationList = true })
                {
                    Timeout = TimeSpan.FromMinutes(10)
                })
                using (var response = client.GetAsync(packageUrl).Result)
                {
                    byte[] remoteBytes = response.Content.ReadAsByteArrayAsync().Result;
 
                    identical = localBytes.SequenceEqual(remoteBytes);
                }
            }
            catch (Exception e)
            {
                Log.LogWarningFromException(e, true);
            }
 
            if (identical)
            {
                Log.LogMessage(
                    MessageImportance.High,
                    $"Package '{PackageFile}' is identical to feed download: ignoring push error.");
            }
            else
            {
                Log.LogMessage(
                    MessageImportance.High,
                    $"Package '{PackageFile}' is different from feed download.");
            }
 
            return identical;
        }
    }
}