Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement some administrative functions in GraphQL #1986

Merged
merged 10 commits into from
Oct 21, 2024
2 changes: 1 addition & 1 deletion build/Version.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<TgsCoreVersion>6.11.1</TgsCoreVersion>
<TgsConfigVersion>5.3.0</TgsConfigVersion>
<TgsRestVersion>10.10.0</TgsRestVersion>
<TgsGraphQLVersion>0.2.0</TgsGraphQLVersion>
<TgsGraphQLVersion>0.3.0</TgsGraphQLVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>16.1.0</TgsApiLibraryVersion>
<TgsClientVersion>19.1.0</TgsClientVersion>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mutation RepositoryBasedServerUpdate($targetVersion: Semver!) {
changeServerNodeVersionViaTrackedRepository(input: { targetVersion: $targetVersion }) {
errors {
... on ErrorMessageError {
additionalData
errorCode
message
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mutation RestartServer() {
restartServerNode() {
errors {
... on ErrorMessageError {
additionalData
errorCode
message
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
query GetUpdateInformation($forceFresh: Boolean!) {
swarm {
updateInformation {
generatedAt
latestVersion(forceFresh: $forceFresh)
updateInProgress
trackedRepositoryUrl(forceFresh: $forceFresh)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
241 changes: 241 additions & 0 deletions src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <inheritdoc cref="IAdministrationAuthority" />
sealed class AdministrationAuthority : AuthorityBase, IAdministrationAuthority
{
/// <summary>
/// Default <see cref="Exception.Message"/> for <see cref="ApiException"/>s.
/// </summary>
const string OctokitException = "Bad GitHub API response, check configuration!";

/// <summary>
/// The <see cref="IMemoryCache"/> key for <see cref="GetUpdateInformation(bool, CancellationToken)"/>.
/// </summary>
static readonly object ReadCacheKey = new();

/// <summary>
/// The <see cref="IGitHubServiceFactory"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IGitHubServiceFactory gitHubServiceFactory;

/// <summary>
/// The <see cref="IServerControl"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IServerControl serverControl;

/// <summary>
/// The <see cref="IServerUpdateInitiator"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IServerUpdateInitiator serverUpdateInitiator;

/// <summary>
/// The <see cref="IFileTransferTicketProvider"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IFileTransferTicketProvider fileTransferService;

/// <summary>
/// The <see cref="IMemoryCache"/> for the <see cref="AdministrationAuthority"/>.
/// </summary>
readonly IMemoryCache cacheService;

/// <summary>
/// Initializes a new instance of the <see cref="AdministrationAuthority"/> class.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to use.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> to use.</param>
/// <param name="logger">The <see cref="ILogger"/> to use.</param>
/// <param name="gitHubServiceFactory">The value of <see cref="gitHubServiceFactory"/>.</param>
/// <param name="serverControl">The value of <see cref="serverControl"/>.</param>
/// <param name="serverUpdateInitiator">The value of <see cref="serverUpdateInitiator"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="cacheService">The value of <see cref="cacheService"/>.</param>
public AdministrationAuthority(
IAuthenticationContext authenticationContext,
IDatabaseContext databaseContext,
ILogger<UserAuthority> 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));
}

/// <inheritdoc />
public async ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken)
{
try
{
async Task<AdministrationResponse> 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<AdministrationResponse> 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<AdministrationResponse>)rawCacheObject!;

return new AuthorityResponse<AdministrationResponse>(await task);
}
catch (RateLimitExceededException e)
{
return RateLimit<AdministrationResponse>(e);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<AdministrationResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}
}

/// <inheritdoc />
public async ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken)
{
var attemptingUpload = uploadZip == true;
if (attemptingUpload)
{
if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion))
return Forbid<ServerUpdateResponse>();
}
else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion))
return Forbid<ServerUpdateResponse>();

if (targetVersion.Major < 4)
return BadRequest<ServerUpdateResponse>(ErrorCode.CannotChangeServerSuite);

if (!serverControl.WatchdogPresent)
return new AuthorityResponse<ServerUpdateResponse>(
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<ServerUpdateResponse>(ex);
}
catch (ApiException e)
{
Logger.LogWarning(e, OctokitException);
return new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.RemoteApiError)
{
AdditionalData = e.Message,
},
HttpFailureResponse.FailedDependency);
}

return updateResult switch
{
ServerUpdateResult.Started => new AuthorityResponse<ServerUpdateResponse>(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted),
ServerUpdateResult.ReleaseMissing => Gone<ServerUpdateResponse>(),
ServerUpdateResult.UpdateInProgress => BadRequest<ServerUpdateResponse>(ErrorCode.ServerUpdateInProgress),
ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse<ServerUpdateResponse>(
new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed),
HttpFailureResponse.FailedDependency),
_ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"),
};
}

/// <inheritdoc />
public async ValueTask<AuthorityResponse> 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();
}
}
}
4 changes: 2 additions & 2 deletions src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ protected static AuthorityResponse<TResult> Unauthorized<TResult>()
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> Gone<TResult>()
=> new(
new ErrorMessageResponse(),
new ErrorMessageResponse(ErrorCode.ResourceNotPresent),
HttpFailureResponse.Gone);

/// <summary>
Expand All @@ -80,7 +80,7 @@ protected static AuthorityResponse<TResult> Forbid<TResult>()
/// <returns>A new, errored <see cref="AuthorityResponse{TResult}"/>.</returns>
protected static AuthorityResponse<TResult> NotFound<TResult>()
=> new(
new ErrorMessageResponse(),
new ErrorMessageResponse(ErrorCode.ResourceNeverPresent),
HttpFailureResponse.NotFound);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@ public enum HttpFailureResponse
/// HTTP 501.
/// </summary>
NotImplemented,

/// <summary>
/// HTTP 503.
/// </summary>
ServiceUnavailable,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(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),
Expand All @@ -65,6 +65,7 @@ static IActionResult CreateSuccessfulActionResult<TResult, TApiModel>(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}"),
};
}
Expand Down
43 changes: 43 additions & 0 deletions src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// <see cref="IAuthority"/> for administrative server operations.
/// </summary>
public interface IAdministrationAuthority : IAuthority
{
/// <summary>
/// Gets the <see cref="AdministrationResponse"/> containing server update information.
/// </summary>
/// <param name="forceFresh">Bypass the caching that the authority performs for this request, forcing it to contact GitHub.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AdministrationResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion)]
ValueTask<AuthorityResponse<AdministrationResponse>> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken);

/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given <paramref name="targetVersion"/>.
/// </summary>
/// <param name="targetVersion">The <see cref="Version"/> TGS will switch to upon reboot.</param>
/// <param name="uploadZip">If <see langword="true"/> a <see cref="FileTicketResponse.FileTicket"/> will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="ServerUpdateResponse"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
[TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)]
ValueTask<AuthorityResponse<ServerUpdateResponse>> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken);

/// <summary>
/// Triggers a restart of tgstation-server without terminating running game instances.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="AuthorityResponse"/>.</returns>
[TgsAuthorize(AdministrationRights.RestartHost)]
ValueTask<AuthorityResponse> TriggerServerRestart();
}
}
Loading
Loading