// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using EpicGames.Horde.Artifacts; using EpicGames.Horde.Compute; using EpicGames.Horde.Compute.Clients; using EpicGames.Horde.Logs; using EpicGames.Horde.Projects; using EpicGames.Horde.Secrets; using EpicGames.Horde.Server; using EpicGames.Horde.Storage; using EpicGames.Horde.Storage.Backends; using EpicGames.Horde.Storage.Bundles; using EpicGames.Horde.Tools; using Grpc.Core; using Grpc.Net.Client; using Grpc.Net.Client.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; #pragma warning disable CA2234 // Use URIs instead of strings namespace EpicGames.Horde { /// /// Default implementation of /// public abstract class HordeClient : IHordeClient, IAsyncDisposable { readonly Uri _serverUrl; readonly BundleCache _bundleCache; readonly HordeOptions _hordeOptions; readonly ILoggerFactory _loggerFactory; readonly ILogger _logger; ServerComputeClient? _serverComputeClient; BackgroundTask? _grpcChannel; /// public Uri ServerUrl => _serverUrl; /// public IArtifactCollection Artifacts { get; } /// public IComputeClient Compute => _serverComputeClient ??= CreateComputeClient(); /// public IProjectCollection Projects { get; } /// public ISecretCollection Secrets { get; } /// public IToolCollection Tools { get; } /// public event Action? OnAccessTokenStateChanged; /// /// Accessor for the logger instance /// protected ILogger Logger => _logger; /// /// Constructor /// protected HordeClient(Uri serverUrl, BundleCache bundleCache, IOptionsSnapshot hordeOptions, ILoggerFactory loggerFactory) { _serverUrl = serverUrl; _bundleCache = bundleCache; _hordeOptions = hordeOptions.Value; _loggerFactory = loggerFactory; _logger = _loggerFactory.CreateLogger(); Artifacts = new HttpArtifactCollection(this); Projects = new HttpProjectCollection(this); Secrets = new HttpSecretCollection(this); Tools = new HttpToolCollection(this); } /// public virtual async ValueTask DisposeAsync() { _serverComputeClient?.Dispose(); if (_grpcChannel != null) { await _grpcChannel.DisposeAsync(); _grpcChannel = null; } GC.SuppressFinalize(this); } /// /// Notify listeners that the auth state has changed /// protected void NotifyAuthStateChanged() { OnAccessTokenStateChanged?.Invoke(); } /// public abstract Task LoginAsync(bool interactive, CancellationToken cancellationToken); /// public abstract bool HasValidAccessToken(); /// public abstract Task GetAccessTokenAsync(bool interactive, CancellationToken cancellationToken); /// public async Task CreateGrpcChannelAsync(CancellationToken cancellationToken) { _grpcChannel ??= BackgroundTask.StartNew(ctx => CreateGrpcChannelInternalAsync(ctx)); return await _grpcChannel.WaitAsync(cancellationToken); } async Task CreateGrpcChannelInternalAsync(CancellationToken cancellationToken) { Uri serverUri = ServerUrl; bool useInsecureConnection = serverUri.Scheme.Equals("http", StringComparison.Ordinal); // Get the server URL for gRPC traffic. If we're using an unencrypted connection we need to use a different port for HTTP/2, // so send a HTTP/1 request to the server to query it. if (useInsecureConnection) { _logger.LogInformation("Querying server {BaseUrl} for rpc port", serverUri); using HttpClient httpClient = CreateUnauthenticatedHttpClient(); httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); httpClient.Timeout = TimeSpan.FromSeconds(210); // Need to make sure this doesn't cancel any long running gRPC streaming calls (eg. session update) using HttpResponseMessage response = await httpClient.GetAsync(new Uri(serverUri, "api/v1/server/ports"), cancellationToken); GetPortsResponse? ports = await response.Content.ReadFromJsonAsync(HordeHttpClient.JsonSerializerOptions, cancellationToken); if (ports is { UnencryptedHttp2: not null } && ports.UnencryptedHttp2.Value != 0) { UriBuilder builder = new UriBuilder(serverUri); builder.Port = ports.UnencryptedHttp2.Value; serverUri = builder.Uri; } } #pragma warning disable CA2000 // Dispose objects before losing scope SocketsHttpHandler httpHandler = new SocketsHttpHandler(); #pragma warning restore CA2000 // Dispose objects before losing scope // Create options for the new channel GrpcChannelOptions channelOptions = new GrpcChannelOptions(); channelOptions.MaxReceiveMessageSize = 1024 * 1024 * 1024; // 1 GB // Required payloads coming from CAS service can be large channelOptions.MaxSendMessageSize = 1024 * 1024 * 1024; // 1 GB channelOptions.LoggerFactory = _loggerFactory; channelOptions.HttpHandler = httpHandler; channelOptions.DisposeHttpClient = true; channelOptions.ServiceConfig = new ServiceConfig(); channelOptions.ServiceConfig.MethodConfigs.Add(new MethodConfig { Names = { MethodName.Default }, RetryPolicy = new Grpc.Net.Client.Configuration.RetryPolicy { MaxAttempts = 3, InitialBackoff = TimeSpan.FromSeconds(1), MaxBackoff = TimeSpan.FromSeconds(10), BackoffMultiplier = 2.0, RetryableStatusCodes = { StatusCode.Unavailable }, } }); // Configure requests to send the bearer token string? bearerToken = await GetAccessTokenAsync(_hordeOptions.AllowAuthPrompt, cancellationToken); if (!String.IsNullOrEmpty(bearerToken)) { CallCredentials callCredentials = CallCredentials.FromInterceptor((context, metadata) => { metadata.Add("Authorization", $"Bearer {bearerToken}"); return Task.CompletedTask; }); channelOptions.Credentials = ChannelCredentials.Create(useInsecureConnection ? ChannelCredentials.Insecure : ChannelCredentials.SecureSsl, callCredentials); channelOptions.UnsafeUseInsecureChannelCallCredentials = useInsecureConnection; } // Create the channel _logger.LogInformation("gRPC channel connecting to {BaseUrl} ...", serverUri); return GrpcChannel.ForAddress(serverUri, channelOptions); } /// public async Task CreateGrpcClientAsync(CancellationToken cancellationToken = default) where TClient : ClientBase { GrpcChannel channel = await CreateGrpcChannelAsync(cancellationToken); return (TClient)Activator.CreateInstance(typeof(TClient), channel)!; } /// public HordeHttpClient CreateHttpClient() => new HordeHttpClient(CreateAuthenticatedHttpClient()); /// ServerComputeClient CreateComputeClient() { string? sessionId = null; string? jobId = Environment.GetEnvironmentVariable("UE_HORDE_JOBID"); string? batchId = Environment.GetEnvironmentVariable("UE_HORDE_BATCHID"); string? stepId = Environment.GetEnvironmentVariable("UE_HORDE_STEPID"); if (jobId != null && batchId != null && stepId != null) { sessionId = $"{jobId}-{batchId}-{stepId}"; } return new ServerComputeClient(CreateAuthenticatedHttpClient(), sessionId, _loggerFactory.CreateLogger()); } /// public IStorageNamespace GetStorageNamespace(string basePath, string? accessToken = null) { HttpClient CreateClient() { if (accessToken != null) { HttpClient httpClient = CreateUnauthenticatedHttpClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); return httpClient; } else { return CreateAuthenticatedHttpClient(); } } HttpStorageBackend httpStorageBackend = new HttpStorageBackend(basePath, CreateClient, CreateUnauthenticatedHttpClient, _loggerFactory.CreateLogger()); return new BundleStorageNamespace(httpStorageBackend, _bundleCache, _hordeOptions.Bundle, _loggerFactory.CreateLogger()); } /// public IServerLogger CreateServerLogger(LogId logId, LogLevel minimumLevel = LogLevel.Information) => new ServerLogger(this, logId, minimumLevel, _logger); /// /// Creates an http client for satisfying requests /// protected abstract HttpClient CreateAuthenticatedHttpClient(); /// /// Creates an http client for satisfying requests /// protected abstract HttpClient CreateUnauthenticatedHttpClient(); } /// /// Default implementation of /// class HordeClientWithStaticCredentials : HordeClient { readonly string? _accessToken; readonly IHordeHttpMessageHandler _httpMessageHandler; /// /// Constructor /// public HordeClientWithStaticCredentials(Uri serverUrl, string? accessToken, IHordeHttpMessageHandler httpMessageHandler, BundleCache bundleCache, IOptionsSnapshot hordeOptions, ILoggerFactory loggerFactory) : base(serverUrl, bundleCache, hordeOptions, loggerFactory) { _accessToken = accessToken; _httpMessageHandler = httpMessageHandler; } /// public override async ValueTask DisposeAsync() { await base.DisposeAsync(); } /// public override async Task LoginAsync(bool allowLogin, CancellationToken cancellationToken) { using (HttpClient httpClient = CreateAuthenticatedHttpClient()) { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "api/v1/dashboard/challenge"); using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken); return response.IsSuccessStatusCode; } } /// public override bool HasValidAccessToken() => true; /// public override Task GetAccessTokenAsync(bool interactive, CancellationToken cancellationToken) => Task.FromResult(_accessToken); /// protected override HttpClient CreateAuthenticatedHttpClient() { HttpClient httpClient = new HttpClient(_httpMessageHandler.Instance, false); httpClient.BaseAddress = ServerUrl; if (!String.IsNullOrEmpty(_accessToken)) { httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); } return httpClient; } /// protected override HttpClient CreateUnauthenticatedHttpClient() { HttpClient httpClient = new HttpClient(_httpMessageHandler.Instance, false); httpClient.BaseAddress = ServerUrl; return httpClient; } } /// /// Default implementation of /// class HordeClientWithDynamicCredentials : HordeClient { readonly HordeHttpAuthHandlerState _authHandlerState; readonly IHordeHttpMessageHandler _baseHttpMessageHandler; [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Lifetime is managed by caller")] readonly HttpMessageHandler _authHttpMessageHandler; /// /// Constructor /// public HordeClientWithDynamicCredentials(Uri serverUrl, BundleCache bundleCache, IHordeHttpMessageHandler baseHttpMessageHandler, IOptionsSnapshot hordeOptions, ILoggerFactory loggerFactory) : base(serverUrl, bundleCache, hordeOptions, loggerFactory) { _baseHttpMessageHandler = baseHttpMessageHandler; _authHandlerState = new HordeHttpAuthHandlerState(_baseHttpMessageHandler.Instance, serverUrl, hordeOptions, loggerFactory.CreateLogger()); _authHttpMessageHandler = new HordeHttpAuthHandler(_baseHttpMessageHandler.Instance, _authHandlerState, hordeOptions); _authHandlerState.OnStateChanged += NotifyAuthStateChanged; } /// public override async ValueTask DisposeAsync() { _authHandlerState.OnStateChanged -= NotifyAuthStateChanged; await _authHandlerState.DisposeAsync(); await base.DisposeAsync(); } /// public override async Task LoginAsync(bool allowLogin, CancellationToken cancellationToken) { // Reset any cached state in the auth handler _authHandlerState.Reset(); // Send a request to the server to log in automatically using (HttpClient httpClient = CreateAuthenticatedHttpClient()) { using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "api/v1/dashboard/challenge"); request.Options.Set(HordeHttpAuthHandler.AllowInteractiveLogin, true); using HttpResponseMessage response = await httpClient.SendAsync(request, cancellationToken); return response.IsSuccessStatusCode; } } /// public override bool HasValidAccessToken() { try { return _authHandlerState.IsLoggedIn(); } catch { return false; } } /// public override Task GetAccessTokenAsync(bool interactive, CancellationToken cancellationToken) => _authHandlerState.GetAccessTokenAsync(interactive, cancellationToken); /// protected override HttpClient CreateAuthenticatedHttpClient() { HttpClient httpClient = new HttpClient(_authHttpMessageHandler, false); httpClient.BaseAddress = ServerUrl; return httpClient; } /// protected override HttpClient CreateUnauthenticatedHttpClient() { HttpClient httpClient = new HttpClient(_baseHttpMessageHandler.Instance, false); httpClient.BaseAddress = ServerUrl; return httpClient; } } /// /// Allows creating instances /// public class HordeClientFactory { readonly BundleCache _bundleCache; readonly IHordeHttpMessageHandler _httpMessageHandler; readonly IOptionsSnapshot _hordeOptions; readonly ILoggerFactory _loggerFactory; /// /// Constructor /// public HordeClientFactory(BundleCache bundleCache, IHordeHttpMessageHandler httpMessageHandler, IOptionsSnapshot hordeOptions, ILoggerFactory loggerFactory) { _bundleCache = bundleCache; _httpMessageHandler = httpMessageHandler; _hordeOptions = hordeOptions; _loggerFactory = loggerFactory; } Uri GetDefaultServerUrl() { Uri? serverUrl = _hordeOptions.Value.GetServerUrlOrDefault(); if (serverUrl == null) { throw new Exception("No Horde server is configured, or can be detected from the environment. Consider specifying a URL when calling AddHordeHttpClient()."); } return serverUrl; } /// /// Create a client using the user's default access token /// public HordeClient Create(Uri? serverUrl = null, string? accessToken = null) { serverUrl ??= GetDefaultServerUrl(); if (accessToken == null) { return new HordeClientWithDynamicCredentials(serverUrl, _bundleCache, _httpMessageHandler, _hordeOptions, _loggerFactory); } else { return new HordeClientWithStaticCredentials(serverUrl, accessToken, _httpMessageHandler, _bundleCache, _hordeOptions, _loggerFactory); } } } }