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