agones.dev/agones@v1.53.0/sdks/csharp/sdk/AgonesSDK.cs (about)

     1  // Copyright 2020 Google LLC All Rights Reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  // http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  using Agones.Dev.Sdk;
    15  using Microsoft.Extensions.Logging;
    16  using System;
    17  using System.Threading;
    18  using System.Threading.Tasks;
    19  using Grpc.Core;
    20  using Grpc.Net.Client;
    21  
    22  [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Agones.Test")]
    23  namespace Agones
    24  {
    25  	public sealed class AgonesSDK : IAgonesSDK
    26  	{
    27  		public string Host { get; } = Environment.GetEnvironmentVariable("AGONES_SDK_GRPC_HOST") ?? "localhost";
    28  		public int Port { get; } = Convert.ToInt32(Environment.GetEnvironmentVariable("AGONES_SDK_GRPC_PORT") ?? "9357");
    29  
    30  		/// <summary>
    31  		/// The timeout for gRPC calls.
    32  		/// </summary>
    33  		public double RequestTimeoutSec { get; set; }
    34  
    35  		internal SDK.SDKClient client;
    36  		internal readonly Alpha alpha;
    37  		internal readonly Beta beta;
    38  		internal readonly GrpcChannel channel;
    39  		internal AsyncClientStreamingCall<Empty,Empty> healthStream;
    40  		internal readonly CancellationTokenSource cts;
    41  		internal readonly bool ownsCts;
    42  		internal CancellationToken ctoken;
    43  		internal volatile bool isWatchingGameServer;
    44  
    45  		/// <summary>
    46  		/// Fired every time the GameServer k8s data is updated.
    47  		/// A more efficient way to emulate WatchGameServer behavior
    48  		/// without starting a new task for every subscription request.
    49  		/// </summary>
    50  		private event Action<GameServer> GameServerUpdated;
    51  		internal Delegate[] GameServerUpdatedCallbacks => GameServerUpdated?.GetInvocationList();
    52  		private readonly ILogger _logger;
    53  		private readonly SemaphoreSlim _healthStreamSemaphore = new SemaphoreSlim(1, 1);
    54  		private readonly object _gameServerWatchSyncRoot = new object();
    55  		
    56  		private bool _disposed;
    57  
    58  		public AgonesSDK(
    59  			double requestTimeoutSec = 15,
    60  			SDK.SDKClient sdkClient = null,
    61  			CancellationTokenSource cancellationTokenSource = null,
    62  			ILogger logger = null)
    63  		{
    64  			_logger = logger;
    65  			RequestTimeoutSec = requestTimeoutSec;
    66  			
    67  			if (cancellationTokenSource == null)
    68  			{
    69  				cts = new CancellationTokenSource();
    70  				ownsCts = true;
    71  			}
    72  			else
    73  			{
    74  				cts = cancellationTokenSource;
    75  				ownsCts = false;
    76  			}
    77  			
    78  			ctoken = cts.Token;
    79  			channel = GrpcChannel.ForAddress(
    80  				$"http://{Host}:{Port}"
    81  			);
    82  
    83  			client = sdkClient ?? new SDK.SDKClient(channel);
    84  			alpha = new Alpha(channel, requestTimeoutSec, cancellationTokenSource, logger);
    85  			beta = new Beta(channel, requestTimeoutSec, cancellationTokenSource, logger);
    86  		}
    87  
    88  		/// <summary>
    89  		/// Alpha returns the Alpha SDK
    90  		/// </summary>
    91  		/// <returns>Agones alpha SDK</returns>
    92  		public IAgonesAlphaSDK Alpha()
    93  		{
    94  			return alpha;
    95  		}
    96  
    97  		/// <summary>
    98  		/// Beta returns the AlphBeta SDK
    99  		/// </summary>
   100  		/// <returns>Agones beta SDK</returns>
   101  		public IAgonesBetaSDK Beta()
   102  		{
   103  			return beta;
   104  		}
   105  
   106  		/// <summary>
   107  		/// Tells Agones that the Game Server is ready to take player connections
   108  		/// </summary>
   109  		/// <returns>gRPC Status of the request</returns>
   110  		public async Task<Status> ReadyAsync()
   111  		{
   112  			try
   113  			{
   114  				await client.ReadyAsync(new Empty(), deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec), cancellationToken: ctoken);
   115  				return new Status(StatusCode.OK, "Ready request successful.");
   116  			}
   117  			catch (RpcException ex)
   118  			{
   119  				LogError("Unable to mark GameServer to 'Ready' state.", ex);
   120  				return ex.Status;
   121  			}
   122  		}
   123  
   124  		/// <summary>
   125  		/// Marks the game server as Allocated.
   126  		/// </summary>
   127  		/// <returns>gRPC Status of the request</returns>
   128  		public async Task<Status> AllocateAsync()
   129  		{
   130  			try
   131  			{
   132  				await client.AllocateAsync(new Empty(),
   133  					deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec),
   134  					cancellationToken: ctoken);
   135  				return new Status(StatusCode.OK, "Allocate request successful.");
   136  			}
   137  			catch (RpcException ex)
   138  			{
   139  				LogError("Unable to mark the GameServer to 'Allocated' state.", ex);
   140  				return ex.Status;
   141  			}
   142  		}
   143  
   144  		/// <summary>
   145  		/// Reserve(seconds) will move the GameServer into the Reserved state for the specified number of seconds (0 is forever),
   146  		/// and then it will be moved back to Ready state. While in Reserved state,
   147  		/// the GameServer will not be deleted on scale down or Fleet update, and also it could not be Allocated using GameServerAllocation.
   148  		/// </summary>
   149  		/// <param name="seconds">Amount of seconds to reserve.</param>
   150  		/// <returns>gRPC Status of the request</returns>
   151  		public async Task<Status> ReserveAsync(long seconds)
   152  		{
   153  			try
   154  			{
   155  				await client.ReserveAsync(new Duration { Seconds = seconds},
   156  					deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec),
   157  					cancellationToken: ctoken);
   158  				return new Status(StatusCode.OK, $"Reserve({seconds}) request successful.");
   159  			}
   160  			catch (RpcException ex)
   161  			{
   162  				LogError("Unable to mark the GameServer to 'Reserved' state.", ex);
   163  				return ex.Status;
   164  			}
   165  		}
   166  
   167  		/// <summary>
   168  		/// This returns most of the backing GameServer configuration and Status.
   169  		/// This can be useful for instances where you may want to know Health check configuration, or the IP and Port the GameServer is currently allocated to.
   170  		/// </summary>
   171  		/// <returns>A GameServer object containing this GameServer's configuration data</returns>
   172  		public async Task<GameServer> GetGameServerAsync()
   173  		{
   174  			try
   175  			{
   176  				return await client.GetGameServerAsync(new Empty(),
   177  					deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec),
   178  					cancellationToken: ctoken);
   179  			}
   180  			catch (RpcException ex)
   181  			{
   182  				LogError("Unable to get GameServer configuration and status.", ex);
   183  				throw;
   184  			}
   185  		}
   186  
   187  		/// <summary>
   188  		/// Starts watching the GameServer updates in the background in it's own task.
   189  		/// On update, it fires the GameServerUpdate event.
   190  		/// </summary>
   191  		private async Task BeginInternalWatchAsync()
   192  		{
   193  			// Begin WatchGameServer in the background for the provided callback(s).
   194  			while (!ctoken.IsCancellationRequested)
   195  			{
   196  				try
   197  				{
   198  					using (var watchStreamingCall = client.WatchGameServer(new Empty(), cancellationToken: ctoken))
   199  					{
   200  						var reader = watchStreamingCall.ResponseStream;
   201  						while (await reader.MoveNext(ctoken))
   202  						{
   203  							try
   204  							{
   205  								GameServerUpdated?.Invoke(reader.Current);
   206  							}
   207  							catch (Exception ex)
   208  							{
   209  								// Swallow any exception thrown here. We don't want a callback's exception to cause
   210  								// our watch to be torn down.
   211  								LogWarning($"A {nameof(WatchGameServer)} callback threw an exception", ex);
   212  							}
   213  						}
   214  					}
   215  				}
   216  				catch (OperationCanceledException) when (ctoken.IsCancellationRequested)
   217  				{
   218  					return;
   219  				}
   220  				catch (RpcException) when (ctoken.IsCancellationRequested)
   221  				{
   222  					return;
   223  				}
   224  				catch (RpcException ex)
   225  				{
   226  					LogError("An error occurred while watching GameServer events, will retry.", ex);
   227  				}
   228  			}
   229  		}
   230  
   231  		/// <summary>
   232  		/// This executes the passed in callback with the current GameServer details whenever the underlying GameServer configuration is updated.
   233  		/// This can be useful to track GameServer > Status > State changes, metadata changes, such as labels and annotations, and more.
   234  		/// </summary>
   235  		/// <param name="callback">The action to be called when the underlying GameServer metadata changes.</param>
   236  		public void WatchGameServer(Action<GameServer> callback)
   237  		{
   238  			GameServerUpdated += callback;
   239  			
   240  			lock (_gameServerWatchSyncRoot)
   241  			{
   242  				if (isWatchingGameServer)
   243  				{
   244  					return;
   245  				}
   246  				
   247  				isWatchingGameServer = true;
   248  			}
   249  
   250  			// Kick off the watch in a task so the caller doesn't need to handle exceptions that could potentially be
   251  			// thrown before reaching the first yielding async point.
   252  			Task.Run(async () => await BeginInternalWatchAsync(), ctoken);
   253  		}
   254  
   255  		/// <summary>
   256  		/// Cancels all running tasks & tells Agones to shut down the currently running game server.
   257  		/// </summary>
   258  		/// <returns>gRPC Status of the request</returns>
   259  		public async Task<Status> ShutDownAsync()
   260  		{
   261  			try
   262  			{
   263  				await client.ShutdownAsync(new Empty(), deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec));
   264  				return new Status(StatusCode.OK, "Shutdown request successful.");
   265  			}
   266  			catch (RpcException ex)
   267  			{
   268  				LogError("Unable to mark the GameServer to 'Shutdown' state.", ex);
   269  				return ex.Status;
   270  			}
   271  		}
   272  
   273  		/// <summary>
   274  		/// Set a Label value on the backing GameServer record that is stored in Kubernetes, with the prefix 'agones.dev/sdk-'.
   275  		/// </summary>
   276  		/// <param name="key">Label key</param>
   277  		/// <param name="value">Label value</param>
   278  		/// <returns>gRPC Status of the request</returns>
   279  		public async Task<Status> SetLabelAsync(string key, string value)
   280  		{
   281  			try
   282  			{
   283  				await client.SetLabelAsync(new KeyValue()
   284  				{
   285  					Key = key,
   286  					Value = value
   287  				}, deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec), cancellationToken: ctoken);
   288  				return new Status(StatusCode.OK, $"SetLabel {key}:{value} request successful.");
   289  			}
   290  			catch(RpcException ex)
   291  			{
   292  				LogError($"Unable to set the GameServer label '{key}' to '{value}'.", ex);
   293  				return ex.Status;
   294  			}
   295  		}
   296  
   297  		/// <summary>
   298  		/// Set a Annotation value on the backing Gameserver record that is stored in Kubernetes, with the prefix 'agones.dev/sdk-'.
   299  		/// </summary>
   300  		/// <param name="key">Annotation key</param>
   301  		/// <param name="value">Annotation value</param>
   302  		/// <returns>gRPC Status of the request</returns>
   303  		public async Task<Status> SetAnnotationAsync(string key, string value)
   304  		{
   305  			try
   306  			{
   307  				await client.SetAnnotationAsync(new KeyValue()
   308  				{
   309  					Key = key,
   310  					Value = value
   311  				}, deadline: DateTime.UtcNow.AddSeconds(RequestTimeoutSec), cancellationToken: ctoken);
   312  				return new Status(StatusCode.OK, $"SetAnnotation {key}:{value} request successful.");
   313  			}
   314  			catch (RpcException ex)
   315  			{
   316  				LogError($"Unable to set the GameServer annotation '{key}' to '{value}'.", ex);
   317  				return ex.Status;
   318  			}
   319  		}
   320  
   321  		/// <summary>
   322  		/// Sends a single ping to designate that the Game Server is alive and healthy.
   323  		/// </summary>
   324  		/// <returns>gRPC Status of the request</returns>
   325  		public async Task<Status> HealthAsync()
   326  		{
   327  			try
   328  			{
   329  				await _healthStreamSemaphore.WaitAsync(ctoken);
   330  			}
   331  			catch (OperationCanceledException)
   332  			{
   333  				return Status.DefaultCancelled;
   334  			}
   335  
   336  			try
   337  			{
   338  				if (healthStream == null)
   339  				{
   340  					// Create a new stream if it's the first time we're being called or if the previous stream threw
   341  					// an exception.
   342  					healthStream = client.Health();
   343  				}
   344  
   345  				var writer = healthStream.RequestStream;
   346  				await writer.WriteAsync(new Empty());
   347  				return new Status(StatusCode.OK, "Health ping successful.");
   348  			}
   349  			catch (RpcException ex)
   350  			{
   351  				LogError("Unable to invoke the GameServer health check.", ex);
   352  
   353  				if (healthStream != null)
   354  				{
   355  					try
   356  					{
   357  						// Best effort to clean up.
   358  						healthStream.Dispose();
   359  					}
   360  					catch (Exception innerEx)
   361  					{
   362  						LogWarning($"Failed to dispose existing {nameof(client.Health)} client stream", innerEx);
   363  					}
   364  				}
   365  				
   366  				// Null out the stream so the subsequent call causes it to be recreated.
   367  				healthStream = null;
   368  				return ex.Status;
   369  			}
   370  			finally
   371  			{
   372  				_healthStreamSemaphore.Release();
   373  			}
   374  		}
   375  
   376  		public void Dispose()
   377  		{
   378  			if (_disposed)
   379  			{
   380  				return;
   381  			}
   382  
   383  			cts.Cancel();
   384              
   385  			if (ownsCts)
   386  			{
   387  				cts.Dispose();
   388  			}
   389  
   390  			channel.Dispose();
   391  
   392  			// Since we don't provide any facility to unregister a WatchGameServer callback, set the event to null to
   393  			// clear its underlying invocation list, so we don't keep holding references to objects that would prevent
   394  			// them to be GC'd in case we don't go out of scope.
   395  			GameServerUpdated = null;
   396              
   397  			_disposed = true;
   398  			GC.SuppressFinalize(this);
   399  		}
   400  
   401  		private void LogDebug(string message, Exception ex = null)
   402  		{
   403  			_logger?.LogDebug(ex, message);
   404  		}
   405  
   406  		private void LogInformation(string message, Exception ex = null)
   407  		{
   408  			_logger?.LogInformation(ex, message);
   409  		}
   410  
   411  		private void LogWarning(string message, Exception ex = null)
   412  		{
   413  			_logger?.LogWarning(ex, message);
   414  		}
   415  
   416  		private void LogError(string message, Exception ex = null)
   417  		{
   418  			_logger?.LogError(ex, message);
   419  		}
   420  	}
   421  }