Skip to content

Commit

Permalink
Merge pull request #1986 from tgstation/AdminAuthority [GQLDeploy]
Browse files Browse the repository at this point in the history
Implement some administrative functions in GraphQL
  • Loading branch information
Cyberboss authored Oct 21, 2024
2 parents 27b671f + 6ad8009 commit 299c1e0
Show file tree
Hide file tree
Showing 23 changed files with 803 additions and 319 deletions.
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

0 comments on commit 299c1e0

Please sign in to comment.