From 8afe1ae5abf084a83a660c91376fc94cdaf1404d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 12:36:32 -0400 Subject: [PATCH 01/10] Partway port administrative actions to an `IAuthority` Also fixed a bug that would allow unsuccessful attempts to downgrade to TGS3 --- .../Authority/AdministrationAuthority.cs | 241 ++++++++++++++++++ .../Authority/Core/HttpFailureResponse.cs | 5 + .../Core/RestAuthorityInvoker{TAuthority}.cs | 1 + .../Authority/IAdministrationAuthority.cs | 43 ++++ .../Controllers/AdministrationController.cs | 240 +++-------------- src/Tgstation.Server.Host/Core/Application.cs | 1 + 6 files changed, 323 insertions(+), 208 deletions(-) create mode 100644 src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs create mode 100644 src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs diff --git a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs new file mode 100644 index 00000000000..0655ebf6f2d --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs @@ -0,0 +1,241 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +using Octokit; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Transfer; +using Tgstation.Server.Host.Utils.GitHub; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class AdministrationAuthority : AuthorityBase, IAdministrationAuthority + { + /// + /// Default for s. + /// + const string OctokitException = "Bad GitHub API response, check configuration!"; + + /// + /// The key for . + /// + static readonly object ReadCacheKey = new(); + + /// + /// The for the . + /// + readonly IGitHubServiceFactory gitHubServiceFactory; + + /// + /// The for the . + /// + readonly IServerControl serverControl; + + /// + /// The for the . + /// + readonly IServerUpdateInitiator serverUpdateInitiator; + + /// + /// The for the . + /// + readonly IFileTransferTicketProvider fileTransferService; + + /// + /// The for the . + /// + readonly IMemoryCache cacheService; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public AdministrationAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IGitHubServiceFactory gitHubServiceFactory, + IServerControl serverControl, + IServerUpdateInitiator serverUpdateInitiator, + IFileTransferTicketProvider fileTransferService, + IMemoryCache cacheService) + : base( + authenticationContext, + databaseContext, + logger) + { + this.gitHubServiceFactory = gitHubServiceFactory ?? throw new ArgumentNullException(nameof(gitHubServiceFactory)); + this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); + this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); + this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + } + + /// + public async ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken) + { + try + { + async Task CacheFactory() + { + Version? greatestVersion = null; + Uri? repoUrl = null; + try + { + var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken); + var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken); + var releases = await gitHubService.GetTgsReleases(cancellationToken); + + foreach (var kvp in releases) + { + var version = kvp.Key; + var release = kvp.Value; + if (version.Major > 3 // Forward/backward compatible but not before TGS4 + && (greatestVersion == null || version > greatestVersion)) + greatestVersion = version; + } + + repoUrl = await repositoryUrlTask; + } + catch (NotFoundException e) + { + Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); + } + + return new AdministrationResponse + { + LatestVersion = greatestVersion, + TrackedRepositoryUrl = repoUrl, + GeneratedAt = DateTimeOffset.UtcNow, + }; + } + + var ttl = TimeSpan.FromMinutes(30); + Task task; + if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) + { + using var entry = cacheService.CreateEntry(ReadCacheKey); + entry.AbsoluteExpirationRelativeToNow = ttl; + entry.Value = task = CacheFactory(); + } + else + task = (Task)rawCacheObject!; + + return new AuthorityResponse(await task); + } + catch (RateLimitExceededException e) + { + return RateLimit(e); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + } + + /// + public async ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) + { + var attemptingUpload = uploadZip == true; + if (attemptingUpload) + { + if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) + return Forbid(); + } + else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) + return Forbid(); + + if (targetVersion.Major < 4) + return BadRequest(ErrorCode.CannotChangeServerSuite); + + if (!serverControl.WatchdogPresent) + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + + IFileUploadTicket? uploadTicket = attemptingUpload + ? fileTransferService.CreateUpload(FileUploadStreamKind.None) + : null; + + ServerUpdateResult updateResult; + try + { + try + { + updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken); + } + catch + { + if (attemptingUpload) + await uploadTicket!.DisposeAsync(); + + throw; + } + } + catch (RateLimitExceededException ex) + { + return RateLimit(ex); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + + return updateResult switch + { + ServerUpdateResult.Started => new AuthorityResponse(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), + ServerUpdateResult.ReleaseMissing => Gone(), + ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), + ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), + HttpFailureResponse.FailedDependency), + _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), + }; + } + + /// + public async ValueTask TriggerServerRestart() + { + if (!serverControl.WatchdogPresent) + { + Logger.LogDebug("Restart request failed due to lack of host watchdog!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + } + + await serverControl.Restart(); + return new AuthorityResponse(); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs index 5aa3c18089d..af2956826e1 100644 --- a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs +++ b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs @@ -59,5 +59,10 @@ public enum HttpFailureResponse /// HTTP 501. /// NotImplemented, + + /// + /// HTTP 503. + /// + ServiceUnavailable, } } diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 680dc98a4ef..91118a55862 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -65,6 +65,7 @@ static IActionResult CreateSuccessfulActionResult(ApiControl HttpFailureResponse.FailedDependency => controller.StatusCode(HttpStatusCode.FailedDependency, errorMessage), HttpFailureResponse.RateLimited => controller.StatusCode(HttpStatusCode.TooManyRequests, errorMessage), HttpFailureResponse.NotImplemented => controller.StatusCode(HttpStatusCode.NotImplemented, errorMessage), + HttpFailureResponse.ServiceUnavailable => controller.StatusCode(HttpStatusCode.ServiceUnavailable, errorMessage), _ => throw new InvalidOperationException($"Invalid {nameof(HttpFailureResponse)}: {failureResponse}"), }; } diff --git a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs new file mode 100644 index 00000000000..7fb28925b54 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for administrative server operations. + /// + public interface IAdministrationAuthority : IAuthority + { + /// + /// Gets the containing server update information. + /// + /// Bypass the caching that the authority performs for this request, forcing it to contact GitHub. + /// The for the operation. + /// A resulting in the . + [TgsAuthorize(AdministrationRights.ChangeVersion)] + ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); + + /// + /// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given . + /// + /// The TGS will switch to upon reboot. + /// If a will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service. + /// The for the operation. + /// A resulting in the . + [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] + ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); + + /// + /// Triggers a restart of tgstation-server without terminating running game instances. + /// + /// A resulting in the . + [TgsAuthorize(AdministrationRights.RestartHost)] + ValueTask TriggerServerRestart(); + } +} diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index c41113069d2..52004ad9685 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -1,34 +1,28 @@ using System; using System.IO; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Octokit; - using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Transfer; using Tgstation.Server.Host.Utils; -using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Controllers { @@ -39,29 +33,9 @@ namespace Tgstation.Server.Host.Controllers public sealed class AdministrationController : ApiController { /// - /// Default for s. - /// - const string OctokitException = "Bad GitHub API response, check configuration!"; - - /// - /// The key for . + /// The for the . /// - static readonly object ReadCacheKey = new(); - - /// - /// The for the . - /// - readonly IGitHubServiceFactory gitHubServiceFactory; - - /// - /// The for the . - /// - readonly IServerControl serverControl; - - /// - /// The for the . - /// - readonly IServerUpdateInitiator serverUpdateInitiator; + readonly IRestAuthorityInvoker administrationAuthority; /// /// The for the . @@ -83,11 +57,6 @@ public sealed class AdministrationController : ApiController /// readonly IFileTransferTicketProvider fileTransferService; - /// - /// The for the . - /// - readonly IMemoryCache cacheService; - /// /// The for the . /// @@ -98,46 +67,37 @@ public sealed class AdministrationController : ApiController /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . + /// The for the . + /// The for the . + /// The value of . /// The value of . /// The value of . /// The value of . /// The value of . - /// The value of . - /// The for the . /// The containing value of . - /// The for the . public AdministrationController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - IGitHubServiceFactory gitHubServiceFactory, - IServerControl serverControl, - IServerUpdateInitiator serverUpdateInitiator, + IApiHeadersProvider apiHeadersProvider, + ILogger logger, + IRestAuthorityInvoker administrationAuthority, IAssemblyInformationProvider assemblyInformationProvider, IIOManager ioManager, IPlatformIdentifier platformIdentifier, IFileTransferTicketProvider fileTransferService, - IMemoryCache cacheService, - ILogger logger, - IOptions fileLoggingConfigurationOptions, - IApiHeadersProvider apiHeadersProvider) + IOptions fileLoggingConfigurationOptions) : base( - databaseContext, - authenticationContext, - apiHeadersProvider, - logger, - true) + databaseContext, + authenticationContext, + apiHeadersProvider, + logger, + true) { - this.gitHubServiceFactory = gitHubServiceFactory ?? throw new ArgumentNullException(nameof(gitHubServiceFactory)); - this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); - this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); + this.administrationAuthority = administrationAuthority ?? throw new ArgumentNullException(nameof(administrationAuthority)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); - this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } @@ -151,74 +111,14 @@ public AdministrationController( /// The GitHub API rate limit was hit. See response header Retry-After. /// A GitHub API error occurred. See error message for details. [HttpGet] - [TgsAuthorize(AdministrationRights.ChangeVersion)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.GetUpdateInformation))] [ProducesResponseType(typeof(AdministrationResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 424)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] - public async ValueTask Read([FromQuery] bool? fresh, CancellationToken cancellationToken) - { - try - { - async Task CacheFactory() - { - Version? greatestVersion = null; - Uri? repoUrl = null; - try - { - var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken); - var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken); - var releases = await gitHubService.GetTgsReleases(cancellationToken); - - foreach (var kvp in releases) - { - var version = kvp.Key; - var release = kvp.Value; - if (version.Major > 3 // Forward/backward compatible but not before TGS4 - && (greatestVersion == null || version > greatestVersion)) - greatestVersion = version; - } - - repoUrl = await repositoryUrlTask; - } - catch (NotFoundException e) - { - Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); - } - - return Json(new AdministrationResponse - { - LatestVersion = greatestVersion, - TrackedRepositoryUrl = repoUrl, - GeneratedAt = DateTimeOffset.UtcNow, - }); - } - - var ttl = TimeSpan.FromMinutes(30); - Task task; - if (fresh == true || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) - { - using var entry = cacheService.CreateEntry(ReadCacheKey); - entry.AbsoluteExpirationRelativeToNow = ttl; - entry.Value = task = CacheFactory(); - } - else - task = (Task)rawCacheObject!; - - return await task; - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }); - } - } + public ValueTask Read([FromQuery] bool? fresh, CancellationToken cancellationToken) + => administrationAuthority.Invoke( + this, + authority => authority.GetUpdateInformation(fresh ?? false, cancellationToken)); /// /// Attempt to perform a server upgrade. @@ -232,7 +132,7 @@ async Task CacheFactory() /// A GitHub rate limit was encountered or the swarm integrity check failed. /// A GitHub API error occurred. [HttpPost] - [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerVersionChange))] [ProducesResponseType(typeof(ServerUpdateResponse), 202)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] @@ -242,28 +142,15 @@ public async ValueTask Update([FromBody] ServerUpdateRequest mode { ArgumentNullException.ThrowIfNull(model); - var attemptingUpload = model.UploadZip == true; - if (attemptingUpload) - { - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) - return Forbid(); - } - else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) - return Forbid(); - if (model.NewVersion == null) return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure) { AdditionalData = "newVersion is required!", }); - if (model.NewVersion.Major < 3) - return BadRequest(new ErrorMessageResponse(ErrorCode.CannotChangeServerSuite)); - - if (!serverControl.WatchdogPresent) - return UnprocessableEntity(new ErrorMessageResponse(ErrorCode.MissingHostWatchdog)); - - return await AttemptInitiateUpdate(model.NewVersion, attemptingUpload, cancellationToken); + return await administrationAuthority.Invoke( + this, + authority => authority.TriggerServerVersionChange(model.NewVersion, model.UploadZip ?? false, cancellationToken)); } /// @@ -273,27 +160,15 @@ public async ValueTask Update([FromBody] ServerUpdateRequest mode /// Restart begun successfully. /// Restart operations are unavailable due to the launch configuration of TGS. [HttpDelete] - [TgsAuthorize(AdministrationRights.RestartHost)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] - public async ValueTask Delete() - { - try - { - if (!serverControl.WatchdogPresent) - { - Logger.LogDebug("Restart request failed due to lack of host watchdog!"); - return UnprocessableEntity(new ErrorMessageResponse(ErrorCode.MissingHostWatchdog)); - } - - await serverControl.Restart(); - return NoContent(); - } - catch (InvalidOperationException) - { - return StatusCode(HttpStatusCode.ServiceUnavailable); - } - } + public ValueTask Delete() +#pragma warning disable API1001 // Action returns undeclared success result + => administrationAuthority.Invoke( + this, + authority => authority.TriggerServerRestart()); +#pragma warning restore API1001 // Action returns undeclared success result /// /// List s present. @@ -399,56 +274,5 @@ public async ValueTask GetLog(string path, CancellationToken canc }); } } - - /// - /// Attempt to initiate an update. - /// - /// The being updated to. - /// If an upload is being attempted. - /// The for the operation. - /// A resulting in the of the request. - async ValueTask AttemptInitiateUpdate(Version newVersion, bool attemptingUpload, CancellationToken cancellationToken) - { - IFileUploadTicket? uploadTicket = attemptingUpload - ? fileTransferService.CreateUpload(FileUploadStreamKind.None) - : null; - - ServerUpdateResult updateResult; - try - { - try - { - updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, newVersion, cancellationToken); - } - catch - { - if (attemptingUpload) - await uploadTicket!.DisposeAsync(); - - throw; - } - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }); - } - - return updateResult switch - { - ServerUpdateResult.Started => Accepted(new ServerUpdateResponse(newVersion, uploadTicket?.Ticket.FileTicket)), - ServerUpdateResult.ReleaseMissing => this.Gone(), - ServerUpdateResult.UpdateInProgress => BadRequest(new ErrorMessageResponse(ErrorCode.ServerUpdateInProgress)), - ServerUpdateResult.SwarmIntegrityCheckFailed => this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed)), - _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), - }; - } } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 429cbc89898..2517f8502fc 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -487,6 +487,7 @@ void AddTypedContext() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // configure misc services services.AddSingleton(); From 3bbd1142d9a0714c9f667d196972b50b4141c4e8 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 13:53:29 -0400 Subject: [PATCH 02/10] Fix some bad authority HTTP response code handling --- src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs | 4 ++-- .../Authority/Core/RestAuthorityInvoker{TAuthority}.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index f0e39151a39..0ea01a89da0 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -60,7 +60,7 @@ protected static AuthorityResponse Unauthorized() /// A new, errored . protected static AuthorityResponse Gone() => new( - new ErrorMessageResponse(), + new ErrorMessageResponse(ErrorCode.ResourceNotPresent), HttpFailureResponse.Gone); /// @@ -80,7 +80,7 @@ protected static AuthorityResponse Forbid() /// A new, errored . protected static AuthorityResponse NotFound() => new( - new ErrorMessageResponse(), + new ErrorMessageResponse(ErrorCode.ResourceNeverPresent), HttpFailureResponse.NotFound); /// diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 91118a55862..52532c2f0eb 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -55,7 +55,7 @@ static IActionResult CreateSuccessfulActionResult(ApiControl return failureResponse switch { HttpFailureResponse.BadRequest => controller.BadRequest(errorMessage), - HttpFailureResponse.Unauthorized => controller.Unauthorized(errorMessage), + HttpFailureResponse.Unauthorized => controller.Unauthorized(), HttpFailureResponse.Forbidden => controller.Forbid(), HttpFailureResponse.NotFound => controller.NotFound(errorMessage), HttpFailureResponse.NotAcceptable => controller.StatusCode(HttpStatusCode.NotAcceptable, errorMessage), From 8cd94ed1ae4f8a95eb4cfce31b11bafbcdb263a6 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 14:54:48 -0400 Subject: [PATCH 03/10] Simpler class for string based scalars --- .../GraphQL/Scalars/JwtType.cs | 22 +---------- .../GraphQL/Scalars/StringScalarType.cs | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs index 170a744a42e..fd6057392af 100644 --- a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs @@ -1,14 +1,11 @@ using System; -using HotChocolate.Language; -using HotChocolate.Types; - namespace Tgstation.Server.Host.GraphQL.Scalars { /// - /// A for encoded JSON Web Tokens. + /// A for encoded JSON Web Tokens. /// - public sealed class JwtType : ScalarType + public sealed class JwtType : StringScalarType { /// /// Initializes a new instance of the class. @@ -19,20 +16,5 @@ public JwtType() Description = "Represents an encoded JSON Web Token"; SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519"); } - - /// - public override IValueNode ParseResult(object? resultValue) - => ParseValue(resultValue); - - /// - protected override string ParseLiteral(StringValueNode valueSyntax) - { - ArgumentNullException.ThrowIfNull(valueSyntax); - return valueSyntax.Value; - } - - /// - protected override StringValueNode ParseValue(string runtimeValue) - => new(runtimeValue); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs new file mode 100644 index 00000000000..05f43289468 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs @@ -0,0 +1,37 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Scalars +{ + /// + /// A for specialized types. + /// + public abstract class StringScalarType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the GraphQL scalar type. + public StringScalarType(string name) + : base(name) + { + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + protected override string ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return valueSyntax.Value; + } + + /// + protected override StringValueNode ParseValue(string runtimeValue) + => new(runtimeValue); + } +} From 770178c586a1840746334452d36aa7b0f62f7cd0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 14:55:00 -0400 Subject: [PATCH 04/10] Add `FileUploadTicket` scalar --- .../GraphQL/Scalars/FileUploadTicketType.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs new file mode 100644 index 00000000000..24d7ca08087 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs @@ -0,0 +1,17 @@ +namespace Tgstation.Server.Host.GraphQL.Scalars +{ + /// + /// A for upload s. + /// + public sealed class FileUploadTicketType : StringScalarType + { + /// + /// Initializes a new instance of the class. + /// + public FileUploadTicketType() + : base("FileUploadTicket") + { + Description = "Represents a ticket that can be used with the file transfer service to upload a file"; + } + } +} From b43cf98df9c4e03c0e60f0aee22a5c3e5de34b1e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 14:55:23 -0400 Subject: [PATCH 05/10] Implement server restarts and updates via GraphQL --- .../RepositoryBasedServerUpdate.graphql | 11 ++ .../GQL/Mutations/RestartServer.graphql | 11 ++ .../GQL/Queries/GetUpdateInformation.graphql | 10 ++ .../Mutations/AdministrationMutations.cs | 84 +++++++++ .../GraphQL/Types/ServerSwarm.cs | 21 +-- .../GraphQL/Types/UpdateInformation.cs | 117 ++++++++++++ .../Live/AdministrationTest.cs | 77 +++++--- .../Live/TestLiveServer.cs | 168 ++++++++++++------ 8 files changed, 413 insertions(+), 86 deletions(-) create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql create mode 100644 src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql create mode 100644 src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs create mode 100644 src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql new file mode 100644 index 00000000000..4f8a3bdbccb --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql @@ -0,0 +1,11 @@ +mutation RepositoryBasedServerUpdate($targetVersion: Semver!) { + changeServerVersionViaTrackedRepository(input: { targetVersion: $targetVersion }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql new file mode 100644 index 00000000000..c9abfea9ef9 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql @@ -0,0 +1,11 @@ +mutation RestartServer() { + restartServer() { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql new file mode 100644 index 00000000000..dfa34982160 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql @@ -0,0 +1,10 @@ +query GetUpdateInformation($forceFresh: Boolean!) { + swarm { + updateInformation { + generatedAt + latestVersion(forceFresh: $forceFresh) + updateInProgress + trackedRepositoryUrl(forceFresh: $forceFresh) + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs new file mode 100644 index 00000000000..6214227d807 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Scalars; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + [GraphQLDescription(Mutation.GraphQLDescription)] + public sealed class AdministrationMutations + { + /// + /// Restarts the server node without terminating running game instances. + /// + /// The for the . + /// A representing the running operation. + [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] + [Error(typeof(ErrorMessageException))] + public async ValueTask RestartServer( + [Service] IGraphQLAuthorityInvoker administrationAuthority) + { + ArgumentNullException.ThrowIfNull(administrationAuthority); + await administrationAuthority.Invoke( + authority => authority.TriggerServerRestart()); + + return new Query(); + } + + /// + /// Restarts the server node without terminating running game instances and changes its . + /// + /// The semver of the server available in the tracked repository to switch to. + /// The for the . + /// The for the operation. + /// A representing the running operation. + [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] + [Error(typeof(ErrorMessageException))] + public async ValueTask ChangeServerVersionViaTrackedRepository( + Version targetVersion, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetVersion); + ArgumentNullException.ThrowIfNull(administrationAuthority); + await administrationAuthority.Invoke( + authority => authority.TriggerServerVersionChange(targetVersion, false, cancellationToken)); + return new Query(); + } + + /// + /// Restarts the server node without terminating running game instances and changes its . + /// + /// The semver of the server available in the tracked repository to switch to. + /// The for the . + /// The for the operation. + /// A FileTicket that should be used to upload a zip containing the update data to the file transfer service. + [TgsGraphQLAuthorize(AdministrationRights.UploadVersion)] + [Error(typeof(ErrorMessageException))] + [GraphQLType] + public async ValueTask ChangeServerVersionViaUpload( + Version targetVersion, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetVersion); + ArgumentNullException.ThrowIfNull(administrationAuthority); + var response = await administrationAuthority.Invoke( + authority => authority.TriggerServerVersionChange(targetVersion, true, cancellationToken)); + + return response.FileTicket ?? throw new InvalidOperationException("Administration authority did not generate a FileUploadTicket!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 286aad2adfc..35d82985dd6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Properties; using Tgstation.Server.Host.Security; @@ -20,19 +19,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class ServerSwarm { - /// - /// If there is a swarm update in progress. - /// - /// The to use. - /// if there is an update in progress, otherwise. - [TgsGraphQLAuthorize] - public bool UpdateInProgress( - [Service] IServerControl serverControl) - { - ArgumentNullException.ThrowIfNull(serverControl); - return serverControl.UpdateInProgress; - } - /// /// Gets the swarm protocol major version in use. /// @@ -78,5 +64,12 @@ public IServerNode CurrentNode( ArgumentNullException.ThrowIfNull(swarmService); return swarmService.GetSwarmServers()?.Select(x => new SwarmNode(x)).ToList(); } + + /// + /// Gets the for the swarm. + /// + /// A new . + [TgsGraphQLAuthorize] + public UpdateInformation UpdateInformation() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs new file mode 100644 index 00000000000..27e758bd79e --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Gets information about updates for the . + /// + public sealed class UpdateInformation : IDisposable + { + /// + /// to prevent duplicate cache generations in one query. + /// + readonly SemaphoreSlim cacheReadSemaphore; + + /// + /// If the cache was already force generated this query. + /// + bool cacheForceGenerated; + + /// + /// Initializes a new instance of the class. + /// + public UpdateInformation() + { + cacheReadSemaphore = new SemaphoreSlim(1, 1); + } + + /// + public void Dispose() + => cacheReadSemaphore.Dispose(); + + /// + /// If there is a swarm update in progress. This is not affected by . + /// + /// The to use. + /// if there is an update in progress, otherwise. + public bool UpdateInProgress( + [Service] IServerControl serverControl) + { + ArgumentNullException.ThrowIfNull(serverControl); + return serverControl.UpdateInProgress; + } + + /// + /// Gets the of the GitHub repository updates are sourced from. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// The of the GitHub repository updates are sourced from on success. if a GitHub API error occurred. + public async ValueTask TrackedRepositoryUrl( + bool forceFresh, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(forceFresh, administrationAuthority, cancellationToken)).TrackedRepositoryUrl; + + /// + /// Gets the time the was generated. + /// + /// The for the to use. + /// The for the operation. + /// The time the was generated on success. if a GitHub API error occurred. + public async ValueTask GeneratedAt( + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(false, administrationAuthority, cancellationToken)).GeneratedAt; + + /// + /// Gets the latest of tgstation-server available on the GitHub repository updates are sourced from. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// The of the latest TGS version on success. if a GitHub API error occurred. + public async ValueTask LatestVersion( + bool forceFresh, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(forceFresh, administrationAuthority, cancellationToken)).LatestVersion; + + /// + /// Safely retrieve the from a given without generating the cache multiple times in one query. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// A resulting in the from the . + async ValueTask GetAdministrationResponseSafe( + bool forceFresh, + IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + using (await SemaphoreSlimContext.Lock(cacheReadSemaphore, cancellationToken)) + { + if (cacheForceGenerated) + forceFresh = false; + else + cacheForceGenerated |= forceFresh; + + ArgumentNullException.ThrowIfNull(administrationAuthority); + var response = await administrationAuthority.Invoke( + authority => authority.GetUpdateInformation(forceFresh, cancellationToken)); + + return response; + } + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs index afeabef6f5d..d50205da90d 100644 --- a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs @@ -13,11 +13,13 @@ namespace Tgstation.Server.Tests.Live { sealed class AdministrationTest { - readonly IAdministrationClient client; + readonly IMultiServerClient client; + readonly IAdministrationClient restClient; - public AdministrationTest(IAdministrationClient client) + public AdministrationTest(MultiServerClient client) { this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.restClient = client.RestClient.Administration; } public async Task Run(CancellationToken cancellationToken) @@ -29,24 +31,24 @@ public async Task Run(CancellationToken cancellationToken) async Task TestLogs(CancellationToken cancellationToken) { - var logs = await client.ListLogs(null, cancellationToken); + var logs = await restClient.ListLogs(null, cancellationToken); Assert.AreNotEqual(0, logs.Count); var logFile = logs[0]; Assert.IsNotNull(logFile); Assert.IsFalse(string.IsNullOrWhiteSpace(logFile.Name)); Assert.IsNull(logFile.FileTicket); - var downloadedTuple = await client.GetLog(logFile, cancellationToken); + var downloadedTuple = await restClient.GetLog(logFile, cancellationToken); Assert.AreEqual(logFile.Name, downloadedTuple.Item1.Name); Assert.IsTrue(logFile.LastModified <= downloadedTuple.Item1.LastModified); Assert.IsNull(logFile.FileTicket); - await ApiAssert.ThrowsException>(() => client.GetLog(new LogFileResponse + await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse { Name = "very_fake_path.log" }, cancellationToken), ErrorCode.IOError); - await ApiAssert.ThrowsException>(() => client.GetLog(new LogFileResponse + await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse { Name = "../out_of_bounds.file" }, cancellationToken)); @@ -54,24 +56,59 @@ await ApiAssert.ThrowsException + { + var restClient = restServerClient.Administration; - //we've released a few 5.x versions now, check the release checker is at least somewhat functional - Assert.IsTrue(4 < model.LatestVersion.Major); - Assert.IsNotNull(model.TrackedRepositoryUrl); - Assert.IsTrue(model.GeneratedAt.HasValue); - Assert.IsTrue(model.GeneratedAt.Value <= DateTimeOffset.UtcNow); + var model = await restClient.Read(false, cancellationToken); - // test the cache - var newerModel = await client.Read(false, cancellationToken); - Assert.AreEqual(model.GeneratedAt, newerModel.GeneratedAt); + //we've released a few 5.x versions now, check the release checker is at least somewhat functional + Assert.IsNotNull(model.LatestVersion); + Assert.IsTrue(4 < model.LatestVersion.Major); + Assert.IsNotNull(model.TrackedRepositoryUrl); + Assert.IsTrue(model.GeneratedAt.HasValue); + Assert.IsTrue(model.GeneratedAt.Value <= DateTimeOffset.UtcNow); - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + // test the cache + var newerModel = await restClient.Read(false, cancellationToken); + Assert.AreEqual(model.GeneratedAt, newerModel.GeneratedAt); - var newestModel = await client.Read(true, cancellationToken); - Assert.AreNotEqual(model.GeneratedAt, newestModel.GeneratedAt); - Assert.IsNotNull(newestModel.GeneratedAt); - Assert.IsTrue(model.GeneratedAt < newestModel.GeneratedAt); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + var newestModel = await restClient.Read(true, cancellationToken); + Assert.AreNotEqual(model.GeneratedAt, newestModel.GeneratedAt); + Assert.IsNotNull(newestModel.GeneratedAt); + Assert.IsTrue(model.GeneratedAt < newestModel.GeneratedAt); + }, + async gqlClient => + { + var queryResult = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(false, cancellationToken), + cancellationToken); + + // we've released a few 5.x versions now, check the release checker is at least somewhat functional + Assert.IsNotNull(queryResult.Swarm.UpdateInformation.LatestVersion); + Assert.IsTrue(4 < queryResult.Swarm.UpdateInformation.LatestVersion.Major); + Assert.IsNotNull(queryResult.Swarm.UpdateInformation.TrackedRepositoryUrl); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.HasValue); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.Value <= DateTimeOffset.UtcNow); + + // test the cache + var queryResult2 = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(false, cancellationToken), + cancellationToken); + Assert.AreEqual(queryResult.Swarm.UpdateInformation.GeneratedAt, queryResult2.Swarm.UpdateInformation.GeneratedAt); + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + var queryResult3 = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(true, cancellationToken), + cancellationToken); + + Assert.AreNotEqual(queryResult.Swarm.UpdateInformation.GeneratedAt, queryResult3.Swarm.UpdateInformation.GeneratedAt); + Assert.IsNotNull(queryResult3.Swarm.UpdateInformation.GeneratedAt); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.Value < queryResult3.Swarm.UpdateInformation.GeneratedAt.Value); + }); } } } diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 9140a29df3b..5df923a85cb 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -340,21 +340,29 @@ async ValueTask TestWithoutAndWithPermission(Func adminClient.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion, - UploadZip = false, - }, - null, - cancellationToken), - adminClient.RestClient, - AdministrationRights.ChangeVersion); - - Assert.IsNotNull(responseModel); - Assert.IsNull(responseModel.FileTicket); - Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + await adminClient.Execute( + async restClient => + { + var responseModel = await TestWithoutAndWithPermission( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion, + UploadZip = false, + }, + null, + cancellationToken), + adminClient.RestClient, + AdministrationRights.ChangeVersion); + + Assert.IsNotNull(responseModel); + Assert.IsNull(responseModel.FileTicket); + Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + }, + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result, + cancellationToken)); try { @@ -524,17 +532,25 @@ static void CheckInfo(ServerInformationResponse serverInformation) CheckInfo(controllerInfo); // test update - var responseModel = await controllerClient.RestClient.Administration.Update( - new ServerUpdateRequest + await controllerClient.Execute( + async restClient => { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken); + var responseModel = await restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken); - Assert.IsNotNull(responseModel); - Assert.IsNull(responseModel.FileTicket); - Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + Assert.IsNotNull(responseModel); + Assert.IsNull(responseModel.FileTicket); + Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + }, + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result, + cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); Assert.IsTrue(serverTask.IsCompleted); @@ -711,13 +727,18 @@ await Task.WhenAny( await ApiAssert.ThrowsException(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update - await node1Client.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken); + await node1Client.Execute( + async restClient => await restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result, + cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); Assert.IsTrue(serverTask.IsCompleted); @@ -752,13 +773,22 @@ void CheckServerUpdated(LiveTestingServer server) await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken); await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException(() => controllerClient2.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken), Api.Models.ErrorCode.SwarmIntegrityCheckFailed); + await controllerClient2.Execute( + async restClient => await ApiAssert.ThrowsException( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + Api.Models.ErrorCode.SwarmIntegrityCheckFailed), + async gqlClient => await ApiAssert.OperationFails( + gqlClient, + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result, + Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, + cancellationToken)); // regression: test updating also works from the controller serverTask = Task.WhenAll( @@ -954,7 +984,13 @@ await Task.WhenAny( Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); // restart the controller - await controllerClient.RestClient.Administration.Restart(cancellationToken); + await controllerClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result, + cancellationToken)); + await Task.WhenAny( controllerTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -976,7 +1012,12 @@ await Task.WhenAny( await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); // restart node2 - await node2Client.RestClient.Administration.Restart(cancellationToken); + await node2Client.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result, + cancellationToken)); await Task.WhenAny( node2Task, Task.Delay(TimeSpan.FromMinutes(1))); @@ -988,14 +1029,22 @@ await Task.WhenAny( Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); // update should fail - await ApiAssert.ThrowsException( - () => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken), - Api.Models.ErrorCode.SwarmIntegrityCheckFailed); + await controllerClient2.Execute( + async restClient => await ApiAssert.ThrowsException( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + Api.Models.ErrorCode.SwarmIntegrityCheckFailed), + async gqlClient => await ApiAssert.OperationFails( + gqlClient, + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result, + Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, + cancellationToken)); node2Task = node2.Run(cancellationToken).AsTask(); await using var node2Client2 = await CreateAdminClient(node2.ApiUrl, cancellationToken); @@ -1486,7 +1535,7 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminMultiClient).Run(cancellationToken)); var usersTest = FailFast(new UsersTest(firstAdminMultiClient).Run(cancellationToken).AsTask()); var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); @@ -1632,7 +1681,12 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( await Task.Delay(1000, cancellationToken); jobsHubTest.ExpectShutdown(); - await firstAdminRestClient.Administration.Restart(cancellationToken); + await firstAdminMultiClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result, + cancellationToken)); } catch { @@ -1777,7 +1831,12 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( Assert.AreEqual(WatchdogStatus.Offline, dd.Status); jobsHubTest.ExpectShutdown(); - await adminClient.Administration.Restart(cancellationToken); + await multiClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result, + cancellationToken)); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1844,7 +1903,12 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest expectedStaged = compileJob.Id.Value; jobsHubTest.ExpectShutdown(); - await restAdminClient.Administration.Restart(cancellationToken); + await adminClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result, + cancellationToken)); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); From 5b9603cd8b94df537860914ddeaa51ea76643d5a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 14:55:29 -0400 Subject: [PATCH 06/10] GraphQL version bump --- build/Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Version.props b/build/Version.props index dc59a0b959f..908457bd861 100644 --- a/build/Version.props +++ b/build/Version.props @@ -6,7 +6,7 @@ 6.11.1 5.3.0 10.10.0 - 0.2.0 + 0.3.0 7.0.0 16.1.0 19.1.0 From 7daea8dec48fdfd60d44be14b2b04fe5a666f749 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 16:29:05 -0400 Subject: [PATCH 07/10] Improve naming of admin mutations --- .../Mutations/RepositoryBasedServerUpdate.graphql | 2 +- .../GQL/Mutations/RestartServer.graphql | 2 +- .../GraphQL/Mutations/AdministrationMutations.cs | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql index 4f8a3bdbccb..2b23e65443c 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql @@ -1,5 +1,5 @@ mutation RepositoryBasedServerUpdate($targetVersion: Semver!) { - changeServerVersionViaTrackedRepository(input: { targetVersion: $targetVersion }) { + changeServerNodeVersionViaTrackedRepository(input: { targetVersion: $targetVersion }) { errors { ... on ErrorMessageError { additionalData diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql index c9abfea9ef9..62b9fea1fc6 100644 --- a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql @@ -1,5 +1,5 @@ mutation RestartServer() { - restartServer() { + restartServerNode() { errors { ... on ErrorMessageError { additionalData diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs index 6214227d807..7390113a24a 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs @@ -21,13 +21,13 @@ namespace Tgstation.Server.Host.GraphQL.Mutations public sealed class AdministrationMutations { /// - /// Restarts the server node without terminating running game instances. + /// Restarts the mutated without terminating running game instances. /// /// The for the . /// A representing the running operation. [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [Error(typeof(ErrorMessageException))] - public async ValueTask RestartServer( + public async ValueTask RestartServerNode( [Service] IGraphQLAuthorityInvoker administrationAuthority) { ArgumentNullException.ThrowIfNull(administrationAuthority); @@ -38,7 +38,7 @@ await administrationAuthority.Invoke( } /// - /// Restarts the server node without terminating running game instances and changes its . + /// Restarts the mutated without terminating running game instances and changes its . /// /// The semver of the server available in the tracked repository to switch to. /// The for the . @@ -46,7 +46,7 @@ await administrationAuthority.Invoke( /// A representing the running operation. [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] [Error(typeof(ErrorMessageException))] - public async ValueTask ChangeServerVersionViaTrackedRepository( + public async ValueTask ChangeServerNodeVersionViaTrackedRepository( Version targetVersion, [Service] IGraphQLAuthorityInvoker administrationAuthority, CancellationToken cancellationToken) @@ -59,7 +59,7 @@ await administrationAuthority.Invoke } /// - /// Restarts the server node without terminating running game instances and changes its . + /// Restarts the mutated without terminating running game instances and changes its . /// /// The semver of the server available in the tracked repository to switch to. /// The for the . @@ -68,7 +68,7 @@ await administrationAuthority.Invoke [TgsGraphQLAuthorize(AdministrationRights.UploadVersion)] [Error(typeof(ErrorMessageException))] [GraphQLType] - public async ValueTask ChangeServerVersionViaUpload( + public async ValueTask ChangeServerNodeVersionViaUpload( Version targetVersion, [Service] IGraphQLAuthorityInvoker administrationAuthority, CancellationToken cancellationToken) From 3ead90c90290e1e0e04cec5ae8d7755989f2d53a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 16:29:28 -0400 Subject: [PATCH 08/10] Add FileUploadTicket scalar to client schema extensions --- src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 8991e1fa70c..81915bf7e45 100644 --- a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -16,3 +16,4 @@ extend schema @key(fields: "id") extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken") +extend scalar FileUploadTicket @serializationType(name: "global::System.String") @runtimeType(name: "global::System.String") From 6d391a5eb73f56690214cfce9bd2bc92f4022b4a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 16:47:38 -0400 Subject: [PATCH 09/10] Fix issues with semver parsing server-side --- src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs index de8c1e530a3..3f6f6608af6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs @@ -67,7 +67,8 @@ protected override StringValueNode ParseValue(Version runtimeValue) protected override bool IsInstanceOfType(StringValueNode valueSyntax) { ArgumentNullException.ThrowIfNull(valueSyntax); - return IsInstanceOfType(valueSyntax.Value); + return Version.TryParse(valueSyntax.Value, out var parsedVersion) + && IsInstanceOfType(parsedVersion); } /// From 6ad80095ff272c0558ddf328157becccc42269bb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 20 Oct 2024 17:10:34 -0400 Subject: [PATCH 10/10] Fix bad test mutation operation invocations --- .../Tgstation.Server.Tests/Live/ApiAssert.cs | 3 +++ .../Live/GraphQLServerClientExtensions.cs | 1 + .../Live/TestLiveServer.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 5bd736c6cce..2792289dd6a 100644 --- a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs @@ -11,6 +11,8 @@ using Tgstation.Server.Client; using Tgstation.Server.Client.GraphQL; +using static HotChocolate.ErrorCodes; + namespace Tgstation.Server.Tests.Live { /// @@ -78,6 +80,7 @@ public static async ValueTask OperationFails( var payload = payloadSelector(operationResult.Data); + Assert.AreNotSame(operationResult.Data, payload, "Select the mutation payload from the operation result!"); var payloadErrors = (IEnumerable)payload.GetType().GetProperty("Errors").GetValue(payload); var error = payloadErrors.Single(); diff --git a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs index 993b8999ad9..db373b61f6c 100644 --- a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs +++ b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs @@ -33,6 +33,7 @@ public static async ValueTask RunMutationEnsureNoErrors(result.Data, data, "Select the mutation payload from the operation result!"); var errorsObject = data.GetType().GetProperty("Errors").GetValue(data); if (errorsObject != null) { diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 5df923a85cb..6c695ec0d6f 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -361,7 +361,7 @@ await adminClient.Execute( }, async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), - result => result, + result => result.ChangeServerNodeVersionViaTrackedRepository, cancellationToken)); try @@ -549,7 +549,7 @@ await controllerClient.Execute( }, async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), - result => result, + result => result.ChangeServerNodeVersionViaTrackedRepository, cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); @@ -737,7 +737,7 @@ await node1Client.Execute( cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), - result => result, + result => result.ChangeServerNodeVersionViaTrackedRepository, cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); Assert.IsTrue(serverTask.IsCompleted); @@ -786,7 +786,7 @@ await controllerClient2.Execute( async gqlClient => await ApiAssert.OperationFails( gqlClient, gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), - result => result, + result => result.ChangeServerNodeVersionViaTrackedRepository, Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, cancellationToken)); @@ -988,7 +988,7 @@ await controllerClient.Execute( restClient => restClient.Administration.Restart(cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RestartServer.ExecuteAsync(cancellationToken), - result => result, + result => result.RestartServerNode, cancellationToken)); await Task.WhenAny( @@ -1016,7 +1016,7 @@ await node2Client.Execute( restClient => restClient.Administration.Restart(cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RestartServer.ExecuteAsync(cancellationToken), - result => result, + result => result.RestartServerNode, cancellationToken)); await Task.WhenAny( node2Task, @@ -1042,7 +1042,7 @@ await controllerClient2.Execute( async gqlClient => await ApiAssert.OperationFails( gqlClient, gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), - result => result, + result => result.ChangeServerNodeVersionViaTrackedRepository, Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, cancellationToken)); @@ -1685,7 +1685,7 @@ await firstAdminMultiClient.Execute( restClient => restClient.Administration.Restart(cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RestartServer.ExecuteAsync(cancellationToken), - result => result, + result => result.RestartServerNode, cancellationToken)); } catch @@ -1835,7 +1835,7 @@ await multiClient.Execute( restClient => restClient.Administration.Restart(cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RestartServer.ExecuteAsync(cancellationToken), - result => result, + result => result.RestartServerNode, cancellationToken)); } @@ -1907,7 +1907,7 @@ await adminClient.Execute( restClient => restClient.Administration.Restart(cancellationToken), async gqlClient => await gqlClient.RunMutationEnsureNoErrors( gql => gql.RestartServer.ExecuteAsync(cancellationToken), - result => result, + result => result.RestartServerNode, cancellationToken)); }