diff --git a/build/Version.props b/build/Version.props index dc59a0b959..908457bd86 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 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 0000000000..2b23e65443 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql @@ -0,0 +1,11 @@ +mutation RepositoryBasedServerUpdate($targetVersion: Semver!) { + changeServerNodeVersionViaTrackedRepository(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 0000000000..62b9fea1fc --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql @@ -0,0 +1,11 @@ +mutation RestartServer() { + restartServerNode() { + 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 0000000000..dfa3498216 --- /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.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 8991e1fa70..81915bf7e4 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") diff --git a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs new file mode 100644 index 0000000000..0655ebf6f2 --- /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/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index f0e39151a3..0ea01a89da 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/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs index 5aa3c18089..af2956826e 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 680dc98a4e..52532c2f0e 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), @@ -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 0000000000..7fb28925b5 --- /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 c41113069d..52004ad968 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 429cbc8989..2517f8502f 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(); 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 0000000000..7390113a24 --- /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 mutated without terminating running game instances. + /// + /// The for the . + /// A representing the running operation. + [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] + [Error(typeof(ErrorMessageException))] + public async ValueTask RestartServerNode( + [Service] IGraphQLAuthorityInvoker administrationAuthority) + { + ArgumentNullException.ThrowIfNull(administrationAuthority); + await administrationAuthority.Invoke( + authority => authority.TriggerServerRestart()); + + return new Query(); + } + + /// + /// 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 . + /// The for the operation. + /// A representing the running operation. + [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] + [Error(typeof(ErrorMessageException))] + public async ValueTask ChangeServerNodeVersionViaTrackedRepository( + 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 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 . + /// 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 ChangeServerNodeVersionViaUpload( + 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/Scalars/FileUploadTicketType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs new file mode 100644 index 0000000000..24d7ca0808 --- /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"; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs index 170a744a42..fd6057392a 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/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs index de8c1e530a..3f6f6608af 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); } /// 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 0000000000..05f4328946 --- /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); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 286aad2adf..35d82985dd 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 0000000000..27e758bd79 --- /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 afeabef6f5..d50205da90 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/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 5bd736c6cc..2792289dd6 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 993b8999ad..db373b61f6 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 9140a29df3..6c695ec0d6 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.ChangeServerNodeVersionViaTrackedRepository, + 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.ChangeServerNodeVersionViaTrackedRepository, + 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.ChangeServerNodeVersionViaTrackedRepository, + 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.ChangeServerNodeVersionViaTrackedRepository, + 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.RestartServerNode, + 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.RestartServerNode, + 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.ChangeServerNodeVersionViaTrackedRepository, + 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.RestartServerNode, + 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.RestartServerNode, + 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.RestartServerNode, + cancellationToken)); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken));