From ff79f814c1cd7bcc9902fb713fe016e6534418b4 Mon Sep 17 00:00:00 2001 From: Juan Hoyos <19413848+hoyosjs@users.noreply.github.com> Date: Fri, 20 Sep 2024 13:14:01 -0700 Subject: [PATCH 1/2] [release/8.0] Backport symbol helper tool (#15095) --- Arcade.sln | 15 + eng/Version.Details.xml | 4 + eng/Versions.props | 3 + ...rosoft.DotNet.Internal.SymbolHelper.csproj | 15 + .../ScopedTracer.cs | 67 +++ .../SymbolPromotionHelper.cs | 363 ++++++++++++ .../SymbolPublisherOptions.cs | 100 ++++ .../SymbolRequestHelpers.cs | 28 + .../SymbolUploadHelper.cs | 520 ++++++++++++++++++ .../SymbolUploadHelperFactory.cs | 181 ++++++ 10 files changed, 1296 insertions(+) create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/Microsoft.DotNet.Internal.SymbolHelper.csproj create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/ScopedTracer.cs create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPromotionHelper.cs create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPublisherOptions.cs create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/SymbolRequestHelpers.cs create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelper.cs create mode 100644 src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelperFactory.cs diff --git a/Arcade.sln b/Arcade.sln index 9f08067418c..fef54315118 100644 --- a/Arcade.sln +++ b/Arcade.sln @@ -155,6 +155,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.VersionToo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Tar", "src\Microsoft.DotNet.Tar\Microsoft.DotNet.Tar.csproj", "{B7489F31-1C2D-4FD9-BB42-6F8EF1340BAC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.Internal.SymbolHelper", "src\Microsoft.DotNet.Internal.SymbolHelper\Microsoft.DotNet.Internal.SymbolHelper.csproj", "{17C9E506-7A74-46B7-A345-ABB360E7390E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -965,6 +967,18 @@ Global {B7489F31-1C2D-4FD9-BB42-6F8EF1340BAC}.Release|x64.Build.0 = Release|Any CPU {B7489F31-1C2D-4FD9-BB42-6F8EF1340BAC}.Release|x86.ActiveCfg = Release|Any CPU {B7489F31-1C2D-4FD9-BB42-6F8EF1340BAC}.Release|x86.Build.0 = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|x64.ActiveCfg = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|x64.Build.0 = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|x86.ActiveCfg = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Debug|x86.Build.0 = Debug|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|Any CPU.Build.0 = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x64.ActiveCfg = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x64.Build.0 = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x86.ActiveCfg = Release|Any CPU + {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1007,6 +1021,7 @@ Global {AB8D5F86-60FA-416A-B047-83B1E9118425} = {3C542789-2576-48C8-9772-C9D7575F7E42} {14462553-E4E1-4F67-B954-4BF24B1DAAFE} = {3C542789-2576-48C8-9772-C9D7575F7E42} {650B7526-7B8A-45B5-B14E-C16D828891B2} = {C53DD924-C212-49EA-9BC4-1827421361EF} + {17C9E506-7A74-46B7-A345-ABB360E7390E} = {6DA9F58A-34D5-45A6-998E-5D2B8037C3FE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {32B9C883-432E-4FC8-A1BF-090EB033DD5B} diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 8778b595490..ebdb97a1562 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -60,6 +60,10 @@ https://github.com/dotnet/linker 3efd231da430baa0fd670e278f6b5c3e62834bde + + https://github.com/dotnet/diagnostics + 3007744d190d50dc586acdb761c4ce6c0d5bdc15 + https://github.com/dotnet/symreader-converter c5ba7c88f92e2dde156c324a8c8edc04d9fa4fe0 diff --git a/eng/Versions.props b/eng/Versions.props index a0f418bff2c..c198358f61a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -72,6 +72,9 @@ 8.0.100-preview.3.23178.3 1.1.0-beta2-19575-01 + 1.1.0-beta2-19575-01 + + 8.0.0-preview.24461.2 8.0.100-rtm.23479.1 diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/Microsoft.DotNet.Internal.SymbolHelper.csproj b/src/Microsoft.DotNet.Internal.SymbolHelper/Microsoft.DotNet.Internal.SymbolHelper.csproj new file mode 100644 index 00000000000..955d204aaf7 --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/Microsoft.DotNet.Internal.SymbolHelper.csproj @@ -0,0 +1,15 @@ + + + + $(NetCurrent) + true + true + + + + + + + + + diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/ScopedTracer.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/ScopedTracer.cs new file mode 100644 index 00000000000..861a4d69070 --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/ScopedTracer.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using System; +using Microsoft.SymbolStore; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +internal class ScopedTracer : ITracer +{ + private readonly ITracer _logger; + private readonly string _operationIdentifier; + + private string? _subScope; + + public ScopedTracer(ITracer logger, string operationName) + { + _logger = logger; + _operationIdentifier = $"{operationName}/{Guid.NewGuid()}"; + } + + private string ScopedMessage(string message) => _subScope is null + ? $"[{_operationIdentifier}] {message}" + : $"[{_operationIdentifier}/{_subScope}] {message}"; + + private void LogToMethod(Action logMethod, string format, object[] arguments) => logMethod(ScopedMessage(format), arguments); + + private void LogToMethod(Action logMethod, string message) => logMethod(ScopedMessage(message)); + + + public void Error(string message) => LogToMethod(_logger.Error, message); + public void Error(string format, params object[] arguments) => LogToMethod(_logger.Error, format, arguments); + public void Information(string message) => LogToMethod(_logger.Information, message); + public void Information(string format, params object[] arguments) => LogToMethod(_logger.Information, format, arguments); + public void Verbose(string message) => LogToMethod(_logger.Verbose, message); + public void Verbose(string format, params object[] arguments) => LogToMethod(_logger.Verbose, format, arguments); + public void Warning(string message) => LogToMethod(_logger.Warning, message); + public void Warning(string format, params object[] arguments) => LogToMethod(_logger.Warning, format, arguments); + public void WriteLine(string message) => LogToMethod(_logger.WriteLine, message); + public void WriteLine(string format, params object[] arguments) => LogToMethod(_logger.WriteLine, format, arguments); + public IDisposable AddSubScope(string scope) => new TokenScope(this, scope); + + private sealed class TokenScope : IDisposable + { + private readonly ScopedTracer _tracer; + private readonly string? _priorScope; + + public TokenScope(ScopedTracer tracer, string? newScope) + { + _tracer = tracer; + _priorScope = _tracer._subScope; + _tracer._subScope = _priorScope is null ? newScope : $"{_priorScope}/{newScope}"; + } + + public void Dispose() => _tracer._subScope = _priorScope; + } +} + +internal class ScopedTracerFactory +{ + private readonly ITracer _logger; + + public ScopedTracerFactory(ITracer logger) => _logger = logger; + + public ScopedTracer CreateTracer(string operationName) => new ScopedTracer(_logger, operationName); +} diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPromotionHelper.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPromotionHelper.cs new file mode 100644 index 00000000000..4198c6e5a55 --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPromotionHelper.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Core; +using Microsoft.SymbolStore; +using Polly.Retry; +using Polly; +using System.Text.Json; +using System.IO; +using System.Net.Http.Headers; +using Newtonsoft.Json.Linq; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +/// +/// This class implements the symbol request processes described in https://www.osgwiki.com/wiki/Symbols_Publishing_Pipeline_to_SymWeb_and_MSDL +/// Generally publishing workflows will just call RegisterAndPublishRequest +/// - If the request doesn't exist in the symbolrequest service, it will get registered and symbols will be published with the expected TTL and to the internal/public servers as requested. +/// - If the request is registered, the method will update the servers it's published to and the TTL. +/// - If the request is registered and is available in all target servers, only the TTL will be updated. +/// +public static class SymbolPromotionHelper +{ + private static readonly HttpClient s_client = new(); + + private static readonly JsonSerializerOptions s_options = new() { PropertyNameCaseInsensitive = true }; + + public static readonly ResiliencePropertyKey s_loggerKey = new("logger"); + + private static readonly ResiliencePipeline s_retryPipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = static args => + { + if (args.Outcome.Exception is null) { return ValueTask.FromResult(false); } + if (args.Outcome.Exception is HttpRequestException httpException) + { + bool isRetryable = (httpException.StatusCode == HttpStatusCode.Unauthorized && args.AttemptNumber == 0) // In case the token was grabbed from cache and died shortly. Retry only once in this case. + || httpException.StatusCode == HttpStatusCode.RequestTimeout + || httpException.StatusCode == HttpStatusCode.TooManyRequests + || httpException.StatusCode == HttpStatusCode.BadGateway + || httpException.StatusCode == HttpStatusCode.ServiceUnavailable + || httpException.StatusCode == HttpStatusCode.GatewayTimeout; + return ValueTask.FromResult(isRetryable); + } + return ValueTask.FromResult(false); + }, + Delay = TimeSpan.FromSeconds(5), + MaxRetryAttempts = 3, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + MaxDelay = TimeSpan.FromMinutes(1), + OnRetry = args => + { + _ = args.Context.Properties.TryGetValue(s_loggerKey, out ITracer? logger); + if (args.Outcome.Exception is HttpRequestException httpException) + { + logger?.Information("Try {0} failed with '{1}', delaying {2}", args.AttemptNumber + 1, httpException.Message, args.RetryDelay); + } + else + { + logger?.Information("Try {0} failed, delaying {1}", args.AttemptNumber, args.RetryDelay); + } + return default; + } + }) + .Build(); + + public static async Task RegisterAndPublishRequest(ITracer logger, TokenCredential credential, Environment env, + string symbolRequestProject, string requestName, uint symbolExpirationInDays, Visibility visibility, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(symbolRequestProject); + SymbolRequestHelpers.ValidateRequestName(requestName, logger); + + if (!Enum.IsDefined(visibility)) + { + logger.Error("Invalid visibility requested {0}", visibility); + return false; + } + + (string? requestRegistrationEndpoint, string? requestSpecificEndpoint, string? tokenResource) = GetEnvironmentResources(logger, env, symbolRequestProject, requestName); + if (tokenResource is null || requestSpecificEndpoint is null || requestRegistrationEndpoint is null) + { + return false; + } + + // This does mean an extra request. But this is common for cases where there's promotion of a build into different channels if we decide to optimize for + // already published requests. + SymbolRequestStatus? registration = await CheckRequestRegistration(logger, credential, env, symbolRequestProject, requestName, ct); + + if (registration is null) + { + DateTime expirationDate = DateTime.UtcNow.AddDays(symbolExpirationInDays); + JsonObject registrationPayload = new() + { + ["requestName"] = requestName, + ["expirationTime"] = expirationDate + }; + + logger.WriteLine("Requesting request '{0}' registration to '{1}' with expiration {2}", requestName, requestRegistrationEndpoint, expirationDate); + if (!await SendPostRequestWithRetries(requestRegistrationEndpoint, registrationPayload)) + { + return false; + } + } + else if (RegistrationIsRequestedInTargetServers(registration, visibility)) + { + logger.WriteLine("Registration published to all servers already. Requesting expiration in {0} days.", symbolExpirationInDays); + // if we are in all target servers, then we need to patch the servers with the new TTL which is not a post request. Call the appropriate logic. + return await UpdateRequestExpiration(logger, credential, env, symbolRequestProject, requestName, symbolExpirationInDays, ct); + } + + // We get here if we had to register, or if we are not in all target servers. Post the request as usual. + JsonObject visibilityPayload = new() + { + ["publishToInternalServer"] = (visibility >= Visibility.Internal), + ["publishToPublicServer"] = (visibility >= Visibility.Public) + }; + + logger.WriteLine("Requesting '{0}' to be visible in as follows: '{1}'", requestName, visibilityPayload); + if (!await SendPostRequestWithRetries(requestSpecificEndpoint, visibilityPayload)) + { + return false; + } + + logger.WriteLine("Successfully added request to all requested symbol servers."); + return true; + + async Task SendPostRequestWithRetries(string url, JsonObject payload) + { + ResilienceContext context = ResilienceContextPool.Shared.Get(ct); + try + { + context.Properties.Set(s_loggerKey, logger); + await s_retryPipeline.ExecuteAsync(async _ => + { + using HttpRequestMessage registerRequest = new(HttpMethod.Post, url) + { + Headers = + { + Authorization = await GetSymbolRequestAuthHeader(credential, tokenResource, ct), + }, + Content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json") + }; + + using HttpResponseMessage regResponse = await s_client.SendAsync(registerRequest, ct); + regResponse.EnsureSuccessStatusCode(); + }, context); + } + catch (Exception ex) + { + logger.Error("Request failed: {0}", ex); + if (ex is HttpRequestException httpEx && httpEx.StatusCode == HttpStatusCode.BadRequest) + { + logger.Warning("This request returned BadRequest. Make sure the request '{0}' exists in the temporary server and is finalized.", requestName); + } + return false; + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + + return true; + } + + static bool RegistrationIsRequestedInTargetServers(SymbolRequestStatus registration, Visibility visibility) => + ((visibility >= Visibility.Public) == registration.PublishToPublicServer) + && ((visibility >= Visibility.Internal) == registration.PublishToInternalServer); + } + + + public static async Task CheckRequestRegistration(ITracer logger, TokenCredential credential, + Environment env, string symbolRequestProject, string requestName, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(symbolRequestProject); + + (_, string? requestSpecificEndpoint, string? tokenResource) = GetEnvironmentResources(logger, env, symbolRequestProject, requestName); + if (tokenResource is null || requestSpecificEndpoint is null) + { + logger.Error("Can't get token resource/registration url for env {0} and project {1}", env, symbolRequestProject); + return default; + } + + logger.WriteLine("Requesting status of '{0}' from {1}", requestName, requestSpecificEndpoint); + ResilienceContext context = ResilienceContextPool.Shared.Get(ct); + try + { + return await s_retryPipeline.ExecuteAsync(async _ => + { + using HttpRequestMessage statusRequest = new(HttpMethod.Get, requestSpecificEndpoint) + { + Headers = + { + Authorization = await GetSymbolRequestAuthHeader(credential, tokenResource, ct), + Accept = { new("application/json") } + } + }; + using HttpResponseMessage statusResponse = await s_client.SendAsync(statusRequest, ct); + + if (statusResponse.StatusCode == HttpStatusCode.NotFound) + { + logger.WriteLine("Request '{0}' hasn't been registered", requestName); + return null; + } + + statusResponse.EnsureSuccessStatusCode(); + Stream result = await statusResponse.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(result, s_options, cancellationToken: ct); + }, context); + } + catch (Exception ex) + { + logger.Error("Unable to get status of request: {0}", ex); + return null; + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + private async static Task GetSymbolRequestAuthHeader(TokenCredential credential, string tokenResource, CancellationToken ct) + { + AccessToken token = await credential.GetTokenAsync(new TokenRequestContext([tokenResource]), ct); + return new AuthenticationHeaderValue("Bearer", token.Token); + } + + public static async Task UpdateRequestExpiration(ITracer logger, TokenCredential credential, + Environment env, string symbolRequestProject, string requestName, uint symbolExpirationInDays, CancellationToken ct = default) + { + SymbolRequestHelpers.ValidateRequestName(requestName, logger); + ArgumentException.ThrowIfNullOrWhiteSpace(symbolRequestProject); + + (_, string? requestSpecificEndpoint, string? tokenResource) = GetEnvironmentResources(logger, env, symbolRequestProject, requestName); + if (tokenResource is null || requestSpecificEndpoint is null) + { + logger.Error("Can't get token resource/urls for env {0} and project {1}", env, symbolRequestProject); + return default; + } + + DateTime expirationDate = DateTime.UtcNow.AddDays(symbolExpirationInDays); + JsonObject extensionPayload = new() + { + ["expirationTime"] = expirationDate + }; + logger.WriteLine("Requesting '{0}' to expire at '{1}'", requestName, expirationDate); + ResilienceContext context = ResilienceContextPool.Shared.Get(ct); + try + { + + return await s_retryPipeline.ExecuteAsync(async _ => + { + using HttpRequestMessage statusRequest = new(HttpMethod.Patch, requestSpecificEndpoint) + { + Headers = + { + Authorization = await GetSymbolRequestAuthHeader(credential, tokenResource, ct), + }, + Content = new StringContent(extensionPayload.ToString(), Encoding.UTF8, "application/json") + }; + using HttpResponseMessage statusResponse = await s_client.SendAsync(statusRequest, ct); + statusResponse.EnsureSuccessStatusCode(); + return true; + }, context); + } + catch (Exception ex) + { + logger.Error("Unable to extend request lifetime: {0}", ex); + return false; + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + private static (string? RequestRegistrationEndpoint, string? RequestSpecificEndpoint, string? TokenResource) GetEnvironmentResources(ITracer logger, Environment env, string project, string requestName) + { + + string? tokenResource = env switch + { + Environment.PPE => "api://2748228d-54c2-4c34-a8ed-c4ae31661b39", + Environment.Prod => "api://30471ccf-0966-45b9-a979-065dbedb24c1", + _ => default + }; + + if (tokenResource is null) + { + logger.Error("Can't get token resource for env {0}", env); + return default; + } + + string? requestRegistrationEndpoint = env switch + { + Environment.PPE => $"https://symbolrequestppe.trafficmanager.net/projects/{project}/requests", + Environment.Prod => $"https://symbolrequestprod.trafficmanager.net/projects/{project}/requests", + _ => default + }; + + if (requestRegistrationEndpoint is null) + { + logger.Error("Can't get registration endpoint for env {0}", env); + return default; + } + + SymbolRequestHelpers.ValidateRequestName(requestName, logger); + string requestSpecificEndpoint = $"{requestRegistrationEndpoint}/{requestName}"; + + return (requestRegistrationEndpoint, requestSpecificEndpoint, tokenResource); + } + + public enum Environment + { + PPE, + Prod + } + + public enum Visibility + { + Internal, + Public + } + + public enum Status + { + NotRequested = 0, + Submitted, + Processing, + Completed + } + + public enum Result + { + Pending = 0, + Succeeded, + Failed, + Cancelled + } + + public sealed record class SymbolRequestStatus( + string? RequestName, + DateTime? ExpirationTime, + bool PublishToInternalServer, + Status PublishToInternalServerStatus, + Result PublishToInternalServerResult, + string? PublishToInternalServerFailureMessage, + bool PublishToPublicServer, + Status PublishToPublicServerStatus, + Result PublishToPublicServerResult, + string? PublishToPublicServerFailureMessage, + string[]? FilesPublishedAsPrivateSymbolsToPublicServer, + string[]? FilesBlockedFromPublicServer); +} diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPublisherOptions.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPublisherOptions.cs new file mode 100644 index 00000000000..0d4dbb8dc62 --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolPublisherOptions.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using Azure.Core; +using System; +using System.Collections.Frozen; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +/// +/// Represents the options for a symbol client's publishing. +/// +public sealed class SymbolPublisherOptions +{ + /// The Azure DevOps organization to publish to. + /// The token credential with symbol write scope. + /// The list of package files to exclude from package publishing. Doesn't contribute to loose file publishing. Empty by default. + /// A flag indicating whether to convert portable PDBs to windows PDBs. Defaults to false. + /// A flag indicating whether to treat PDB conversion issues as informational rather than warn/error. Defaults to false. + /// The list of PDB conversion issue IDs to treat as warnings. Defaults to empty. + /// Symbol client per-operation timeout in minutes. Defaults to 10 mins. + /// A flag indicating whether to publish CLR files under their special diagnostic indexes. Defaults to false. + /// A flag indicating whether to enable verbose client logging. Defaults to false. + /// A flag indicating whether to perform a dry run. Defaults to false. + public SymbolPublisherOptions( + string azdoOrg, + TokenCredential credential, + IEnumerable? packageFileExcludeList = null, + bool convertPortablePdbs = false, + bool treatPdbConversionIssuesAsInfo = false, + IEnumerable? pdbConversionTreatAsWarning = null, + uint operationTimeoutInMins = 10, + bool dotnetInternalPublishSpecialClrFiles = false, + bool verboseClient = false, + bool isDryRun = false) + { + AzdoOrg = azdoOrg is not null and not "" ? azdoOrg : throw new ArgumentException("Organization can't be null or empty", nameof(azdoOrg)); + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + PackageFileExcludeList = packageFileExcludeList is null ? FrozenSet.Empty : packageFileExcludeList.ToFrozenSet(); + ConvertPortablePdbs = convertPortablePdbs; + TreatPdbConversionIssuesAsInfo = treatPdbConversionIssuesAsInfo; + PdbConversionTreatAsWarning = pdbConversionTreatAsWarning is null ? FrozenSet.Empty : pdbConversionTreatAsWarning.ToFrozenSet(); + OperationTimeoutInMins = operationTimeoutInMins; + DotnetInternalPublishSpecialClrFiles = dotnetInternalPublishSpecialClrFiles; + VerboseClient = verboseClient; + IsDryRun = isDryRun; + } + + /// + /// The Azure DevOps organization a symbol upload targets. + /// + public string AzdoOrg { get; } + + /// + /// The token credential with vso.symbols_write perms to the associated Azure DevOps org. + /// + public TokenCredential Credential { get; } + + /// + /// List of package-root-relative files to exclude from publishing if found in symbol packages. + /// + public FrozenSet PackageFileExcludeList { get; } + + /// + /// A flag indicating whether the client should try to convert portable PDBs to classic on upload. + /// + public bool ConvertPortablePdbs { get; } + + /// + /// A flag indicating whether to treat PDB conversion issues as information. + /// + public bool TreatPdbConversionIssuesAsInfo { get; } + + /// + /// List of PDB conversion issue IDs to treat as warnings. + /// + public FrozenSet PdbConversionTreatAsWarning { get; } + + /// + /// Symbol client per-operation timeout in minutes. + /// + public uint OperationTimeoutInMins { get; } + + /// + /// Flag indicating whether to publish special CLR files for dotnet internal builds. + /// + public bool DotnetInternalPublishSpecialClrFiles { get; } + + /// + /// Flag indicating whether to enable verbose client logging. + /// + public bool VerboseClient { get; } + + /// + /// Flag indicating whether to perform a dry run, unwrapping packages and logging commands and files to be uploaded without executing uploading agent. + /// + public bool IsDryRun { get; } +} diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolRequestHelpers.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolRequestHelpers.cs new file mode 100644 index 00000000000..a8eee6b775b --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolRequestHelpers.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using System; +using Microsoft.SymbolStore; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +internal static class SymbolRequestHelpers +{ + internal static void ValidateRequestName(string? name, ITracer logger) + { + if (name is null or "") + { + logger.Error("Can't create a request with an empty name."); + throw new ArgumentException("Name must be specified", nameof(name)); + } + + if (name.Contains('+')) + { + // This is a restriction of the symbol request pipeline and not of symbol.exe + // we share this between upload and promotion to prevent downstream issues. + logger.Error("Requests can't contain '+' in their name"); + throw new ArgumentException("Request can't contain a '+'", nameof(name)); + } + } +} diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelper.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelper.cs new file mode 100644 index 00000000000..a918f0e0317 --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelper.cs @@ -0,0 +1,520 @@ + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.DiaSymReader.Tools; +using Microsoft.SymbolStore; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +/// +/// Helper class for uploading symbols to a symbol server. This file assumes the logger to be thread safe. +/// All state within this is immutable after construction, and the class is thread safe. Multiple uploads +/// can be done in parallel with the same instance. +/// The usual workflow is to create a request, add files and packages to it as needed. Finally, the request +/// can be finalized with some TTL if all uploads. Otherwise, if assets fail to upload, the request can be +/// deleted. +/// There's a few options for the helper that can be controlled by the passed in, +/// notably the ability to convert portable PDBs to Windows PDBs and the ability to generate a special manifest +/// for the official runtime builds. +/// +public sealed class SymbolUploadHelper +{ + public const string ConversionFolderName = "_convertedPdbs"; + private const string AzureDevOpsResource = "499b84ac-1321-427f-aa17-267ca6975798"; + private const string PathEnvVarName = "AzureDevOpsToken"; + private static readonly FrozenSet s_validExtensions = FrozenSet.ToFrozenSet(["", ".exe", ".dll", ".pdb", ".so", ".dbg", ".dylib", ".dwarf", ".r2rmap"]); + private readonly ScopedTracerFactory _tracerFactory; + private readonly ScopedTracer _globalTracer; + private readonly string _workingDir; + private readonly TokenCredential _credential; + private readonly string _commonArgs; + private readonly string _symbolToolPath; + private readonly PdbConverter? _pdbConverter; + private readonly uint _symbolToolTimeoutInMins; + private readonly bool _shouldGenerateManifest; + private readonly bool _shouldConvertPdbs; + private readonly bool _isDryRun; + private readonly FrozenSet _packageFileExclusions; + private readonly bool _treatPdbConversionIssuesAsInfo; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + /// The path to the symbol tool. + /// The symbol publisher options. + /// The working directory. + internal SymbolUploadHelper(ITracer logger, string symbolToolPath, SymbolPublisherOptions options, string? workingDir = null) + { + // These are all validated by the factory since this constructor is internal. + // If these invariants change, the factory should be updated. + Debug.Assert(logger is not null); + Debug.Assert(options is not null); + Debug.Assert(!string.IsNullOrEmpty(symbolToolPath) && (File.Exists(symbolToolPath) || options.IsDryRun)); + + _tracerFactory = new ScopedTracerFactory(logger!); + _globalTracer = _tracerFactory.CreateTracer(nameof(SymbolUploadHelper)); + _symbolToolTimeoutInMins = options.OperationTimeoutInMins; + + _commonArgs = $"-s https://artifacts.dev.azure.com/{options!.AzdoOrg} --patAuthEnvVar {PathEnvVarName} -t --timeout {_symbolToolTimeoutInMins}"; + if (options.VerboseClient) + { + // the true verbosity level is "verbose" but the tool is very chatty at that level. + // "info" is a good balance for the errors that tend to come up in our layer. + _commonArgs += " --tracelevel info"; + } + else + { + _commonArgs += " --tracelevel warn"; + } + + _workingDir = workingDir ?? Path.GetTempPath(); + _credential = options.Credential; + _symbolToolPath = symbolToolPath; + + // This is a special case for dotnet internal builds, particularly to control the special indexing of + // diagnostic artifacts coming from the runtime build. Any runtime pack or cross OS diagnostic symbol + // package needs this - and it will generate a special JSON manifest for the symbol client to consume. + // All other builds should not set this flag in the interest of perf. + _shouldGenerateManifest = options.DotnetInternalPublishSpecialClrFiles; + + // This is an extremely slow operation and should be used sparingly. We usually only want to do this + // in the staging/release pipeline, not in the nightly build pipeline. + _shouldConvertPdbs = options.ConvertPortablePdbs; + _isDryRun = options.IsDryRun; + _packageFileExclusions = options.PackageFileExcludeList; + + if (_shouldConvertPdbs) + { + _treatPdbConversionIssuesAsInfo = options.TreatPdbConversionIssuesAsInfo; + _pdbConverter = new PdbConverter(diagnostic => + { + string message = diagnostic.ToString(); + if (_treatPdbConversionIssuesAsInfo) + { + _globalTracer.Information(message); + } + else if (options.PdbConversionTreatAsWarning.Contains((int)diagnostic.Id)) + { + _globalTracer.Warning(message); + } + else + { + _globalTracer.Error(message); + } + }); + } + } + + public async Task GetClientDiagnosticInfo() + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(GetClientDiagnosticInfo)); + logger.Information("Client Path: {0}", _symbolToolPath); + return await RunSymbolCommand("help", ".", logger).ConfigureAwait(false); + } + + /// + /// Creates a symbol request. + /// + /// The name of the symbol request. + /// The result of the operation. + public async Task CreateRequest(string? name) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(CreateRequest)); + + SymbolRequestHelpers.ValidateRequestName(name, logger); + + logger.Information("Creating symbol request: {0}", name!); + string arguments = $"create {_commonArgs} --name {name}"; + return await RunSymbolCommand(arguments, ".", logger).ConfigureAwait(false); + } + + /// + /// Adds directory to a symbol request. This will convert portable PDBs as long as the PE is next to the + /// PDB and the options specified conversion. + /// + /// The name of the symbol request to append to. Must be non-finalized. + /// The files to add. + /// The result of the operation. + public async Task AddDirectory(string? name, string pathToAdd) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(AddDirectory)); + SymbolRequestHelpers.ValidateRequestName(name, logger); + try + { + if (_shouldConvertPdbs) + { + ConvertPortablePdbsInDirectory(logger, pathToAdd); + } + + return await AddDirectoryCore(name!, pathToAdd, manifestPath: null, logger).ConfigureAwait(false); + } + finally + { + string convertedFolder = GetConvertedPdbFolder(pathToAdd); + if (_shouldConvertPdbs && !Directory.Exists(convertedFolder)) + { + logger.Information("Cleaning up symbol conversion directory {0}", convertedFolder); + try { Directory.Delete(convertedFolder, recursive: true); } catch { } + } + } + } + + /// + /// Adds a package to a symbol request. This respects conversion requests and manifest generation + /// if such options were specified at helper creation time. + /// + /// The name of the symbol request. + /// The path to the package. + /// The result of the operation. + public async Task AddPackageToRequest(string? name, string packagePath) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(AddPackagesToRequest)); + SymbolRequestHelpers.ValidateRequestName(name, logger); + string packageName = Path.GetFileName(packagePath); + using IDisposable scopeToken = logger.AddSubScope(packageName); + return await AddPackageToRequestCore(name!, packagePath, logger).ConfigureAwait(false); + } + + /// + /// Adds multiple packages to a symbol request. This respects conversion requests and manifest generation + /// if such options were specified at helper creation time. + /// + /// The name of the symbol request. + /// The paths to the packages. + /// The result of the operation. + public async Task AddPackagesToRequest(string? name, IEnumerable packagePaths) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(AddPackagesToRequest)); + SymbolRequestHelpers.ValidateRequestName(name, logger); + + int result = 0; + + foreach (string package in packagePaths) + { + string packageName = Path.GetFileName(package); + using IDisposable scopeToken = logger.AddSubScope(packageName); + result = await AddPackageToRequestCore(name!, package, logger).ConfigureAwait(false); + if (result != 0) + { + break; + } + } + return result; + } + + /// + /// Finalizes a symbol request. + /// + /// The name of the symbol request. + /// The number of days to retain the request. + /// The result of the operation. + public async Task FinalizeRequest(string? name, uint daysToRetain) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(FinalizeRequest)); + SymbolRequestHelpers.ValidateRequestName(name, logger); + + logger.WriteLine("Finalize symbol request: {0}", name!); + string arguments = $"finalize {_commonArgs} --name {name} --expirationInDays {daysToRetain}"; + return await RunSymbolCommand(arguments, ".", logger).ConfigureAwait(false); + } + + /// + /// Deletes a symbol request. + /// + /// The name of the symbol request. + /// The result of the operation. + public async Task DeleteRequest(string? name, bool synchronous = false) + { + ScopedTracer logger = _tracerFactory.CreateTracer(nameof(DeleteRequest)); + SymbolRequestHelpers.ValidateRequestName(name, logger); + logger.WriteLine("Deleting symbol request: {0}", name!); + string arguments = $"delete {_commonArgs} --name {name} --quiet"; + if (synchronous) + { + arguments += " --synchronous"; + } + return await RunSymbolCommand(arguments, ".", logger).ConfigureAwait(false); + } + + private async Task AddDirectoryCore(string name, string pathToAdd, string? manifestPath, ScopedTracer logger) + { + logger.WriteLine("Adding directory {0} to request {1}", pathToAdd, name); + string arguments = $"adddirectory {_commonArgs} -n {name} --directory {pathToAdd} --recurse true"; + + if (manifestPath is not null) + { + arguments += " --manifest " + manifestPath; + } + + return await RunSymbolCommand(arguments, pathToAdd, logger).ConfigureAwait(false); + } + + private async Task AddPackageToRequestCore(string name, string packagePath, ScopedTracer logger) + { + // Create a temporary directory to extract the package contents. + DirectoryInfo packageDirInfo = CreateTempDirectory(); + string packageExtractDir = packageDirInfo.FullName; + try + { + logger.WriteLine("Processing package"); + using ZipArchive archive = ZipFile.Open(packagePath, ZipArchiveMode.Read); + + logger.Information("Extracting symbol package {0} to {1}", packagePath, packageExtractDir); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (entry.FullName.EndsWith('/')) + { + Debug.Assert(entry.Length == 0); + continue; + } + + if (!ShouldIndexPackageFile(entry.FullName)) + { + logger.Verbose("Skipping {0}", entry.FullName); + continue; + } + + logger.Verbose("Extracting {0}", entry.FullName); + string entryPath = Path.Combine(packageExtractDir, entry.FullName); + _ = Directory.CreateDirectory(Path.GetDirectoryName(entryPath)!); + using Stream entryStream = entry.Open(); + using FileStream entryFile = File.Create(entryPath); + await entryStream.CopyToAsync(entryFile).ConfigureAwait(false); + } + + if (_shouldConvertPdbs) + { + ConvertPortablePdbsInDirectory(logger, packageExtractDir); + } + + string? manifest = null; + if (_shouldGenerateManifest) + { + manifest = Path.Combine(packageExtractDir, "correlatedSymKeysManifest.json"); + if (!SymbolManifestGenerator.SymbolManifestGenerator.GenerateManifest(logger, packageDirInfo, manifest, specialFilesRequireAdjacentRuntime: false)) + { + logger.Error("Failed to generate symbol manifest"); + return -1; + } + logger.Verbose("Generated manifest in {0}", manifest); + } + + logger.WriteLine("Adding package {0} to request {1}", packagePath, name); + return await AddDirectoryCore(name, packageExtractDir, manifest, logger).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Error("Failed to process package {0}: {1}", packagePath, ex); + return -1; + } + finally + { + logger.Information("Cleaning up temporary directory {0}", packageDirInfo.FullName); + try { packageDirInfo.Delete(recursive: true); } catch {} + } + + bool ShouldIndexPackageFile(string relativeFilePath) + { + if (relativeFilePath.StartsWith("ref/") + || relativeFilePath.StartsWith("_rels/") + || relativeFilePath.StartsWith("package/") + || relativeFilePath.EndsWith("_.pdb")) + { + // Quick bail - special nupkg files and ref assemblies are not indexed. + return false; + } + + relativeFilePath = relativeFilePath.Replace("//", "/"); + + if (_packageFileExclusions.Contains(relativeFilePath)) + { + return false; + } + + string extension = Path.GetExtension(relativeFilePath); + return s_validExtensions.Contains(extension); + } + } + + private void ConvertPortablePdbsInDirectory(ScopedTracer logger, string filesDir) + { + Action logWarning = _treatPdbConversionIssuesAsInfo ? logger.Information : logger.Error; + string convertedPdbFolder = GetConvertedPdbFolder(filesDir); + _ = Directory.CreateDirectory(convertedPdbFolder); + foreach (string file in Directory.EnumerateFiles(filesDir, "*.pdb", SearchOption.AllDirectories)) + { + using Stream pdbStream = File.OpenRead(file); + if (!PdbConverter.IsPortable(pdbStream)) + { + continue; + } + + logger.Verbose("Converting {0} to classic PDB format", file); + + string pePath = Path.ChangeExtension(file, ".dll"); + // Try to fall back to the framework exe scenario. + if (!File.Exists(pePath)) + { + pePath = Path.ChangeExtension(file, ".exe"); + } + + if (!File.Exists(pePath)) + { + logWarning($"Conversion error: could not find matching PE file for {file}"); + continue; + } + + string convertedPdbPath = Path.Combine(convertedPdbFolder, Path.GetFileName(file)); + + try + { + using Stream peStream = File.OpenRead(pePath); + using Stream convertedPdbStream = File.Create(convertedPdbPath); + _pdbConverter!.ConvertPortableToWindows(peStream, pdbStream, convertedPdbStream); + } + catch (Exception ex) + { + logWarning($"Conversion error: {ex.Message}"); + continue; + } + + logger.Verbose("Converted successfully to {0}.", convertedPdbPath); + } + } + + private static string GetConvertedPdbFolder(string filesDir) => Path.Combine(filesDir, ConversionFolderName); + + private DirectoryInfo CreateTempDirectory() + { + string tempDir = Path.Combine(_workingDir, Path.GetRandomFileName()); + while (Directory.Exists(tempDir) || File.Exists(tempDir)) + { + tempDir = Path.Combine(_workingDir, Path.GetRandomFileName()); + } + + return Directory.CreateDirectory(tempDir); + } + + private async Task RunSymbolCommand(string arguments, string directory, ScopedTracer logger, CancellationToken ct = default) + { + // TODO: Add retry logic. Need to parse output stream for this. + logger.Verbose("Running command: {0} {1} from '{2}'", _symbolToolPath, arguments, directory); + using IDisposable scopedTrace = logger.AddSubScope("symbol.exe"); + + if (_isDryRun) + { + logger.Information("Would run command: {0} {1} from '{2}'", _symbolToolPath, arguments, directory); + return 0; + } + + // This sentinel task is used to indicate that the output has been fully read. It's never completed. + TaskCompletionSource outputFinishedSentinel = new(); + Stopwatch sw = Stopwatch.StartNew(); + + try + { + AccessToken token = await _credential.GetTokenAsync(new TokenRequestContext([AzureDevOpsResource]), ct).ConfigureAwait(false); + ProcessStartInfo info = new(_symbolToolPath, arguments) + { + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + WorkingDirectory = directory, + Environment = { [PathEnvVarName] = token.Token } + }; + + using CancellationTokenSource lcts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + using Process process = new() + { + StartInfo = info + }; + + _ = process.Start(); + + lcts.CancelAfter(TimeSpan.FromMinutes(_symbolToolTimeoutInMins)); + ct = lcts.Token; + + Task processExit = process.WaitForExitAsync(ct); + + StreamReader standardOutput = process.StandardOutput; + Task outputAvailable = standardOutput.ReadLineAsync(ct).AsTask(); + + StreamReader standardError = process.StandardError; + Task errorAvailable = standardError.ReadLineAsync(ct).AsTask(); + + while (!ct.IsCancellationRequested && (outputAvailable != outputFinishedSentinel.Task || errorAvailable != outputFinishedSentinel.Task)) + { + if (processExit.IsCompleted) + { + // We already did the work. Might as well drain the IO. + lcts.Dispose(); + logger.Verbose("uploader completion detected after {0}. Draining I/O streams.", sw.Elapsed); + } + + Task alertedTask = await Task.WhenAny(outputAvailable, errorAvailable).ConfigureAwait(false); + + if (alertedTask == outputAvailable) + { + outputAvailable = await LogFromStreamReader(outputAvailable, standardOutput.ReadLineAsync, logger.Verbose, ct); + } + else if (alertedTask == errorAvailable) + { + errorAvailable = await LogFromStreamReader(errorAvailable, standardError.ReadLineAsync, logger.Error, ct); + } + } + + if (ct.IsCancellationRequested && !process.HasExited) + { + try { process.Kill(); } catch (InvalidOperationException) { } + return -1; + } + + // This should be a no-op if the process has already exited. Since it's not the ct doing this or we'd have exited, and we drained both + // output streams, this is the expected scenario. + await processExit.ConfigureAwait(false); + logger.Information("completed after {0} with exit code {1}", sw.Elapsed, process.ExitCode); + return process.ExitCode; + } + catch (Exception ex) + { + logger.Error("Unable to finish invocation or drain its output after {0}: {1}", sw.Elapsed, ex); + return -1; + } + + async Task> LogFromStreamReader(Task outputTask, Func> readLine, Action logMethod, CancellationToken ct) + { + ValueTask vt = new(outputTask); + + while (!ct.IsCancellationRequested && outputTask.IsCompleted) + { + string? line = await vt.ConfigureAwait(false); + if (line is not null) + { + logMethod(line); + vt = readLine(ct); + } + else + { + return outputFinishedSentinel.Task; + } + } + + return vt.AsTask(); + } + } +} diff --git a/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelperFactory.cs b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelperFactory.cs new file mode 100644 index 00000000000..3bda102a19f --- /dev/null +++ b/src/Microsoft.DotNet.Internal.SymbolHelper/SymbolUploadHelperFactory.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable enable +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Azure.Core; +using Microsoft.SymbolStore; +using Polly; +using Polly.Retry; + +namespace Microsoft.DotNet.Internal.SymbolHelper; + +public class SymbolUploadHelperFactory +{ + private static readonly HttpClient s_symbolDownloadClient = new(); + + /// + /// Gets a instance, downloading the client for the appropriate Azure DevOps organization. + /// + /// An instance to log to and pass to the client. + /// The options for the symbol upload client. + /// Optional. The directory to install the symbol tool. This folder will get cleaned before download. If not supplied, a random temporary folder is used. + /// Optional. The number of times to retry the download for transient errors. Defaults to 3. + /// Optional. The cancellation token to use during symbol download. + /// A instance for the Azure DevOps organization's symbol server version. + /// If or is null. + /// If the host is not supported for symbol publishing. + /// If the download response does not contain the expected URI. + /// If the symbol client download fails after retries. + /// If the symbol tool is not found after download. + public static async Task GetSymbolHelperWithDownloadAsync(ITracer logger, SymbolPublisherOptions options, string? installDirectory = null, string? workingDir = null, int retryCount = 3, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(options); + ThrowIfHostUnsupported(); + + installDirectory ??= Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + if (Directory.Exists(installDirectory)) + { + Directory.Delete(installDirectory, recursive: true); + } + + _ = Directory.CreateDirectory(installDirectory); + + string localToolPath = await DownloadSymbolsToolAsync(logger, options.AzdoOrg, installDirectory, retryCount, token); + + return GetSymbolHelperFromLocalTool(logger, options, localToolPath, workingDir); + } + + /// + /// Gets a instance from a local available client tool. + /// + /// An instance to log to and pass to the client. + /// The directory containing the symbol tool. + /// The options for the symbol upload client. + /// If or is null. + /// If the host is not supported for symbol publishing. + /// If the symbol tool is not found after download. + public static SymbolUploadHelper GetSymbolHelperFromLocalTool(ITracer logger, SymbolPublisherOptions options, string symbolToolDirectory, string? workingDir = null) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(options); + ThrowIfHostUnsupported(); + + string expectedSymbolPath = GetSymbolToolPathFromInstallDir(symbolToolDirectory); + + if (!options.IsDryRun && !File.Exists(expectedSymbolPath)) + { + logger.Error($"Symbol tool not found at {expectedSymbolPath}"); + throw new FileNotFoundException("Symbol tool not found", expectedSymbolPath); + } + + return new SymbolUploadHelper(logger, expectedSymbolPath, options, workingDir); + } + + /// If or is null. + /// If is null or empty. + /// If the host is not supported for symbol publishing. + /// If the symbol client download fails after retries. + /// If the symbol tool is not found after download. + private static async Task DownloadSymbolsToolAsync( + ITracer logger, string azdoOrg, + string installDirectory, int retryCount = 3, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(installDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(azdoOrg); + ThrowIfHostUnsupported(); + + ResiliencePipeline pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = static args => + { + if (args.Outcome.Exception is null) { return ValueTask.FromResult(false); } + if (args.Outcome.Exception is HttpRequestException httpException) + { + return ValueTask.FromResult( + httpException.StatusCode == HttpStatusCode.RequestTimeout + || httpException.StatusCode == HttpStatusCode.TooManyRequests + || httpException.StatusCode == HttpStatusCode.BadGateway + || httpException.StatusCode == HttpStatusCode.ServiceUnavailable + || httpException.StatusCode == HttpStatusCode.GatewayTimeout); + } + return ValueTask.FromResult(false); + }, + Delay = TimeSpan.FromSeconds(15), + MaxRetryAttempts = retryCount, + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, + MaxDelay = TimeSpan.FromMinutes(5), + OnRetry = args => + { + if (args.Outcome.Exception is HttpRequestException httpException) + { + logger.Information("Try {0} failed with '{1}', delaying {2}", args.AttemptNumber + 1, httpException.Message, args.RetryDelay); + } + else + { + logger.Information("Try {0} failed, delaying {1}", args.AttemptNumber, args.RetryDelay); + } + return default; + } + }) + .Build(); + + string toolZipPath = await pipeline.ExecuteAsync(async token => await GetToolUrl(logger, azdoOrg, installDirectory, token), token); + + using ZipArchive archive = ZipFile.OpenRead(toolZipPath); + archive.ExtractToDirectory(installDirectory); + + return installDirectory; + + static async Task GetToolUrl(ITracer logger, string azdoOrg, string installDirectory, CancellationToken token) + { + string downloadUri = $"https://vsblob.dev.azure.com/{azdoOrg}/_apis/clienttools/symbol/download?osName=windows&arch=x86_64"; + + logger.Information($"Fetching symbol tool from {downloadUri}. Installing to {installDirectory}"); + + using HttpRequestMessage getToolRequest = new(HttpMethod.Get, downloadUri) { Headers = { Accept = { new ("application/zip") } } }; + + // Suppress the redirect to the login page + getToolRequest.Headers.Add("X-TFS-FedAuthRedirect", "Suppress"); + + using HttpResponseMessage response = await s_symbolDownloadClient.SendAsync(getToolRequest, token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + string zipFilePath = Path.Combine(installDirectory, "symbol.zip"); + using (FileStream fileStream = new(zipFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (Stream zipStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false)) + { + await zipStream.CopyToAsync(fileStream, token).ConfigureAwait(false); + } + + logger.Information($"Successfully downloaded tool from {zipFilePath}"); + return zipFilePath; + } + } + + private static string GetSymbolToolPathFromInstallDir(string installDirectory) => Path.Combine(installDirectory, "symbol.exe"); + + // This method is used to ensure that the host is supported for symbol publishing. + // We rely on DIA for symbol conversion (windows only) and on x64 for the upload client. + private static void ThrowIfHostUnsupported() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.ProcessArchitecture != Architecture.X64) + { + throw new InvalidOperationException("Symbol publishing currently relies on Windows x64 hosting"); + } + } +} From 69abe6b2063083c0b35fc3a5b16cb2bdbaf5e8b0 Mon Sep 17 00:00:00 2001 From: Pavel Purma Date: Wed, 25 Sep 2024 17:41:09 +0200 Subject: [PATCH 2/2] Add short lived memory cache for DefaultAzureCredential (#15053) (#15105) Co-authored-by: Pavel Purma --- Arcade.sln | 50 +++++++ eng/Versions.props | 6 +- ...AzureCliCredentialWithAzNoUpdateWrapper.cs | 139 ++++++++++++++++++ .../DefaultIdentityTokenCredential.cs | 137 +++++++++++++++++ .../DefaultIdentityTokenCredentialOptions.cs | 17 +++ ...osoft.DotNet.ArcadeAzureIntegration.csproj | 12 ++ .../TokenCredentialShortCache.cs | 93 ++++++++++++ .../Microsoft.DotNet.Build.Tasks.Feed.csproj | 1 + .../src/AssetPublisherFactory.cs | 13 +- 9 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 src/Microsoft.DotNet.ArcadeAzureIntegration/AzureCliCredentialWithAzNoUpdateWrapper.cs create mode 100644 src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredential.cs create mode 100644 src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredentialOptions.cs create mode 100644 src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj create mode 100644 src/Microsoft.DotNet.ArcadeAzureIntegration/TokenCredentialShortCache.cs diff --git a/Arcade.sln b/Arcade.sln index fef54315118..0e90005f88f 100644 --- a/Arcade.sln +++ b/Arcade.sln @@ -157,6 +157,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Tar", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.Internal.SymbolHelper", "src\Microsoft.DotNet.Internal.SymbolHelper\Microsoft.DotNet.Internal.SymbolHelper.csproj", "{17C9E506-7A74-46B7-A345-ABB360E7390E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.ArcadeAzureIntegration", "src\Microsoft.DotNet.ArcadeAzureIntegration\Microsoft.DotNet.ArcadeAzureIntegration.csproj", "{CA159C84-CD7D-4364-9121-3842F97D4B60}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -979,6 +981,54 @@ Global {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x64.Build.0 = Release|Any CPU {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x86.ActiveCfg = Release|Any CPU {17C9E506-7A74-46B7-A345-ABB360E7390E}.Release|x86.Build.0 = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|x64.ActiveCfg = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|x64.Build.0 = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|x86.ActiveCfg = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Debug|x86.Build.0 = Debug|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|Any CPU.Build.0 = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|x64.ActiveCfg = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|x64.Build.0 = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|x86.ActiveCfg = Release|Any CPU + {19A9472B-3984-4FF1-B0BF-28A50E69A240}.Release|x86.Build.0 = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|x64.ActiveCfg = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|x64.Build.0 = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|x86.ActiveCfg = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Debug|x86.Build.0 = Debug|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|Any CPU.Build.0 = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|x64.ActiveCfg = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|x64.Build.0 = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|x86.ActiveCfg = Release|Any CPU + {6BA81447-C61D-4F91-BF0F-5B17AF4CFFAC}.Release|x86.Build.0 = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|x64.Build.0 = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Debug|x86.Build.0 = Debug|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|Any CPU.Build.0 = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|x64.ActiveCfg = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|x64.Build.0 = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|x86.ActiveCfg = Release|Any CPU + {D2B5D28E-CC80-4468-A037-05C2385FA4C4}.Release|x86.Build.0 = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|x64.Build.0 = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Debug|x86.Build.0 = Debug|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|Any CPU.Build.0 = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x64.ActiveCfg = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x64.Build.0 = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x86.ActiveCfg = Release|Any CPU + {CA159C84-CD7D-4364-9121-3842F97D4B60}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/eng/Versions.props b/eng/Versions.props index c198358f61a..c517099497a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -84,9 +84,9 @@ 1.0.0-beta.23475.1 - 1.34.0 - 1.11.4 - 12.16.0 + 1.42.0 + 1.12.0 + 12.19.1 2.5.0 1.0.1 5.10.3 diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/AzureCliCredentialWithAzNoUpdateWrapper.cs b/src/Microsoft.DotNet.ArcadeAzureIntegration/AzureCliCredentialWithAzNoUpdateWrapper.cs new file mode 100644 index 00000000000..1aab8408c4d --- /dev/null +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/AzureCliCredentialWithAzNoUpdateWrapper.cs @@ -0,0 +1,139 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET472_OR_GREATER + +#nullable enable + +using System; +using System.IO; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; + +namespace Microsoft.DotNet.ArcadeAzureIntegration; + + +// This class is an workaround for disable az cli auto update mechanism which cause timeout when waiting for +// console input in case of new version of az available +// - this wrppper will run "az config set auto-upgrade.enable=no" only once before first call to az for acquiring the token +public class AzureCliCredentialWithAzNoUpdateWrapper : TokenCredential +{ + private readonly AzureCliCredential _azureCliCredential; + + public AzureCliCredentialWithAzNoUpdateWrapper(AzureCliCredential azureCliCredential) + { + _azureCliCredential = azureCliCredential; + } + + public static string? EnvProgramFilesX86 => GetNonEmptyStringOrNull(Environment.GetEnvironmentVariable("ProgramFiles(x86)")); + public static string? EnvProgramFiles => GetNonEmptyStringOrNull(Environment.GetEnvironmentVariable("ProgramFiles")); + public static string? EnvPath => GetNonEmptyStringOrNull(Environment.GetEnvironmentVariable("PATH")); + + private static readonly string DefaultPathWindows = $"{EnvProgramFilesX86}\\Microsoft SDKs\\Azure\\CLI2\\wbin;{EnvProgramFiles}\\Microsoft SDKs\\Azure\\CLI2\\wbin"; + private static readonly string DefaultWorkingDirWindows = Environment.GetFolderPath(Environment.SpecialFolder.System); + private const string DefaultPathNonWindows = "/usr/bin:/usr/local/bin"; + private const string DefaultWorkingDirNonWindows = "/bin/"; + private static readonly string DefaultPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? DefaultPathWindows : DefaultPathNonWindows; + private static readonly string DefaultWorkingDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? DefaultWorkingDirWindows : DefaultWorkingDirNonWindows; + + private static string? GetNonEmptyStringOrNull(string? str) + { + return !string.IsNullOrEmpty(str) ? str : null; + } + + private static SemaphoreSlim _azCliInitSemaphore = new SemaphoreSlim(1, 1); + private static bool _azCliInitialized = false; + + private async Task SetUpAzAsync() + { + await _azCliInitSemaphore.WaitAsync(); + try + { + if (_azCliInitialized) return; + + string fileName; + string argument; + string command = $"az config set auto-upgrade.enable=no"; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); + argument = $"/d /c \"{command}\""; + } + else + { + fileName = "/bin/sh"; + argument = $"-c \"{command}\""; + } + + string path = !string.IsNullOrEmpty(EnvPath) ? EnvPath : DefaultPath; + var processInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = argument, + UseShellExecute = false, + ErrorDialog = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = DefaultWorkingDir, + Environment = { { "PATH", path } } + }; + + using Process? process = Process.Start(processInfo); + if (process == null) + { + throw new InvalidOperationException("Failed to start process to disable auto update of Azure CLI"); + } + + using var tokenSource = new CancellationTokenSource(); + tokenSource.CancelAfter(TimeSpan.FromSeconds(30)); + await process.WaitForExitAsync(tokenSource.Token); + + if (!process.HasExited) + { + // try clean up the process if it is still running after timeout + try { process.Kill(); } catch { /* ignore this excpetion */} + throw new InvalidOperationException("Could not finish az config command to disable auto update on time"); + } + + process.StandardInput.Close(); + process.Close(); + } + catch (Exception e) + { + // silent catch with direct console output as this is not a critical error + Console.WriteLine($"Warning - Disable auto update of Azure CLI failed: {e.Message}"); + } + finally + { + _azCliInitialized = true; + _azCliInitSemaphore.Release(); + } + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken = default) + { + if (!_azCliInitialized) + { + SetUpAzAsync().Wait(); + } + return _azureCliCredential.GetToken(requestContext, cancellationToken); + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken = default) + { + if (!_azCliInitialized) + { + await SetUpAzAsync(); + } + return await _azureCliCredential.GetTokenAsync(requestContext, cancellationToken); + } +} + +#endif diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredential.cs b/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredential.cs new file mode 100644 index 00000000000..17eb93b76de --- /dev/null +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredential.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET472_OR_GREATER + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using Azure.Core; +using Azure.Identity; + +namespace Microsoft.DotNet.ArcadeAzureIntegration; + + +// This implementation of TokenCredential will try to cover all common ways of +// authentication to Azure services used in Arcade tooling +public class DefaultIdentityTokenCredential : ChainedTokenCredential +{ + public DefaultIdentityTokenCredential() + : this(new DefaultIdentityTokenCredentialOptions()) + { + } + + public DefaultIdentityTokenCredential(DefaultIdentityTokenCredentialOptions options) + : base(CreateAvailableTokenCredentials(options)) + { + } + + private static TokenCredential[] CreateAvailableTokenCredentials(DefaultIdentityTokenCredentialOptions options) + { + List tokenCredentials = []; + + // Add Managed Identity credential if the client id is provided + if (!string.IsNullOrEmpty(options.ManagedIdentityClientId)) + { + tokenCredentials.Add( + new ManagedIdentityCredential(options.ManagedIdentityClientId) + ); + } + + // Add work load identity credential if the environment variables are set + var workloadIdentityCredential = GetWorkloadIdentityCredentialForAzurePipelineTask(); + if (workloadIdentityCredential != null) + { + tokenCredentials.Add(workloadIdentityCredential); + } + + // Add Azure Pipelines credential if the environment variables are set + var azurePipelinesCredential = GetAzurePipelinesCredentialForAzurePipelineTask(); + if (azurePipelinesCredential != null) + { + tokenCredentials.Add(azurePipelinesCredential); + } + + if (!options.ExcludeAzureCliCredential) + { + // Add Azure CLI credential as the last resort + // az command to disable auto update of the Azure CLI to avoid timeout waiting for + // console input will be called before first use of AzureCliCredential + tokenCredentials.Add( + new AzureCliCredentialWithAzNoUpdateWrapper( + new AzureCliCredential( + new AzureCliCredentialOptions + { + ProcessTimeout = TimeSpan.FromSeconds(30) + } + ) + ) + ); + } + + if (tokenCredentials.Count == 0) + { + throw new InvalidOperationException("No valid credential class detected and configured for authentication to Azure services."); + } + + return tokenCredentials.ToArray(); + } + + private static object _workloadTokenFileLock = new object(); + private static string? _workloadTokenFile = null; + private static string? _workloadToken = null; + + // Create WorkloadIdentityCredential if the environment variables set by AzurePipeline are provided + private static WorkloadIdentityCredential? GetWorkloadIdentityCredentialForAzurePipelineTask() + { + string? servicePrincipalId = Environment.GetEnvironmentVariable("servicePrincipalId"); + string? idToken = Environment.GetEnvironmentVariable("idToken"); + string? tenantId = Environment.GetEnvironmentVariable("tenantId"); + + if (!string.IsNullOrEmpty(idToken) && + !string.IsNullOrEmpty(tenantId) && + !string.IsNullOrEmpty(servicePrincipalId)) + { + lock (_workloadTokenFileLock) + { + if (idToken != _workloadToken) + { + // create token file + var tokenFileName = Path.GetTempFileName(); + File.WriteAllText(tokenFileName, idToken); + _workloadTokenFile = tokenFileName; + _workloadToken = idToken; + } + return new WorkloadIdentityCredential(new WorkloadIdentityCredentialOptions + { + ClientId = servicePrincipalId, + TokenFilePath = _workloadTokenFile, + TenantId = tenantId, + }); + } + } + return null; + } + + // Create AzurePipelinesCredential if the environment variables set by AzureCli task and SYSTEM_ACCESSTOKEN are provided + private static AzurePipelinesCredential? GetAzurePipelinesCredentialForAzurePipelineTask() + { + string? systemAccessToken = Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + string? clientId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_CLIENT_ID"); + string? tenantId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_TENANT_ID"); + string? serviceConnectionId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_SERVICE_CONNECTION_ID"); + + if (!string.IsNullOrEmpty(systemAccessToken) && + !string.IsNullOrEmpty(clientId) && + !string.IsNullOrEmpty(tenantId) && + !string.IsNullOrEmpty(serviceConnectionId)) + { + return new AzurePipelinesCredential(tenantId, clientId, serviceConnectionId, systemAccessToken); + } + return null; + } +} + +#endif diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredentialOptions.cs b/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredentialOptions.cs new file mode 100644 index 00000000000..8210cc35b44 --- /dev/null +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/DefaultIdentityTokenCredentialOptions.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET472_OR_GREATER + +#nullable enable + +namespace Microsoft.DotNet.ArcadeAzureIntegration; + + +public class DefaultIdentityTokenCredentialOptions +{ + public string? ManagedIdentityClientId { get; set; } = null; + public bool ExcludeAzureCliCredential { get; set; } +} + +#endif diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj b/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj new file mode 100644 index 00000000000..b713bcbd576 --- /dev/null +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/Microsoft.DotNet.ArcadeAzureIntegration.csproj @@ -0,0 +1,12 @@ + + + + $(NetCurrent);$(NetFrameworkToolCurrent) + true + + + + + + + diff --git a/src/Microsoft.DotNet.ArcadeAzureIntegration/TokenCredentialShortCache.cs b/src/Microsoft.DotNet.ArcadeAzureIntegration/TokenCredentialShortCache.cs new file mode 100644 index 00000000000..2f263ece4d6 --- /dev/null +++ b/src/Microsoft.DotNet.ArcadeAzureIntegration/TokenCredentialShortCache.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if !NET472_OR_GREATER + +#nullable enable + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; + +namespace Microsoft.DotNet.ArcadeAzureIntegration; + + +/// +/// TokenCredentialShortCache is a wrapper around TokenCredential that caches the token for the same scope and request parameters. +/// Cache time is short, 3 minutes only because we don't want to affect the expiration window that's still handled by the underlying TokenCredential implementation. +/// It helps with reducing the number of requests to Entra or AzureCLI external process during heavy paralellized operations. +/// +public class TokenCredentialShortCache : TokenCredential +{ + private const int CacheExpirationMinutes = 3; + + public TokenCredentialShortCache(TokenCredential tokenCredential) + { + _tokenCredential = tokenCredential; + } + + private TokenCredential _tokenCredential; + private ConcurrentDictionary _tokenCache = new ConcurrentDictionary(); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return GetTokenAsync(requestContext, cancellationToken).Result; + } + + public override async ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + CacheKey cacheKey = new CacheKey + { + Scopes = string.Join(":", requestContext.Scopes), + Claims = requestContext.Claims, + TenantId = requestContext.TenantId, + IsCaeEnabled = requestContext.IsCaeEnabled + }; + + CachedToken cachedToken = _tokenCache.GetOrAdd(cacheKey, _ => new CachedToken()); + var token = await cachedToken.GetToken(requestContext, () => { + return _tokenCredential.GetTokenAsync(requestContext, cancellationToken); + }); + + return token; + } + + private record struct CacheKey + { + public required string Scopes { get; init; } + public required string? Claims { get; init; } + public required string? TenantId { get; init; } + public required bool IsCaeEnabled { get; init; } + } + + private class CachedToken + { + private AccessToken? _token; + private DateTime _shortTimeCacheExpiresOn = DateTime.UtcNow; + private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + public async ValueTask GetToken(TokenRequestContext requestContext, Func> getFreshToken) + { + await _semaphore.WaitAsync(); + try + { + if (_token != null && _shortTimeCacheExpiresOn > DateTime.UtcNow) + { + return _token.Value; + } + + _token = await getFreshToken(); + _shortTimeCacheExpiresOn = DateTime.UtcNow.AddMinutes(CacheExpirationMinutes); + return _token.Value; + } + finally + { + _semaphore.Release(); + } + } + } +} + +#endif diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/Microsoft.DotNet.Build.Tasks.Feed.csproj b/src/Microsoft.DotNet.Build.Tasks.Feed/Microsoft.DotNet.Build.Tasks.Feed.csproj index 5f952b15ddd..bae610e9c12 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/Microsoft.DotNet.Build.Tasks.Feed.csproj +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/Microsoft.DotNet.Build.Tasks.Feed.csproj @@ -38,6 +38,7 @@ + diff --git a/src/Microsoft.DotNet.Build.Tasks.Feed/src/AssetPublisherFactory.cs b/src/Microsoft.DotNet.Build.Tasks.Feed/src/AssetPublisherFactory.cs index c6f85a5fbf5..82b073ebc02 100644 --- a/src/Microsoft.DotNet.Build.Tasks.Feed/src/AssetPublisherFactory.cs +++ b/src/Microsoft.DotNet.Build.Tasks.Feed/src/AssetPublisherFactory.cs @@ -3,12 +3,12 @@ #if !NET472_OR_GREATER using Azure; -using Azure.Identity; using System; using Microsoft.Build.Utilities; using Microsoft.DotNet.Build.Tasks.Feed.Model; using Azure.Core; using System.Collections.Concurrent; +using Microsoft.DotNet.ArcadeAzureIntegration; namespace Microsoft.DotNet.Build.Tasks.Feed { @@ -54,15 +54,10 @@ private TokenCredential GetAzureTokenCredential(string managedIdentityClientId) { TokenCredential tokenCredential = _tokenCredentialsPerManagedIdentity.GetOrAdd(managedIdentityClientId ?? string.Empty, static (mi) => new TokenCredentialShortCache( - new DefaultAzureCredential( - new DefaultAzureCredentialOptions + new DefaultIdentityTokenCredential( + new DefaultIdentityTokenCredentialOptions { - ExcludeVisualStudioCodeCredential = true, - ExcludeVisualStudioCredential = true, - ExcludeAzureDeveloperCliCredential = true, - ExcludeInteractiveBrowserCredential = true, - ManagedIdentityClientId = string.IsNullOrEmpty(mi) ? null : mi, - CredentialProcessTimeout = TimeSpan.FromSeconds(60.0) + ManagedIdentityClientId = string.IsNullOrEmpty(mi) ? null : mi } ) )