Merge pull request #1924 from tgstation/1077-GraphQL [NugetDeploy]

GraphQL API Initial Implementation + Webpanel Update
This commit is contained in:
Jordan Dominion
2024-09-09 20:08:55 -04:00
committed by GitHub
68 changed files with 1398 additions and 189 deletions
+2
View File
@@ -40,6 +40,7 @@ env:
TGS_WEBPANEL_NODE_VERSION: 20.x
TGS_TEST_GITHUB_TOKEN: ${{ secrets.LIVE_TESTS_TOKEN }}
PACKAGING_PRIVATE_KEY_PASSPHRASE: ${{ secrets.PACKAGING_PRIVATE_KEY_PASSPHRASE }}
Internal__EnableGraphQL: true
concurrency:
group: "ci-${{ (github.event_name != 'push' && github.event_name != 'schedule' && github.event.inputs.pull_request_number) || github.run_id }}-${{ github.event_name }}"
@@ -577,6 +578,7 @@ jobs:
configuration: ["Debug", "Release"]
env:
TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt
TGS_TEST_GRAPHQL: true
runs-on: windows-latest
steps:
- name: Setup dotnet
+1 -4
View File
@@ -15,6 +15,7 @@ artifacts/
*.dmb
*.int
*.lk
/src/Tgstation.Server.Client.GraphQL/schema.graphql
/src/Tgstation.Server.Host/appsettings.*.json
/src/Tgstation.Server.Host/appsettings.*.yml
/src/Tgstation.Server.Host/wwwroot
@@ -27,8 +28,4 @@ changelog.yml
*nupkg
*.sqlite3
packaging/
/src/Tgstation.Server.Common/node_modules
/src/Tgstation.Server.Common/package.json
/src/Tgstation.Server.Common/yarn.lock
/src/Tgstation.Server.Common/logo_bg_white.svg
yarn-error.log*
+2 -2
View File
@@ -7,8 +7,8 @@
<TgsConfigVersion>5.2.0</TgsConfigVersion>
<TgsApiVersion>10.9.0</TgsApiVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>14.1.0</TgsApiLibraryVersion>
<TgsClientVersion>17.1.0</TgsClientVersion>
<TgsApiLibraryVersion>15.0.0</TgsApiLibraryVersion>
<TgsClientVersion>18.0.0</TgsClientVersion>
<TgsDmapiVersion>7.3.0</TgsDmapiVersion>
<TgsInteropVersion>5.10.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.5.0</TgsHostWatchdogVersion>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!-- This is in it's own file to help incremental building, changing it causes a complete rebuild of the web panel -->
<TgsWebpanelVersion>6.1.0</TgsWebpanelVersion>
<TgsWebpanelVersion>6.2.0</TgsWebpanelVersion>
</PropertyGroup>
</Project>
@@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Information about the local tgstation-server.
/// </summary>
public class LocalServerInformation : ServerInformationBase
{
/// <summary>
/// If the server is running on a windows operating system.
/// </summary>
public bool WindowsHost { get; set; }
/// <summary>
/// Map of <see cref="OAuthProvider"/> to the <see cref="OAuthProviderInfo"/> for them.
/// </summary>
public Dictionary<OAuthProvider, OAuthProviderInfo>? OAuthProviderInfos { get; set; }
}
}
@@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// Information about a server in the swarm.
/// </summary>
public abstract class SwarmServer
public abstract class SwarmServer : IEquatable<SwarmServer>
{
/// <summary>
/// The public address of the server.
@@ -24,5 +24,35 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
[Required]
public string? Identifier { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="SwarmServer"/> class.
/// </summary>
protected SwarmServer()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SwarmServer"/> class.
/// </summary>
/// <param name="copy">The <see cref="SwarmServer"/> to copy.</param>
protected SwarmServer(SwarmServer copy)
{
if (copy == null)
{
throw new ArgumentNullException(nameof(copy));
}
Address = copy.Address;
PublicAddress = copy.PublicAddress;
Identifier = copy.Identifier;
}
/// <inheritdoc />
public bool Equals(SwarmServer other)
=> other != null
&& other.Identifier == Identifier
&& other.PublicAddress == PublicAddress
&& other.Address == Address;
}
}
@@ -0,0 +1,39 @@
using System;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Represents information about a running <see cref="SwarmServer"/>.
/// </summary>
public class SwarmServerInformation : SwarmServer, IEquatable<SwarmServerInformation>
{
/// <summary>
/// If the <see cref="SwarmServerResponse"/> is the controller.
/// </summary>
public bool Controller { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="SwarmServerInformation"/> class.
/// </summary>
public SwarmServerInformation()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SwarmServerInformation"/> class.
/// </summary>
/// <param name="copy">The <see cref="SwarmServerInformation"/> to copy.</param>
public SwarmServerInformation(SwarmServerInformation copy)
: base(copy)
{
Controller = copy.Controller;
}
/// <inheritdoc />
public bool Equals(SwarmServerInformation other)
=> base.Equals(other)
&& other.Controller == Controller;
}
}
@@ -0,0 +1,20 @@
using System;
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Indicates data from the TGS update source.
/// </summary>
public class UpdateInformation
{
/// <summary>
/// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If <see cref="Version.Major"/> is less than 4 the update cannot be applied due to API changes.
/// </summary>
public Version? LatestVersion { get; set; }
/// <summary>
/// This response is cached. This field indicates the <see cref="DateTimeOffset"/> the <see cref="UpdateInformation"/> was generated.
/// </summary>
public DateTimeOffset? GeneratedAt { get; set; }
}
}
@@ -1,25 +1,17 @@
using System;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models.Response
{
/// <summary>
/// Represents administrative server information.
/// </summary>
public sealed class AdministrationResponse
public sealed class AdministrationResponse : UpdateInformation
{
/// <summary>
/// The GitHub repository the server is built to receive updates from.
/// </summary>
public Uri? TrackedRepositoryUrl { get; set; }
/// <summary>
/// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If <see cref="Version.Major"/> is not equal to 4 the update cannot be applied due to API changes.
/// </summary>
public Version? LatestVersion { get; set; }
/// <summary>
/// This response is cached. This field indicates the <see cref="DateTimeOffset"/> when it was generated.
/// </summary>
public DateTimeOffset? GeneratedAt { get; set; }
}
}
@@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Models.Response
/// <summary>
/// Represents basic server information.
/// </summary>
public sealed class ServerInformationResponse : Internal.ServerInformationBase
public sealed class ServerInformationResponse : Internal.LocalServerInformation
{
/// <summary>
/// The version of the host.
@@ -23,11 +23,6 @@ namespace Tgstation.Server.Api.Models.Response
/// </summary>
public Version? DMApiVersion { get; set; }
/// <summary>
/// If the server is running on a windows operating system.
/// </summary>
public bool WindowsHost { get; set; }
/// <summary>
/// If there is a server update in progress.
/// </summary>
@@ -38,10 +33,5 @@ namespace Tgstation.Server.Api.Models.Response
/// </summary>
[ResponseOptions]
public ICollection<SwarmServerResponse>? SwarmServers { get; set; }
/// <summary>
/// Map of <see cref="OAuthProvider"/> to the <see cref="OAuthProviderInfo"/> for them.
/// </summary>
public IDictionary<OAuthProvider, OAuthProviderInfo>? OAuthProviderInfos { get; set; }
}
}
@@ -1,13 +1,27 @@
using Tgstation.Server.Api.Models.Internal;
using System;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models.Response
{
/// <inheritdoc />
public sealed class SwarmServerResponse : SwarmServer
public sealed class SwarmServerResponse : SwarmServerInformation
{
/// <summary>
/// If the <see cref="SwarmServerResponse"/> is the controller.
/// Initializes a new instance of the <see cref="SwarmServerResponse"/> class.
/// </summary>
public bool Controller { get; set; }
[Obsolete("For JSON deserialization only", true)]
public SwarmServerResponse()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SwarmServerResponse"/> class.
/// </summary>
/// <param name="swarmServerInfo">The <see cref="SwarmServerInformation"/> to copy.</param>
public SwarmServerResponse(SwarmServerInformation swarmServerInfo)
: base(swarmServerInfo)
{
}
}
}
+5
View File
@@ -12,6 +12,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string ApiRoot = "/api/";
/// <summary>
/// The GraphQL route.
/// </summary>
public const string GraphQL = ApiRoot + "graphql";
/// <summary>
/// The root route of all hubs.
/// </summary>
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"strawberryshake.tools": {
"version": "13.9.12",
"commands": [
"dotnet-graphql"
]
}
}
}
@@ -0,0 +1,21 @@
{
"schema": "schema.graphql",
"documents": "**/*.graphql",
"extensions": {
"strawberryShake": {
"name": "GraphQLClient",
"url": "../../artifacts/tgs-api.graphql",
"namespace": "Tgstation.Server.Client.GraphQL",
"records": {
"inputs": false,
"entities": false
},
"transportProfiles": [
{
"default": "Http",
"subscription": "WebSocket"
}
]
}
}
}
@@ -0,0 +1,34 @@
query ServerInformationQuery {
swarm {
metadata {
apiVersion
dmApiVersion
updateInProgress
version
}
localServer {
information {
instanceLimit
minimumPasswordLength
userGroupLimit
userLimit
validInstancePaths
windowsHost
oAuthProviderInfos {
key
value {
clientId
redirectUri
serverUrl
}
}
}
}
servers {
address
controller
identifier
publicAddress
}
}
}
@@ -0,0 +1,42 @@
using System;
using System.Threading.Tasks;
namespace Tgstation.Server.Client.GraphQL
{
/// <inheritdoc />
class GraphQLServerClient : IGraphQLServerClient
{
/// <summary>
/// The <see cref="IGraphQLClient"/> for the <see cref="GraphQLServerClient"/>.
/// </summary>
readonly IGraphQLClient graphQLClient;
/// <summary>
/// The <see cref="IAsyncDisposable"/> to be <see cref="DisposeAsync"/>'d with the <see cref="GraphQLServerClient"/>.
/// </summary>
readonly IAsyncDisposable serviceProvider;
/// <summary>
/// Initializes a new instance of the <see cref="GraphQLServerClient"/> class.
/// </summary>
/// <param name="graphQLClient">The value of <see cref="graphQLClient"/>.</param>
/// <param name="serviceProvider">The value of <see cref="serviceProvider"/>.</param>
public GraphQLServerClient(
IGraphQLClient graphQLClient,
IAsyncDisposable serviceProvider)
{
this.graphQLClient = graphQLClient ?? throw new ArgumentNullException(nameof(graphQLClient));
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
/// <inheritdoc />
public ValueTask DisposeAsync() => serviceProvider.DisposeAsync();
/// <inheritdoc />
public virtual ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector)
{
ArgumentNullException.ThrowIfNull(queryExector);
return queryExector(graphQLClient);
}
}
}
@@ -0,0 +1,67 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Tgstation.Server.Api;
using Tgstation.Server.Client.GraphQL.Serializers;
namespace Tgstation.Server.Client.GraphQL
{
/// <inheritdoc />
public sealed class GraphQLServerClientFactory : IGraphQLServerClientFactory
{
/// <summary>
/// The <see cref="IRestServerClientFactory"/> for the <see cref="GraphQLServerClientFactory"/>.
/// </summary>
readonly IRestServerClientFactory restClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="GraphQLServerClientFactory"/> class.
/// </summary>
/// <param name="restClientFactory">The value of <see cref="restClientFactory"/>.</param>
public GraphQLServerClientFactory(IRestServerClientFactory restClientFactory)
{
this.restClientFactory = restClientFactory ?? throw new ArgumentNullException(nameof(restClientFactory));
}
/// <inheritdoc />
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(Uri host, string username, string password, bool attemptLoginRefresh = true, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(Uri host, string oAuthCode, OAuthProvider oAuthProvider, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public IAuthenticatedGraphQLServerClient CreateFromToken(Uri host, string token)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public IGraphQLServerClient CreateUnauthenticated(Uri host)
{
var serviceCollection = new ServiceCollection();
var clientBuilder = serviceCollection
.AddGraphQLClient();
var graphQLEndpoint = new Uri(host, Routes.GraphQL);
clientBuilder.ConfigureHttpClient(client => client.BaseAddress = graphQLEndpoint);
serviceCollection.AddSerializer<UnsignedIntSerializer>();
serviceCollection.AddSerializer<SemverSerializer>();
var serviceProvider = serviceCollection.BuildServiceProvider();
return new GraphQLServerClient(
serviceProvider.GetRequiredService<IGraphQLClient>(),
serviceProvider);
}
}
}
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// A <see cref="IGraphQLServerClient"/> known to be authenticated.
/// </summary>
public interface IAuthenticatedGraphQLServerClient : IGraphQLServerClient
{
/// <summary>
/// The REST <see cref="ITransferClient"/>.
/// </summary>
ITransferClient TransferClient { get; }
}
}
@@ -0,0 +1,18 @@
using System;
using System.Threading.Tasks;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// Wrapper for using a TGS <see cref="IGraphQLClient"/>.
/// </summary>
public interface IGraphQLServerClient : IAsyncDisposable
{
/// <summary>
/// Runs a given <paramref name="queryExector"/>. It may be invoked multiple times depending on the behavior of the <see cref="IGraphQLServerClient"/>.
/// </summary>
/// <param name="queryExector">A <see cref="Func{T, TResult}"/> which executes a single query on a given <see cref="IGraphQLClient"/> and returns a <see cref="ValueTask"/> representing the running operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask RunQuery(Func<IGraphQLClient, ValueTask> queryExector);
}
}
@@ -0,0 +1,61 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client.GraphQL
{
/// <summary>
/// Factory for creating <see cref="IGraphQLServerClient"/>s.
/// </summary>
public interface IGraphQLServerClientFactory
{
/// <summary>
/// Create an unauthenticated <see cref="IGraphQLServerClient"/>.
/// </summary>
/// <param name="host">The <see cref="Uri"/> of tgstation-server.</param>
/// <returns>A new <see cref="IGraphQLServerClient"/>.</returns>
IGraphQLServerClient CreateUnauthenticated(Uri host);
/// <summary>
/// Create a <see cref="IGraphQLServerClient"/> using a password login.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="username">The username to for the <see cref="IGraphQLServerClient"/>.</param>
/// <param name="password">The password for the <see cref="IGraphQLServerClient"/>.</param>
/// <param name="attemptLoginRefresh">Attempt to refresh the received <see cref="TokenResponse"/> when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromLogin(
Uri host,
string username,
string password,
bool attemptLoginRefresh = true,
CancellationToken cancellationToken = default);
/// <summary>
/// Create a <see cref="IGraphQLServerClient"/> using an OAuth login.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="oAuthCode">The OAuth code used to complete the flow.</param>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IAuthenticatedGraphQLServerClient"/>.</returns>
ValueTask<IAuthenticatedGraphQLServerClient> CreateFromOAuth(
Uri host,
string oAuthCode,
OAuthProvider oAuthProvider,
CancellationToken cancellationToken = default);
/// <summary>
/// Create a <see cref="IRestServerClient"/>.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="token">The <see cref="TokenResponse"/> to access the API with.</param>
/// <returns>A new <see cref="IGraphQLServerClient"/>.</returns>
IAuthenticatedGraphQLServerClient CreateFromToken(
Uri host,
string token);
}
}
@@ -0,0 +1,9 @@
# tgstation-server GraphQL Client Library
This GraphQL library is used for accessing [tgstation-server](https://github.com/tgstation/tgstation-server) instances via .NET code.
## Examples
### Connecting to a Server:
!!!TODO!!!
@@ -0,0 +1,35 @@
using System;
using StrawberryShake.Serialization;
using Tgstation.Server.Common.Extensions;
#pragma warning disable CA1812 // not detecting service provider usage
namespace Tgstation.Server.Client.GraphQL.Serializers
{
/// <summary>
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="UInt32"/>s.
/// </summary>
sealed class SemverSerializer : ScalarSerializer<string, Version>
{
/// <summary>
/// Initializes a new instance of the <see cref="SemverSerializer"/> class.
/// </summary>
public SemverSerializer()
: base("Semver")
{
}
/// <inheritdoc />
public override Version Parse(string serializedValue)
=> Version.Parse(serializedValue ?? throw new ArgumentNullException(nameof(serializedValue)));
/// <inheritdoc />
protected override string Format(Version runtimeValue)
{
ArgumentNullException.ThrowIfNull(runtimeValue);
return runtimeValue.Semver().ToString();
}
}
}
@@ -0,0 +1,22 @@
using System;
using StrawberryShake.Serialization;
#pragma warning disable CA1812 // not detecting service provider usage
namespace Tgstation.Server.Client.GraphQL.Serializers
{
/// <summary>
/// <see cref="ScalarSerializer{TSerialized, TRuntime}"/> for <see cref="UInt32"/>s.
/// </summary>
sealed class UnsignedIntSerializer : ScalarSerializer<uint>
{
/// <summary>
/// Initializes a new instance of the <see cref="UnsignedIntSerializer"/> class.
/// </summary>
public UnsignedIntSerializer()
: base("UnsignedInt")
{
}
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../../build/SrcCommon.props" />
<PropertyGroup>
<TargetFramework>$(TgsFrameworkVersion)</TargetFramework>
<Version>$(TgsApiVersion)</Version>
</PropertyGroup>
<ItemGroup>
<!-- GraphQL connector and code generator -->
<PackageReference Include="StrawberryShake.Server" Version="13.9.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tgstation.Server.Client\Tgstation.Server.Client.csproj" />
</ItemGroup>
<!-- https://github.com/dotnet/msbuild/issues/2661#issuecomment-338808147 -->
<Target Name="WorkaroundSdk939" BeforeTargets="ImportGraphQLApiSchema">
<MSBuild Projects="..\Tgstation.Server.Host\Tgstation.Server.Host.csproj" />
</Target>
<Target Name="DeleteGeneratedFiles" BeforeTargets="ImportGraphQLApiSchema">
<RemoveDir Directories="$(IntermediateOutputPath)berry" />
</Target>
<!-- https://github.com/ChilliCream/graphql-platform/blob/c0c8df525ca0f47bf3b3b409a8b22cbe37f7a9c0/src/StrawberryShake/MetaPackages/Common/MSBuild/StrawberryShake.targets#L20 -->
<Target Name="ImportGraphQLApiSchema" BeforeTargets="_GraphQLCodeGenerationRoot" Inputs="../../artifacts/tgs-api.graphql" Outputs="schema.graphql">
<Copy SkipUnchangedFiles="true" SourceFiles="../../artifacts/tgs-api.graphql" DestinationFiles="schema.graphql" />
</Target>
<Target Name="FixWarningsInGeneratedSchema" AfterTargets="GenerateGraphQLCode">
<PropertyGroup>
<InputFile>$(IntermediateOutputPath)berry/GraphQLClient.Client.cs</InputFile>
<OutputFile>$(IntermediateOutputPath)berry/GraphQLClient.Client.cs</OutputFile>
</PropertyGroup>
<WriteLinesToFile File="$(OutputFile)" Lines="$([System.IO.File]::ReadAllText($(InputFile)).Replace('/ &lt;auto-generated/&gt;','/ &lt;auto-generated /&gt;%0d%0a#pragma warning disable'))" Overwrite="true" Encoding="Unicode" />
</Target>
</Project>
@@ -0,0 +1,17 @@
scalar _KeyFieldSet
directive @key(fields: _KeyFieldSet!) on SCHEMA | OBJECT
directive @serializationType(name: String!) on SCALAR
directive @runtimeType(name: String!) on SCALAR
directive @enumValue(value: String!) on ENUM_VALUE
directive @rename(name: String!) on INPUT_FIELD_DEFINITION | INPUT_OBJECT | ENUM | ENUM_VALUE
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")
@@ -5,7 +5,7 @@ using System.Net.Http;
namespace Tgstation.Server.Client
{
/// <summary>
/// Exceptions thrown by <see cref="IServerClient"/>s.
/// Exceptions thrown by <see cref="IRestServerClient"/>s.
/// </summary>
public abstract class ClientException : Exception
{
+2 -21
View File
@@ -1,5 +1,4 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -8,14 +7,13 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
{
/// <summary>
/// Web interface for the API.
/// </summary>
interface IApiClient : IAsyncDisposable
interface IApiClient : ITransferClient, IAsyncDisposable
{
/// <summary>
/// The <see cref="ApiHeaders"/> the <see cref="IApiClient"/> uses.
@@ -39,7 +37,7 @@ namespace Tgstation.Server.Client
void AddRequestLogger(IRequestLogger requestLogger);
/// <summary>
/// Subscribe to all job updates available to the <see cref="IServerClient"/>.
/// Subscribe to all job updates available to the <see cref="IRestServerClient"/>.
/// </summary>
/// <typeparam name="THubImplementation">The <see cref="Type"/> of the hub being implemented.</typeparam>
/// <param name="hubImplementation">The <typeparamref name="THubImplementation"/> to use for proxying the methods of the hub connection.</param>
@@ -239,22 +237,5 @@ namespace Tgstation.Server.Client
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask<TResult> Delete<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken)
where TBody : class;
/// <summary>
/// Downloads a file <see cref="Stream"/> for a given <paramref name="ticket"/>.
/// </summary>
/// <param name="ticket">The <see cref="FileTicketResponse"/> to download.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the downloaded <see cref="Stream"/>.</returns>
ValueTask<Stream> Download(FileTicketResponse ticket, CancellationToken cancellationToken);
/// <summary>
/// Uploads a given <paramref name="uploadStream"/> for a given <paramref name="ticket"/>.
/// </summary>
/// <param name="ticket">The <see cref="FileTicketResponse"/> to download.</param>
/// <param name="uploadStream">The <see cref="Stream"/> to upload. <see langword="null"/> represents an empty file.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Upload(FileTicketResponse ticket, Stream? uploadStream, CancellationToken cancellationToken);
}
}
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Client
/// <summary>
/// Main client for communicating with a server.
/// </summary>
public interface IServerClient : IAsyncDisposable
public interface IRestServerClient : IAsyncDisposable
{
/// <summary>
/// The connected server's root <see cref="Uri"/>.
@@ -51,14 +51,20 @@ namespace Tgstation.Server.Client
IUserGroupsClient Groups { get; }
/// <summary>
/// The <see cref="ServerInformationResponse"/> of the <see cref="IServerClient"/>.
/// Access the <see cref="ITransferClient"/>.
/// </summary>
/// <remarks>Most client methods handle transfers in their invocations. There is rarely any reason to use the <see cref="ITransferClient"/> directly.</remarks>
ITransferClient Transfer { get; }
/// <summary>
/// The <see cref="ServerInformationResponse"/> of the <see cref="IRestServerClient"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ServerInformationResponse"/> of the target server.</returns>
ValueTask<ServerInformationResponse> ServerInformation(CancellationToken cancellationToken);
/// <summary>
/// Subscribe to all job updates available to the <see cref="IServerClient"/>.
/// Subscribe to all job updates available to the <see cref="IRestServerClient"/>.
/// </summary>
/// <param name="jobsReceiver">The <see cref="IJobsHub"/> to use to subscribe to updates.</param>
/// <param name="retryPolicy">The optional <see cref="IRetryPolicy"/> to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly.</param>
@@ -9,9 +9,9 @@ using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
{
/// <summary>
/// Factory for creating <see cref="IServerClient"/>s.
/// Factory for creating <see cref="IRestServerClient"/>s.
/// </summary>
public interface IServerClientFactory
public interface IRestServerClientFactory
{
/// <summary>
/// Gets the <see cref="ServerInformationResponse"/> for a given <paramref name="host"/>.
@@ -28,17 +28,17 @@ namespace Tgstation.Server.Client
CancellationToken cancellationToken = default);
/// <summary>
/// Create a <see cref="IServerClient"/> using a password login.
/// Create a <see cref="IRestServerClient"/> using a password login.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="username">The username to for the <see cref="IServerClient"/>.</param>
/// <param name="password">The password for the <see cref="IServerClient"/>.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IServerClient"/>.</param>
/// <param name="username">The username to for the <see cref="IRestServerClient"/>.</param>
/// <param name="password">The password for the <see cref="IRestServerClient"/>.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IRestServerClient"/>.</param>
/// <param name="timeout">Optional <see cref="TimeSpan"/> representing timeout for the connection.</param>
/// <param name="attemptLoginRefresh">Attempt to refresh the received <see cref="TokenResponse"/> when it expires or becomes invalid. <paramref name="username"/> and <paramref name="password"/> will be stored in memory if this is <see langword="true"/>.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IServerClient"/>.</returns>
ValueTask<IServerClient> CreateFromLogin(
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IRestServerClient"/>.</returns>
ValueTask<IRestServerClient> CreateFromLogin(
Uri host,
string username,
string password,
@@ -48,16 +48,16 @@ namespace Tgstation.Server.Client
CancellationToken cancellationToken = default);
/// <summary>
/// Create a <see cref="IServerClient"/> using am OAuth login.
/// Create a <see cref="IRestServerClient"/> using an OAuth login.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="oAuthCode">The OAuth code used to complete the flow.</param>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/>.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IServerClient"/>.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IRestServerClient"/>.</param>
/// <param name="timeout">Optional <see cref="TimeSpan"/> representing timeout for the connection.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IServerClient"/>.</returns>
ValueTask<IServerClient> CreateFromOAuth(
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IRestServerClient"/>.</returns>
ValueTask<IRestServerClient> CreateFromOAuth(
Uri host,
string oAuthCode,
OAuthProvider oAuthProvider,
@@ -66,12 +66,12 @@ namespace Tgstation.Server.Client
CancellationToken cancellationToken = default);
/// <summary>
/// Create a <see cref="IServerClient"/>.
/// Create a <see cref="IRestServerClient"/>.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="token">The <see cref="TokenResponse"/> to access the API with.</param>
/// <returns>A new <see cref="IServerClient"/>.</returns>
IServerClient CreateFromToken(
/// <returns>A new <see cref="IRestServerClient"/>.</returns>
IRestServerClient CreateFromToken(
Uri host,
TokenResponse token);
}
@@ -0,0 +1,31 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
{
/// <summary>
/// For transferring data <see cref="Stream"/>s.
/// </summary>
public interface ITransferClient
{
/// <summary>
/// Downloads a file <see cref="Stream"/> for a given <paramref name="ticket"/>.
/// </summary>
/// <param name="ticket">The <see cref="FileTicketResponse"/> to download.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the downloaded <see cref="Stream"/>.</returns>
ValueTask<Stream> Download(FileTicketResponse ticket, CancellationToken cancellationToken);
/// <summary>
/// Uploads a given <paramref name="uploadStream"/> for a given <paramref name="ticket"/>.
/// </summary>
/// <param name="ticket">The <see cref="FileTicketResponse"/> to download.</param>
/// <param name="uploadStream">The <see cref="Stream"/> to upload. <see langword="null"/> represents an empty file.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
ValueTask Upload(FileTicketResponse ticket, Stream? uploadStream, CancellationToken cancellationToken);
}
}
@@ -12,7 +12,7 @@ using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
{
/// <inheritdoc />
sealed class ServerClient : IServerClient
sealed class RestServerClient : IRestServerClient
{
/// <inheritdoc />
public Uri Url => apiClient.Url;
@@ -43,16 +43,19 @@ namespace Tgstation.Server.Client
/// <inheritdoc />
public IUserGroupsClient Groups { get; }
/// <inheritdoc />
public ITransferClient Transfer => apiClient;
/// <summary>
/// The <see cref="IApiClient"/> for the <see cref="ServerClient"/>.
/// The <see cref="IApiClient"/> for the <see cref="RestServerClient"/>.
/// </summary>
readonly IApiClient apiClient;
/// <summary>
/// Initializes a new instance of the <see cref="ServerClient"/> class.
/// Initializes a new instance of the <see cref="RestServerClient"/> class.
/// </summary>
/// <param name="apiClient">The value of <see cref="apiClient"/>.</param>
public ServerClient(IApiClient apiClient)
public RestServerClient(IApiClient apiClient)
{
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
@@ -12,37 +12,37 @@ using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
{
/// <inheritdoc />
public sealed class ServerClientFactory : IServerClientFactory
public sealed class RestServerClientFactory : IRestServerClientFactory
{
/// <summary>
/// The <see cref="IApiClientFactory"/> for the <see cref="ServerClientFactory"/>.
/// The <see cref="IApiClientFactory"/> for the <see cref="RestServerClientFactory"/>.
/// </summary>
internal static IApiClientFactory ApiClientFactory { get; set; }
/// <summary>
/// The <see cref="ProductHeaderValue"/> for the <see cref="ServerClientFactory"/>.
/// The <see cref="ProductHeaderValue"/> for the <see cref="RestServerClientFactory"/>.
/// </summary>
readonly ProductHeaderValue productHeaderValue;
/// <summary>
/// Initializes static members of the <see cref="ServerClientFactory"/> class.
/// Initializes static members of the <see cref="RestServerClientFactory"/> class.
/// </summary>
static ServerClientFactory()
static RestServerClientFactory()
{
ApiClientFactory = new ApiClientFactory();
}
/// <summary>
/// Initializes a new instance of the <see cref="ServerClientFactory"/> class.
/// Initializes a new instance of the <see cref="RestServerClientFactory"/> class.
/// </summary>
/// <param name="productHeaderValue">The value of <see cref="productHeaderValue"/>.</param>
public ServerClientFactory(ProductHeaderValue productHeaderValue)
public RestServerClientFactory(ProductHeaderValue productHeaderValue)
{
this.productHeaderValue = productHeaderValue ?? throw new ArgumentNullException(nameof(productHeaderValue));
}
/// <inheritdoc />
public ValueTask<IServerClient> CreateFromLogin(
public ValueTask<IRestServerClient> CreateFromLogin(
Uri host,
string username,
string password,
@@ -69,7 +69,7 @@ namespace Tgstation.Server.Client
}
/// <inheritdoc />
public ValueTask<IServerClient> CreateFromOAuth(
public ValueTask<IRestServerClient> CreateFromOAuth(
Uri host,
string oAuthCode,
OAuthProvider oAuthProvider,
@@ -93,7 +93,7 @@ namespace Tgstation.Server.Client
}
/// <inheritdoc />
public IServerClient CreateFromToken(Uri host, TokenResponse token)
public IRestServerClient CreateFromToken(Uri host, TokenResponse token)
{
if (host == null)
throw new ArgumentNullException(nameof(host));
@@ -102,7 +102,7 @@ namespace Tgstation.Server.Client
if (token.Bearer == null)
throw new InvalidOperationException("token.Bearer should not be null!");
var serverClient = new ServerClient(
var serverClient = new RestServerClient(
ApiClientFactory.CreateApiClient(
host,
new ApiHeaders(
@@ -142,16 +142,16 @@ namespace Tgstation.Server.Client
}
/// <summary>
/// Creates a <see cref="IServerClient"/> from a login operation.
/// Creates a <see cref="IRestServerClient"/> from a login operation.
/// </summary>
/// <param name="host">The URL to access TGS.</param>
/// <param name="loginHeaders">The <see cref="ApiHeaders"/> to use for the login operation.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IServerClient"/>.</param>
/// <param name="requestLoggers">Optional initial <see cref="IRequestLogger"/>s to add to the <see cref="IRestServerClient"/>.</param>
/// <param name="timeout">Optional <see cref="TimeSpan"/> representing timeout for the connection.</param>
/// <param name="attemptLoginRefresh">If <paramref name="loginHeaders"/> may be used to re-login in the future.</param>
/// <param name="cancellationToken">Optional <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IServerClient"/>.</returns>
async ValueTask<IServerClient> CreateWithNewToken(
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="IRestServerClient"/>.</returns>
async ValueTask<IRestServerClient> CreateWithNewToken(
Uri host,
ApiHeaders loginHeaders,
IEnumerable<IRequestLogger>? requestLoggers,
@@ -173,7 +173,7 @@ namespace Tgstation.Server.Client
}
var apiHeaders = new ApiHeaders(productHeaderValue, token);
var client = new ServerClient(
var client = new RestServerClient(
ApiClientFactory.CreateApiClient(
host,
apiHeaders,
@@ -30,6 +30,21 @@
/// </summary>
public bool UsingDocker { get; set; }
/// <summary>
/// Used at compile time to write the GraphQL API schema to this path and exit.
/// </summary>
public string? DumpGraphQLApiPath { get; set; }
/// <summary>
/// Enables hosting the experimental GraphQL API in Release builds.
/// </summary>
public bool EnableGraphQL
#if DEBUG
=> true;
#else
{ get; set; }
#endif
/// <summary>
/// The base path for the app settings configuration files.
/// </summary>
@@ -181,7 +181,10 @@ namespace Tgstation.Server.Host.Controllers
UserGroupLimit = generalConfiguration.UserGroupLimit,
ValidInstancePaths = generalConfiguration.ValidInstancePaths,
WindowsHost = platformIdentifier.IsWindows,
SwarmServers = swarmService.GetSwarmServers(),
SwarmServers = swarmService
.GetSwarmServers()
?.Select(swarmServerInfo => new SwarmServerResponse(swarmServerInfo))
.ToList(),
OAuthProviderInfos = oAuthProviders.ProviderInfos(),
UpdateInProgress = serverControl.UpdateInProgress,
});
@@ -8,6 +8,8 @@ using Cyberboss.AspNetCore.AsyncInitializer;
using Elastic.CommonSchema.Serilog;
using HotChocolate.Types;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
@@ -50,6 +52,8 @@ using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.GraphQL;
using Tgstation.Server.Host.GraphQL.Types.Scalars;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Properties;
@@ -283,6 +287,15 @@ namespace Tgstation.Server.Host.Core
services.AddHttpClient();
services.AddSingleton<IAbstractHttpClientFactory, AbstractHttpClientFactory>();
// configure graphql
if (postSetupServices.InternalConfiguration.EnableGraphQL)
services
.AddGraphQLServer()
.AddAuthorization()
.AddType<UnsignedIntType>()
.BindRuntimeType<Version, SemverType>()
.AddQueryType<Query>();
void AddTypedContext<TContext>()
where TContext : DatabaseContext
{
@@ -454,6 +467,7 @@ namespace Tgstation.Server.Host.Core
/// <param name="controlPanelConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="ControlPanelConfiguration"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="GeneralConfiguration"/> to use.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="SwarmConfiguration"/> to use.</param>
/// <param name="internalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the <see cref="InternalConfiguration"/> to use.</param>
/// <param name="logger">The <see cref="Microsoft.Extensions.Logging.ILogger"/> for the <see cref="Application"/>.</param>
public void Configure(
IApplicationBuilder applicationBuilder,
@@ -464,6 +478,7 @@ namespace Tgstation.Server.Host.Core
IOptions<ControlPanelConfiguration> controlPanelConfigurationOptions,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<SwarmConfiguration> swarmConfigurationOptions,
IOptions<InternalConfiguration> internalConfigurationOptions,
ILogger<Application> logger)
{
ArgumentNullException.ThrowIfNull(applicationBuilder);
@@ -477,6 +492,7 @@ namespace Tgstation.Server.Host.Core
var controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions));
var generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
var swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
var internalConfiguration = internalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(internalConfigurationOptions));
ArgumentNullException.ThrowIfNull(logger);
@@ -596,6 +612,12 @@ namespace Tgstation.Server.Host.Core
// majority of handling is done in the controllers
endpoints.MapControllers();
if (internalConfiguration.EnableGraphQL)
{
logger.LogWarning("Enabling GraphQL. This API is experimental and breaking changes may occur at any time!");
endpoints.MapGraphQL(Routes.GraphQL);
}
});
// 404 anything that gets this far
@@ -66,6 +66,7 @@ namespace Tgstation.Server.Host.Extensions
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<ControlPanelConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<GeneralConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<SwarmConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<IOptions<InternalConfiguration>>(),
applicationBuilder.ApplicationServices.GetRequiredService<ILogger<Application>>());
}
}
@@ -0,0 +1,11 @@
namespace Tgstation.Server.Host.GraphQL
{
/// <summary>
/// Root type for GraphQL mutations.
/// </summary>
public sealed class Mutation
{
// Intentionally left blank, use type extensions to properly scope operations to domains
// https://chillicream.com/docs/hotchocolate/v13/defining-a-schema/extending-types
}
}
@@ -0,0 +1,18 @@
#pragma warning disable CA1724
using Tgstation.Server.Host.GraphQL.Types;
namespace Tgstation.Server.Host.GraphQL
{
/// <summary>
/// GraphQL query <see cref="global::System.Type"/>.
/// </summary>
public sealed class Query
{
/// <summary>
/// Gets the <see cref="ServerSwarm"/>.
/// </summary>
/// <returns>A new <see cref="ServerSwarm"/>.</returns>
public ServerSwarm Swarm() => new();
}
}
@@ -0,0 +1,22 @@
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a database entity.
/// </summary>
public abstract class Entity
{
/// <summary>
/// The ID of the <see cref="Entity"/>.
/// </summary>
public long Id { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Entity"/> class.
/// </summary>
/// <param name="id">The value of <see cref="Id"/>.</param>
protected Entity(long id)
{
Id = id;
}
}
}
@@ -0,0 +1,49 @@
using System;
using HotChocolate;
using HotChocolate.Authorization;
using Microsoft.Extensions.Options;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents the local tgstation-server.
/// </summary>
public sealed class LocalServer
{
/// <summary>
/// Gets <see cref="LocalServerInformation"/>.
/// </summary>
/// <param name="oAuthProviders">The <see cref="IOAuthProviders"/> to use.</param>
/// <param name="platformIdentifier">The <see cref="IPlatformIdentifier"/> to use.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptionsSnapshot{TOptions}"/> containing the <see cref="GeneralConfiguration"/> to use.</param>
/// <returns>A new <see cref="LocalServerInformation"/>.</returns>
[AllowAnonymous]
public LocalServerInformation Information(
[Service] IOAuthProviders oAuthProviders,
[Service] IPlatformIdentifier platformIdentifier,
[Service] IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
{
ArgumentNullException.ThrowIfNull(oAuthProviders);
ArgumentNullException.ThrowIfNull(platformIdentifier);
ArgumentNullException.ThrowIfNull(generalConfigurationOptions);
var generalConfiguration = generalConfigurationOptions.Value;
return new LocalServerInformation
{
MinimumPasswordLength = generalConfiguration.MinimumPasswordLength,
InstanceLimit = generalConfiguration.InstanceLimit,
UserLimit = generalConfiguration.UserLimit,
UserGroupLimit = generalConfiguration.UserGroupLimit,
ValidInstancePaths = generalConfiguration.ValidInstancePaths,
WindowsHost = platformIdentifier.IsWindows,
OAuthProviderInfos = oAuthProviders.ProviderInfos(),
};
}
}
}
@@ -0,0 +1,26 @@
using System;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// An <see cref="Entity"/> with a <see cref="Name"/>.
/// </summary>
public abstract class NamedEntity : Entity
{
/// <summary>
/// The name of the <see cref="NamedEntity"/>.
/// </summary>
public string Name { get; }
/// <summary>
/// Initializes a new instance of the <see cref="NamedEntity"/> class.
/// </summary>
/// <param name="id">The ID for the <see cref="Entity"/>.</param>
/// <param name="name">The value of <see cref="Name"/>.</param>
protected NamedEntity(long id, string name)
: base(id)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
}
}
@@ -0,0 +1,33 @@
using System;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a valid OAuth connection.
/// </summary>
public sealed class OAuthConnection
{
/// <summary>
/// The <see cref="OAuthProvider"/> of the <see cref="OAuthConnection"/>.
/// </summary>]
public OAuthProvider Provider { get; }
/// <summary>
/// The ID of the user in the <see cref="Provider"/>.
/// </summary>
public string ExternalUserId { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OAuthConnection"/> class.
/// </summary>
/// <param name="externalUserId">The value of <see cref="ExternalUserId"/>.</param>
/// <param name="provider">The value of <see cref="OAuthProvider"/>.</param>
public OAuthConnection(string externalUserId, OAuthProvider provider)
{
ExternalUserId = externalUserId ?? throw new ArgumentNullException(nameof(externalUserId));
Provider = provider;
}
}
}
@@ -0,0 +1,33 @@
using Tgstation.Server.Api.Rights;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a set of permissions for the server.
/// </summary>
public sealed class PermissionSet : Entity
{
/// <summary>
/// The <see cref="Api.Rights.AdministrationRights"/> for the <see cref="PermissionSet"/>.
/// </summary>
public AdministrationRights AdministrationRights { get; }
/// <summary>
/// The <see cref="Api.Rights.InstanceManagerRights"/> for the <see cref="PermissionSet"/>.
/// </summary>
public InstanceManagerRights InstanceManagerRights { get; }
/// <summary>
/// Initializes a new instance of the <see cref="PermissionSet"/> class.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/>.</param>
/// <param name="administrationRights">The value of <see cref="AdministrationRights"/>.</param>
/// <param name="instanceManagerRights">The value of <see cref="InstanceManagerRights"/>.</param>
public PermissionSet(long id, AdministrationRights administrationRights, InstanceManagerRights instanceManagerRights)
: base(id)
{
AdministrationRights = administrationRights;
InstanceManagerRights = instanceManagerRights;
}
}
}
@@ -0,0 +1,79 @@
using System;
using HotChocolate.Language;
using HotChocolate.Types;
using Tgstation.Server.Common.Extensions;
namespace Tgstation.Server.Host.GraphQL.Types.Scalars
{
/// <summary>
/// A <see cref="ScalarType{TRuntimeType, TLiteral}"/> for semantic <see cref="Version"/>s.
/// </summary>
public sealed class SemverType : ScalarType<Version, StringValueNode>
{
/// <summary>
/// Initializes a new instance of the <see cref="SemverType"/> class.
/// </summary>
public SemverType()
: base("Semver")
{
Description = "Represents a version in semver format as defined by https://semver.org/spec/v2.0.0.html";
}
/// <inheritdoc />
public override IValueNode ParseResult(object? resultValue)
=> ParseValue(resultValue);
/// <inheritdoc />
public override bool TryDeserialize(object? resultValue, out object? runtimeValue)
{
if (resultValue is not string resultString)
{
runtimeValue = null;
return false;
}
var result = Version.TryParse(resultString, out var resultVersion);
runtimeValue = resultVersion;
return result;
}
/// <inheritdoc />
public override bool TrySerialize(object? runtimeValue, out object? resultValue)
{
if (runtimeValue is not Version runtimeVersion)
{
resultValue = null;
return false;
}
resultValue = runtimeVersion.Semver().ToString();
return true;
}
/// <inheritdoc />
protected override Version ParseLiteral(StringValueNode valueSyntax)
{
ArgumentNullException.ThrowIfNull(valueSyntax);
return Version.Parse(valueSyntax.Value);
}
/// <inheritdoc />
protected override StringValueNode ParseValue(Version runtimeValue)
=> new StringValueNode(runtimeValue.Semver().ToString());
/// <inheritdoc />
protected override bool IsInstanceOfType(StringValueNode valueSyntax)
{
ArgumentNullException.ThrowIfNull(valueSyntax);
return IsInstanceOfType(valueSyntax.Value);
}
/// <inheritdoc />
protected override bool IsInstanceOfType(Version runtimeValue)
{
ArgumentNullException.ThrowIfNull(runtimeValue);
return runtimeValue.Build != -1 && runtimeValue.Revision == -1;
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using HotChocolate;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Swarm;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a tgstation-server swarm.
/// </summary>
public sealed class ServerSwarm
{
/// <summary>
/// Gets the <see cref="SwarmMetadata"/> for the swarm.
/// </summary>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to use.</param>
/// <param name="serverControl">The <see cref="IServerControl"/> to use.</param>
/// <returns>A new <see cref="SwarmMetadata"/>.</returns>
public SwarmMetadata Metadata(
[Service] IAssemblyInformationProvider assemblyInformationProvider,
[Service] IServerControl serverControl)
{
ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
ArgumentNullException.ThrowIfNull(serverControl);
return new SwarmMetadata(assemblyInformationProvider, serverControl.UpdateInProgress);
}
/// <summary>
/// Gets the local <see cref="Types.LocalServer"/>.
/// </summary>
/// <returns>A new <see cref="Types.LocalServer"/>.</returns>
public LocalServer LocalServer() => new();
/// <summary>
/// Gets the <see cref="SwarmServerInformation"/> for all servers in a swarm.
/// </summary>
/// <param name="swarmService">The <see cref="ISwarmService"/> to use.</param>
/// <returns>A <see cref="List{T}"/> of <see cref="SwarmServerInformation"/>s if the local server is part of a swarm, <see langword="null"/> otherwise.</returns>
public List<SwarmServerInformation>? Servers(
[Service] ISwarmService swarmService)
{
ArgumentNullException.ThrowIfNull(swarmService);
return swarmService.GetSwarmServers();
}
}
}
@@ -0,0 +1,46 @@
using System;
using Tgstation.Server.Api;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents information that is constant across all servers in a <see cref="ServerSwarm"/>.
/// </summary>
public sealed class SwarmMetadata
{
/// <summary>
/// The version of the host.
/// </summary>
public Version Version { get; }
/// <summary>
/// The <see cref="Api"/> version of the host.
/// </summary>
public Version ApiVersion => ApiHeaders.Version;
/// <summary>
/// The DMAPI interop version the server uses.
/// </summary>
public Version DMApiVersion => DMApiConstants.InteropVersion;
/// <summary>
/// If there is a server update in progress.
/// </summary>
public bool UpdateInProgress { get; }
/// <summary>
/// Initializes a new instance of the <see cref="SwarmMetadata"/> class.
/// </summary>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> used to derive the <see cref="Version"/>.</param>
/// <param name="updateInProgress">The value of <see cref="UpdateInProgress"/>.</param>
public SwarmMetadata(IAssemblyInformationProvider assemblyInformationProvider, bool updateInProgress)
{
ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
Version = assemblyInformationProvider.Version;
UpdateInProgress = updateInProgress;
}
}
}
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// A user registered in the server.
/// </summary>
public sealed class User : NamedEntity
{
/// <summary>
/// If the <see cref="User"/> is enabled since users cannot be deleted. System users cannot be disabled.
/// </summary>
public bool Enabled { get; }
/// <summary>
/// When the <see cref="User"/> was created.
/// </summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>
/// The SID/UID of the <see cref="User"/> on Windows/POSIX respectively.
/// </summary>
public string? SystemIdentifier { get; }
/// <summary>
/// The <see cref="Entity.Id"/> of the <see cref="CreatedBy"/> <see cref="User"/>.
/// </summary>
readonly long createdById;
/// <summary>
/// Initializes a new instance of the <see cref="User"/> class.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/>.</param>
/// <param name="name">The <see cref="NamedEntity.Name"/>.</param>
/// <param name="systemIdentifier">The value of <see cref="SystemIdentifier"/>.</param>
/// <param name="createdById">The value of <see cref="createdById"/>.</param>
/// <param name="createdAt">The value of <see cref="CreatedAt"/>.</param>
/// <param name="enabled">The value of <see cref="Enabled"/>.</param>
public User(
long id,
string name,
string? systemIdentifier,
DateTimeOffset createdAt,
long createdById,
bool enabled)
: base(id, name)
{
SystemIdentifier = systemIdentifier;
CreatedAt = createdAt;
this.createdById = createdById;
Enabled = enabled;
}
/// <summary>
/// The <see cref="User"/> who created this <see cref="User"/>.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="User"/> who created this <see cref="User"/>, if any.</returns>
public ValueTask<User?> CreatedBy()
=> throw new NotImplementedException();
/// <summary>
/// List of <see cref="OAuthConnection"/>s associated with the user if OAuth is configured.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="List{T}"/> of <see cref="OAuthConnection"/>s for the <see cref="User"/> if OAuth is configured.</returns>
public ValueTask<List<OAuthConnection>>? OAuthConnections()
=> throw new NotImplementedException();
/// <summary>
/// The <see cref="Types.PermissionSet"/> directly associated with the <see cref="User"/>, if any.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="Types.PermissionSet"/> directly associated with the <see cref="User"/>, if any.</returns>
public ValueTask<PermissionSet?> PermissionSet()
=> throw new NotImplementedException();
/// <summary>
/// The <see cref="UserGroup"/> asociated with the user, if any.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="UserGroup"/> associated with the <see cref="User"/>, if any.</returns>
public ValueTask<UserGroup?> Group()
=> throw new NotImplementedException();
}
}
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using HotChocolate.Types;
namespace Tgstation.Server.Host.GraphQL.Types
{
/// <summary>
/// Represents a group of <see cref="User"/>s.
/// </summary>
public sealed class UserGroup : NamedEntity
{
/// <summary>
/// The <see cref="Entity.Id"/> of the <see cref="PermissionSet"/>.
/// </summary>
readonly long permissionSetId;
/// <summary>
/// Initializes a new instance of the <see cref="UserGroup"/> class.
/// </summary>
/// <param name="id">The <see cref="Entity.Id"/>.</param>
/// <param name="name">The <see cref="NamedEntity.Name"/>.</param>
/// <param name="permissionSetId">The value of <see cref="permissionSetId"/>.</param>
public UserGroup(
long id,
string name,
long permissionSetId)
: base(id, name)
{
this.permissionSetId = permissionSetId;
}
/// <summary>
/// The <see cref="PermissionSet"/> of the <see cref="UserGroup"/>.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="Types.PermissionSet"/> for the <see cref="UserGroup"/>.</returns>
public ValueTask<PermissionSet> PermissionSet()
=> throw new NotImplementedException();
/// <summary>
/// Gets the <see cref="User"/>s in the <see cref="UserGroup"/>.
/// </summary>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="List{T}"/> of <see cref="User"/>s in the <see cref="UserGroup"/>.</returns>
[UsePaging(IncludeTotalCount = true)]
public List<User> Users()
=> throw new NotImplementedException();
}
}
+32
View File
@@ -2,9 +2,12 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using HotChocolate.Execution;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -13,6 +16,7 @@ using Microsoft.Extensions.Options;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host
@@ -137,6 +141,9 @@ namespace Tgstation.Server.Host
{
using (cancellationToken.Register(() => logger.LogInformation("Server termination requested!")))
{
if (await DumpGraphQLSchemaIfRequested(Host.Services, cancellationToken))
return;
var generalConfigurationOptions = Host.Services.GetRequiredService<IOptions<GeneralConfiguration>>();
generalConfiguration = generalConfigurationOptions.Value;
await Host.RunAsync(cancellationTokenSource.Token);
@@ -264,6 +271,31 @@ namespace Tgstation.Server.Host
return ValueTask.CompletedTask;
}
/// <summary>
/// Checks if <see cref="InternalConfiguration.DumpGraphQLApiPath"/> is set and dumps the GraphQL API Schema to it if so.
/// </summary>
/// <param name="services">The <see cref="IServiceProvider"/> to resolve services from.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if the GraphQL API was dumped, <see langword="false"/> otherwise.</returns>
async ValueTask<bool> DumpGraphQLSchemaIfRequested(IServiceProvider services, CancellationToken cancellationToken)
{
var internalConfigurationOptions = services.GetRequiredService<IOptions<InternalConfiguration>>();
var apiDumpPath = internalConfigurationOptions.Value.DumpGraphQLApiPath;
if (String.IsNullOrWhiteSpace(apiDumpPath))
return false;
logger!.LogInformation("Dumping GraphQL API spec to {path} and exiting...", apiDumpPath);
// https://github.com/ChilliCream/graphql-platform/discussions/5885
var resolver = services.GetRequiredService<IRequestExecutorResolver>();
var executor = await resolver.GetRequestExecutorAsync(cancellationToken: cancellationToken);
var sdl = executor.Schema.Print();
var ioManager = services.GetRequiredService<IIOManager>();
await ioManager.WriteAllBytes(apiDumpPath, Encoding.UTF8.GetBytes(sdl), cancellationToken);
return true;
}
/// <summary>
/// Throws an <see cref="InvalidOperationException"/> if the <see cref="IServerControl"/> cannot be used.
/// </summary>
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Host.Swarm
{
@@ -15,8 +15,8 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Pass in an updated list of <paramref name="swarmServers"/> to the node.
/// </summary>
/// <param name="swarmServers">An <see cref="IEnumerable{T}"/> of the updated <see cref="SwarmServerResponse"/>s.</param>
void UpdateSwarmServersList(IEnumerable<SwarmServerResponse> swarmServers);
/// <param name="swarmServers">An <see cref="IEnumerable{T}"/> of the updated <see cref="SwarmServerInformation"/>s.</param>
void UpdateSwarmServersList(IEnumerable<SwarmServerInformation> swarmServers);
/// <summary>
/// Notify the node of an update request from the controller.
@@ -36,11 +36,11 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Attempt to register a given <paramref name="node"/> with the controller.
/// </summary>
/// <param name="node">The <see cref="SwarmServerResponse"/> that is registering.</param>
/// <param name="node">The <see cref="SwarmServer"/> that is registering.</param>
/// <param name="registrationId">The registration <see cref="Guid"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if the registration was successful, <see langword="false"/> otherwise.</returns>
ValueTask<bool> RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId, CancellationToken cancellationToken);
ValueTask<bool> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken);
/// <summary>
/// Attempt to unregister a node with a given <paramref name="registrationId"/> with the controller.
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Swarm
@@ -35,9 +35,9 @@ namespace Tgstation.Server.Host.Swarm
ValueTask<SwarmCommitResult> CommitUpdate(CancellationToken cancellationToken);
/// <summary>
/// Gets the list of <see cref="SwarmServerResponse"/>s in the swarm, including the current one.
/// Gets the list of <see cref="SwarmServerInformation"/>s in the swarm, including the current one.
/// </summary>
/// <returns>A <see cref="List{T}"/> of <see cref="SwarmServerResponse"/>s in the swarm. If the server is not part of a swarm, <see langword="null"/> will be returned.</returns>
List<SwarmServerResponse>? GetSwarmServers();
/// <returns>A <see cref="List{T}"/> of <see cref="SwarmServerInformation"/>s in the swarm. If the server is not part of a swarm, <see langword="null"/> will be returned.</returns>
List<SwarmServerInformation>? GetSwarmServers();
}
}
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Host.Swarm
{
@@ -11,9 +11,9 @@ namespace Tgstation.Server.Host.Swarm
public sealed class SwarmServersUpdateRequest
{
/// <summary>
/// The <see cref="ICollection{T}"/> of updated <see cref="SwarmServerResponse"/>s.
/// The <see cref="ICollection{T}"/> of updated <see cref="SwarmServerInformation"/>s.
/// </summary>
[Required]
public ICollection<SwarmServerResponse>? SwarmServers { get; set; }
public ICollection<SwarmServerInformation>? SwarmServers { get; set; }
}
}
+30 -29
View File
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Common.Http;
@@ -104,12 +105,12 @@ namespace Tgstation.Server.Host.Swarm
readonly CancellationTokenSource? serverHealthCheckCancellationTokenSource;
/// <summary>
/// <see cref="List{T}"/> of connected <see cref="SwarmServerResponse"/>s.
/// <see cref="List{T}"/> of connected <see cref="SwarmServerInformation"/>s.
/// </summary>
readonly List<SwarmServerResponse>? swarmServers;
readonly List<SwarmServerInformation>? swarmServers;
/// <summary>
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="Api.Models.Internal.SwarmServer.Identifier"/>s to registration <see cref="Guid"/>s and when they were created.
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="SwarmServer.Identifier"/>s to registration <see cref="Guid"/>s and when they were created.
/// </summary>
readonly Dictionary<string, (Guid RegistrationId, DateTimeOffset RegisteredAt)>? registrationIdsAndTimes;
@@ -196,9 +197,9 @@ namespace Tgstation.Server.Host.Swarm
serverHealthCheckCancellationTokenSource = new CancellationTokenSource();
forceHealthCheckTcs = new TaskCompletionSource();
swarmServers = new List<SwarmServerResponse>
swarmServers = new List<SwarmServerInformation>
{
new SwarmServerResponse
new SwarmServerInformation
{
Address = swarmConfiguration.Address,
PublicAddress = swarmConfiguration.PublicAddress,
@@ -315,7 +316,7 @@ namespace Tgstation.Server.Host.Swarm
// on the controller, we first need to signal for nodes to go ahead
// if anything fails at this point, there's nothing we can do
logger.LogDebug("Sending remote commit message to nodes...");
async ValueTask SendRemoteCommitUpdate(SwarmServerResponse swarmServer)
async ValueTask SendRemoteCommitUpdate(SwarmServerInformation swarmServer)
{
using var request = PrepareSwarmRequest(
swarmServer,
@@ -349,7 +350,7 @@ namespace Tgstation.Server.Host.Swarm
}
/// <inheritdoc />
public List<SwarmServerResponse>? GetSwarmServers()
public List<SwarmServerInformation>? GetSwarmServers()
{
if (!SwarmMode)
return null;
@@ -427,7 +428,7 @@ namespace Tgstation.Server.Host.Swarm
{
logger.LogTrace("Begin Shutdown");
async ValueTask SendUnregistrationRequest(SwarmServerResponse? swarmServer)
async ValueTask SendUnregistrationRequest(SwarmServerInformation? swarmServer)
{
using var httpClient = httpClientFactory.CreateClient();
using var request = PrepareSwarmRequest(
@@ -503,7 +504,7 @@ namespace Tgstation.Server.Host.Swarm
}
/// <inheritdoc />
public void UpdateSwarmServersList(IEnumerable<SwarmServerResponse> swarmServers)
public void UpdateSwarmServersList(IEnumerable<SwarmServerInformation> swarmServers)
{
ArgumentNullException.ThrowIfNull(swarmServers);
@@ -539,7 +540,7 @@ namespace Tgstation.Server.Host.Swarm
}
/// <inheritdoc />
public async ValueTask<bool> RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId, CancellationToken cancellationToken)
public async ValueTask<bool> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(node);
@@ -586,7 +587,7 @@ namespace Tgstation.Server.Host.Swarm
registrationIdsAndTimes.Remove(node.Identifier);
}
swarmServers.Add(new SwarmServerResponse
swarmServers.Add(new SwarmServerInformation
{
PublicAddress = node.PublicAddress,
Address = node.Address,
@@ -693,7 +694,7 @@ namespace Tgstation.Server.Host.Swarm
logger.LogInformation("Aborting swarm update!");
using var httpClient = httpClientFactory.CreateClient();
async ValueTask SendRemoteAbort(SwarmServerResponse swarmServer)
async ValueTask SendRemoteAbort(SwarmServerInformation swarmServer)
{
using var request = PrepareSwarmRequest(
swarmServer,
@@ -719,7 +720,7 @@ namespace Tgstation.Server.Host.Swarm
}
if (!swarmController)
return SendRemoteAbort(new SwarmServerResponse
return SendRemoteAbort(new SwarmServerInformation
{
Address = swarmConfiguration.ControllerAddress,
});
@@ -735,10 +736,10 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Create the <see cref="RequestFileStreamProvider"/> for an update package retrieval from a given <paramref name="sourceNode"/>.
/// </summary>
/// <param name="sourceNode">The <see cref="SwarmServerResponse"/> to download the update package from.</param>
/// <param name="sourceNode">The <see cref="SwarmServerInformation"/> to download the update package from.</param>
/// <param name="ticket">The <see cref="FileTicketResponse"/> to use for the download.</param>
/// <returns>A new <see cref="RequestFileStreamProvider"/> for the update package.</returns>
RequestFileStreamProvider CreateUpdateStreamProvider(SwarmServerResponse sourceNode, FileTicketResponse ticket)
RequestFileStreamProvider CreateUpdateStreamProvider(SwarmServerInformation sourceNode, FileTicketResponse ticket)
{
var httpClient = httpClientFactory.CreateClient();
try
@@ -791,8 +792,8 @@ namespace Tgstation.Server.Host.Swarm
SwarmUpdateOperation localUpdateOperation;
try
{
SwarmServerResponse? sourceNode = null;
List<SwarmServerResponse> currentNodes;
SwarmServerInformation? sourceNode = null;
List<SwarmServerInformation> currentNodes;
lock (swarmServers)
{
currentNodes = swarmServers
@@ -1082,12 +1083,12 @@ namespace Tgstation.Server.Host.Swarm
/// Create a <see cref="FileTicketResponse"/> for downloading the content of a given <paramref name="initiatorProvider"/> for the rest of the swarm nodes.
/// </summary>
/// <param name="initiatorProvider">The <see cref="ISeekableFileStreamProvider"/> containing the server update package.</param>
/// <param name="involvedServers">An <see cref="IEnumerable{T}"/> of the involved <see cref="SwarmServerResponse"/>.</param>
/// <param name="involvedServers">An <see cref="IEnumerable{T}"/> of the involved <see cref="SwarmServerInformation"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="Dictionary{TKey, TValue}"/> of unique <see cref="FileTicketResponse"/>s keyed by their <see cref="Api.Models.Internal.SwarmServer.Identifier"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a new <see cref="Dictionary{TKey, TValue}"/> of unique <see cref="FileTicketResponse"/>s keyed by their <see cref="SwarmServer.Identifier"/>.</returns>
async ValueTask<Dictionary<string, FileTicketResponse>> CreateDownloadTickets(
ISeekableFileStreamProvider initiatorProvider,
IReadOnlyCollection<SwarmServerResponse> involvedServers,
IReadOnlyCollection<SwarmServerInformation> involvedServers,
CancellationToken cancellationToken)
{
// we need to ensure this thing is loaded before we start providing downloads or it'll create unnecessary delays
@@ -1126,12 +1127,12 @@ namespace Tgstation.Server.Host.Swarm
{
using var httpClient = httpClientFactory.CreateClient();
List<SwarmServerResponse> currentSwarmServers;
List<SwarmServerInformation> currentSwarmServers;
lock (swarmServers!)
currentSwarmServers = swarmServers.ToList();
var registrationIdsAndTimes = this.registrationIdsAndTimes!;
async ValueTask HealthRequestForServer(SwarmServerResponse swarmServer)
async ValueTask HealthRequestForServer(SwarmServerInformation swarmServer)
{
using var request = PrepareSwarmRequest(
swarmServer,
@@ -1320,7 +1321,7 @@ namespace Tgstation.Server.Host.Swarm
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
async ValueTask SendUpdatedServerListToNodes(CancellationToken cancellationToken)
{
List<SwarmServerResponse> currentSwarmServers;
List<SwarmServerInformation> currentSwarmServers;
lock (swarmServers!)
{
serversDirty = false;
@@ -1336,7 +1337,7 @@ namespace Tgstation.Server.Host.Swarm
logger.LogDebug("Sending updated server list to all {nodeCount} nodes...", currentSwarmServers.Count - 1);
using var httpClient = httpClientFactory.CreateClient();
async ValueTask UpdateRequestForServer(SwarmServerResponse swarmServer)
async ValueTask UpdateRequestForServer(SwarmServerInformation swarmServer)
{
using var request = PrepareSwarmRequest(
swarmServer,
@@ -1374,20 +1375,20 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Prepares a <see cref="HttpRequestMessage"/> for swarm communication.
/// </summary>
/// <param name="swarmServer">The <see cref="SwarmServerResponse"/> the message is for. Must have <see cref="Api.Models.Internal.SwarmServer.Address"/> and <see cref="Api.Models.Internal.SwarmServer.Identifier"/> set. If <see langword="null"/>, will be sent to swarm controller.</param>
/// <param name="swarmServer">The <see cref="SwarmServerInformation"/> the message is for. Must have <see cref="SwarmServer.Address"/> and <see cref="SwarmServer.Identifier"/> set. If <see langword="null"/>, will be sent to swarm controller.</param>
/// <param name="httpMethod">The <see cref="HttpMethod"/>.</param>
/// <param name="route">The route on <see cref="SwarmConstants.ControllerRoute"/> to use.</param>
/// <param name="body">The body <see cref="object"/> if any.</param>
/// <param name="registrationIdOverride">An optional override to the <see cref="SwarmConstants.RegistrationIdHeader"/>.</param>
/// <returns>A new <see cref="HttpRequestMessage"/>.</returns>
HttpRequestMessage PrepareSwarmRequest(
SwarmServerResponse? swarmServer,
SwarmServerInformation? swarmServer,
HttpMethod httpMethod,
string route,
object? body,
Guid? registrationIdOverride = null)
{
swarmServer ??= new SwarmServerResponse
swarmServer ??= new SwarmServerInformation
{
Address = swarmConfiguration.ControllerAddress,
};
@@ -1518,10 +1519,10 @@ namespace Tgstation.Server.Host.Swarm
}
/// <summary>
/// Gets the <see cref="Api.Models.Internal.SwarmServer.Identifier"/> from a given <paramref name="registrationId"/>.
/// Gets the <see cref="SwarmServer.Identifier"/> from a given <paramref name="registrationId"/>.
/// </summary>
/// <param name="registrationId">The registration <see cref="Guid"/>.</param>
/// <returns>The registered <see cref="Api.Models.Internal.SwarmServer.Identifier"/> or <see langword="null"/> if it does not exist.</returns>
/// <returns>The registered <see cref="SwarmServer.Identifier"/> or <see langword="null"/> if it does not exist.</returns>
string? NodeIdentifierFromRegistration(Guid registrationId)
{
if (!swarmController)
@@ -4,7 +4,6 @@ using System.Linq;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Host.Swarm
{
@@ -16,7 +15,7 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// All of the <see cref="SwarmServer"/>s that are involved in the updates.
/// </summary>
public IReadOnlyList<SwarmServerResponse> InvolvedServers => initialInvolvedServers ?? throw new InvalidOperationException("This property can only be checked on controller SwarmUpdateOperations!");
public IReadOnlyList<SwarmServerInformation> InvolvedServers => initialInvolvedServers ?? throw new InvalidOperationException("This property can only be checked on controller SwarmUpdateOperations!");
/// <summary>
/// The <see cref="Version"/> being updated to.
@@ -31,7 +30,7 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Backing field for <see cref="InvolvedServers"/>.
/// </summary>
readonly IReadOnlyList<SwarmServerResponse>? initialInvolvedServers;
readonly IReadOnlyList<SwarmServerInformation>? initialInvolvedServers;
/// <summary>
/// The backing <see cref="TaskCompletionSource{TResult}"/> for <see cref="CommitGate"/>.
@@ -58,9 +57,9 @@ namespace Tgstation.Server.Host.Swarm
/// Initializes a new instance of the <see cref="SwarmUpdateOperation"/> class.
/// </summary>
/// <param name="targetVersion">The value of <see cref="TargetVersion"/>.</param>
/// <param name="currentNodes">An <see cref="IEnumerable{T}"/> of the controller's current nodes as <see cref="SwarmServerResponse"/>s. Must have <see cref="SwarmServer.Address"/> and <see cref="SwarmServer.Identifier"/> set.</param>
/// <param name="currentNodes">An <see cref="IEnumerable{T}"/> of the controller's current nodes as <see cref="SwarmServerInformation"/>s. Must have <see cref="SwarmServer.Address"/> and <see cref="SwarmServer.Identifier"/> set.</param>
/// <remarks>This is the variant for use by the controller.</remarks>
public SwarmUpdateOperation(Version targetVersion, IEnumerable<SwarmServerResponse> currentNodes)
public SwarmUpdateOperation(Version targetVersion, IEnumerable<SwarmServerInformation> currentNodes)
: this(targetVersion)
{
initialInvolvedServers = currentNodes?.ToList() ?? throw new ArgumentNullException(nameof(currentNodes));
@@ -38,6 +38,11 @@
<Exec WorkingDirectory="ClientApp" Command="yarn run msbuild" />
</Target>
<Target Name="ExportGraphQLApiSchema" AfterTargets="AfterBuild" Outputs="../../artifacts/tgs-api.graphql">
<Message Text="Exporting GraphQL API Schema..." Importance="high" />
<Exec Command="dotnet $(TargetPath)" EnvironmentVariables="General__SetupWizardMode=Never;Internal__DumpGraphQLApiPath=../../artifacts/tgs-api.graphql;Internal__EnableGraphQL=true" />
</Target>
<Target Name="NpmClean" AfterTargets="Clean">
<Message Text="Cleaning web control panel..." Importance="high" />
<RemoveDir Directories="wwwroot" />
@@ -61,11 +66,8 @@
<Target Condition="'$(TGS_TELEMETRY_KEY_FILE)' != ''" Name="ApplyTelemetryAppSerializedKey" BeforeTargets="CoreCompile">
<Error Condition="!Exists('$(TGS_TELEMETRY_KEY_FILE)')" Text="TGS_TELEMETRY_KEY_FILE set but does not exist!" />
<ReadLinesFromFile
File="$(TGS_TELEMETRY_KEY_FILE)" >
<Output
TaskParameter="Lines"
ItemName="SerializedTelemetryKey"/>
<ReadLinesFromFile File="$(TGS_TELEMETRY_KEY_FILE)">
<Output TaskParameter="Lines" ItemName="SerializedTelemetryKey" />
</ReadLinesFromFile>
<ItemGroup>
<TelemetryAppSerializedKeyAssemblyAttributes Include="Tgstation.Server.Host.Properties.TelemetryAppSerializedKeyAttribute">
@@ -79,7 +81,7 @@
</Target>
<Target Condition="'$(TGS_TELEMETRY_KEY_FILE)' == '' And '$(CI)' != ''" Name="FailBuildInCIWithoutTelemetryKey" BeforeTargets="CoreCompile">
<Error Text="The TGS_TELEMETRY_KEY_FILE environment variable should be set in CI!"/>
<Error Text="The TGS_TELEMETRY_KEY_FILE environment variable should be set in CI!" />
</Target>
<ItemGroup>
@@ -97,6 +99,12 @@
<PackageReference Include="Elastic.CommonSchema.Serilog" Version="8.11.1" />
<!-- Usage: GitLab interop -->
<PackageReference Include="GitLabApiClient" Version="1.8.0" />
<!-- Usage: GraphQL API Engine -->
<PackageReference Include="HotChocolate.AspNetCore" Version="13.9.12" />
<!-- Usage: GraphQL Authorization Plugin -->
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="13.9.12" />
<!-- Usage: GraphQL additional scalar type definitions -->
<PackageReference Include="HotChocolate.Types.Scalars" Version="13.9.12" />
<!-- Usage: git interop -->
<PackageReference Include="LibGit2Sharp" Version="0.30.0" />
<!-- Usage: Support ""legacy"" Newotonsoft.Json in HTTP pipeline. The rest of our codebase uses Newtonsoft. -->
@@ -172,6 +180,8 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Authority\" />
<Folder Include="GraphQL\DataLoaders\" />
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>
@@ -10,8 +10,8 @@ namespace Tgstation.Server.Client.Tests
[TestMethod]
public void TestConstruction()
{
Assert.ThrowsException<ArgumentNullException>(() => new ServerClientFactory(null));
new ServerClientFactory(new ProductHeaderValue("Tgstation.Server.Client.Tests", GetType().Assembly.GetName().Version.ToString()));
Assert.ThrowsException<ArgumentNullException>(() => new RestServerClientFactory(null));
new RestServerClientFactory(new ProductHeaderValue("Tgstation.Server.Client.Tests", GetType().Assembly.GetName().Version.ToString()));
}
}
}
@@ -40,37 +40,43 @@ namespace Tgstation.Server.Host.Core.Tests
Assert.ThrowsException<ArgumentNullException>(() => app.ConfigureServices(mockServiceCollection, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null, null, null, null, null, null, null, null));
var mockAppBuilder = new Mock<IApplicationBuilder>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null, null, null, null));
var mockServerControl = new Mock<IServerControl>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null, null, null, null));
var mockTokenFactory = new Mock<ITokenFactory>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null, null, null, null));
var mockServerPortProvider = new Mock<IServerPortProvider>();
mockServerPortProvider.SetupGet(x => x.HttpApiPort).Returns(5345);
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, null, null, null, null, null, null));
var mockAssemblyInformationProvider = Mock.Of<IAssemblyInformationProvider>();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, null, null, null, null, null));
var mockControlPanelOptions = new Mock<IOptions<ControlPanelConfiguration>>();
mockControlPanelOptions.SetupGet(x => x.Value).Returns(new ControlPanelConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, null, null, null, null));
var mockGeneralOptions = new Mock<IOptions<GeneralConfiguration>>();
mockGeneralOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, null, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, null, null, null));
var mockSwarmOptions = new Mock<IOptions<SwarmConfiguration>>();
mockSwarmOptions.SetupGet(x => x.Value).Returns(new SwarmConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockSwarmOptions.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockSwarmOptions.Object, null, null));
var mockInternalOptions = new Mock<IOptions<InternalConfiguration>>();
mockInternalOptions.SetupGet(x => x.Value).Returns(new InternalConfiguration()).Verifiable();
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockServerPortProvider.Object, mockAssemblyInformationProvider, mockControlPanelOptions.Object, mockGeneralOptions.Object, mockSwarmOptions.Object, mockInternalOptions.Object, null));
mockControlPanelOptions.VerifyAll();
mockInternalOptions.VerifyAll();
mockSwarmOptions.VerifyAll();
mockGeneralOptions.VerifyAll();
}
@@ -20,8 +20,8 @@ namespace Tgstation.Server.Tests.Live.Instance
{
sealed class JobsHubTests : IJobsHub
{
readonly IServerClient permedUser;
readonly IServerClient permlessUser;
readonly IRestServerClient permedUser;
readonly IRestServerClient permlessUser;
readonly TaskCompletionSource finishTcs;
@@ -34,7 +34,7 @@ namespace Tgstation.Server.Tests.Live.Instance
long? permlessPsId;
public JobsHubTests(IServerClient permedUser, IServerClient permlessUser)
public JobsHubTests(IRestServerClient permedUser, IRestServerClient permlessUser)
{
this.permedUser = permedUser;
this.permlessUser = permlessUser;
@@ -24,12 +24,12 @@ namespace Tgstation.Server.Tests.Live
{
public const string TestInstanceName = "IntegrationTestInstance";
readonly IServerClient serverClient;
readonly IRestServerClient serverClient;
readonly IInstanceManagerClient instanceManagerClient;
readonly IUsersClient usersClient;
readonly string testRootPath;
public InstanceManagerTest(IServerClient serverClient, string testRootPath)
public InstanceManagerTest(IRestServerClient serverClient, string testRootPath)
{
this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient));
instanceManagerClient = serverClient.Instances;
@@ -0,0 +1,48 @@
using System;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Tgstation.Server.Client;
using Tgstation.Server.Client.GraphQL;
namespace Tgstation.Server.Tests.Live
{
sealed class MultiServerClient
{
readonly IRestServerClient restServerClient;
readonly IGraphQLServerClient graphQLServerClient;
readonly bool useGraphQL;
public MultiServerClient(IRestServerClient restServerClient, IGraphQLServerClient graphQLServerClient)
{
this.restServerClient = restServerClient ?? throw new ArgumentNullException(nameof(restServerClient));
this.graphQLServerClient = graphQLServerClient ?? throw new ArgumentNullException(nameof(graphQLServerClient));
this.useGraphQL = Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_GRAPHQL"), out var result) && result;
}
public ValueTask Execute(
Func<IRestServerClient, ValueTask> restAction,
Func<IGraphQLClient, ValueTask> graphQLAction)
{
if (useGraphQL)
return graphQLServerClient.RunQuery(graphQLAction);
return restAction(restServerClient);
}
public async ValueTask ExecuteReadOnlyConfirmEquivalence<TRestResult, TGraphQLResult>(
Func<IRestServerClient, ValueTask<TRestResult>> restAction,
Func<IGraphQLClient, ValueTask<TGraphQLResult>> graphQLAction,
Func<TRestResult, TGraphQLResult, bool> comparison)
{
var restTask = restAction(this.restServerClient);
TGraphQLResult graphQLResult = default;
await this.graphQLServerClient.RunQuery(async gqlClient => graphQLResult = await graphQLAction(gqlClient));
var restResult = await restTask;
Assert.IsTrue(comparison(restResult, graphQLResult), "REST/GraphQL results differ!");
}
}
}
@@ -31,7 +31,7 @@ namespace Tgstation.Server.Tests.Live
{
static class RawRequestTests
{
static async Task TestRequestValidation(IServerClient serverClient, CancellationToken cancellationToken)
static async Task TestRequestValidation(IRestServerClient serverClient, CancellationToken cancellationToken)
{
var url = serverClient.Url;
var token = serverClient.Token.Bearer;
@@ -196,7 +196,7 @@ namespace Tgstation.Server.Tests.Live
}
}
static async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken)
static async Task TestServerInformation(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken)
{
var serverInfo = await serverClient.ServerInformation(default);
@@ -220,7 +220,7 @@ namespace Tgstation.Server.Tests.Live
await ApiAssert.ThrowsException<UnauthorizedException, ServerInformationResponse>(() => badClient.ServerInformation(cancellationToken));
}
static async Task TestOAuthFails(IServerClient serverClient, CancellationToken cancellationToken)
static async Task TestOAuthFails(IRestServerClient serverClient, CancellationToken cancellationToken)
{
var url = serverClient.Url;
var token = serverClient.Token.Bearer;
@@ -242,7 +242,7 @@ namespace Tgstation.Server.Tests.Live
}
}
static async Task TestInvalidTransfers(IServerClient serverClient, CancellationToken cancellationToken)
static async Task TestInvalidTransfers(IRestServerClient serverClient, CancellationToken cancellationToken)
{
var url = serverClient.Url;
var token = serverClient.Token.Bearer;
@@ -317,7 +317,7 @@ namespace Tgstation.Server.Tests.Live
}
}
static async Task RegressionTestForLeakedPasswordHashesBug(IServerClient serverClient, CancellationToken cancellationToken)
static async Task RegressionTestForLeakedPasswordHashesBug(IRestServerClient serverClient, CancellationToken cancellationToken)
{
// See what https://github.com/tgstation/tgstation-server/commit/6c8dc87c4af36620885b262175d7974aca2b3c2b fixed
@@ -361,7 +361,7 @@ namespace Tgstation.Server.Tests.Live
=> ProxyFunc(job, cancellationToken);
}
static async Task TestSignalRUsage(IServerClientFactory serverClientFactory, IServerClient serverClient, CancellationToken cancellationToken)
static async Task TestSignalRUsage(IRestServerClientFactory serverClientFactory, IRestServerClient serverClient, CancellationToken cancellationToken)
{
// test regular creation works without error
var hubConnectionBuilder = new HubConnectionBuilder();
@@ -374,7 +374,7 @@ namespace Tgstation.Server.Tests.Live
options =>
{
options.AccessTokenProvider = () => tokenRetrivalFunc();
((IApiClient)typeof(ServerClient)
((IApiClient)typeof(RestServerClient)
.GetField(
"apiClient",
BindingFlags.NonPublic | BindingFlags.Instance)
@@ -457,7 +457,7 @@ namespace Tgstation.Server.Tests.Live
}
}
public static Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken)
public static Task Run(IRestServerClientFactory clientFactory, IRestServerClient serverClient, CancellationToken cancellationToken)
=> Task.WhenAll(
TestRequestValidation(serverClient, cancellationToken),
TestOAuthFails(serverClient, cancellationToken),
@@ -35,6 +35,7 @@ using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Client;
using Tgstation.Server.Client.Components;
using Tgstation.Server.Client.GraphQL;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Configuration;
@@ -71,7 +72,7 @@ namespace Tgstation.Server.Tests.Live
_ = mainDMPort.Value;
}
readonly ServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
readonly RestServerClientFactory clientFactory = new (new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString()));
public static List<System.Diagnostics.Process> GetEngineServerProcessesOnPort(EngineType engineType, ushort? port)
{
@@ -205,7 +206,7 @@ namespace Tgstation.Server.Tests.Live
await CachingFileDownloader.InitializeAndInjectForLiveTests(default);
DummyChatProvider.RandomDisconnections(true);
ServerClientFactory.ApiClientFactory = new RateLimitRetryingApiClientFactory();
RestServerClientFactory.ApiClientFactory = new RateLimitRetryingApiClientFactory();
var connectionString = Environment.GetEnvironmentVariable("TGS_TEST_CONNECTION_STRING");
if (String.IsNullOrWhiteSpace(connectionString))
@@ -290,7 +291,7 @@ namespace Tgstation.Server.Tests.Live
var serverTask = server.Run(cancellationToken).AsTask();
try
{
async ValueTask<ServerUpdateResponse> TestWithoutAndWithPermission(Func<ValueTask<ServerUpdateResponse>> action, IServerClient client, AdministrationRights right)
async ValueTask<ServerUpdateResponse> TestWithoutAndWithPermission(Func<ValueTask<ServerUpdateResponse>> action, IRestServerClient client, AdministrationRights right)
{
var ourUser = await client.Users.Read(cancellationToken);
var update = new UserUpdateRequest
@@ -321,7 +322,7 @@ namespace Tgstation.Server.Tests.Live
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json));
request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version);
request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.OAuthAuthenticationScheme, adminClient.Token.Bearer);
request.Headers.Add(ApiHeaders.OAuthProviderHeader, OAuthProvider.GitHub.ToString());
request.Headers.Add(ApiHeaders.OAuthProviderHeader, Api.Models.OAuthProvider.GitHub.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken);
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
@@ -875,7 +876,7 @@ namespace Tgstation.Server.Tests.Live
var controllerInfo = await controllerClient.ServerInformation(cancellationToken);
async Task WaitForSwarmServerUpdate(IServerClient client, int currentServerCount)
async Task WaitForSwarmServerUpdate(IRestServerClient client, int currentServerCount)
{
ServerInformationResponse serverInformation;
do
@@ -1371,7 +1372,43 @@ namespace Tgstation.Server.Tests.Live
await ApiAssert.ThrowsException<UnauthorizedException, UserResponse>(() => tokenOnlyClient.Users.Read(cancellationToken), null);
}
async ValueTask<IServerClient> CreateUserWithNoInstancePerms()
// basic graphql test, to be used everywhere eventually
await using (var graphQLClient = new GraphQLServerClientFactory(clientFactory).CreateUnauthenticated(server.RootUrl))
{
// test getting server info
var multiClient = new MultiServerClient(firstAdminClient, graphQLClient);
await multiClient.ExecuteReadOnlyConfirmEquivalence(
restClient => restClient.ServerInformation(cancellationToken),
async gqlClient => (await gqlClient.ServerInformationQuery.ExecuteAsync(cancellationToken)).Data,
(restServerInfo, gqlServerInfo) => restServerInfo.UpdateInProgress == gqlServerInfo.Swarm.Metadata.UpdateInProgress
&& restServerInfo.Version == gqlServerInfo.Swarm.Metadata.Version
&& restServerInfo.DMApiVersion == gqlServerInfo.Swarm.Metadata.DmApiVersion
&& restServerInfo.InstanceLimit == gqlServerInfo.Swarm.LocalServer.Information.InstanceLimit
&& restServerInfo.UserGroupLimit == gqlServerInfo.Swarm.LocalServer.Information.UserGroupLimit
&& restServerInfo.ValidInstancePaths.SequenceEqual(gqlServerInfo.Swarm.LocalServer.Information.ValidInstancePaths)
&& restServerInfo.UserLimit == gqlServerInfo.Swarm.LocalServer.Information.UserLimit
&& restServerInfo.MinimumPasswordLength == gqlServerInfo.Swarm.LocalServer.Information.MinimumPasswordLength
&& (restServerInfo.SwarmServers == gqlServerInfo.Swarm.Servers
|| restServerInfo.SwarmServers.SequenceEqual(gqlServerInfo.Swarm.Servers.Select(x => new SwarmServerResponse(new Api.Models.Internal.SwarmServerInformation
{
Address = x.Address,
PublicAddress = x.PublicAddress,
Controller = x.Controller,
Identifier = x.Identifier,
}))))
&& (restServerInfo.OAuthProviderInfos == gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos
|| restServerInfo.OAuthProviderInfos.All(kvp =>
{
var info = gqlServerInfo.Swarm.LocalServer.Information.OAuthProviderInfos.FirstOrDefault(x => (int)x.Key == (int)kvp.Key);
return info != null
&& info.Value.ServerUrl == kvp.Value.ServerUrl
&& info.Value.ClientId == kvp.Value.ClientId
&& info.Value.RedirectUri == kvp.Value.RedirectUri;
})));
}
async ValueTask<IRestServerClient> CreateUserWithNoInstancePerms()
{
var createRequest = new UserCreateRequest()
{
@@ -1457,10 +1494,10 @@ namespace Tgstation.Server.Tests.Live
}
var instanceTest = new InstanceTest(
firstAdminClient.Instances,
fileDownloader,
GetInstanceManager(),
(ushort)server.ApiUrl.Port);
firstAdminClient.Instances,
fileDownloader,
GetInstanceManager(),
(ushort)server.ApiUrl.Port);
async Task RunInstanceTests()
{
@@ -1811,7 +1848,7 @@ namespace Tgstation.Server.Tests.Live
await serverTask;
}
async Task<IServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
async Task<IRestServerClient> CreateAdminClient(Uri url, CancellationToken cancellationToken)
{
url = new Uri(url.ToString().Replace(Routes.ApiRoot, String.Empty));
var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2);
@@ -17,9 +17,9 @@ namespace Tgstation.Server.Tests.Live
{
sealed class UsersTest
{
readonly IServerClient serverClient;
readonly IRestServerClient serverClient;
public UsersTest(IServerClient serverClient)
public UsersTest(IRestServerClient serverClient)
{
this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient));
}
+1 -1
View File
@@ -270,7 +270,7 @@ namespace Tgstation.Server.Tests
var versionString = versionsPropertyGroup.Element(xmlNamespace + "TgsClientVersion").Value + ".0";
Assert.IsNotNull(versionString);
Assert.IsTrue(Version.TryParse(versionString, out var expected));
var actual = typeof(ServerClientFactory).Assembly.GetName().Version;
var actual = typeof(RestServerClientFactory).Assembly.GetName().Version;
Assert.AreEqual(expected, actual);
}
@@ -10,7 +10,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Tgstation.Server.Client\Tgstation.Server.Client.csproj" />
<ProjectReference Include="..\..\src\Tgstation.Server.Client.GraphQL\Tgstation.Server.Client.GraphQL.csproj" />
<ProjectReference Include="..\..\src\Tgstation.Server.Host.Watchdog\Tgstation.Server.Host.Watchdog.csproj" />
<ProjectReference Include="..\..\src\Tgstation.Server.Host\Tgstation.Server.Host.csproj" />
</ItemGroup>
+14
View File
@@ -270,6 +270,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Shared", "
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Shared.Tests", "tests\Tgstation.Server.Shared.Tests\Tgstation.Server.Shared.Tests.csproj", "{EAB84FD0-5514-4254-B188-7D90ACB7284D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Client.GraphQL", "src\Tgstation.Server.Client.GraphQL\Tgstation.Server.Client.GraphQL.csproj", "{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -564,6 +566,18 @@ Global
{EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU
{EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU
{EAB84FD0-5514-4254-B188-7D90ACB7284D}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWindows|Any CPU.Build.0 = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWix|Any CPU.ActiveCfg = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.DebugNoWix|Any CPU.Build.0 = Debug|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.Release|Any CPU.Build.0 = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWindows|Any CPU.ActiveCfg = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU
{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -72,7 +72,7 @@ static class Program
assemblyName.Version!.Semver().ToString());
var serverUrl = new Uri($"http://localhost:{apiPort}");
var clientFactory = new ServerClientFactory(productInfoHeaderValue.Product);
var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product);
var TGS6Client = await clientFactory.CreateFromLogin(
serverUrl,
DefaultCredentials.AdminUserName,
+2 -2
View File
@@ -476,8 +476,8 @@ try
var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(MaxWaitMinutes);
var serverUrl = new Uri($"http://localhost:{configuredApiPort}");
var clientFactory = new ServerClientFactory(productInfoHeaderValue.Product);
IServerClient TGS6Client;
var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product);
IRestServerClient TGS6Client;
for (var I = 1; ; ++I)
{
try