Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[150] DRS Transformation Strategy #655

Merged
merged 13 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CommonUtilities/Models/NodeTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public class TerraRuntimeOptions
public string? WsmApiHost { get; set; }
public string? LandingZoneApiHost { get; set; }
public string? SasAllowedIpRange { get; set; }
public string? DrsHubApiHost { get; set; }
}

[JsonConverter(typeof(JsonStringEnumConverter))]
Expand Down
83 changes: 83 additions & 0 deletions src/Tes.ApiClients.Tests/DrsHubApiClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Core;
using CommonUtilities.Options;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;

namespace Tes.ApiClients.Tests
{
[TestClass]
[TestCategory("Unit")]
public class DrsHubApiClientTests
{
private Mock<TokenCredential> tokenCredentialsMock = null!;
private CachingRetryPolicyBuilder cachingRetryPolicyBuilder = null!;
private CommonUtilities.AzureEnvironmentConfig azureEnvironmentConfig = null!;
private DrsHubApiClient apiClient = null!;

private const string DrsApiHost = "https://drshub.foo";

[TestInitialize]
public void Setup()
{
var retryPolicyOptions = new RetryPolicyOptions();
var appCache = new MemoryCache(new MemoryCacheOptions());
cachingRetryPolicyBuilder = new CachingRetryPolicyBuilder(appCache, Options.Create(retryPolicyOptions));

tokenCredentialsMock = new Mock<TokenCredential>();
azureEnvironmentConfig = new CommonUtilities.AzureEnvironmentConfig()
{
TokenScope = "https://management.azure.com/.default"
};
apiClient = new DrsHubApiClient(DrsApiHost, tokenCredentialsMock.Object, cachingRetryPolicyBuilder, azureEnvironmentConfig, NullLogger<DrsHubApiClient>.Instance);
}

[TestMethod]
public void CreateDrsHubApiClient_ReturnsValidDrsApiClient()
{
var drsApiClient = DrsHubApiClient.CreateDrsHubApiClient("https://drshub.foo", tokenCredentialsMock.Object, azureEnvironmentConfig);

Assert.IsNotNull(drsApiClient);
}

[TestMethod]
public async Task GetDrsResolveRequestContent_ValidDrsUri_ReturnsValidRequestContentWithExpectedValues()
{
var drsUriString = "drs://drs.foo";
var drsUri = new Uri(drsUriString);
var content = await apiClient.GetDrsResolveRequestContent(drsUri).ReadAsStringAsync();

Assert.IsNotNull(ExpectedDrsResolveRequestJson, content);
}

[TestMethod]
public async Task GetDrsResolveApiResponse_ResponseWithAccessUrl_CanDeserializeJSon()
{
var httpResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK);
httpResponse.Content = new StringContent(ExpectedRsResolveResponseJson);

var drsResolveResponse = await DrsHubApiClient.GetDrsResolveApiResponseAsync(httpResponse, CancellationToken.None);

Assert.IsNotNull(drsResolveResponse);
Assert.IsNotNull(drsResolveResponse.AccessUrl);
Assert.AreEqual("https://storage.foo/bar", drsResolveResponse.AccessUrl.Url);
}

private const string ExpectedRsResolveResponseJson = @"{
""accessUrl"": {
""url"": ""https://storage.foo/bar"",
""headers"": null
}
}";

private const string ExpectedDrsResolveRequestJson = @"{
""url"": ""drs://drs.foo"",
""cloudPlatform"": ""azure"",
""fields"":[""accessUrl""]
}";
}
}
2 changes: 1 addition & 1 deletion src/Tes.ApiClients/CachingRetryPolicyBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Tes.ApiClients
/// <summary>
/// Contains an App Cache instances and retry policies.
/// </summary>
public partial class CachingRetryPolicyBuilder : RetryPolicyBuilder, CachingRetryPolicyBuilder.ICachingPolicyBuilderHandler
public class CachingRetryPolicyBuilder : RetryPolicyBuilder, CachingRetryPolicyBuilder.ICachingPolicyBuilderHandler
{
private readonly IMemoryCache appCache = null!;
public virtual IMemoryCache AppCache => appCache;
Expand Down
100 changes: 100 additions & 0 deletions src/Tes.ApiClients/DrsHubApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Azure.Core;
using CommonUtilities;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Tes.ApiClients.Models.Terra;

namespace Tes.ApiClients
{
public class DrsHubApiClient : TerraApiClient
{
private const string DrsHubApiSegments = "/api/v4/drs";
private static readonly IMemoryCache SharedMemoryCache = new MemoryCache(new MemoryCacheOptions());

/// <summary>
/// Parameterless constructor for mocking
/// </summary>
protected DrsHubApiClient()
{
}

/// <summary>
/// Constructor of DrsHubApiClient
/// </summary>
/// <param name="apiUrl">WSM API host</param>
/// <param name="tokenCredential"></param>
/// <param name="cachingRetryPolicyBuilder"></param>
/// <param name="azureCloudIdentityConfig"></param>
/// <param name="logger"></param>
public DrsHubApiClient(string apiUrl, TokenCredential tokenCredential, CachingRetryPolicyBuilder cachingRetryPolicyBuilder,
AzureEnvironmentConfig azureCloudIdentityConfig, ILogger<DrsHubApiClient> logger) : base(apiUrl, tokenCredential, cachingRetryPolicyBuilder, azureCloudIdentityConfig, logger)
{

}

public static DrsHubApiClient CreateDrsHubApiClient(string apiUrl, TokenCredential tokenCredential, AzureEnvironmentConfig azureCloudIdentityConfig)
{
return CreateTerraApiClient<DrsHubApiClient>(apiUrl, SharedMemoryCache, tokenCredential, azureCloudIdentityConfig);
}

public virtual async Task<DrsResolveApiResponse> ResolveDrsUriAsync(Uri drsUri, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(drsUri);

HttpResponseMessage response = null!;
try
{
var apiUrl = GetResolveDrsApiUrl();

Logger.LogInformation(@"Resolving DRS URI calling: {uri}", apiUrl);

response =
await HttpSendRequestWithRetryPolicyAsync(() => new HttpRequestMessage(HttpMethod.Post, apiUrl) { Content = GetDrsResolveRequestContent(drsUri) },
cancellationToken, setAuthorizationHeader: true);

var apiResponse = await GetDrsResolveApiResponseAsync(response, cancellationToken);

Logger.LogInformation(@"Successfully resolved URI: {drsUri}", drsUri);

return apiResponse;
}
catch (Exception ex)
{
await LogResponseContentAsync(response, "Failed to resolve DRS URI", ex, cancellationToken);
throw;
}
}

public static async Task<DrsResolveApiResponse> GetDrsResolveApiResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var apiResponse = await GetApiResponseContentAsync<DrsResolveApiResponse>(response, cancellationToken);
return apiResponse;
}

public HttpContent GetDrsResolveRequestContent(Uri drsUri)
{
ArgumentNullException.ThrowIfNull(drsUri);

var drsResolveApiRequestBody = new DrsResolveRequestContent
{
Url = drsUri.AbsoluteUri,
CloudPlatform = CloudPlatform.Azure,
Fields = new List<string> { "accessUrl" }
};

return CreateJsonStringContent(drsResolveApiRequestBody);
}

private string GetResolveDrsApiUrl()
{
var apiRequestUrl = $"{ApiUrl.TrimEnd('/')}{DrsHubApiSegments}/resolve";

var builder = new UriBuilder(apiRequestUrl);

return builder.Uri.AbsoluteUri;
}
}
}
41 changes: 33 additions & 8 deletions src/Tes.ApiClients/HttpApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.Core;
using CommonUtilities;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -37,16 +38,16 @@ public abstract class HttpApiClient
/// <summary>
/// Constructor of base HttpApiClient
/// </summary>
/// <param name="cachingRetryBuilder"></param>
/// <param name="cachingRetryPolicyBuilder"></param>
/// <param name="logger"></param>
protected HttpApiClient(CachingRetryPolicyBuilder cachingRetryBuilder, ILogger logger)
protected HttpApiClient(CachingRetryPolicyBuilder cachingRetryPolicyBuilder, ILogger logger)
{
ArgumentNullException.ThrowIfNull(cachingRetryBuilder);
ArgumentNullException.ThrowIfNull(cachingRetryPolicyBuilder);
ArgumentNullException.ThrowIfNull(logger);

this.Logger = logger;

cachingRetryHandler = cachingRetryBuilder
cachingRetryHandler = cachingRetryPolicyBuilder
.DefaultRetryHttpResponseMessagePolicyBuilder()
.SetOnRetryBehavior(onRetry: LogRetryErrorOnRetryHttpResponseMessageHandler())
.AddCaching()
Expand All @@ -57,11 +58,11 @@ protected HttpApiClient(CachingRetryPolicyBuilder cachingRetryBuilder, ILogger l
/// Constructor of base HttpApiClient
/// </summary>
/// <param name="tokenCredential"></param>
/// <param name="cachingRetryHandler"></param>
/// <param name="cachingRetryPolicyBuilder"></param>
/// <param name="tokenScope"></param>
/// <param name="logger"></param>
protected HttpApiClient(TokenCredential tokenCredential, string tokenScope,
CachingRetryPolicyBuilder cachingRetryHandler, ILogger logger) : this(cachingRetryHandler, logger)
CachingRetryPolicyBuilder cachingRetryPolicyBuilder, ILogger logger) : this(cachingRetryPolicyBuilder, logger)
{
ArgumentNullException.ThrowIfNull(tokenCredential);
ArgumentException.ThrowIfNullOrEmpty(tokenScope);
Expand All @@ -80,7 +81,7 @@ protected HttpApiClient() { }
/// </summary>
/// <returns><see cref="RetryHandler.OnRetryHandler{HttpResponseMessage}"/></returns>
private RetryHandler.OnRetryHandler<HttpResponseMessage> LogRetryErrorOnRetryHttpResponseMessageHandler()
=> new((result, timeSpan, retryCount, correlationId, caller) =>
=> (result, timeSpan, retryCount, correlationId, caller) =>
{
if (result.Exception is null)
{
Expand All @@ -92,7 +93,7 @@ private RetryHandler.OnRetryHandler<HttpResponseMessage> LogRetryErrorOnRetryHtt
Logger?.LogError(result.Exception, @"Retrying in {Method} due to '{Message}': RetryCount: {RetryCount} TimeSpan: {TimeSpan} CorrelationId: {CorrelationId}",
caller, result.Exception.Message, retryCount, timeSpan.ToString("c"), correlationId.ToString("D"));
}
});
};

/// <summary>
/// Sends request with a retry policy
Expand Down Expand Up @@ -364,5 +365,29 @@ protected static async Task<string> ReadResponseBodyAsync(HttpResponseMessage re
{
return await response.Content.ReadAsStringAsync(cancellationToken);
}


/// <summary>
/// Creates a string content from an object
/// </summary>
protected static HttpContent CreateJsonStringContent<T>(T contentObject)
{
var stringContent = new StringContent(JsonSerializer.Serialize(contentObject,
new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }), Encoding.UTF8, "application/json");

return stringContent;
}

protected async Task LogResponseContentAsync(HttpResponseMessage response, string errMessage, Exception ex, CancellationToken cancellationToken)
{
var responseContent = string.Empty;

if (response is not null)
{
responseContent = await ReadResponseBodyAsync(response, cancellationToken);
}

Logger.LogError(ex, @"{ErrorMessage}. Response content:{ResponseContent}", errMessage, responseContent);
}
}
}
35 changes: 35 additions & 0 deletions src/Tes.ApiClients/Models/Terra/DrsResolveApiRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Tes.ApiClients.Models.Terra
{

public class DrsResolveRequestContent
{
[JsonPropertyName("url")]
public string Url { get; set; }

[JsonPropertyName("cloudPlatform")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public CloudPlatform CloudPlatform { get; set; }

[JsonPropertyName("fields")]
public List<string> Fields { get; set; }
}
public enum CloudPlatform
{
[JsonPropertyName("azure")]
Azure,
[JsonPropertyName("google")]
Google
}

}



66 changes: 66 additions & 0 deletions src/Tes.ApiClients/Models/Terra/DrsResolveApiResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace Tes.ApiClients.Models.Terra
{

public class DrsResolveApiResponse
{
[JsonPropertyName("contentType")]
public string ContentType { get; set; }

[JsonPropertyName("size")]
public long Size { get; set; }

[JsonPropertyName("timeCreated")]
public DateTimeOffset TimeCreated { get; set; }

[JsonPropertyName("timeUpdated")]
public DateTimeOffset TimeUpdated { get; set; }

[JsonPropertyName("bucket")]
public string Bucket { get; set; }

[JsonPropertyName("name")]
public string Name { get; set; }

[JsonPropertyName("gsUri")]
public string GsUri { get; set; }

[JsonPropertyName("googleServiceAccount")]
public SaKeyObject GoogleServiceAccount { get; set; }

[JsonPropertyName("fileName")]
public string FileName { get; set; }

[JsonPropertyName("accessUrl")]
public AccessUrl AccessUrl { get; set; }

[JsonPropertyName("hashes")]
public Dictionary<string, string> Hashes { get; set; }

[JsonPropertyName("localizationPath")]
public string LocalizationPath { get; set; }

[JsonPropertyName("bondProvider")]
public string BondProvider { get; set; }
}

public class SaKeyObject
{
[JsonPropertyName("data")]
public Dictionary<string, object> Data { get; set; }
}

public class AccessUrl
{
[JsonPropertyName("url")]
public string Url { get; set; }

[JsonPropertyName("headers")]
public Dictionary<string, string> Headers { get; set; }
}

}
Loading
Loading