|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Build.Framework;
using Microsoft.DotNet.Helix.Client;
using Microsoft.DotNet.Helix.Sdk;
using Newtonsoft.Json.Linq;
namespace Microsoft.DotNet.Helix.AzureDevOps
{
public abstract class AzureDevOpsTask : BaseTask
{
private bool InAzurePipeline => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("BUILD_BUILDNUMBER"));
protected string GetEnvironmentVariable(string name)
{
var result = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrEmpty(result))
{
throw new InvalidOperationException($"Required environment variable {name} not set.");
}
return result;
}
protected string AccessToken => GetEnvironmentVariable("SYSTEM_ACCESSTOKEN");
protected string CollectionUri => GetEnvironmentVariable("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI");
protected string TeamProject => GetEnvironmentVariable("SYSTEM_TEAMPROJECT");
protected string BuildId => GetEnvironmentVariable("BUILD_BUILDID");
protected abstract Task ExecuteCoreAsync(HttpClient client);
public override bool Execute()
=> ExecuteAsync().GetAwaiter().GetResult();
private async Task<bool> ExecuteAsync()
{
try
{
if (!InAzurePipeline)
{
Log.LogError(FailureCategory.Build, "This task must be run inside an Azure Pipelines Build");
}
else
{
// Configure the cert revocation check in a fail-open state to avoid intermittent failures
// on Mac if the endpoint is not available. This is only available on .NET Core, but has only been
// observed on Mac anyway.
// https://github.com/dotnet/dnceng/issues/6410
#if NET
using SocketsHttpHandler handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
};
handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy
{
// Yes, check revocation.
// Yes, allow it to be downloaded if needed.
// Online is the default, but it doesn't hurt to be explicit.
RevocationMode = X509RevocationMode.Online,
// Roots never bother with revocation.
// ExcludeRoot is the default, but it doesn't hurt to be explicit.
RevocationFlag = X509RevocationFlag.ExcludeRoot,
// RevocationStatusUnknown at the EndEntity/Leaf certificate will not fail the chain build.
// RevocationStatusUnknown for any intermediate CA will not fail the chain build.
// IgnoreRootRevocationUnknown could also be specified, but it won't apply given ExcludeRoot above.
// The default is that all status codes are bad, this is not the default.
VerificationFlags =
X509VerificationFlags.IgnoreCertificateAuthorityRevocationUnknown |
X509VerificationFlags.IgnoreEndRevocationUnknown,
// Always use the "now" when building the chain, rather than the "now" of when this policy object was constructed.
VerificationTimeIgnored = true,
};
using (var client = new HttpClient(handler)
#else
using (var client = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
})
#endif
{
DefaultRequestHeaders =
{
Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes("unused:" + AccessToken))),
UserAgent =
{
Helpers.UserAgentHeaderValue
},
},
})
{
await ExecuteCoreAsync(client);
}
}
}
catch (Exception ex)
{
Log.LogErrorFromException(FailureCategory.Helix, ex, true);
}
return !Log.HasLoggedErrors;
}
protected async Task RetryAsync(Func<Task> function)
{
try
{
// Grab the retry logic from the helix api client
await RetryAsync(
async () =>
{
await function();
return false; // the retry function requires a return, give it one
},
ex => Log.LogMessage(MessageImportance.Low, $"Azure Dev Ops Operation failed: {ex}\nRetrying..."),
CancellationToken.None);
}
catch (HttpRequestException ex)
{
Log.LogError(FailureCategory.Helix, ex.ToString());
}
}
protected async Task<T> RetryAsync<T>(Func<Task<T>> function)
{
// Grab the retry logic from the helix api client
try
{
return await RetryAsync(
async () => await function(),
ex => Log.LogMessage(MessageImportance.Normal, $"Azure Dev Ops Operation failed: {ex}\nRetrying..."),
CancellationToken.None);
}
catch (HttpRequestException ex)
{
Log.LogError(FailureCategory.Helix, ex.ToString());
return default;
}
}
protected async Task HandleFailedRequest(HttpRequestMessage req, HttpResponseMessage res)
{
if (res.StatusCode == HttpStatusCode.Unauthorized || res.StatusCode == HttpStatusCode.Found)
{
Log.LogError(
FailureCategory.Build,
$"A call to an Azure DevOps api returned {(int)res.StatusCode}, which may indicate a bad 'System.AccessToken' value.\n\nPlease Check the 'Make secrets available to builds of forks' in the pipeline pull request validation trigger settings.\nWe have evaluated the security considerations of this setting and have determined that it is fine to use for our public PR validation builds.");
return;
}
var statusCodeValue = (int)res.StatusCode;
var message = $"Request to {req.RequestUri} returned failed status {statusCodeValue} {res.ReasonPhrase}\n\n{(res.Content != null ? await res.Content.ReadAsStringAsync() : "")}";
if (statusCodeValue >= 400 && statusCodeValue < 500)
{
Log.LogError(FailureCategory.Build, message);
}
else
{
// we want to engage retry logic from HelixApi.RetryAsync in this case
Log.LogMessage(MessageImportance.Normal, message);
throw new HttpRequestException(message);
}
}
protected async Task<JObject> ParseResponseAsync(HttpRequestMessage req, HttpResponseMessage res)
{
if (!res.IsSuccessStatusCode)
{
await HandleFailedRequest(req, res);
return null;
}
var responseContent = await res.Content.ReadAsStringAsync();
try
{
return JObject.Parse(responseContent);
}
catch (Exception)
{
Log.LogError(FailureCategory.Helix, $"Request to {req.RequestUri} returned unexpected response: {responseContent}");
}
return null;
}
private static readonly Random s_rand = new Random();
public int RetryCount { get; set; } = 15;
public double RetryBackOffFactor { get; set; } = 1.3;
protected virtual int GetRetryDelay(int attempt)
{
var factor = RetryBackOffFactor;
var min = (int)(Math.Pow(factor, attempt) * 1000);
var max = (int)(Math.Pow(factor, attempt + 1) * 1000);
return s_rand.Next(min, max);
}
public static bool IsRetryableHttpException(Exception ex)
{
return ex is TaskCanceledException ||
ex is OperationCanceledException ||
ex is HttpRequestException ||
ex is IOException ||
ex is SocketException
;
}
public Task<T> RetryAsync<T>(Func<Task<T>> function, Action<Exception> logRetry,
CancellationToken cancellationToken)
{
return RetryAsync<T>(function, logRetry, IsRetryableHttpException, cancellationToken);
}
public async Task<T> RetryAsync<T>(Func<Task<T>> function, Action<Exception> logRetry,
Func<Exception, bool> isRetryable, CancellationToken cancellationToken)
{
var attempt = 0;
var maxAttempt = RetryCount;
cancellationToken.ThrowIfCancellationRequested();
while (true)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
return await function().ConfigureAwait(false);
}
catch (OperationCanceledException ocex) when (ocex.CancellationToken == cancellationToken)
{
throw;
}
catch (Exception ex) when (isRetryable(ex))
{
if (attempt >= maxAttempt)
{
throw;
}
logRetry(ex);
}
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(GetRetryDelay(attempt)).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
attempt++;
}
}
}
}
|