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
}
)
)