diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c05ad9d61..96eb6e3108 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -189,6 +189,20 @@ We have a script to do this. 1. Run `dotnet ef migrations add SL --context SqliteDatabaseContext`. 1. Follow the above steps. +## Adding OAuth Providers + +OAuth providers are hardcoded but it is fairly easy to add new ones. The flow doesn't need to be strict OAuth either (r.e. /tg/ forums). Follow the following steps: + +1. Add the name to the [Tgstation.Server.Api.Models.OAuthProviders](../src/Tgstation.Server.Api/Models/OAuthProviders.cs) enum (Also necessitates a minor HTTP API version bump). +1. Create an implementation of [IOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs). + - Most providers can simply override the [GenericOAuthValidator](../src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs). +1. Construct the implementation in the [OAuthProviders](../src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs) class. +1. Add a null entry to the default [appsettings.json](../src/Tgstation.Server.Host/appsettings.json). +1. Update the main [README.md](../README.md) to indicate the new provider. +1. Update the [API documentation](../docs/API.dox) to indicate the new provider. + +TGS should now be able to accept authentication response tokens from your provider. + ### Important Note About the \[Required\] Attribute. We use this attribute to ensure EFCore generated tables are not nullable for specific properties. They are valid to be null in API communication. Do not use this attribute expecting the model validator to prevent null data in API request. diff --git a/.github/workflows/ci-suite.yml b/.github/workflows/ci-suite.yml index 15c75bb15d..1e33654d09 100644 --- a/.github/workflows/ci-suite.yml +++ b/.github/workflows/ci-suite.yml @@ -11,6 +11,7 @@ on: - master env: + TGS4_DOTNET_VERSION: 3.1.x TGS4_TEST_DISCORD_CHANNEL: ${{ secrets.DISCORD_CHANNEL_ID }} TGS4_TEST_DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} TGS4_TEST_IRC_CHANNEL: ${{ secrets.IRC_CHANNEL }} @@ -23,7 +24,7 @@ jobs: name: Build DMAPI env: BYOND_MAJOR: 513 - BYOND_MINOR: 1527 + BYOND_MINOR: 1536 runs-on: ubuntu-latest steps: - name: Install x86 libc Dependencies @@ -130,10 +131,10 @@ jobs: with: node-version: 12.x - - name: Setup dotnet 3.1.X + - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.1.x + dotnet-version: ${{ env.TGS4_DOTNET_VERSION }} - name: Checkout uses: actions/checkout@v1 @@ -157,9 +158,17 @@ jobs: configuration: [ 'Debug', 'Release' ] runs-on: windows-latest steps: + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.TGS4_DOTNET_VERSION }} + - name: Checkout uses: actions/checkout@v1 + - name: Clean package cache as a temporary workaround for actions/setup-dotnet#155 + run: dotnet clean && dotnet nuget locals all --clear + - name: Build run: dotnet build -c ${{ matrix.configuration }} @@ -184,9 +193,14 @@ jobs: configuration: [ 'Debug', 'Release' ] runs-on: windows-latest steps: + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.TGS4_DOTNET_VERSION }} + - name: Set General__UseBasicWatchdog if: ${{ matrix.watchdog-type == 'Basic' }} - run: echo "General__UseBasicWatchdog=true" >> $env:GITHUB_ENV + run: echo "General__UseBasicWatchdog=true" >> $Env:GITHUB_ENV - name: Set TGS4_TEST_CONNECTION_STRING shell: bash @@ -199,15 +213,21 @@ jobs: - name: Set TGS4_TEST_PULL_REQUEST_NUMBER if: ${{ github.event_name == 'pull_request' }} - run: echo "TGS4_TEST_PULL_REQUEST_NUMBER=${{ github.event.number }}" >> $env:GITHUB_ENV + run: echo "TGS4_TEST_PULL_REQUEST_NUMBER=${{ github.event.number }}" >> $Env:GITHUB_ENV - name: Set TGS4_GITHUB_REF for PR if: ${{ github.event_name == 'pull_request' }} - run: echo "TGS4_GITHUB_REF=${{ github.event.base_ref }}" >> $env:GITHUB_ENV + run: echo "TGS4_GITHUB_REF=${{ github.base_ref }}" >> $Env:GITHUB_ENV - name: Set TGS4_GITHUB_REF for push if: ${{ github.event_name == 'push' }} - run: echo "TGS4_GITHUB_REF=${{ github.event.ref }}" >> $env:GITHUB_ENV + shell: bash + run: | + TEMP_GITHUB_REF="${{ github.event.ref }}" + echo "TGS4_GITHUB_REF=${TEMP_GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Clean package cache as a temporary workaround for actions/setup-dotnet#155 + run: dotnet clean && dotnet nuget locals all --clear - name: Run Integration Test run: | @@ -285,7 +305,7 @@ jobs: matrix: database-type: [ 'Sqlite', 'PostgresSql', 'MariaDB', 'MySql' ] watchdog-type: [ 'Basic', 'System' ] - configuration: [ 'Debug', 'Release' ] + configuration: [ 'Release' ] runs-on: ubuntu-latest steps: - name: Disable ptrace_scope @@ -305,7 +325,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: ${{ matrix.dotnet }} + dotnet-version: ${{ env.TGS4_DOTNET_VERSION }} - name: Set Sqlite Connection Info if: ${{ matrix.database-type == 'Sqlite' }} @@ -345,17 +365,20 @@ jobs: - name: Set TGS4_GITHUB_REF for PR if: ${{ github.event_name == 'pull_request' }} - run: echo "TGS4_GITHUB_REF=${{ github.event.base_ref }}" >> $GITHUB_ENV + run: echo "TGS4_GITHUB_REF=${{ github.base_ref }}" >> $GITHUB_ENV - name: Set TGS4_GITHUB_REF for push if: ${{ github.event_name == 'push' }} - run: echo "TGS4_GITHUB_REF=${{ github.event.ref }}" >> $GITHUB_ENV + shell: bash + run: | + TEMP_GITHUB_REF="${{ github.event.ref }}" + echo "TGS4_GITHUB_REF=${TEMP_GITHUB_REF##*/}" >> $GITHUB_ENV - name: Run Integration Test run: | cd tests/Tgstation.Server.Tests sleep 10 - dotnet test -c ${{ matrix.configuration }} -l "console;verbosity=detailed;noprogress=true" --collect:"XPlat Code Coverage" --settings ../../build/coverlet.runsettings -r ./TestResults + dotnet test -c ${{ matrix.configuration }}NoService -l "console;verbosity=detailed;noprogress=true" --collect:"XPlat Code Coverage" --settings ../../build/coverlet.runsettings -r ./TestResults - name: Store Code Coverage uses: actions/upload-artifact@v2 @@ -369,13 +392,13 @@ jobs: cd src/Tgstation.Server.Host.Console dotnet publish -c ${{ matrix.configuration }} -o ../../Artifacts/Console cd ../Tgstation.Server.Host - dotnet publish -c ${{ matrix.configuration }} --no-build -o ../../Artifacts/Console/lib/Default + dotnet publish -c ${{ matrix.configuration }}NoService --no-build -o ../../Artifacts/Console/lib/Default - name: Package Server Update Package if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'PostgresSql' }} run: | cd src/Tgstation.Server.Host - dotnet publish -c ${{ matrix.configuration }} --no-build -o ../../Artifacts/ServerUpdate + dotnet publish -c ${{ matrix.configuration }}NoService --no-build -o ../../Artifacts/ServerUpdate - name: Store Server Console if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'MariaDB' }} @@ -436,96 +459,48 @@ jobs: name: linux-unit-test-coverage-Release path: ./code_coverage/unit_tests/linux_unit_tests_release - - name: Retrieve Linux Integration Test Coverage (Debug, Basic, Sqlite) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-Basic-Sqlite - path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_sqlite - - name: Retrieve Linux Integration Test Coverage (Release, Basic, Sqlite) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-Basic-Sqlite path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_sqlite - - name: Retrieve Linux Integration Test Coverage (Debug, System, Sqlite) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-System-Sqlite - path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_sqlite - - name: Retrieve Linux Integration Test Coverage (Release, System, Sqlite) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-System-Sqlite path: ./code_coverage/integration_tests/linux_integration_tests_release_system_sqlite - - name: Retrieve Linux Integration Test Coverage (Debug, Basic, PostgresSql) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-Basic-PostgresSql - path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_postgressql - - name: Retrieve Linux Integration Test Coverage (Release, Basic, PostgresSql) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-Basic-PostgresSql path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_postgressql - - name: Retrieve Linux Integration Test Coverage (Debug, System, PostgresSql) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-System-PostgresSql - path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_postgressql - - name: Retrieve Linux Integration Test Coverage (Release, System, PostgresSql) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-System-PostgresSql path: ./code_coverage/integration_tests/linux_integration_tests_release_system_mariadb - - name: Retrieve Linux Integration Test Coverage (Debug, Basic, MariaDB) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-Basic-MariaDB - path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_mariadb - - name: Retrieve Linux Integration Test Coverage (Release, Basic, MariaDB) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-Basic-MariaDB path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_mariadb - - name: Retrieve Linux Integration Test Coverage (Debug, System, MariaDB) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-System-MariaDB - path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_mariadb - - name: Retrieve Linux Integration Test Coverage (Release, System, MariaDB) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-System-MariaDB path: ./code_coverage/integration_tests/linux_integration_tests_release_system_mysql - - name: Retrieve Linux Integration Test Coverage (Debug, Basic, MySql) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-Basic-MySql - path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_mysql - - name: Retrieve Linux Integration Test Coverage (Release, Basic, MySql) uses: actions/download-artifact@v2 with: name: linux-integration-test-coverage-Release-Basic-MySql path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_mysql - - name: Retrieve Linux Integration Test Coverage (Debug, System, MySql) - uses: actions/download-artifact@v2 - with: - name: linux-integration-test-coverage-Debug-System-MySql - path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_mysql - - name: Retrieve Linux Integration Test Coverage (Release, System, MySql) uses: actions/download-artifact@v2 with: @@ -586,7 +561,7 @@ jobs: name: Deploy HTTP API needs: [upload-code-coverage, validate-openapi-spec] runs-on: windows-latest - if: github.event_name == 'push' && github.event.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[APIDeploy]') + if: github.event_name == 'push' && contains(github.event.head_commit.message, '[APIDeploy]') steps: - name: Checkout uses: actions/checkout@v1 @@ -629,7 +604,7 @@ jobs: name: Deploy DreamMaker API needs: [upload-code-coverage, validate-openapi-spec] runs-on: windows-latest - if: github.event_name == 'push' && github.event.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[DMDeploy]') + if: github.event_name == 'push' && contains(github.event.head_commit.message, '[DMDeploy]') steps: - name: Checkout @@ -672,8 +647,13 @@ jobs: name: Deploy Nuget Packages needs: [upload-code-coverage, validate-openapi-spec] runs-on: ubuntu-latest - if: github.event_name == 'push' && github.event.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[NugetDeploy]') + if: github.event_name == 'push' && contains(github.event.head_commit.message, '[NugetDeploy]') steps: + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.TGS4_DOTNET_VERSION }} + - name: Checkout uses: actions/checkout@v1 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4110b68ad6..9c2ef53894 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,9 +27,6 @@ jobs: with: fetch-depth: 2 - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: diff --git a/README.md b/README.md index c583d54fe7..642ac2513a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tgstation-server v4: -![CI](https://github.com/tgstation/tgstation-server/workflows/CI/badge.svg) [![Build Status](https://travis-ci.org/tgstation/tgstation-server.svg?branch=master)](https://travis-ci.org/tgstation/tgstation-server) [![codecov](https://codecov.io/gh/tgstation/tgstation-server/branch/master/graph/badge.svg)](https://codecov.io/gh/tgstation/tgstation-server) +![CI](https://github.com/tgstation/tgstation-server/workflows/CI/badge.svg) [![codecov](https://codecov.io/gh/tgstation/tgstation-server/branch/master/graph/badge.svg)](https://codecov.io/gh/tgstation/tgstation-server) [![GitHub license](https://img.shields.io/github/license/tgstation/tgstation-server.svg)](LICENSE) [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/tgstation/tgstation-server.svg)](http://isitmaintained.com/project/tgstation/tgstation-server "Average time to resolve an issue") [![NuGet version](https://img.shields.io/nuget/v/Tgstation.Server.Api.svg)](https://www.nuget.org/packages/Tgstation.Server.Api) [![NuGet version](https://img.shields.io/nuget/v/Tgstation.Server.Client.svg)](https://www.nuget.org/packages/Tgstation.Server.Client) @@ -57,7 +57,7 @@ docker run \ --name="tgs" \ # Name for the container --cap-add=sys_nice \ # Recommended, allows tgs to schedule DreamDaemon as a higher priority process --init \ #Highly recommended, reaps potential zombie processes - -p : \ # Port bridge for accessing TGS + -p 5000:5000 \ # Port bridge for accessing TGS, you can change this if you need -p 0.0.0.0:: \ # Port bridge for accessing DreamDaemon -v /path/to/your/configfile/directory:/config_data \ # Recommended, create a volume mapping for server configuration -v /path/to/store/instances:/tgs4_instances \ # Recommended, create a volume mapping for server instances @@ -86,9 +86,19 @@ The first time you run TGS4 you should be prompted with a configuration wizard w This wizard will, generally, run whenever the server is launched without detecting the config json. Follow the instructions below to perform this process manually. +#### Configuration Methods + +There are 3 primary supported ways to configure TGS: + +- Modify the `appsettings.Production.json` file (Recommended). +- Set environment variables in the form `Section__Subsection=value` or `Section__ArraySubsection__0=value` for arrays. +- Set command line arguments in the form `--Section:Subsection=value` or `--Section:ArraySubsection:0=value` for arrays. + +The latter two are not recommended as they cannot be dynamically changed at runtime. See more on ASP.NET core configuration [here](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1). + #### Manual Configuration -Create an `appsettings.Production.json` file next to `appsettings.json`. This will override the default settings in appsettings.json with your production settings. There are a few keys meant to be changed by hosts. Modifying any config files while the server is running will trigger a safe restart (Keeps DreamDaemon's running). Note these are all case-sensitive: +Create an `appsettings.Production.json` file next to `appsettings.json`. This will override the default settings in appsettings.json with your production settings. There are a few keys meant to be changed by hosts. Modifying any config files while the server is running will trigger a safe restart (Keeps DreamDaemon instances running). Note these are all case-sensitive: - `General:ConfigVersion`: Suppresses warnings about out of date config versions. You should change this after updating TGS to one with a new config version. The current version can be found on the releases page for your server version (This field did not exist before v4.4.0). @@ -120,6 +130,41 @@ Create an `appsettings.Production.json` file next to `appsettings.json`. This wi - `ControlPanel:AllowedOrigins`: Set the Access-Control-Allow-Origin headers to this list of origins for all responses (also enables all headers and methods). This is overridden by `ControlPanel:AllowAnyOrigin` +- `Swarm`: This section should be left `null` unless using the server swarm system. If this is to happen, ensure all swarm servers are set to connect to the same database. + +- `Swarm:PrivateKey`: Should be a secure string set identically on all swarmed servers. + +- `Swarm:ControllerAddress`: Should be set on all swarmed servers that are **not** the controller server and should be an address the controller server may be reached at. + +- `Swarm:Address`: Should be set on all swarmed servers. Should be an address the server can be reached at by other servers in the swarm. + +- `Swarm:Identifier` should be set uniquely on all swarmed servers. Used to identify the current server. This is also used to select which instances exist on the current machine and should not be changed post-setup. + +- `Security:OAuth:`: Sets the OAuth client ID and secret for a given ``. The currently supported providers are `GitHub`, `Discord`, and `TGForums`. Setting these fields to `null` disables logins with the provider, but does not stop users from associating their accounts using the API. Sample Entry: +```json +{ + "Security": { + "OAuth": { + "Keycloak": { + "ClientId": "...", + "ClientSecret": "...", + "RedirectUrl": "...", + "ServerUrl": "..." + } + } + } +} +``` +The following providers use the `RedirectUrl` setting: + +- GitHub +- TGForums +- Keycloak + +The following providers use the `ServerUrl` setting: + +- Keycloak + ### Database Configuration If using a MariaDB/MySQL server, our client library [recommends you set 'utf8mb4' as your default charset](https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql#1-recommended-server-charset) disregard at your own risk. @@ -281,6 +326,12 @@ Example VirtualHost Entry ``` +## Swarmed Servers + +Multiple tgstation-servers can be linked together in a swarm. The main benefit of this is allowing for users, groups, and permissions to be shared across the servers. Servers in a swarm must connect to the same database, use the same tgstation-server version, and have their own unique names. + +In a swarm, one server is designated the 'controller'. This is the server other 'node's in the swarm communicate with and coordinates group updates. Issuing an update command to one server in a swarm will update them all to the specified version. + ## Usage tgstation-server v4 is controlled via a RESTful HTTP json API. Documentation on this API can be found [here](https://tgstation.github.io/tgstation-server/api.html). This section serves to document the concepts of the server. The API is versioned separately from the release version. A specification for it can be found in the api-vX.X.X git releases/tags. @@ -371,11 +422,10 @@ TGS 4 can self update without stopping your DreamDaemon servers. Any V4 release Here are tools for interacting with the TGS 4 web API -- [tgstation-server-control-panel]: Official client and included with the server (WIP). A react web app for using tgstation-server. -- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server -- [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server -- [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server -- [Postman](https://www.getpostman.com/): This repository contains [TGS.postman_collection.json](tools/TGS.postman_collection.json) which is used during development for testing. Contains example requests for all endpoints but takes some knowledge to use (Note that the pre-request script is configured to login the default admin user for every request) +- [tgstation-server-webpanel](https://github.com/tgstation/tgstation-server-webpanel): Official client and included with the server (WIP). A react web app for using tgstation-server. +- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server. Feature complete but lacks OAuth login options. +- [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server. Feature complete. +- [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server. Feature complete. Contact project maintainers to get your client added to this list diff --git a/build/ControlPanelVersion.props b/build/ControlPanelVersion.props index d6e0a945fe..ba79100b84 100644 --- a/build/ControlPanelVersion.props +++ b/build/ControlPanelVersion.props @@ -1,6 +1,6 @@ - 1.0.1 + 2.0.1 diff --git a/build/Dockerfile b/build/Dockerfile index 63efba1465..6a1d46b45d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,10 +1,10 @@ -FROM mcr.microsoft.com/dotnet/core/sdk:3.1-bionic AS build +FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build # install node and npm # replace shell with bash so we can source files RUN curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.2/install.sh | sh -ENV NODE_VERSION=10.13.0 +ENV NODE_VERSION 10.13.0 ENV NVM_DIR /root/.nvm RUN . $NVM_DIR/nvm.sh \ @@ -12,7 +12,7 @@ RUN . $NVM_DIR/nvm.sh \ && nvm use $NODE_VERSION \ && apt-get update \ && apt-get install -y \ - dos2unix \ + dos2unix \ && rm -rf /var/lib/apt/lists/* ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules @@ -56,14 +56,18 @@ RUN dotnet publish -c Release -o /app WORKDIR /repo/src/Tgstation.Server.Host RUN dotnet publish -c Release -o /app/lib/Default && mv /app/lib/Default/appsettings* /app -FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-bionic +FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim #needed for byond RUN apt-get update \ && apt-get install -y \ gcc-multilib \ gdb \ - && rm -rf /var/lib/apt/lists/* + multiarch-support \ + && rm -rf /var/lib/apt/lists/* \ + && curl http://security.debian.org/debian-security/pool/updates/main/o/openssl/libssl1.0.0_1.0.1t-1+deb8u12_amd64.deb --output libssl1.0.0.deb \ + && dpkg -i libssl1.0.0.deb \ + && rm libssl1.0.0.deb EXPOSE 5000 diff --git a/build/OpenApiValidationSettings.json b/build/OpenApiValidationSettings.json index aa6faf8c1d..af87e9cf4b 100644 --- a/build/OpenApiValidationSettings.json +++ b/build/OpenApiValidationSettings.json @@ -4,8 +4,9 @@ "no_operation_id": "error", "operation_id_case_convention": "off", "no_summary": "error", - "no_array_responses": "off", + "no_array_responses": "error", "parameter_order": "error", + "undefined_tag": "off", "unused_tag": "error", "operation_id_naming_convention": "off" }, @@ -44,12 +45,15 @@ "no_property_description": "off", "description_mentions_json": "error", "array_of_arrays": "error", + "inconsistent_property_type": "error", "property_case_convention": "off", - "enum_case_convention": "error" + "property_case_collision": "error", + "enum_case_convention": "off", + "undefined_required_properties": "error" }, "walker": { "no_empty_descriptions": "error", - "has_circular_references": "off", + "has_circular_references": "error", "$ref_siblings": "error", "duplicate_sibling_description": "error", "incorrect_ref_pattern": "error" @@ -76,7 +80,11 @@ "responses": { "no_response_codes": "error", "no_success_response_codes": "error", + "no_response_body": "error", "ibm_status_code_guidelines": "off" + }, + "schemas": { + "json_or_param_binary_string": "error" } } -} \ No newline at end of file +} diff --git a/build/Version.props b/build/Version.props index 5f97310783..d4a35b4ba5 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,11 +3,11 @@ - 4.6.3 - 2.1.1 - 7.4.0 - 8.4.0 - 5.2.9 + 4.7.0 + 2.2.0 + 8.1.2 + 9.1.1 + 5.2.10 1.1.0 1.2.0 diff --git a/docs/API.dox b/docs/API.dox index e4320e103b..66f79a3174 100644 --- a/docs/API.dox +++ b/docs/API.dox @@ -63,7 +63,7 @@ TGS will only every return the response codes listed here - 204: No Content. Identical to 200 with no response body. - 400: Bad Request. The response body will contain an @ref Tgstation.Server.Api.Models.ErrorMessage model detailing the error - 401: Unauthorized. Invalid or expired credentials were provided. Check rights APIs for updates. See @ref api_auth for details -- 403: Forbidden. User tried to make a request they were not allowed to perform. +- 403: Forbidden. User tried to make a request they were not allowed to perform. - 404: Not found. A resource was requested that had never existed. In the case of retrieving a resource by ID, it could potentially exist in the future - 406: Not Acceptable. Consequence of failing to provide an Accept header - 408: Request Timeout. The client took to long to continue a request @@ -77,9 +77,27 @@ TGS will only every return the response codes listed here - 501: Not implemented. Functionality not available in the current server version - 503: Service unavailable. The server is either starting up or shutting down and isn't ready to respond to requests. You can try again soon and a response/lack thereof will indicate which of the two events it was +@section api_ver Server Information + +The versions of the TGS host can be retireved with this request. + +Note: This endpoint does not require authentication + +GET "/" => @ref Tgstation.Server.Api.Models.ServerInformation + +The Version model fields are based on the C# one and looks like this: + +@code{.json} +{ + "version": "..." +} +@endcode + +Other fields may be present in the Version model but should be ignored. See a description of these version numbers here. + @section api_auth Authentication -Every request made to TGS requires authentication. It is provided in the form of the Authorization header. +Every request made to TGS requires authentication. It is provided in the form of the Authorization header. The first request made to TGS must be to login the user @@ -87,7 +105,7 @@ POST "/" => @ref Tgstation.Server.Api.Models.Token Headers: -- Authorization:basic `` +- Authorization:Basic `` If the provided credentials are valid and your user account is enabled you will recieve a @ref Tgstation.Server.Api.Models.Token object @code{.json} @@ -100,10 +118,37 @@ If your account is disabled, you will recieve a 403 response. You may recognize the bearer value as a Json Web Token. This is a secure representation of your identity to the server. It expires after a set period of time or until your password changes. It must be present for requests made to all other APIs. To do so add the following header to your other requests -- Authorization:bearer `` +- Authorization:Bearer `` Continue to use this token until you begin to recieve 401 responses from the API. Then repeat the process to get a new one if your credentials are still valid +@subsection api_auth_o OAuth 2.0 + +TGS4 supports OAuth 2.0 with select providers for authentication. + +The flow for this is as follows: + +- Retrieve the @ref api_ver to find out which @ref Tgstation.Server.Api.Models.OAuthProvider are enabled and their respective information. +- Send the user to the Authorization Request endpoint for the provider using the client ID from above. See https://tools.ietf.org/html/rfc6749#section-4.1.1. DO NOT specify a redirect URI, this should be configured in the provider. +- Retrieve the authorization response code after successfully completing the authorize step above. +- Perform the following request: + +POST "/" => @ref Tgstation.Server.Api.Models.Token + +Headers: + +- Authorization:OAuth `OAuth Authorization Response Code` +- OAuthProvider: + +You will be granted a bearer token as in basic auth. This will have an extended expiration to avoid repeating the entire process. + +@subsubsection api_auth_o_providers Supported Providers + +- GitHub: https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps +- Discord: https://discord.com/developers/docs/topics/oauth2 +- TGForums: https://tgstation13.org/phpBB/viewtopic.php?f=45&t=9922 +- Keycloak: https://plugins.miniorange.com/keycloak-single-sign-on-wordpress-sso-oauth-openid-connect + @section api_perms Permissions Almost all actions available require some level of user permissions. Which are divided into two categories: @@ -118,6 +163,20 @@ I GET "/InstanceUser" => @ref Tgstation.Server.Api.Models.InstanceUser See individual documentation of each permission enum for their usage +@section api_transfer File Transfers + +Certain responses inherit from @ref Tgstation.Server.Api.Models.FileTicketResult. These are special in that, having that model's @ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket field populated indicates there is a pending file download or upload to take place. These are handled in the "/Transfer" endpoint. + +To perform a file download make the following request: + +GET "/Transfer?ticket=<@ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket>" => application/octet-stream + +To perform a file upload make the following request: + +PUT "/Transfer?ticket=<@ref Tgstation.Server.Api.Models.FileTicketResult.FileTicket>" application/octet-stream => OK + +File tickets are only valid for a short time after the initial request is made and should be dealt with immediately. Ensure that the file tickets are properly URL encoded. + @section api_user User Management TGS start with one user: "Admin". The password is "ISolemlySwearToDeleteTheDataDirectory" @@ -150,22 +209,6 @@ I DELETE "/InstanceUser/{UserId}" => OK Users with the permission to modify @ref Tgstation.Server.Api.Models.Instance objects can also gain user editing rights for any Instance. See @ref api_instance -@section api_ver Version - -The versions of the TGS host can be retireved with this request - -GET "/" => @ref Tgstation.Server.Api.Models.ServerInformation - -The Version model fields are based on the C# one and looks like this: - -@code{.json} -{ - "version": "..." -} -@endcode - -Other fields may be present in the Version model but should be ignored. See a description of these version numbers here. - @section api_admin Server-wide Administrative Actions You can retrieve server update information with this @@ -349,7 +392,7 @@ If the server detects a set of @ref Tgstation.Server.Api.Models.TestMergeParamet @subsubsection api_repounsetauth Unsetting Authentication -The repository uses the @ref Tgstation.Server.Api.Models.Repository.AccessUser and @ref Tgstation.Server.Api.Models.Repository.AccessToken credentials to access the remote repository if these fields are set. To unset them you must set both of them to an empty string like so +The repository uses the @ref Tgstation.Server.Api.Models.Repository.AccessUser and @ref Tgstation.Server.Api.Models.Repository.AccessToken credentials to access the remote repository if these fields are set. To unset them you must set both of them to an empty string like so @code{.json} { diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index 2562bfe4d3..3225f14d8c 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -1,6 +1,6 @@ // tgstation-server DMAPI -#define TGS_DMAPI_VERSION "5.2.9" +#define TGS_DMAPI_VERSION "5.2.10" // All functions and datums outside this document are subject to change with any version and should not be relied on. @@ -67,7 +67,7 @@ #define TGS_EVENT_REPO_CHECKOUT 1 /// When the repository performs a fetch operation. No parameters #define TGS_EVENT_REPO_FETCH 2 -/// When the repository merges a pull request. Parameters: PR Number, PR Sha, (Nullable) Comment made by TGS user +/// When the repository test merges. Parameters: PR Number, PR Sha, (Nullable) Comment made by TGS user #define TGS_EVENT_REPO_MERGE_PULL_REQUEST 3 /// Before the repository makes a sychronize operation. Parameters: Absolute repostiory path #define TGS_EVENT_REPO_PRE_SYNCHRONIZE 4 @@ -190,21 +190,21 @@ /// Represents a merge of a GitHub pull request. /datum/tgs_revision_information/test_merge - /// The pull request number. + /// The test merge number. var/number - /// The pull request title when it was merged. + /// The test merge source's title when it was merged. var/title - /// The pull request body when it was merged. + /// The test merge source's body when it was merged. var/body - /// The GitHub username of the pull request's author. + /// The Username of the test merge source's author. var/author - /// An http URL to the pull request. + /// An http URL to the test merge source. var/url - /// The SHA of the pull request when that was merged. + /// The SHA of the test merge when that was merged. var/pull_request_commit - /// ISO 8601 timestamp of when the pull request was merged. + /// ISO 8601 timestamp of when the test merge was created on TGS. var/time_merged - /// (Nullable) Comment left by the TGS user who initiated the merge.. + /// Optional comment left by the TGS user who initiated the merge. var/comment /// Represents a connected chat channel. diff --git a/src/DMAPI/tgs/v5/api.dm b/src/DMAPI/tgs/v5/api.dm index 466a986237..7a2ff694e0 100644 --- a/src/DMAPI/tgs/v5/api.dm +++ b/src/DMAPI/tgs/v5/api.dm @@ -98,18 +98,19 @@ return json_encode(response) /datum/tgs_api/v5/OnTopic(T) - if(!initialized) - return FALSE //continue world/Topic - var/list/params = params2list(T) var/json = params[DMAPI5_TOPIC_DATA] if(!json) - return FALSE + return FALSE // continue to /world/Topic var/list/topic_parameters = json_decode(json) if(!topic_parameters) return TopicResponse("Invalid topic parameters json!"); + if(!initialized) + TGS_WARNING_LOG("Missed topic due to not being initialized: [T]") + return TRUE // too early to handle, but it's still our responsibility + var/their_sCK = topic_parameters[DMAPI5_PARAMETER_ACCESS_IDENTIFIER] if(their_sCK != access_identifier) return TopicResponse("Failed to decode [DMAPI5_PARAMETER_ACCESS_IDENTIFIER] from: [json]!"); diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index cecfa88a5a..9ec9c575b1 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -8,6 +8,7 @@ using System.Net.Http.Headers; using System.Net.Mime; using System.Reflection; using System.Text; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Api { @@ -17,24 +18,34 @@ namespace Tgstation.Server.Api public sealed class ApiHeaders { /// - /// The header key + /// The header key. /// public const string ApiVersionHeader = "Api"; /// - /// The header key + /// The header key. /// public const string InstanceIdHeader = "Instance"; /// - /// The JWT authentication header scheme + /// The header key. /// - public const string JwtAuthenticationScheme = "bearer"; + public const string OAuthProviderHeader = "OAuthProvider"; /// /// The JWT authentication header scheme /// - public const string BasicAuthenticationScheme = "basic"; + public const string BearerAuthenticationScheme = "Bearer"; + + /// + /// The JWT authentication header scheme + /// + public const string BasicAuthenticationScheme = "Basic"; + + /// + /// The JWT authentication header scheme + /// + public const string OAuthAuthenticationScheme = "OAuth"; /// /// The current @@ -47,7 +58,7 @@ namespace Tgstation.Server.Api public static readonly Version Version = AssemblyName.Version.Semver(); /// - /// The instance being accessed + /// The instance being accessed /// public long? InstanceId { get; set; } @@ -82,9 +93,14 @@ namespace Tgstation.Server.Api public string? Password { get; } /// - /// If the header uses password or JWT authentication + /// The the is for, if any. /// - public bool IsTokenAuthentication => Token != null; + public OAuthProvider? OAuthProvider { get; } + + /// + /// If the header uses password or TGS JWT authentication. + /// + public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue; /// /// Checks if a given is compatible with our own @@ -98,12 +114,15 @@ namespace Tgstation.Server.Api /// /// The value of /// The value of - public ApiHeaders(ProductHeaderValue userAgent, string token) : this(userAgent, token, null, null) + /// The value of . + public ApiHeaders(ProductHeaderValue userAgent, string token, OAuthProvider? oauthProvider = null) : this(userAgent, token, null, null) { if (userAgent == null) throw new ArgumentNullException(nameof(userAgent)); if (token == null) throw new ArgumentNullException(nameof(token)); + + OAuthProvider = oauthProvider; } /// @@ -126,8 +145,10 @@ namespace Tgstation.Server.Api /// Construct and validates from a set of /// /// The containing the + /// If a missing should be ignored. /// Thrown if the constitue invalid . - public ApiHeaders(RequestHeaders requestHeaders) +#pragma warning disable CA1502 + public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); @@ -164,7 +185,10 @@ namespace Tgstation.Server.Api AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) - AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!"); + { + if (!ignoreMissingAuth) + AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!"); + } else { var auth = authorization.First(); @@ -187,11 +211,22 @@ namespace Tgstation.Server.Api InstanceId = instanceId; } -#pragma warning disable CA1308 // Normalize strings to uppercase - switch (scheme.ToLowerInvariant()) -#pragma warning restore CA1308 // Normalize strings to uppercase + switch (scheme) { - case JwtAuthenticationScheme: + case OAuthAuthenticationScheme: + if (requestHeaders.Headers.TryGetValue(OAuthProviderHeader, out StringValues oauthProviderValues)) + { + var oauthProviderString = oauthProviderValues.First(); + if (Enum.TryParse(oauthProviderString, out var oauthProvider)) + OAuthProvider = oauthProvider; + else + AddError(HeaderTypes.OAuthProvider, "Invalid OAuth provider!"); + } + else + AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); + + goto case BearerAuthenticationScheme; + case BearerAuthenticationScheme: Token = parameter; break; case BasicAuthenticationScheme: @@ -203,12 +238,12 @@ namespace Tgstation.Server.Api } catch { - throw new InvalidOperationException("Invalid basic Authorization header!"); + throw new InvalidOperationException($"Invalid basic {HeaderNames.Authorization} header!"); } var basicAuthSplits = joinedString.Split(new char[] { ':' }, StringSplitOptions.RemoveEmptyEntries); if (basicAuthSplits.Length < 2) - throw new InvalidOperationException("Invalid basic Authorization header!"); + throw new InvalidOperationException($"Invalid basic {HeaderNames.Authorization} header!"); Username = basicAuthSplits.First(); Password = String.Concat(basicAuthSplits.Skip(1)); @@ -226,6 +261,7 @@ namespace Tgstation.Server.Api ApiVersion = apiVersion!.Semver(); } +#pragma warning restore CA1502 /// /// Construct @@ -253,7 +289,7 @@ namespace Tgstation.Server.Api /// Set using the . This initially clears /// /// The to set - /// The instance for the request + /// The instance for the request public void SetRequestHeaders(HttpRequestHeaders headers, long? instanceId = null) { if (headers == null) @@ -263,12 +299,16 @@ namespace Tgstation.Server.Api headers.Clear(); headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - if (IsTokenAuthentication) - headers.Authorization = new AuthenticationHeaderValue(JwtAuthenticationScheme, Token); - else + if (!IsTokenAuthentication) headers.Authorization = new AuthenticationHeaderValue( BasicAuthenticationScheme, Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))); + else + { + headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token); + if (OAuthProvider.HasValue) + headers.Add(OAuthProviderHeader, OAuthProvider.ToString()); + } headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent)); headers.Add(ApiVersionHeader, new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString()); diff --git a/src/Tgstation.Server.Api/HeaderTypes.cs b/src/Tgstation.Server.Api/HeaderTypes.cs index a1d0a6e17e..b45b6b0380 100644 --- a/src/Tgstation.Server.Api/HeaderTypes.cs +++ b/src/Tgstation.Server.Api/HeaderTypes.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Tgstation.Server.Api { @@ -24,13 +24,18 @@ namespace Tgstation.Server.Api Accept = 2, /// - /// Api header. + /// . /// Api = 4, /// /// /// - Authorization = 8 + Authorization = 8, + + /// + /// . + /// + OAuthProvider = 16, } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/Administration.cs b/src/Tgstation.Server.Api/Models/Administration.cs index 9febaa977a..918930f30a 100644 --- a/src/Tgstation.Server.Api/Models/Administration.cs +++ b/src/Tgstation.Server.Api/Models/Administration.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Tgstation.Server.Api.Models { @@ -7,11 +7,6 @@ namespace Tgstation.Server.Api.Models /// public sealed class Administration { - /// - /// If the server is running on a windows operating system - /// - public bool WindowsHost { get; set; } - /// /// The GitHub repository the server is built to recieve updates from /// diff --git a/src/Tgstation.Server.Api/Models/Byond.cs b/src/Tgstation.Server.Api/Models/Byond.cs index 89e7db2a91..6241e28948 100644 --- a/src/Tgstation.Server.Api/Models/Byond.cs +++ b/src/Tgstation.Server.Api/Models/Byond.cs @@ -1,12 +1,11 @@ -using System; -using Tgstation.Server.Api.Models.Internal; +using System; namespace Tgstation.Server.Api.Models { /// - /// Represents a BYOND installation. is used to upload custom BYOND version zip files, though must still be set. + /// Represents a BYOND installation. is used to upload custom BYOND version zip files, though must still be set. /// - public sealed class Byond : RawData + public sealed class Byond : FileTicketResult { /// /// The of the installation used for new compiles. Will be if the user does not have permission to view it or there is no BYOND version installed. Only considers the and numbers. @@ -17,5 +16,10 @@ namespace Tgstation.Server.Api.Models /// The being used to install a new /// public Job? InstallJob { get; set; } + + /// + /// If a custom BYOND version is to be uploaded. + /// + public bool? UploadCustomZip { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/ChatChannel.cs b/src/Tgstation.Server.Api/Models/ChatChannel.cs index 805fa1fbbb..b3490556eb 100644 --- a/src/Tgstation.Server.Api/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Api/Models/ChatChannel.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models { @@ -11,7 +11,7 @@ namespace Tgstation.Server.Api.Models /// The IRC channel name. Also potentially contains the channel passsword (if separated by a colon). /// If multiple copies of the same channel with different keys are added to the server, the one that will be used is undefined. /// - [StringLength(Limits.MaximumIndexableStringLength)] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] public string? IrcChannel { get; set; } /// diff --git a/src/Tgstation.Server.Api/Models/CompileJob.cs b/src/Tgstation.Server.Api/Models/CompileJob.cs index 964bc9dad7..d5a2c12d09 100644 --- a/src/Tgstation.Server.Api/Models/CompileJob.cs +++ b/src/Tgstation.Server.Api/Models/CompileJob.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace Tgstation.Server.Api.Models { @@ -19,5 +19,10 @@ namespace Tgstation.Server.Api.Models /// The the was made with /// public Version? ByondVersion { get; set; } + + /// + /// The origin of the repository the compile job was built from. + /// + public Uri? RepositoryOrigin { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/ConfigurationFile.cs b/src/Tgstation.Server.Api/Models/ConfigurationFile.cs index 83f952d9ca..7d5e0168e9 100644 --- a/src/Tgstation.Server.Api/Models/ConfigurationFile.cs +++ b/src/Tgstation.Server.Api/Models/ConfigurationFile.cs @@ -1,12 +1,11 @@ -using System.ComponentModel.DataAnnotations; -using Tgstation.Server.Api.Models.Internal; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models { /// /// Represents a game configuration file. Create and delete actions uncerimonuously overwrite/delete files /// - public sealed class ConfigurationFile : RawData + public sealed class ConfigurationFile : FileTicketResult { /// /// The path to the file diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 700ac27969..60e03b909d 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -64,10 +64,10 @@ namespace Tgstation.Server.Api.Models CannotChangeServerSuite, /// - /// A required GitHub API request failed. + /// A required remote API request failed. /// - [Description("A required GitHub request returned an API error!")] - GitHubApiError, + [Description("A required remote API request returned an error!")] + RemoteApiError, /// /// A server update was requested while another was in progress. @@ -76,7 +76,7 @@ namespace Tgstation.Server.Api.Models ServerUpdateInProgress, /// - /// Attempted to change something other than the capitalization of a . + /// Attempted to change something other than the capitalization of a . /// [Description("Can only change the capitalization of a user's name!")] UserNameChange, @@ -88,7 +88,7 @@ namespace Tgstation.Server.Api.Models UserSidChange, /// - /// Attempted to create a with a and . + /// Attempted to create a with a and . /// [Description("A user cannot have both a name and systemIdentifier!")] UserMismatchNameSid, @@ -106,13 +106,13 @@ namespace Tgstation.Server.Api.Models UserPasswordLength, /// - /// Attempted to create a with a ':' in the . + /// Attempted to create a with a ':' in the . /// [Description("User names cannot contain the ':' character!")] UserColonInName, /// - /// Attempted to create a with a or whitespace . + /// Attempted to create a with a or whitespace . /// [Description("User's name is missing or invalid whitespace!")] UserMissingName, @@ -166,7 +166,7 @@ namespace Tgstation.Server.Api.Models RequiresPosixSystemIdentity, /// - /// A was attem updated + /// A was updated. /// [Description("This existing file hash does not match, the file has beeen updated!")] ConfigurationFileUpdated, @@ -234,7 +234,7 @@ namespace Tgstation.Server.Api.Models /// /// contained duplicate s. /// - [Description("The same pull request was present more than once in the test merge requests or is already merged!")] + [Description("The same test merge was present more than once or is already merged!")] RepoDuplicateTestMerge, /// @@ -250,18 +250,16 @@ namespace Tgstation.Server.Api.Models RepoWhitespaceCommitterEmail, /// - /// Deprecated. + /// A paginated request asked for too large a page. /// - [Description("Deprecated error code.")] - [Obsolete("With API v7 ", true)] - DreamDaemonDuplicatePorts, + [Description("Requested pageSize is too large!")] + ApiPageTooLarge, /// - /// Deprecated. + /// A paginated request asked for page 0. /// - [Description("Deprecated error code.")] - [Obsolete("With DMAPI-5.0.0, ultrasafe security is now supported.", true)] - InvalidSecurityLevel, + [Description("Cannot request page or pageSize <= 0.")] + ApiInvalidPageOrPageSize, /// /// A requested 's data does not match with its . @@ -288,11 +286,10 @@ namespace Tgstation.Server.Api.Models ChatBotProviderMissing, /// - /// Attempted to update a or without its ID. + /// Tried to edit membership using . /// - [Description("Missing user ID!")] - [Obsolete("Deprecated in favor of code 2", true)] - UserMissingId, + [Description("The " + Routes.UserGroup + " endpoint cannot edit group members. Please update each member user individually.")] + UserGroupControllerCantEditMembers, /// /// Attempted to add a when at or above the or it was set to something lower than the existing amount of . @@ -337,11 +334,10 @@ namespace Tgstation.Server.Api.Models DreamMakerInvalidValidation, /// - /// DMAPI validation timeout. + /// Tried to remove the last for a passwordless . /// - [Description("The DreamDaemon startup timeout was hit before the DMAPI validated!")] - [Obsolete("Deprecated in favor of error code 52", true)] - DreamMakerValidationTimeout, + [Description("This user is passwordless and removing their oAuthConnections would leave them with no authentication method!")] + CannotRemoveLastAuthenticationOption, /// /// No .dme could be found for deployment. @@ -460,7 +456,7 @@ namespace Tgstation.Server.Api.Models /// /// Encounted merge conflicts while test merging. /// - [Description("Encountered merge conflicts while test merging one or more pull requests!")] + [Description("Encountered merge conflicts while test merging one or more sources!")] RepoTestMergeConflict, /// @@ -582,5 +578,47 @@ namespace Tgstation.Server.Api.Models /// [Description("The requested port is either already in use by TGS or could not be allocated!")] PortNotAvailable, + + /// + /// Attempted to set for the admin user. + /// + [Description("The admin user cannot use OAuth connections!")] + AdminUserCannotOAuth, + + /// + /// Attempted to login with a disabled OAuth provider. + /// + [Description("The requested OAuth provider is disabled via configuration!")] + OAuthProviderDisabled, + + /// + /// A requiring a file upload did not receive it before timing out. + /// + [Description("The job did not receive a required upload before timing out!")] + FileUploadExpired, + + /// + /// Tried to update a to have both a and + /// + [Description("A user may not have both a permissionSet and group!")] + UserGroupAndPermissionSet, + + /// + /// Tried to delete a non-empty . + /// + [Description("Cannot delete the user group as it is not empty!")] + UserGroupNotEmpty, + + /// + /// Attempted to create an but the configured limit has been reached. + /// + [Description("The user cannot be created because the configured limit has been reached!")] + UserLimitReached, + + /// + /// Attempted to create an but the configured limit has been reached. + /// + [Description("The user group cannot be created because the configured limit has been reached!")] + UserGroupLimitReached, } } diff --git a/src/Tgstation.Server.Api/Models/FileTicketResult.cs b/src/Tgstation.Server.Api/Models/FileTicketResult.cs new file mode 100644 index 0000000000..87d22a78b2 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/FileTicketResult.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// Response for when file transfers are necessary. + /// + public class FileTicketResult + { + /// + /// The ticket to use to access the controller. + /// + public string? FileTicket { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/InstanceUser.cs b/src/Tgstation.Server.Api/Models/InstancePermissionSet.cs similarity index 64% rename from src/Tgstation.Server.Api/Models/InstanceUser.cs rename to src/Tgstation.Server.Api/Models/InstancePermissionSet.cs index 569d6516e1..eb1a5b80f9 100644 --- a/src/Tgstation.Server.Api/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Api/Models/InstancePermissionSet.cs @@ -1,56 +1,56 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models { /// - /// Represents a s permissions in an + /// Represents a s permissions in an /// - public class InstanceUser + public class InstancePermissionSet { /// - /// The of the the belongs to + /// The of the the belongs to /// - public long UserId { get; set; } + public long PermissionSetId { get; set; } /// - /// The of the + /// The of the /// [Required] - public InstanceUserRights? InstanceUserRights { get; set; } + public InstancePermissionSetRights? InstancePermissionSetRights { get; set; } /// - /// The of the + /// The of the /// [Required] public ByondRights? ByondRights { get; set; } /// - /// The of the + /// The of the /// [Required] public DreamDaemonRights? DreamDaemonRights { get; set; } /// - /// The of the + /// The of the /// [Required] public DreamMakerRights? DreamMakerRights { get; set; } /// - /// The of the + /// The of the /// [Required] public RepositoryRights? RepositoryRights { get; set; } /// - /// The of the + /// The of the /// [Required] public ChatBotRights? ChatBotRights { get; set; } /// - /// The of the + /// The of the /// [Required] public ConfigurationRights? ConfigurationRights { get; set; } diff --git a/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs b/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs index 5db8be2eda..992affc7b2 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models.Internal @@ -17,7 +17,7 @@ namespace Tgstation.Server.Api.Models.Internal /// The name of the connection /// [Required] - [StringLength(Limits.MaximumIndexableStringLength)] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] public string? Name { get; set; } /// diff --git a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs index 06583edaf0..e57bc17784 100644 --- a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs +++ b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; diff --git a/src/Tgstation.Server.Api/Models/Internal/IGitRemoteInformation.cs b/src/Tgstation.Server.Api/Models/Internal/IGitRemoteInformation.cs new file mode 100644 index 0000000000..f687c9908e --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/IGitRemoteInformation.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Provides information about remote providers. + /// + public interface IGitRemoteInformation + { + /// + /// The in use by the repository. + /// + public RemoteGitProvider? RemoteGitProvider { get; } + + /// + /// If is not this will be set with the owner of the repository + /// + public string? RemoteRepositoryOwner { get; } + + /// + /// If is not this will be set with the name of the repository + /// + public string? RemoteRepositoryName { get; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/RawData.cs b/src/Tgstation.Server.Api/Models/Internal/RawData.cs deleted file mode 100644 index 4414e9bba0..0000000000 --- a/src/Tgstation.Server.Api/Models/Internal/RawData.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Tgstation.Server.Api.Models.Internal -{ - /// - /// Represents raw bytes. - /// - public abstract class RawData - { - /// - /// The bytes of the . - /// -#pragma warning disable CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space - public byte[]? Content { get; set; } -#pragma warning restore CA1819, SA1011 // Properties should not return arrays, Closing square bracket should be followed by a space - } -} diff --git a/src/Tgstation.Server.Api/Models/Internal/ServerInformation.cs b/src/Tgstation.Server.Api/Models/Internal/ServerInformation.cs index 0c6768e53a..21f4ed26eb 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ServerInformation.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Tgstation.Server.Api.Models.Internal { @@ -22,6 +22,11 @@ namespace Tgstation.Server.Api.Models.Internal /// public uint UserLimit { get; set; } + /// + /// The maximum number of s allowed. + /// + public uint UserGroupLimit { get; set; } + /// /// Limits the locations instances may be created or attached from. /// diff --git a/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs new file mode 100644 index 0000000000..788e04f850 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/SwarmServer.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Information about a server in the swarm. + /// + public abstract class SwarmServer + { + /// + /// The public address of the server. + /// + [Required] + public Uri? Address { get; set; } + + /// + /// The server's identifier. + /// + [Required] + public string? Identifier { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs b/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs index 6189c0327c..76ee553023 100644 --- a/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs +++ b/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs @@ -1,10 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models.Internal { /// - /// Represents a merge of a GitHub pull request + /// Represents a test merge of a remote "pull request". /// public class TestMerge : TestMergeBase { diff --git a/src/Tgstation.Server.Api/Models/Internal/TestMergeBase.cs b/src/Tgstation.Server.Api/Models/Internal/TestMergeBase.cs index c105e643f7..c8b87ee4d2 100644 --- a/src/Tgstation.Server.Api/Models/Internal/TestMergeBase.cs +++ b/src/Tgstation.Server.Api/Models/Internal/TestMergeBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models.Internal @@ -9,19 +9,19 @@ namespace Tgstation.Server.Api.Models.Internal public abstract class TestMergeBase : TestMergeParameters { /// - /// The title of the pull request + /// The title of the test merge source. /// [Required] public string? TitleAtMerge { get; set; } /// - /// The body of the pull request + /// The body of the test merge source. /// [Required] public string? BodyAtMerge { get; set; } /// - /// The URL of the pull request + /// The URL of the test merge source. /// [Required] #pragma warning disable CA1056 // Uri properties should not be strings @@ -29,7 +29,7 @@ namespace Tgstation.Server.Api.Models.Internal #pragma warning restore CA1056 // Uri properties should not be strings /// - /// The author of the pull request + /// The author of the test merge source. /// [Required] public string? Author { get; set; } @@ -51,7 +51,7 @@ namespace Tgstation.Server.Api.Models.Internal BodyAtMerge = copy.BodyAtMerge; Comment = copy.Comment; Number = copy.Number; - PullRequestRevision = copy.PullRequestRevision; + TargetCommitSha = copy.TargetCommitSha; TitleAtMerge = copy.TitleAtMerge; Url = copy.Url; } diff --git a/src/Tgstation.Server.Api/Models/Internal/User.cs b/src/Tgstation.Server.Api/Models/Internal/User.cs index e50b23afde..ac37d2e28f 100644 --- a/src/Tgstation.Server.Api/Models/Internal/User.cs +++ b/src/Tgstation.Server.Api/Models/Internal/User.cs @@ -1,20 +1,13 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models.Internal { /// /// Represents a server /// - public class User + public abstract class User : UserBase { - /// - /// The ID of the - /// - [Required] - public long? Id { get; set; } - /// /// If the is enabled since users cannot be deleted. System users cannot be disabled /// @@ -30,26 +23,7 @@ namespace Tgstation.Server.Api.Models.Internal /// /// The SID/UID of the on Windows/POSIX respectively /// - // No need for StringLength as the server MUST validate it. + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] public string? SystemIdentifier { get; set; } - - /// - /// The name of the - /// - [Required] - [StringLength(Limits.MaximumStringLength)] - public string? Name { get; set; } - - /// - /// The for the - /// - [Required] - public AdministrationRights? AdministrationRights { get; set; } - - /// - /// The for the - /// - [Required] - public InstanceManagerRights? InstanceManagerRights { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/Internal/UserBase.cs b/src/Tgstation.Server.Api/Models/Internal/UserBase.cs new file mode 100644 index 0000000000..a2301ddd08 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/UserBase.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Base class for . + /// + public class UserBase + { + /// + /// The ID of the + /// + [Required] + public long? Id { get; set; } + + /// + /// The name of the + /// + [Required] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] + public string? Name { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/UserGroup.cs b/src/Tgstation.Server.Api/Models/Internal/UserGroup.cs new file mode 100644 index 0000000000..25c9520ba1 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/UserGroup.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents a group of s. + /// + public class UserGroup : UserGroupBase + { + /// + /// The of the . + /// + public PermissionSet? PermissionSet { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/UserGroupBase.cs b/src/Tgstation.Server.Api/Models/Internal/UserGroupBase.cs new file mode 100644 index 0000000000..0f144dc95d --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/UserGroupBase.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Base class for . + /// + public abstract class UserGroupBase : EntityId + { + /// + /// The name of the . + /// + [Required] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] + public string? Name { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Limits.cs b/src/Tgstation.Server.Api/Models/Limits.cs index c7108f2f25..6acd8a35bf 100644 --- a/src/Tgstation.Server.Api/Models/Limits.cs +++ b/src/Tgstation.Server.Api/Models/Limits.cs @@ -1,3 +1,5 @@ +using System; + namespace Tgstation.Server.Api.Models { /// @@ -19,5 +21,10 @@ namespace Tgstation.Server.Api.Models /// Length limit for git commit SHAs. /// public const int MaximumCommitShaLength = 40; + + /// + /// The maximum size for file transfers. + /// + public const int MaximumFileTransferSize = Int32.MaxValue; } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/LogFile.cs b/src/Tgstation.Server.Api/Models/LogFile.cs index 3f67f93d11..873528fcb4 100644 --- a/src/Tgstation.Server.Api/Models/LogFile.cs +++ b/src/Tgstation.Server.Api/Models/LogFile.cs @@ -1,12 +1,11 @@ using System; -using Tgstation.Server.Api.Models.Internal; namespace Tgstation.Server.Api.Models { /// /// Represents a server log file. /// - public sealed class LogFile : RawData + public sealed class LogFile : FileTicketResult { /// /// The name of the log file. diff --git a/src/Tgstation.Server.Api/Models/OAuthConnection.cs b/src/Tgstation.Server.Api/Models/OAuthConnection.cs new file mode 100644 index 0000000000..678846bbe9 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/OAuthConnection.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a valid OAuth connection. + /// + public class OAuthConnection + { + /// + /// The of the . + /// ] + [EnumDataType(typeof(OAuthProvider))] + public OAuthProvider Provider { get; set; } + + /// + /// The ID of the user in the . + /// + [Required] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] + public string? ExternalUserId { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/OAuthProvider.cs b/src/Tgstation.Server.Api/Models/OAuthProvider.cs new file mode 100644 index 0000000000..cf9adbd91a --- /dev/null +++ b/src/Tgstation.Server.Api/Models/OAuthProvider.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Tgstation.Server.Api.Models +{ + /// + /// List of OAuth providers supported by TGS + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum OAuthProvider + { + /// + /// https://github.com + /// + GitHub, + + /// + /// https://discord.com + /// + Discord, + + /// + /// https://tgstation13.org + /// + TGForums, + + /// + /// https://www.keycloak.org + /// + Keycloak, + } +} diff --git a/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs new file mode 100644 index 0000000000..6163e445f4 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs @@ -0,0 +1,25 @@ +using System; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Public information about a given . + /// + public sealed class OAuthProviderInfo + { + /// + /// The client ID. + /// + public string? ClientId { get; set; } + + /// + /// The redirect URL. + /// + public Uri? RedirectUri { get; set; } + + /// + /// The server URL. + /// + public Uri? ServerUrl { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Paginated.cs b/src/Tgstation.Server.Api/Models/Paginated.cs new file mode 100644 index 0000000000..ba4e187494 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Paginated.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a paginated set of models. + /// + /// The of the returned model. + public sealed class Paginated + { + /// + /// The of the returned s. + /// + [Required] + public ICollection? Content { get; set; } + + /// + /// The total number of pages in the query. + /// + public int TotalPages { get; set; } + + /// + /// The current size of pages in the query. + /// + public int PageSize { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/PermissionSet.cs b/src/Tgstation.Server.Api/Models/PermissionSet.cs new file mode 100644 index 0000000000..a84ae1d27b --- /dev/null +++ b/src/Tgstation.Server.Api/Models/PermissionSet.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a set of server permissions. + /// + public class PermissionSet + { + /// + /// The ID of the . + /// + [Required] + public long? Id { get; set; } + + /// + /// The for the + /// + [Required] + public AdministrationRights? AdministrationRights { get; set; } + + /// + /// The for the + /// + [Required] + public InstanceManagerRights? InstanceManagerRights { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/RemoteGitProvider.cs b/src/Tgstation.Server.Api/Models/RemoteGitProvider.cs new file mode 100644 index 0000000000..df0da1312b --- /dev/null +++ b/src/Tgstation.Server.Api/Models/RemoteGitProvider.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// Indicates the remote git host. + /// + public enum RemoteGitProvider + { + /// + /// Unknown remote git provider. + /// + Unknown, + + /// + /// Remote provider is GitHub.com + /// + GitHub, + + /// + /// Remote provider is GitLab.com + /// + GitLab, + } +} diff --git a/src/Tgstation.Server.Api/Models/Repository.cs b/src/Tgstation.Server.Api/Models/Repository.cs index 88b53ab95c..a88dfe1c54 100644 --- a/src/Tgstation.Server.Api/Models/Repository.cs +++ b/src/Tgstation.Server.Api/Models/Repository.cs @@ -1,17 +1,19 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Models.Internal; namespace Tgstation.Server.Api.Models { /// /// Represents a git repository /// - public sealed class Repository : Internal.RepositorySettings + public sealed class Repository : RepositorySettings, IGitRemoteInformation { /// /// The origin URL. If , the does not exist /// - public string? Origin { get; set; } + public Uri? Origin { get; set; } /// /// If submodules should be recursively cloned. @@ -29,15 +31,14 @@ namespace Tgstation.Server.Api.Models /// public RevisionInformation? RevisionInformation { get; set; } - /// - /// If the repository was cloned from GitHub.com this will be set with the owner of the repository - /// - public string? GitHubOwner { get; set; } + /// + public RemoteGitProvider? RemoteGitProvider { get; set; } - /// - /// If the repository was cloned from GitHub.com this will be set with the name of the repository - /// - public string? GitHubName { get; set; } + /// + public string? RemoteRepositoryOwner { get; set; } + + /// + public string? RemoteRepositoryName { get; set; } /// /// The started by the if any @@ -60,4 +61,4 @@ namespace Tgstation.Server.Api.Models /// public ICollection? NewTestMerges { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/ServerInformation.cs b/src/Tgstation.Server.Api/Models/ServerInformation.cs index 26d9cbc0fd..16b7ea0732 100644 --- a/src/Tgstation.Server.Api/Models/ServerInformation.cs +++ b/src/Tgstation.Server.Api/Models/ServerInformation.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; namespace Tgstation.Server.Api.Models { @@ -21,5 +22,25 @@ namespace Tgstation.Server.Api.Models /// The DMAPI version of the host. /// public Version? DMApiVersion { get; set; } + + /// + /// If the server is running on a windows operating system. + /// + public bool WindowsHost { get; set; } + + /// + /// If there is a server update in progress. + /// + public bool UpdateInProgress { get; set; } + + /// + /// A of connected s. + /// + public ICollection? SwarmServers { get; set; } + + /// + /// Map of to the for them. + /// + public IDictionary? OAuthProviderInfos { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/SwarmServer.cs b/src/Tgstation.Server.Api/Models/SwarmServer.cs new file mode 100644 index 0000000000..392d055518 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/SwarmServer.cs @@ -0,0 +1,11 @@ +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class SwarmServer : Internal.SwarmServer + { + /// + /// If the is the controller. + /// + public bool Controller { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/TestMergeParameters.cs b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs index ba68c72e7e..fbac15c974 100644 --- a/src/Tgstation.Server.Api/Models/TestMergeParameters.cs +++ b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Api.Models { @@ -8,16 +8,16 @@ namespace Tgstation.Server.Api.Models public class TestMergeParameters { /// - /// The number of the pull request + /// The number of the test merge source. /// public int Number { get; set; } /// - /// The sha of the pull request revision to merge. If not specified, the latest commit shall be used (semi-unsafe) + /// The sha of the test merge revision to merge. If not specified, the latest commit shall be used (semi-unsafe) /// [Required] [StringLength(40)] - public string? PullRequestRevision { get; set; } + public virtual string? TargetCommitSha { get; set; } /// /// Optional comment about the test @@ -25,4 +25,4 @@ namespace Tgstation.Server.Api.Models [StringLength(Limits.MaximumStringLength)] public string? Comment { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/User.cs b/src/Tgstation.Server.Api/Models/User.cs index 60ecb7225d..729e81383e 100644 --- a/src/Tgstation.Server.Api/Models/User.cs +++ b/src/Tgstation.Server.Api/Models/User.cs @@ -1,4 +1,7 @@ -namespace Tgstation.Server.Api.Models +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models { /// public class User : Internal.User @@ -16,6 +19,22 @@ /// /// The who created this /// - public Internal.User? CreatedBy { get; set; } + [Required] + public Internal.UserBase? CreatedBy { get; set; } + + /// + /// List of s associated with the . + /// + public ICollection? OAuthConnections { get; set; } + + /// + /// The directly associated with the . + /// + public PermissionSet? PermissionSet { get; set; } + + /// + /// The asociated with the , if any. + /// + public Internal.UserGroup? Group { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/UserGroup.cs b/src/Tgstation.Server.Api/Models/UserGroup.cs new file mode 100644 index 0000000000..cc9394395a --- /dev/null +++ b/src/Tgstation.Server.Api/Models/UserGroup.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class UserGroup : Internal.UserGroup + { + /// + /// The s the has. + /// + public ICollection? Users { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs index 02b984d7f0..f134c7d1c9 100644 --- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs +++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Api.Rights None = 0, /// - /// User can edit their and other s and create new ones. + /// User has complete control over creating/editing s and s (and deleting in the case of the latter). /// WriteUsers = 1, @@ -24,7 +24,7 @@ namespace Tgstation.Server.Api.Rights RestartHost = 2, /// - /// User can upgrade or downgrade TGS through the API. + /// User can read and upgrade/downgrade TGS through the API. /// ChangeVersion = 4, @@ -42,5 +42,10 @@ namespace Tgstation.Server.Api.Rights /// User can list and download s. /// DownloadLogs = 32, + + /// + /// User can modify their own . + /// + EditOwnOAuthConnections = 64, } } diff --git a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs index b8f403843d..b8700b9e30 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs @@ -14,7 +14,7 @@ namespace Tgstation.Server.Api.Rights None = 0, /// - /// User can view s which they have an for. + /// User can view s which they have an for. /// Read = 1, @@ -64,7 +64,7 @@ namespace Tgstation.Server.Api.Rights SetChatBotLimit = 512, /// - /// User can give themselves full rights on ALL instances. + /// User can give themselves or their group full rights on ALL instances. /// GrantPermissions = 1024, } diff --git a/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs b/src/Tgstation.Server.Api/Rights/InstancePermissionSetRights.cs similarity index 51% rename from src/Tgstation.Server.Api/Rights/InstanceUserRights.cs rename to src/Tgstation.Server.Api/Rights/InstancePermissionSetRights.cs index fe966dcca0..285411f32c 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstancePermissionSetRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for an /// [Flags] - public enum InstanceUserRights : ulong + public enum InstancePermissionSetRights : ulong { /// /// User has no rights/ @@ -14,18 +14,18 @@ namespace Tgstation.Server.Api.Rights None = 0, /// - /// Allow read access to all s in the . + /// Allow read access to all s in the . /// - ReadUsers = 1, + Read = 1, /// - /// Allow write and delete access to all for the . + /// Allow write and delete access to all for the . /// - WriteUsers = 2, + Write = 2, /// - /// Allow adding additional s to the . + /// Allow adding additional s to the . /// - CreateUsers = 4 + Create = 4 } } diff --git a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs index 3b3e79578f..a65ca17e0a 100644 --- a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs +++ b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs @@ -29,7 +29,7 @@ namespace Tgstation.Server.Api.Rights SetSha = 4, /// - /// User may fetch and merge GitHub pull requests. + /// User may create s. /// MergePullRequest = 8, diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index eeda047b88..97bfc6be2d 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -22,7 +22,7 @@ namespace Tgstation.Server.Api.Rights { RightsType.DreamDaemon, typeof(DreamDaemonRights) }, { RightsType.ChatBots, typeof(ChatBotRights) }, { RightsType.Configuration, typeof(ConfigurationRights) }, - { RightsType.InstanceUser, typeof(InstanceUserRights) } + { RightsType.InstancePermissionSet, typeof(InstancePermissionSetRights) } }; /// diff --git a/src/Tgstation.Server.Api/Rights/RightsType.cs b/src/Tgstation.Server.Api/Rights/RightsType.cs index a541c5048f..2787c63f48 100644 --- a/src/Tgstation.Server.Api/Rights/RightsType.cs +++ b/src/Tgstation.Server.Api/Rights/RightsType.cs @@ -1,4 +1,4 @@ -namespace Tgstation.Server.Api.Rights +namespace Tgstation.Server.Api.Rights { /// /// The type of rights a model uses @@ -46,8 +46,8 @@ Configuration, /// - /// + /// /// - InstanceUser + InstancePermissionSet } } diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index 231475f728..99784db093 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -28,6 +28,11 @@ namespace Tgstation.Server.Api /// public const string User = Root + nameof(Models.User); + /// + /// The controller. + /// + public const string UserGroup = Root + nameof(Models.UserGroup); + /// /// The controller /// @@ -69,9 +74,9 @@ namespace Tgstation.Server.Api public const string ConfigurationFile = Configuration + "/" + File; /// - /// The controller + /// The controller /// - public const string InstanceUser = Root + nameof(Models.InstanceUser); + public const string InstancePermissionSet = Root + nameof(Models.InstancePermissionSet); /// /// The controller @@ -88,6 +93,11 @@ namespace Tgstation.Server.Api /// public const string Jobs = Root + nameof(Models.Job); + /// + /// The transfer controller. + /// + public const string Transfer = Root + "Transfer"; + /// /// The postfix for list operations /// @@ -109,7 +119,7 @@ namespace Tgstation.Server.Api public static string ListRoute(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, List); /// - /// Sanitize a path for use in a GET . + /// Sanitize a path for use in a GET . /// /// The path to sanitize. /// The sanitized path. diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 7b61d94fec..933cb4cb6c 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -1,4 +1,4 @@ - + @@ -23,6 +23,7 @@ latest enable bin\$(Configuration)\netstandard2.1\Tgstation.Server.Api.xml + true CA1028 @@ -33,17 +34,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - all - runtime; build; native; contentfiles; analyzers - - + + - + diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs index ec1d99513d..e941337ebf 100644 --- a/src/Tgstation.Server.Client/AdministrationClient.cs +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using System.Web; @@ -9,39 +10,49 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { /// - sealed class AdministrationClient : IAdministrationClient + sealed class AdministrationClient : PaginatedClient, IAdministrationClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public AdministrationClient(IApiClient apiClient) + : base(apiClient) + { } + + /// + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.Administration, cancellationToken); + + /// + public Task Update(Administration administration, CancellationToken cancellationToken) => ApiClient.Update(Routes.Administration, administration ?? throw new ArgumentNullException(nameof(administration)), cancellationToken); + + /// + public Task Restart(CancellationToken cancellationToken) => ApiClient.Delete(Routes.Administration, cancellationToken); + + /// + public Task> ListLogs(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.Logs, null, cancellationToken); + + /// + public async Task> GetLog(LogFile logFile, CancellationToken cancellationToken) { - this.apiClient = apiClient; + var resultFile = await ApiClient.Read( + Routes.Logs + Routes.SanitizeGetPath( + HttpUtility.UrlEncode( + logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))), + cancellationToken) + .ConfigureAwait(false); + + var stream = await ApiClient.Download(resultFile, cancellationToken).ConfigureAwait(false); + try + { + return Tuple.Create(resultFile, stream); + } + catch + { + stream.Dispose(); + throw; + } } - - /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.Administration, cancellationToken); - - /// - public Task Update(Administration administration, CancellationToken cancellationToken) => apiClient.Update(Routes.Administration, administration ?? throw new ArgumentNullException(nameof(administration)), cancellationToken); - - /// - public Task Restart(CancellationToken cancellationToken) => apiClient.Delete(Routes.Administration, cancellationToken); - - /// - public Task> ListLogs(CancellationToken cancellationToken) => apiClient.Read>(Routes.Logs, cancellationToken); - - /// - public Task GetLog(LogFile logFile, CancellationToken cancellationToken) => apiClient.Read( - Routes.Logs + Routes.SanitizeGetPath( - HttpUtility.UrlEncode( - logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))), - cancellationToken); } } diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index 0e107e91f8..7c8460b27e 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -1,15 +1,18 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Web; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; @@ -36,7 +39,7 @@ namespace Tgstation.Server.Client } /// - /// The for the + /// The for the /// readonly IHttpClient httpClient; @@ -135,7 +138,8 @@ namespace Tgstation.Server.Client /// /// Main request method /// - /// The resulting POCO type + /// The body . + /// The resulting POCO type. /// The route to run /// The body of the request /// The method of the request @@ -143,38 +147,93 @@ namespace Tgstation.Server.Client /// If this is a token refresh operation. /// The for the operation /// A resulting in the response on success - async Task RunRequest(string route, object? body, HttpMethod method, long? instanceId, bool tokenRefresh, CancellationToken cancellationToken) + Task RunRequest( + string route, + TBody? body, + HttpMethod method, + long? instanceId, + bool tokenRefresh, + CancellationToken cancellationToken) + where TBody : class + { + HttpContent? content = null; + if(body != null) + content = new StringContent( + JsonConvert.SerializeObject(body, typeof(TBody), Formatting.None, GetSerializerSettings()), + Encoding.UTF8, + MediaTypeNames.Application.Json); + + return RunRequest( + route, + content, + method, + instanceId, + tokenRefresh, + cancellationToken); + } + + /// + /// Main request method + /// + /// The resulting POCO type + /// The route to run + /// The of the request if any. + /// The method of the request + /// The optional instance for the request + /// If this is a token refresh operation. + /// The for the operation + /// A resulting in the response on success + async Task RunRequest( + string route, + HttpContent? content, + HttpMethod method, + long? instanceId, + bool tokenRefresh, + CancellationToken cancellationToken) { if (route == null) throw new ArgumentNullException(nameof(route)); if (method == null) throw new ArgumentNullException(nameof(method)); - if (body == null && (method == HttpMethod.Post || method == HttpMethod.Put)) - throw new InvalidOperationException("Body cannot be null for POST or PUT!"); + if (content == null && (method == HttpMethod.Post || method == HttpMethod.Put)) + throw new InvalidOperationException("content cannot be null for POST or PUT!"); HttpResponseMessage response; var fullUri = new Uri(Url, route); var serializerSettings = GetSerializerSettings(); + var fileDownload = typeof(TResult) == typeof(Stream); using (var request = new HttpRequestMessage(method, fullUri)) { - if (body != null) - request.Content = new StringContent( - JsonConvert.SerializeObject(body, serializerSettings), - Encoding.UTF8, - MediaTypeNames.Application.Json); + if (content != null) + request.Content = content; var headersToUse = tokenRefresh ? tokenRefreshHeaders! : headers; headersToUse.SetRequestHeaders(request.Headers, instanceId); + if (fileDownload) + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet)); + await Task.WhenAll(requestLoggers.Select(x => x.LogRequest(request, cancellationToken))).ConfigureAwait(false); response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); } - using (response) + try { await Task.WhenAll(requestLoggers.Select(x => x.LogResponse(response, cancellationToken))).ConfigureAwait(false); + // just stream + if (fileDownload && response.IsSuccessStatusCode) + return (TResult)(object)await CachedResponseStream.Create(response).ConfigureAwait(false); + } + catch + { + response.Dispose(); + throw; + } + + using (response) + { var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -182,7 +241,7 @@ namespace Tgstation.Server.Client if (!tokenRefresh && response.StatusCode == HttpStatusCode.Unauthorized && await RefreshToken(cancellationToken).ConfigureAwait(false)) - return await RunRequest(route, body, method, instanceId, false, cancellationToken).ConfigureAwait(false); + return await RunRequest(route, content, method, instanceId, false, cancellationToken).ConfigureAwait(false); HandleBadResponse(response, json); } @@ -212,7 +271,7 @@ namespace Tgstation.Server.Client if (startingToken != headers.Token) return true; - var token = await RunRequest(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken); + var token = await RunRequest(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken); headers = new ApiHeaders(headers.UserAgent!, token.Bearer!); } catch (ClientException) @@ -228,54 +287,101 @@ namespace Tgstation.Server.Client } /// - public Task Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, false, cancellationToken); + public Task Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, false, cancellationToken); /// - public Task Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, false, cancellationToken); + public Task Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, false, cancellationToken); /// - public Task Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, false, cancellationToken); + public Task Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, false, cancellationToken); /// - public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); + public Task Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// public Task Patch(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Patch, null, false, cancellationToken); /// - public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); + public Task Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Post, null, false, cancellationToken); /// - public Task Create(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, null, false, cancellationToken); + public Task Create(string route, TBody body, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Put, null, false, cancellationToken); /// public Task Delete(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, null, false, cancellationToken); /// - public Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, instanceId, false, cancellationToken); + public Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Put, instanceId, false, cancellationToken); /// public Task Read(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, instanceId, false, cancellationToken); /// - public Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, instanceId, false, cancellationToken); + public Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Post, instanceId, false, cancellationToken); /// public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, false, cancellationToken); /// - public Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Delete, instanceId, false, cancellationToken); + public Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class => RunRequest(route, body, HttpMethod.Delete, instanceId, false, cancellationToken); /// public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, false, cancellationToken); /// - public Task Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, false, cancellationToken); + public Task Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, false, cancellationToken); /// - public Task Patch(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Patch, instanceId, false, cancellationToken); + public Task Patch(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Patch, instanceId, false, cancellationToken); /// public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger))); + + /// + public Task Download(FileTicketResult ticket, CancellationToken cancellationToken) + { + if (ticket == null) + throw new ArgumentNullException(nameof(ticket)); + + return RunRequest( + $"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}", + null, + HttpMethod.Get, + null, + false, + cancellationToken); + } + + /// + public async Task Upload(FileTicketResult ticket, Stream? uploadStream, CancellationToken cancellationToken) + { + if (ticket == null) + throw new ArgumentNullException(nameof(ticket)); + + MemoryStream? memoryStream = null; + if (uploadStream == null) + memoryStream = new MemoryStream(); + + using (memoryStream) + { + var streamContent = new StreamContent(uploadStream ?? memoryStream); + try + { + await RunRequest( + $"{Routes.Transfer}?ticket={HttpUtility.UrlEncode(ticket.FileTicket)}", + streamContent, + HttpMethod.Put, + null, + false, + cancellationToken) + .ConfigureAwait(false); + streamContent = null; //CA2000 + } + finally + { + streamContent?.Dispose(); + } + } + } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/ApiClientFactory.cs b/src/Tgstation.Server.Client/ApiClientFactory.cs index d3f137baeb..02ff485b3a 100644 --- a/src/Tgstation.Server.Client/ApiClientFactory.cs +++ b/src/Tgstation.Server.Client/ApiClientFactory.cs @@ -7,6 +7,6 @@ namespace Tgstation.Server.Client sealed class ApiClientFactory : IApiClientFactory { /// - public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClient(), url, apiHeaders, tokenRefreshHeaders); + public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders) => new ApiClient(new HttpClientImplementation(), url, apiHeaders, tokenRefreshHeaders); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ApiException.cs b/src/Tgstation.Server.Client/ApiException.cs index 00b103b80b..5dc4e1ab6b 100644 --- a/src/Tgstation.Server.Client/ApiException.cs +++ b/src/Tgstation.Server.Client/ApiException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using Tgstation.Server.Api.Models; @@ -31,7 +31,7 @@ namespace Tgstation.Server.Client /// The . protected ApiException(ErrorMessage? errorMessage, HttpResponseMessage responseMessage) : base( responseMessage, - errorMessage?.Message ?? $"HTTP {responseMessage.StatusCode}. Unknown API error, ErrorMessage payload not present!") + errorMessage?.Message ?? $"HTTP {responseMessage?.StatusCode ?? throw new ArgumentNullException(nameof(responseMessage))}. Unknown API error, ErrorMessage payload not present!") { ServerApiVersion = errorMessage?.ServerApiVersion; AdditionalServerData = errorMessage?.AdditionalData; diff --git a/src/Tgstation.Server.Client/CachedResponseStream.cs b/src/Tgstation.Server.Client/CachedResponseStream.cs new file mode 100644 index 0000000000..9ad446f75c --- /dev/null +++ b/src/Tgstation.Server.Client/CachedResponseStream.cs @@ -0,0 +1,96 @@ +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Tgstation.Server.Client +{ + /// + /// Caches the from a for later use. + /// + sealed class CachedResponseStream : Stream + { + /// + /// The for the . + /// + readonly HttpResponseMessage response; + + /// + /// The reponse content . + /// + readonly Stream responseStream; + + /// + /// Asyncronously creates a new . + /// + /// The to build from. + /// A resulting in a new . + public static async Task Create(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + return new CachedResponseStream(response, stream); + } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + CachedResponseStream(HttpResponseMessage response, Stream responseStream) + { + this.response = response; + this.responseStream = responseStream; + } + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing) + return; + responseStream.Dispose(); + response.Dispose(); + } + + /// + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync().ConfigureAwait(false); + await responseStream.DisposeAsync().ConfigureAwait(false); + response.Dispose(); + } + + /// + public override bool CanRead => responseStream.CanRead; + + /// + public override bool CanSeek => responseStream.CanSeek; + + /// + public override bool CanWrite => responseStream.CanWrite; + + /// + public override long Length => responseStream.Length; + + /// + public override long Position + { + get => responseStream.Position; + set => responseStream.Position = value; + } + + /// + public override void Flush() => responseStream.Flush(); + + /// + public override int Read(byte[] buffer, int offset, int count) => responseStream.Read(buffer, offset, count); + + /// + public override long Seek(long offset, SeekOrigin origin) => responseStream.Seek(offset, origin); + + /// + public override void SetLength(long value) => responseStream.SetLength(value); + + /// + public override void Write(byte[] buffer, int offset, int count) => responseStream.Write(buffer, offset, count); + } +} diff --git a/src/Tgstation.Server.Client/Components/ByondClient.cs b/src/Tgstation.Server.Client/Components/ByondClient.cs index 23d56dc64a..0a1847b35a 100644 --- a/src/Tgstation.Server.Client/Components/ByondClient.cs +++ b/src/Tgstation.Server.Client/Components/ByondClient.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -8,13 +9,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ByondClient : IByondClient + sealed class ByondClient : PaginatedClient, IByondClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,21 +19,35 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ByondClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task ActiveVersion(CancellationToken cancellationToken) => apiClient.Read(Routes.Byond, instance.Id, cancellationToken); + public Task ActiveVersion(CancellationToken cancellationToken) => ApiClient.Read(Routes.Byond, instance.Id, cancellationToken); /// - public Task> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken); + public Task> InstalledVersions(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken); /// - public Task SetActiveVersion(Byond byond, CancellationToken cancellationToken) => apiClient.Update(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken); + public async Task SetActiveVersion(Byond byond, Stream zipFileStream, CancellationToken cancellationToken) + { + var result = await ApiClient.Update( + Routes.Byond, + byond ?? throw new ArgumentNullException(nameof(byond)), + instance.Id, + cancellationToken) + .ConfigureAwait(false); + + if (byond.UploadCustomZip == true) + await ApiClient.Upload(result, zipFileStream, cancellationToken).ConfigureAwait(false); + + return result; + } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs index 5a05e8c930..fa58da1c8a 100644 --- a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs +++ b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ChatBotsClient : IChatBotsClient + sealed class ChatBotsClient : PaginatedClient, IChatBotsClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,27 +18,28 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ChatBotsClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Create(ChatBot settings, CancellationToken cancellationToken) => apiClient.Create(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); + public Task Create(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Create(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); /// - public Task Delete(ChatBot settings, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken); + public Task Delete(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken); /// - public Task Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); + public Task Update(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Update(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken); /// - public Task GetId(ChatBot settings, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Chat, (settings ?? throw new ArgumentNullException(nameof(settings))).Id), instance.Id, cancellationToken); + public Task GetId(ChatBot settings, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.Chat, (settings ?? throw new ArgumentNullException(nameof(settings))).Id), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs index 3134fbcf37..e0fdb69db6 100644 --- a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -8,13 +9,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class ConfigurationClient : IConfigurationClient + sealed class ConfigurationClient : PaginatedClient, IConfigurationClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,35 +19,82 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public ConfigurationClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Delete(Routes.Configuration, directory, instance.Id, cancellationToken); + public Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => ApiClient.Delete(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); + public Task CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => ApiClient.Create(Routes.Configuration, directory, instance.Id, cancellationToken); /// - public Task> List(string directory, CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), instance.Id, cancellationToken); + public Task> List( + PaginationSettings? paginationSettings, + string directory, + CancellationToken cancellationToken) + => ReadPaged( + paginationSettings, + Routes.ListRoute(Routes.Configuration) + Routes.SanitizeGetPath(directory), + instance.Id, + cancellationToken); /// - public Task Read(ConfigurationFile file, CancellationToken cancellationToken) + public async Task> Read(ConfigurationFile file, CancellationToken cancellationToken) { if (file == null) throw new ArgumentNullException(nameof(file)); - return apiClient.Read( + var configFile = await ApiClient.Read( Routes.ConfigurationFile + Routes.SanitizeGetPath(file.Path ?? throw new ArgumentException("file.Path should not be null!", nameof(file))), instance.Id, - cancellationToken); + cancellationToken) + .ConfigureAwait(false); + var downloadStream = await ApiClient.Download(configFile, cancellationToken).ConfigureAwait(false); + try + { + return Tuple.Create(configFile, downloadStream); + } + catch + { + downloadStream.Dispose(); + throw; + } } /// - public Task Write(ConfigurationFile file, CancellationToken cancellationToken) => apiClient.Update(Routes.Configuration, file ?? throw new ArgumentNullException(nameof(file)), instance.Id, cancellationToken); + public async Task Write(ConfigurationFile file, Stream uploadStream, CancellationToken cancellationToken) + { + long initialStreamPosition = 0; + MemoryStream? memoryStream = null; + if (uploadStream?.CanSeek == false) + memoryStream = new MemoryStream(); + else if (uploadStream != null) + initialStreamPosition = uploadStream.Position; + + using (memoryStream) + { + var configFileTask = ApiClient.Update( + Routes.Configuration, + file ?? throw new ArgumentNullException(nameof(file)), + instance.Id, + cancellationToken); + + if (memoryStream != null) + await uploadStream!.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + + var configFile = await configFileTask.ConfigureAwait(false); + + var streamUsed = memoryStream ?? uploadStream; + streamUsed?.Seek(initialStreamPosition, SeekOrigin.Begin); + await ApiClient.Upload(configFile, streamUsed, cancellationToken).ConfigureAwait(false); + + return configFile; + } + } } } diff --git a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs index dafa049834..fdce033ef8 100644 --- a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class DreamMakerClient : IDreamMakerClient + sealed class DreamMakerClient : PaginatedClient, IDreamMakerClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,27 +18,28 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public DreamMakerClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Compile(CancellationToken cancellationToken) => apiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); + public Task Compile(CancellationToken cancellationToken) => ApiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); /// - public Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); + public Task GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken); /// - public Task> GetJobIds(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken); + public Task> ListCompileJobs(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken); /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); /// - public Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken) => apiClient.Update(Routes.DreamMaker, dreamMaker ?? throw new ArgumentNullException(nameof(dreamMaker)), instance.Id, cancellationToken); + public Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken) => ApiClient.Update(Routes.DreamMaker, dreamMaker ?? throw new ArgumentNullException(nameof(dreamMaker)), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/IByondClient.cs b/src/Tgstation.Server.Client/Components/IByondClient.cs index da4f4d45a3..ee1befa5c7 100644 --- a/src/Tgstation.Server.Client/Components/IByondClient.cs +++ b/src/Tgstation.Server.Client/Components/IByondClient.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -20,16 +21,18 @@ namespace Tgstation.Server.Client.Components /// /// Get all installed s /// + /// The optional for the operation. /// The for the operation /// A resulting in an of installed s - Task> InstalledVersions(CancellationToken cancellationToken); + Task> InstalledVersions(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Updates the information /// /// The information to update + /// The for the .zip file if is . /// The for the operation /// A resulting in the updated information - Task SetActiveVersion(Byond byond, CancellationToken cancellationToken); + Task SetActiveVersion(Byond byond, Stream zipFileStream, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IChatBotsClient.cs b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs index c3f223938e..92485c0800 100644 --- a/src/Tgstation.Server.Client/Components/IChatBotsClient.cs +++ b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -13,9 +13,10 @@ namespace Tgstation.Server.Client.Components /// /// List the s /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the of the server - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create a diff --git a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs index d61e426af3..9e022fe084 100644 --- a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -13,26 +15,31 @@ namespace Tgstation.Server.Client.Components /// /// List configuration files /// + /// The optional for the operation. /// The path to the directory to list files in /// The for the operation /// A of s in the - Task> List(string directory, CancellationToken cancellationToken); + Task> List( + PaginationSettings? paginationSettings, + string directory, + CancellationToken cancellationToken); /// /// Read a file /// /// The file to read /// The for the operation - /// A representing the running operation - Task Read(ConfigurationFile file, CancellationToken cancellationToken); + /// A resulting in a containing the and downloaded . + Task> Read(ConfigurationFile file, CancellationToken cancellationToken); /// /// Overwrite a file /// /// The file to write + /// The of uploaded data. If , a delete will be attempted. /// The for the operation - /// A resulting in the new - Task Write(ConfigurationFile file, CancellationToken cancellationToken); + /// A resulting in the new . + Task Write(ConfigurationFile file, Stream uploadStream, CancellationToken cancellationToken); /// /// Delete an empty diff --git a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs index 174634bdb4..24d91af12a 100644 --- a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -33,11 +33,12 @@ namespace Tgstation.Server.Client.Components Task Compile(CancellationToken cancellationToken); /// - /// Gets the s of all s for the instance + /// Gets the s for the instance /// + /// The optional for the operation. /// The for the operation /// A resulting in a of s. - Task> GetJobIds(CancellationToken cancellationToken); + Task> ListCompileJobs(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Get a diff --git a/src/Tgstation.Server.Client/Components/IInstanceClient.cs b/src/Tgstation.Server.Client/Components/IInstanceClient.cs index daedcb1634..63ea36d530 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceClient.cs +++ b/src/Tgstation.Server.Client/Components/IInstanceClient.cs @@ -1,4 +1,4 @@ -using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { @@ -33,9 +33,9 @@ namespace Tgstation.Server.Client.Components IConfigurationClient Configuration { get; } /// - /// Access the + /// Access the . /// - IInstanceUserClient Users { get; } + IInstancePermissionSetClient PermissionSets { get; } /// /// Access the @@ -52,4 +52,4 @@ namespace Tgstation.Server.Client.Components /// IJobsClient Jobs { get; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/IInstancePermissionSetClient.cs b/src/Tgstation.Server.Client/Components/IInstancePermissionSetClient.cs new file mode 100644 index 0000000000..570c7b6034 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/IInstancePermissionSetClient.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + /// For managing s + /// + public interface IInstancePermissionSetClient + { + /// + /// Get the associated with the logged on user + /// + /// The for the operation + /// A resulting in the associated with the logged on user + Task Read(CancellationToken cancellationToken); + + /// + /// Get a specific + /// + /// The to get + /// The for the operation + /// A resulting in the requested + Task GetId(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken); + + /// + /// Get the s in the + /// + /// The optional for the operation. + /// The for the operation + /// A resulting in a of s in the instance + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); + + /// + /// Update a + /// + /// The to update + /// The for the operation + /// A representing the running operation + Task Update(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken); + + /// + /// Create a + /// + /// The to create + /// The for the operation + /// A reulting in the new + Task Create(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken); + + /// + /// Delete a + /// + /// The to delete + /// The for the operation + /// A representing the running operation + Task Delete(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs b/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs deleted file mode 100644 index 3d1d54e8d7..0000000000 --- a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api.Models; - -namespace Tgstation.Server.Client.Components -{ - /// - /// For managing s - /// - public interface IInstanceUserClient - { - /// - /// Get the associated with the logged on user - /// - /// The for the operation - /// A resulting in the associated with the logged on user - Task Read(CancellationToken cancellationToken); - - /// - /// Get a specific - /// - /// The to get - /// The for the operation - /// A resulting in the requested - Task GetId(InstanceUser instanceUser, CancellationToken cancellationToken); - - /// - /// Get the s in the - /// - /// The for the operation - /// A resulting in a of s in the instance - Task> List(CancellationToken cancellationToken); - - /// - /// Update a - /// - /// The to update - /// The for the operation - /// A representing the running operation - Task Update(InstanceUser instanceUser, CancellationToken cancellationToken); - - /// - /// Create a - /// - /// The to create - /// The for the operation - /// A reulting in the new - Task Create(InstanceUser instanceUser, CancellationToken cancellationToken); - - /// - /// Delete a - /// - /// The to delete - /// The for the operation - /// A representing the running operation - Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken); - } -} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/IJobsClient.cs b/src/Tgstation.Server.Client/Components/IJobsClient.cs index ae879f485e..b3d800b51b 100644 --- a/src/Tgstation.Server.Client/Components/IJobsClient.cs +++ b/src/Tgstation.Server.Client/Components/IJobsClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -11,18 +11,20 @@ namespace Tgstation.Server.Client.Components public interface IJobsClient { /// - /// List the s in the + /// List the s in the /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the s in the - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// List the active s in the /// + /// The optional for the operation. /// The for the operation /// A resulting in a of the active s in the - Task> ListActive(CancellationToken cancellationToken); + Task> ListActive(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Get a diff --git a/src/Tgstation.Server.Client/Components/InstanceClient.cs b/src/Tgstation.Server.Client/Components/InstanceClient.cs index f5f31907ec..1f854ff680 100644 --- a/src/Tgstation.Server.Client/Components/InstanceClient.cs +++ b/src/Tgstation.Server.Client/Components/InstanceClient.cs @@ -22,7 +22,7 @@ namespace Tgstation.Server.Client.Components public IConfigurationClient Configuration { get; } /// - public IInstanceUserClient Users { get; } + public IInstancePermissionSetClient PermissionSets { get; } /// public IChatBotsClient ChatBots { get; } @@ -49,7 +49,7 @@ namespace Tgstation.Server.Client.Components Repository = new RepositoryClient(apiClient, instance); DreamDaemon = new DreamDaemonClient(apiClient, instance); Configuration = new ConfigurationClient(apiClient, instance); - Users = new InstanceUserClient(apiClient, instance); + PermissionSets = new InstancePermissionSetClient(apiClient, instance); ChatBots = new ChatBotsClient(apiClient, instance); DreamMaker = new DreamMakerClient(apiClient, instance); Jobs = new JobsClient(apiClient, instance); diff --git a/src/Tgstation.Server.Client/Components/InstancePermissionSetClient.cs b/src/Tgstation.Server.Client/Components/InstancePermissionSetClient.cs new file mode 100644 index 0000000000..a202fe285a --- /dev/null +++ b/src/Tgstation.Server.Client/Components/InstancePermissionSetClient.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class InstancePermissionSetClient : PaginatedClient, IInstancePermissionSetClient + { + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct an + /// + /// The for the . + /// The value of + public InstancePermissionSetClient(IApiClient apiClient, Instance instance) + : base(apiClient) + { + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Create(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken) => ApiClient.Create(Routes.InstancePermissionSet, instancePermissionSet ?? throw new ArgumentNullException(nameof(instancePermissionSet)), instance.Id, cancellationToken); + + /// + public Task Delete(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken) => ApiClient.Delete( + Routes.SetID( + Routes.InstancePermissionSet, + instancePermissionSet.PermissionSetId), + instance.Id, + cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.InstancePermissionSet, instance.Id, cancellationToken); + + /// + public Task Update(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken) => ApiClient.Update(Routes.InstancePermissionSet, instancePermissionSet ?? throw new ArgumentNullException(nameof(instancePermissionSet)), instance.Id, cancellationToken); + + /// + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged( + paginationSettings, + Routes.ListRoute(Routes.InstancePermissionSet), + instance.Id, + cancellationToken); + + /// + public Task GetId(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.InstancePermissionSet, instancePermissionSet?.PermissionSetId ?? throw new ArgumentNullException(nameof(instancePermissionSet))), instance.Id, cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs deleted file mode 100644 index 1dd1659ed7..0000000000 --- a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; - -namespace Tgstation.Server.Client.Components -{ - /// - sealed class InstanceUserClient : IInstanceUserClient - { - /// - /// The for the - /// - readonly IApiClient apiClient; - - /// - /// The for the - /// - readonly Instance instance; - - /// - /// Construct an - /// - /// The value of - /// The value of - public InstanceUserClient(IApiClient apiClient, Instance instance) - { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); - } - - /// - public Task Create(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); - - /// - public Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Delete( - Routes.SetID( - Routes.InstanceUser, - instanceUser.UserId), - instance.Id, - cancellationToken); - - /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.InstanceUser, instance.Id, cancellationToken); - - /// - public Task Update(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken); - - /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceUser), instance.Id, cancellationToken); - - /// - public Task GetId(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.InstanceUser, instanceUser?.UserId ?? throw new ArgumentNullException(nameof(instanceUser))), instance.Id, cancellationToken); - } -} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/JobsClient.cs b/src/Tgstation.Server.Client/Components/JobsClient.cs index 228a2c4a9b..cf6a8875ec 100644 --- a/src/Tgstation.Server.Client/Components/JobsClient.cs +++ b/src/Tgstation.Server.Client/Components/JobsClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,13 +8,8 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components { /// - sealed class JobsClient : IJobsClient + sealed class JobsClient : PaginatedClient, IJobsClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// The for the /// @@ -23,24 +18,26 @@ namespace Tgstation.Server.Client.Components /// /// Construct a /// - /// The value of + /// The for the . /// The value of public JobsClient(IApiClient apiClient, Instance instance) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public Task Cancel(Job job, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); + public Task Cancel(Job job, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken); /// - public Task> ListActive(CancellationToken cancellationToken) => apiClient.Read>(Routes.Jobs, instance.Id, cancellationToken); + public Task> ListActive(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.Jobs, instance.Id, cancellationToken); /// - public Task GetId(EntityId job, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); + public Task GetId(EntityId job, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/HttpClient.cs b/src/Tgstation.Server.Client/HttpClientImplementation.cs similarity index 68% rename from src/Tgstation.Server.Client/HttpClient.cs rename to src/Tgstation.Server.Client/HttpClientImplementation.cs index c01042ae2d..5a449b1441 100644 --- a/src/Tgstation.Server.Client/HttpClient.cs +++ b/src/Tgstation.Server.Client/HttpClientImplementation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -6,7 +6,7 @@ using System.Threading.Tasks; namespace Tgstation.Server.Client { /// - sealed class HttpClient : IHttpClient + sealed class HttpClientImplementation : IHttpClient { /// public TimeSpan Timeout @@ -16,16 +16,16 @@ namespace Tgstation.Server.Client } /// - /// The real + /// The real /// - readonly System.Net.Http.HttpClient httpClient; + readonly HttpClient httpClient; /// - /// Construct an + /// Construct an /// - public HttpClient() + public HttpClientImplementation() { - httpClient = new System.Net.Http.HttpClient(); + httpClient = new HttpClient(); } /// diff --git a/src/Tgstation.Server.Client/IAdministrationClient.cs b/src/Tgstation.Server.Client/IAdministrationClient.cs index bbffab6f0f..b8abccfc91 100644 --- a/src/Tgstation.Server.Client/IAdministrationClient.cs +++ b/src/Tgstation.Server.Client/IAdministrationClient.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -35,16 +37,17 @@ namespace Tgstation.Server.Client /// /// Lists the log files available for download. /// + /// The optional for the operation. /// The for the operation /// A resulting in an of metadata. - Task> ListLogs(CancellationToken cancellationToken); + Task> ListLogs(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Download a given . /// /// The to download. /// The for the operation - /// A resulting in the downloaded . - Task GetLog(LogFile logFile, CancellationToken cancellationToken); + /// A resulting a containing the downloaded and associated . + Task> GetLog(LogFile logFile, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/IApiClient.cs b/src/Tgstation.Server.Client/IApiClient.cs index bee5ea5328..47b93e84d7 100644 --- a/src/Tgstation.Server.Client/IApiClient.cs +++ b/src/Tgstation.Server.Client/IApiClient.cs @@ -1,7 +1,9 @@ -using System; +using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { @@ -40,7 +42,7 @@ namespace Tgstation.Server.Client /// The request body /// The for the operation /// A resulting in the response body as a - Task Create(string route, TBody body, CancellationToken cancellationToken); + Task Create(string route, TBody body, CancellationToken cancellationToken) where TBody : class; /// /// Run an HTTP PUT request @@ -69,7 +71,7 @@ namespace Tgstation.Server.Client /// The request body /// The for the operation /// A resulting in the response body as a - Task Update(string route, TBody body, CancellationToken cancellationToken); + Task Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class; /// /// Run an HTTP POST request @@ -88,7 +90,7 @@ namespace Tgstation.Server.Client /// The request body /// The for the operation /// A representing the running operation - Task Update(string route, TBody body, CancellationToken cancellationToken); + Task Update(string route, TBody body, CancellationToken cancellationToken) where TBody : class; /// /// Run an HTTP PATCH request. @@ -113,17 +115,22 @@ namespace Tgstation.Server.Client /// The type of the response body /// The server route to make the request to /// The request body - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a - Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken); + Task Create( + string route, + TBody body, + long instanceId, + CancellationToken cancellationToken) + where TBody : class; /// /// Run an HTTP PUT request /// /// The type of the response body /// The server route to make the request to - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a Task Create(string route, long instanceId, CancellationToken cancellationToken); @@ -133,7 +140,7 @@ namespace Tgstation.Server.Client /// /// The type of the response body /// The server route to make the request to - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a Task Patch(string route, long instanceId, CancellationToken cancellationToken); @@ -143,7 +150,7 @@ namespace Tgstation.Server.Client /// /// The type of the response body /// The server route to make the request to - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a Task Read(string route, long instanceId, CancellationToken cancellationToken); @@ -155,16 +162,21 @@ namespace Tgstation.Server.Client /// The type of the response body /// The server route to make the request to /// The request body - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a - Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken); + Task Update( + string route, + TBody body, + long instanceId, + CancellationToken cancellationToken) + where TBody : class; /// /// Run an HTTP DELETE request /// /// The server route to make the request to - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A representing the running operation Task Delete(string route, long instanceId, CancellationToken cancellationToken); @@ -175,19 +187,36 @@ namespace Tgstation.Server.Client /// The type to of the request body /// The server route to make the request to /// The request body - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A representing the running operation - Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken); + Task Delete(string route, TBody body, long instanceId, CancellationToken cancellationToken) where TBody : class; /// /// Run an HTTP DELETE request /// /// The type of the response body /// The server route to make the request to - /// The instance to make the request to + /// The instance to make the request to /// The for the operation /// A resulting in the response body as a Task Delete(string route, long instanceId, CancellationToken cancellationToken); + + /// + /// Downloads a file for a given . + /// + /// The to download. + /// The for the operation. + /// A resulting in the downloaded . + Task Download(FileTicketResult ticket, CancellationToken cancellationToken); + + /// + /// Uploads a given for a given . + /// + /// The to download. + /// The to upload. represents an empty file. + /// The for the operation. + /// A representing the running operation. + Task Upload(FileTicketResult ticket, Stream? uploadStream, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/IInstanceManagerClient.cs b/src/Tgstation.Server.Client/IInstanceManagerClient.cs index 52bc42e769..4e53f8f32b 100644 --- a/src/Tgstation.Server.Client/IInstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/IInstanceManagerClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -14,9 +14,10 @@ namespace Tgstation.Server.Client /// /// Get all s for s the user can view /// + /// The optional for the operation. /// The for the operation /// A resulting in a of all s the user can view - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create or attach an diff --git a/src/Tgstation.Server.Client/IServerClient.cs b/src/Tgstation.Server.Client/IServerClient.cs index 685606df7d..048d7eb53e 100644 --- a/src/Tgstation.Server.Client/IServerClient.cs +++ b/src/Tgstation.Server.Client/IServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -41,11 +41,16 @@ namespace Tgstation.Server.Client IUsersClient Users { get; } /// - /// The of the + /// Access the . + /// + IUserGroupsClient Groups { get; } + + /// + /// The of the /// /// The for the operation - /// A resulting in the of the target server - Task Version(CancellationToken cancellationToken); + /// A resulting in the of the target server + Task ServerInformation(CancellationToken cancellationToken); /// /// Adds a to the request pipeline diff --git a/src/Tgstation.Server.Client/IUserGroupsClient.cs b/src/Tgstation.Server.Client/IUserGroupsClient.cs new file mode 100644 index 0000000000..02141cad54 --- /dev/null +++ b/src/Tgstation.Server.Client/IUserGroupsClient.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// For managing s. + /// + public interface IUserGroupsClient + { + /// + /// Get a specific . + /// + /// The of the to get. + /// The for the operation. + /// A resulting in the requested . + Task GetId(EntityId group, CancellationToken cancellationToken); + + /// + /// List all s. + /// + /// The optional for the operation. + /// The for the operation. + /// A resulting in a of all s. + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); + + /// + /// Create a new . + /// + /// The to create. + /// The for the operation. + /// The new . + Task Create(UserGroup group, CancellationToken cancellationToken); + + /// + /// Update a . + /// + /// The updated . + /// The for the operation. + /// The updated . + Task Update(UserGroup group, CancellationToken cancellationToken); + + /// + /// Deletes a . + /// + /// The of the to delete. + /// The for the operation. + /// A representing the running operation. + Task Delete(EntityId group, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/IUsersClient.cs b/src/Tgstation.Server.Client/IUsersClient.cs index 8cf253f1bd..0328188210 100644 --- a/src/Tgstation.Server.Client/IUsersClient.cs +++ b/src/Tgstation.Server.Client/IUsersClient.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -23,14 +23,15 @@ namespace Tgstation.Server.Client /// The to get. /// The for the operation /// A resulting in the requested - Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken); + Task GetId(Api.Models.Internal.UserBase user, CancellationToken cancellationToken); /// /// List all s /// + /// The optional for the operation. /// The for the operation /// A resulting in a of all s - Task> List(CancellationToken cancellationToken); + Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken); /// /// Create a new @@ -48,4 +49,4 @@ namespace Tgstation.Server.Client /// The updated Task Update(UserUpdate user, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/InstanceManagerClient.cs b/src/Tgstation.Server.Client/InstanceManagerClient.cs index 9bd9b4846f..910f09896e 100644 --- a/src/Tgstation.Server.Client/InstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/InstanceManagerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -9,41 +9,36 @@ using Tgstation.Server.Client.Components; namespace Tgstation.Server.Client { /// - sealed class InstanceManagerClient : IInstanceManagerClient + sealed class InstanceManagerClient : PaginatedClient, IInstanceManagerClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public InstanceManagerClient(IApiClient apiClient) - { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - } + : base(apiClient) + { } /// - public Task CreateOrAttach(Instance instance, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); + public Task CreateOrAttach(Instance instance, CancellationToken cancellationToken) => ApiClient.Create(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); /// - public Task Detach(Instance instance, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task Detach(Instance instance, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.InstanceManager), cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.InstanceManager), null, cancellationToken); /// - public Task Update(Instance instance, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); + public Task Update(Instance instance, CancellationToken cancellationToken) => ApiClient.Update(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken); /// - public Task GetId(Instance instance, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task GetId(Instance instance, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public Task GrantPermissions(Instance instance, CancellationToken cancellationToken) => apiClient.Patch(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); + public Task GrantPermissions(Instance instance, CancellationToken cancellationToken) => ApiClient.Patch(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public IInstanceClient CreateClient(Instance instance) => new InstanceClient(apiClient, instance); + public IInstanceClient CreateClient(Instance instance) => new InstanceClient(ApiClient, instance); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/InsufficientPermissionsException.cs b/src/Tgstation.Server.Client/InsufficientPermissionsException.cs index da1b511c8e..cb0b11f410 100644 --- a/src/Tgstation.Server.Client/InsufficientPermissionsException.cs +++ b/src/Tgstation.Server.Client/InsufficientPermissionsException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; namespace Tgstation.Server.Client @@ -17,6 +17,11 @@ namespace Tgstation.Server.Client "The current user has insufficient permissions to perform the requested operation!") { } + /// + /// Intializes a new instance of the . + /// + public InsufficientPermissionsException() { } + /// /// Construct an with a /// @@ -30,4 +35,4 @@ namespace Tgstation.Server.Client /// The inner for the base public InsufficientPermissionsException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/MethodNotSupportedException.cs b/src/Tgstation.Server.Client/MethodNotSupportedException.cs index 8c837d29ab..88774c4053 100644 --- a/src/Tgstation.Server.Client/MethodNotSupportedException.cs +++ b/src/Tgstation.Server.Client/MethodNotSupportedException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using Tgstation.Server.Api.Models; @@ -17,6 +17,11 @@ namespace Tgstation.Server.Client public MethodNotSupportedException(ErrorMessage? errorMessage, HttpResponseMessage responseMessage) : base(errorMessage, responseMessage) { } + /// + /// Intializes a new instance of the . + /// + public MethodNotSupportedException() { } + /// /// Construct an with a /// @@ -30,4 +35,4 @@ namespace Tgstation.Server.Client /// The inner for the base public MethodNotSupportedException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/PaginatedClient.cs b/src/Tgstation.Server.Client/PaginatedClient.cs new file mode 100644 index 0000000000..970129e0ec --- /dev/null +++ b/src/Tgstation.Server.Client/PaginatedClient.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Client that deals with getting paginated results. + /// + abstract class PaginatedClient + { + /// + /// The for the + /// + protected IApiClient ApiClient { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatedClient(IApiClient apiClient) + { + ApiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + } + + /// + /// Reads a given with paged results. + /// + /// The of result model. + /// The if any. + /// The route. + /// The optional . + /// The for the operation. + /// A resulting in an of the paginated s. + protected async Task> ReadPaged( + PaginationSettings? paginationSettings, + string route, + long? instanceId, + CancellationToken cancellationToken) + { + if (route == null) + throw new ArgumentNullException(nameof(route)); + + var routeFormatter = $"{route}?page={{0}}"; + var currentPage = 1; + if (paginationSettings != null) + { + if (paginationSettings.RetrieveCount == 0) + return new List(); // that was easy + else if (paginationSettings.RetrieveCount < 0) + throw new ArgumentOutOfRangeException(nameof(paginationSettings), "RetrieveCount cannot be less than 0!"); + + int? pageSize = null; + if (paginationSettings.PageSize.HasValue) + { + // pagesize validates itself on first request + pageSize = paginationSettings.PageSize.Value; + routeFormatter += $"&pageSize={pageSize}"; + } + + if (paginationSettings.Offset.HasValue) + { + if(paginationSettings.Offset.Value < 0) + throw new ArgumentOutOfRangeException(nameof(paginationSettings), "Offset cannot be less than 0!"); + + pageSize ??= paginationSettings.Offset.Value; + currentPage = (paginationSettings.Offset.Value / pageSize.Value) + 1; + } + } + + Task> GetPage() => instanceId.HasValue + ? ApiClient.Read>( + String.Format(CultureInfo.InvariantCulture, routeFormatter, currentPage), + instanceId.Value, + cancellationToken) + : ApiClient.Read>( + String.Format(CultureInfo.InvariantCulture, routeFormatter, currentPage), + cancellationToken); + + var firstPage = await GetPage().ConfigureAwait(false); + + var totalAvailable = firstPage.TotalPages * firstPage.PageSize; + var maximumItems = paginationSettings?.RetrieveCount.HasValue == true + ? Math.Min(paginationSettings.RetrieveCount!.Value, totalAvailable) + : totalAvailable; + + var results = new List(maximumItems); + var currentResults = firstPage; + do + { + // check if first page + if(currentPage > 1) + currentResults = await GetPage().ConfigureAwait(false); + + if (currentResults.Content == null) + throw new ApiConflictException("Paginated results missing content!"); + + IEnumerable rangeToAdd = currentResults.Content; + if (paginationSettings?.Offset.HasValue == true) + { + rangeToAdd = rangeToAdd + .Skip(paginationSettings.Offset!.Value % currentResults.PageSize); + } + + var itemsAvailableInPage = rangeToAdd.Count(); + var itemsStillRequired = maximumItems - results.Count; + if (itemsAvailableInPage > itemsStillRequired) + rangeToAdd = rangeToAdd + .Take(itemsStillRequired); + + results.AddRange(rangeToAdd); + ++currentPage; + } + while (results.Count < maximumItems && currentPage <= currentResults.TotalPages); + + return results; + } + } +} diff --git a/src/Tgstation.Server.Client/PaginationSettings.cs b/src/Tgstation.Server.Client/PaginationSettings.cs new file mode 100644 index 0000000000..8c03734f20 --- /dev/null +++ b/src/Tgstation.Server.Client/PaginationSettings.cs @@ -0,0 +1,24 @@ +namespace Tgstation.Server.Client +{ + /// + /// Settings for a paginated request. + /// + public sealed class PaginationSettings + { + /// + /// The size of a page. Defaults to server settings. + /// + public int? PageSize { get; set; } + + /// + /// The offset to take from. Default 0. + /// + public int? Offset { get; set; } + + /// + /// The maximum amount of items to retrieve. Default everything. + /// + /// Results will be truncated if overflow occurs due to . + public int? RetrieveCount { get; set; } + } +} diff --git a/src/Tgstation.Server.Client/Properties/AssemblyInfo.cs b/src/Tgstation.Server.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7f876c9048 --- /dev/null +++ b/src/Tgstation.Server.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Tgstation.Server.Tests")] diff --git a/src/Tgstation.Server.Client/RateLimitException.cs b/src/Tgstation.Server.Client/RateLimitException.cs index b6968576b7..acef0d769e 100644 --- a/src/Tgstation.Server.Client/RateLimitException.cs +++ b/src/Tgstation.Server.Client/RateLimitException.cs @@ -1,4 +1,4 @@ -using Microsoft.Net.Http.Headers; +using Microsoft.Net.Http.Headers; using System; using System.Linq; using System.Net.Http; @@ -23,12 +23,15 @@ namespace Tgstation.Server.Client /// The for the . public RateLimitException(ErrorMessage? errorMessage, HttpResponseMessage responseMessage) : base(errorMessage, responseMessage) { + if (responseMessage == null) + throw new ArgumentNullException(nameof(responseMessage)); + if (!responseMessage.Headers.TryGetValues(HeaderNames.RetryAfter, out var values)) return; var secondsString = values.FirstOrDefault(); if (UInt32.TryParse(secondsString, out var seconds)) - RetryAfter = DateTimeOffset.Now.AddSeconds(seconds); + RetryAfter = DateTimeOffset.UtcNow.AddSeconds(seconds); } /// @@ -36,6 +39,12 @@ namespace Tgstation.Server.Client /// public RateLimitException() { } + /// + /// Initializes a new instance of the . + /// + /// The message for the . + public RateLimitException(string message) : base(message) { } + /// /// Construct an with a and /// @@ -43,4 +52,4 @@ namespace Tgstation.Server.Client /// The inner for the base public RateLimitException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/RequestTimeoutException.cs b/src/Tgstation.Server.Client/RequestTimeoutException.cs index b979f2428c..5acbcd633f 100644 --- a/src/Tgstation.Server.Client/RequestTimeoutException.cs +++ b/src/Tgstation.Server.Client/RequestTimeoutException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; namespace Tgstation.Server.Client @@ -15,6 +15,11 @@ namespace Tgstation.Server.Client public RequestTimeoutException(HttpResponseMessage responseMessage) : base(responseMessage, "The request timed out!") { } + /// + /// Intializes a new instance of the . + /// + public RequestTimeoutException() { } + /// /// Construct an with a /// @@ -28,4 +33,4 @@ namespace Tgstation.Server.Client /// The inner for the base public RequestTimeoutException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/ServerClient.cs index 950b890094..e1b8756558 100644 --- a/src/Tgstation.Server.Client/ServerClient.cs +++ b/src/Tgstation.Server.Client/ServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -39,6 +39,9 @@ namespace Tgstation.Server.Client /// public IUsersClient Users { get; } + /// + public IUserGroupsClient Groups { get; } + /// /// The for the /// @@ -65,15 +68,16 @@ namespace Tgstation.Server.Client Instances = new InstanceManagerClient(apiClient); Users = new UsersClient(apiClient); Administration = new AdministrationClient(apiClient); + Groups = new UserGroupsClient(apiClient); } /// public void Dispose() => apiClient.Dispose(); /// - public Task Version(CancellationToken cancellationToken) => apiClient.Read(Routes.Root, cancellationToken); + public Task ServerInformation(CancellationToken cancellationToken) => apiClient.Read(Routes.Root, cancellationToken); /// public void AddRequestLogger(IRequestLogger requestLogger) => apiClient.AddRequestLogger(requestLogger); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/ServerClientFactory.cs index 211645a9cb..5dd86c3189 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/ServerClientFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http.Headers; @@ -38,7 +38,7 @@ namespace Tgstation.Server.Client string password, IEnumerable? requestLoggers = null, TimeSpan? timeout = null, - bool attemptRefreshLogin = true, + bool attemptLoginRefresh = true, CancellationToken cancellationToken = default) { if (host == null) @@ -62,6 +62,9 @@ namespace Tgstation.Server.Client token = await api.Update(Routes.Root, cancellationToken).ConfigureAwait(false); } + if (!attemptLoginRefresh) + loginHeaders = null; + var apiHeaders = new ApiHeaders(productHeaderValue, token.Bearer!); var client = new ServerClient(ApiClientFactory.CreateApiClient(host, apiHeaders, loginHeaders), token); diff --git a/src/Tgstation.Server.Client/ServerErrorException.cs b/src/Tgstation.Server.Client/ServerErrorException.cs index 31f6ec2837..c2feecdeaa 100644 --- a/src/Tgstation.Server.Client/ServerErrorException.cs +++ b/src/Tgstation.Server.Client/ServerErrorException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using Tgstation.Server.Api.Models; @@ -23,6 +23,12 @@ namespace Tgstation.Server.Client { } + /// + /// Initializes a new instance of the . + /// + /// The message for the . + public ServerErrorException(string message) : base(message) { } + /// /// Construct an with a and /// @@ -30,4 +36,4 @@ namespace Tgstation.Server.Client /// The inner for the base public ServerErrorException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/ServiceUnavailableException.cs b/src/Tgstation.Server.Client/ServiceUnavailableException.cs index c8a26bb0e8..e471416c9e 100644 --- a/src/Tgstation.Server.Client/ServiceUnavailableException.cs +++ b/src/Tgstation.Server.Client/ServiceUnavailableException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; namespace Tgstation.Server.Client @@ -15,6 +15,11 @@ namespace Tgstation.Server.Client public ServiceUnavailableException(HttpResponseMessage responseMessage) : base(responseMessage, "The service is unavailable!") { } + /// + /// Intializes a new instance of the . + /// + public ServiceUnavailableException() { } + /// /// Construct an with a /// @@ -28,4 +33,4 @@ namespace Tgstation.Server.Client /// The inner for the base public ServiceUnavailableException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 3070b75011..e1449db6b1 100644 --- a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj +++ b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj @@ -1,4 +1,4 @@ - + @@ -23,18 +23,18 @@ latest enable bin\$(Configuration)\netstandard2.1\Tgstation.Server.Client.xml + true true - + - - + all - runtime; build; native; contentfiles; analyzers + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tgstation.Server.Client/UnauthorizedException.cs b/src/Tgstation.Server.Client/UnauthorizedException.cs index 206f0b39b6..07228d299b 100644 --- a/src/Tgstation.Server.Client/UnauthorizedException.cs +++ b/src/Tgstation.Server.Client/UnauthorizedException.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using Tgstation.Server.Api.Models; @@ -17,6 +17,11 @@ namespace Tgstation.Server.Client public UnauthorizedException(ErrorMessage? errorMessage, HttpResponseMessage responseMessage) : base(errorMessage, responseMessage) { } + /// + /// Intializes a new instance of the . + /// + public UnauthorizedException() { } + /// /// Construct an with a /// @@ -30,4 +35,4 @@ namespace Tgstation.Server.Client /// The inner for the base public UnauthorizedException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/UserGroupsClient.cs b/src/Tgstation.Server.Client/UserGroupsClient.cs new file mode 100644 index 0000000000..dadb68c7b4 --- /dev/null +++ b/src/Tgstation.Server.Client/UserGroupsClient.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + sealed class UserGroupsClient : PaginatedClient, IUserGroupsClient + { + /// + /// Initializes a new instance of the . + /// + /// The for the . + public UserGroupsClient(IApiClient apiClient) + : base(apiClient) + { + } + + /// + public Task Create(UserGroup group, CancellationToken cancellationToken) => ApiClient.Create(Routes.UserGroup, group ?? throw new ArgumentNullException(nameof(group)), cancellationToken); + + /// + public Task GetId(EntityId group, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.UserGroup, group?.Id ?? throw new ArgumentNullException(nameof(group))), cancellationToken); + + /// + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.UserGroup), null, cancellationToken); + + /// + public Task Update(UserGroup group, CancellationToken cancellationToken) => ApiClient.Update(Routes.UserGroup, group ?? throw new ArgumentNullException(nameof(group)), cancellationToken); + + /// + public Task Delete(EntityId group, CancellationToken cancellationToken) => ApiClient.Delete(Routes.SetID(Routes.UserGroup, group?.Id ?? throw new ArgumentNullException(nameof(group))), cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/UsersClient.cs b/src/Tgstation.Server.Client/UsersClient.cs index 25ff956856..a8b9168cf9 100644 --- a/src/Tgstation.Server.Client/UsersClient.cs +++ b/src/Tgstation.Server.Client/UsersClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,35 +8,31 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { /// - sealed class UsersClient : IUsersClient + sealed class UsersClient : PaginatedClient, IUsersClient { - /// - /// The for the - /// - readonly IApiClient apiClient; - /// /// Construct an /// - /// The value of + /// The for the . public UsersClient(IApiClient apiClient) + : base(apiClient) { - this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); } /// - public Task Create(UserUpdate user, CancellationToken cancellationToken) => apiClient.Create(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); + public Task Create(UserUpdate user, CancellationToken cancellationToken) => ApiClient.Create(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); /// - public Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); + public Task GetId(Api.Models.Internal.UserBase user, CancellationToken cancellationToken) => ApiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); /// - public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.User), cancellationToken); + public Task> List(PaginationSettings? paginationSettings, CancellationToken cancellationToken) + => ReadPaged(paginationSettings, Routes.ListRoute(Routes.User), null, cancellationToken); /// - public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.User, cancellationToken); + public Task Read(CancellationToken cancellationToken) => ApiClient.Read(Routes.User, cancellationToken); /// - public Task Update(UserUpdate user, CancellationToken cancellationToken) => apiClient.Update(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); + public Task Update(UserUpdate user, CancellationToken cancellationToken) => ApiClient.Update(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host.Console/Program.cs b/src/Tgstation.Server.Host.Console/Program.cs index ab8c475b50..9f3314dcaa 100644 --- a/src/Tgstation.Server.Host.Console/Program.cs +++ b/src/Tgstation.Server.Host.Console/Program.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Threading; diff --git a/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj b/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj index d71f4dfdcd..9f237031c3 100644 --- a/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj +++ b/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj @@ -1,6 +1,6 @@ - + - + Exe netcoreapp3.1 @@ -9,6 +9,7 @@ ../../build/analyzers.ruleset latest false + true @@ -23,9 +24,9 @@ - + all - compile; build; native; contentfiles; analyzers + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj index be8411533d..d2bb63a992 100644 --- a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj +++ b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj @@ -1,4 +1,4 @@ - + @@ -11,6 +11,7 @@ 7.3 bin\Debug\Tgstation.Server.Host.Console.xml false + true @@ -24,17 +25,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - all - runtime; build; native; contentfiles; analyzers - - + diff --git a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj index 82c5faff69..dc6ff60c72 100644 --- a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj +++ b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj @@ -1,4 +1,4 @@ - + @@ -9,6 +9,7 @@ ../../build/analyzers.ruleset latest false + true @@ -23,15 +24,12 @@ - + all - compile; build; native; contentfiles; analyzers + runtime; build; native; contentfiles; analyzers; buildtransitive + - - all - runtime; build; native; contentfiles; analyzers - diff --git a/src/Tgstation.Server.Host/.config/dotnet-tools.json b/src/Tgstation.Server.Host/.config/dotnet-tools.json index 79d13146a3..03fa86ccf3 100644 --- a/src/Tgstation.Server.Host/.config/dotnet-tools.json +++ b/src/Tgstation.Server.Host/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "3.1.7", + "version": "3.1.10", "commands": [ "dotnet-ef" ] diff --git a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs index 72ea830c82..5d97995cb6 100644 --- a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs +++ b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net; using System.Text; @@ -121,10 +122,10 @@ namespace Tgstation.Server.Host.Components.Byond /// Installs a BYOND if it isn't already /// /// The BYOND to install - /// Custom zip file bytes to use. Will cause a number to be added. + /// Custom zip file to use. Will cause a number to be added. /// The for the operation /// A representing the running operation - async Task InstallVersion(Version version, byte[] versionZipBytes, CancellationToken cancellationToken) + async Task InstallVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken) { var ourTcs = new TaskCompletionSource(); Task inProgressTask; @@ -132,7 +133,7 @@ namespace Tgstation.Server.Host.Components.Byond bool installed; lock (installedVersions) { - if (versionZipBytes != null) + if (customVersionStream != null) { int customInstallationNumber = 1; do @@ -157,7 +158,7 @@ namespace Tgstation.Server.Host.Components.Byond return versionKey; } - if (versionZipBytes != null) + if (customVersionStream != null) logger.LogInformation("Installing custom BYOND version as {0}...", versionKey); else if (version.Build > 0) throw new JobException(ErrorCode.ByondNonExistentCustomVersion); @@ -168,26 +169,43 @@ namespace Tgstation.Server.Host.Components.Byond try { await eventConsumer.HandleEvent(EventType.ByondInstallStart, new List { versionKey }, cancellationToken).ConfigureAwait(false); - var zipFileBytesTask = versionZipBytes == null - ? byondInstaller.DownloadVersion(version, cancellationToken) - : Task.FromResult(versionZipBytes); - await ioManager.DeleteDirectory(versionKey, cancellationToken).ConfigureAwait(false); + var extractPath = ioManager.ResolvePath(versionKey); + async Task DirectoryCleanup() + { + await ioManager.DeleteDirectory(extractPath, cancellationToken).ConfigureAwait(false); + await ioManager.CreateDirectory(extractPath, cancellationToken).ConfigureAwait(false); + } + var directoryCleanupTask = DirectoryCleanup(); try { - versionZipBytes = await zipFileBytesTask.ConfigureAwait(false); - await ioManager.CreateDirectory(versionKey, cancellationToken).ConfigureAwait(false); + Stream versionZipStream; + Stream downloadedStream = null; + if (customVersionStream == null) + { + var bytes = await byondInstaller.DownloadVersion(version, cancellationToken).ConfigureAwait(false); + downloadedStream = new MemoryStream(bytes); + versionZipStream = downloadedStream; + } + else + versionZipStream = customVersionStream; - var extractPath = ioManager.ResolvePath(versionKey); - logger.LogTrace("Extracting downloaded BYOND zip to {0}...", extractPath); - await ioManager.ZipToDirectory(extractPath, versionZipBytes, cancellationToken).ConfigureAwait(false); - versionZipBytes = null; + using (downloadedStream) + { + await directoryCleanupTask.ConfigureAwait(false); + logger.LogTrace("Extracting downloaded BYOND zip to {0}...", extractPath); + await ioManager.ZipToDirectory(extractPath, versionZipStream, cancellationToken).ConfigureAwait(false); + } await byondInstaller.InstallByond(extractPath, version, cancellationToken).ConfigureAwait(false); // make sure to do this last because this is what tells us we have a valid version in the future - await ioManager.WriteAllBytes(ioManager.ConcatPath(versionKey, VersionFileName), Encoding.UTF8.GetBytes(versionKey), cancellationToken).ConfigureAwait(false); + await ioManager.WriteAllBytes( + ioManager.ConcatPath(versionKey, VersionFileName), + Encoding.UTF8.GetBytes(versionKey), + cancellationToken) + .ConfigureAwait(false); } catch (WebException e) { @@ -220,12 +238,12 @@ namespace Tgstation.Server.Host.Components.Byond } /// - public async Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken) + public async Task ChangeVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken) { if (version == null) throw new ArgumentNullException(nameof(version)); - var versionKey = await InstallVersion(version, customVersionBytes, cancellationToken).ConfigureAwait(false); + var versionKey = await InstallVersion(version, customVersionStream, cancellationToken).ConfigureAwait(false); using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) { await ioManager.WriteAllBytes(ActiveVersionFileName, Encoding.UTF8.GetBytes(versionKey), cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs b/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs index 650bce0209..129fa54037 100644 --- a/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs +++ b/src/Tgstation.Server.Host/Components/Byond/IByondManager.cs @@ -1,6 +1,7 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -25,10 +26,10 @@ namespace Tgstation.Server.Host.Components.Byond /// Change the active BYOND version /// /// The new - /// Optional s of a custom BYOND version zip file. + /// Optional of a custom BYOND version zip file. /// The for the operation /// A representing the running operation - Task ChangeVersion(Version version, byte[] customVersionBytes, CancellationToken cancellationToken); + Task ChangeVersion(Version version, Stream customVersionStream, CancellationToken cancellationToken); /// /// Lock the current installation's location and return a @@ -38,4 +39,4 @@ namespace Tgstation.Server.Host.Components.Byond /// A resulting in the requested Task UseExecutables(Version requiredVersion, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs index 25807b4d2f..5380171269 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs @@ -111,6 +111,11 @@ namespace Tgstation.Server.Host.Components.Chat /// Task initialProviderConnectionsTask; + /// + /// A that represents all sent messages. + /// + Task messageSendTask; + /// /// The that completes when s change /// @@ -159,6 +164,8 @@ namespace Tgstation.Server.Host.Components.Chat trackingContexts = new List(); handlerCts = new CancellationTokenSource(); connectionsUpdated = new TaskCompletionSource(); + + messageSendTask = Task.CompletedTask; channelIdCounter = 1; } @@ -170,6 +177,8 @@ namespace Tgstation.Server.Host.Components.Chat handlerCts.Dispose(); foreach (var I in providers) await I.Value.DisposeAsync().ConfigureAwait(false); + + await messageSendTask.ConfigureAwait(false); } /// @@ -614,16 +623,29 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public async Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken) + public void QueueMessage(string message, IEnumerable channelIds) { if (message == null) throw new ArgumentNullException(nameof(message)); if (channelIds == null) throw new ArgumentNullException(nameof(channelIds)); + var task = SendMessage(message, channelIds, handlerCts.Token); + AddMessageTask(task); + } + + /// + /// Asynchronously send a given to a set of . + /// + /// The message to send. + /// The s of the s to send to. + /// The for the operation. + /// A representing the running operation. + Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken) + { logger.LogTrace("Chat send \"{0}\" to channels: {1}", message, String.Join(", ", channelIds)); - await Task.WhenAll( + return Task.WhenAll( channelIds.Select(x => { ChannelMapping channelMapping; @@ -635,12 +657,11 @@ namespace Tgstation.Server.Host.Components.Chat if (!providers.TryGetValue(channelMapping.ProviderId, out provider)) return Task.CompletedTask; return provider.SendMessage(channelMapping.ProviderChannelId, message, cancellationToken); - })) - .ConfigureAwait(false); + })); } /// - public async Task SendWatchdogMessage(string message, CancellationToken cancellationToken) + public async Task QueueWatchdogMessage(string message, CancellationToken cancellationToken) { List wdChannels = null; message = String.Format(CultureInfo.InvariantCulture, "WD: {0}", message); @@ -654,18 +675,17 @@ namespace Tgstation.Server.Host.Components.Chat lock (mappedChannels) wdChannels = mappedChannels.Where(x => x.Value.IsWatchdogChannel).Select(x => x.Key).ToList(); - await SendMessage(message, wdChannels, cancellationToken).ConfigureAwait(false); + QueueMessage(message, wdChannels); } /// - public async Task> SendDeploymentMessage( + public Action QueueDeploymentMessage( Models.RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset? estimatedCompletionTime, string gitHubOwner, string gitHubRepo, - bool localCommitPushed, - CancellationToken cancellationToken) + bool localCommitPushed) { List wdChannels; lock (mappedChannels) // so it doesn't change while we're using it @@ -675,7 +695,7 @@ namespace Tgstation.Server.Host.Components.Chat var callbacks = new List>(); - await Task.WhenAll( + var task = Task.WhenAll( wdChannels.Select( async x => { @@ -697,7 +717,7 @@ namespace Tgstation.Server.Host.Components.Chat gitHubRepo, channelMapping.ProviderChannelId, localCommitPushed, - cancellationToken) + handlerCts.Token) .ConfigureAwait(false); callbacks.Add(callback); @@ -709,10 +729,16 @@ namespace Tgstation.Server.Host.Components.Chat "Error sending deploy message to provider {0}!", channelMapping.ProviderId); } - })) - .ConfigureAwait(false); + })); - return (errorMessage, dreamMakerOutput) => Task.WhenAll(callbacks.Select(x => x(errorMessage, dreamMakerOutput))); + AddMessageTask(task); + + return (errorMessage, dreamMakerOutput) => AddMessageTask( + Task.WhenAll( + callbacks.Select( + x => x( + errorMessage, + dreamMakerOutput)))); } /// @@ -744,6 +770,7 @@ namespace Tgstation.Server.Host.Components.Chat if (chatHandler != null) await chatHandler.ConfigureAwait(false); await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken))).ConfigureAwait(false); + await messageSendTask.ConfigureAwait(false); } /// @@ -800,7 +827,31 @@ namespace Tgstation.Server.Host.Components.Chat List wdChannels; lock (mappedChannels) // so it doesn't change while we're using it wdChannels = mappedChannels.Select(x => x.Key).ToList(); + return SendMessage(message, wdChannels, cancellationToken); } + + /// + /// Adds a given to . + /// + /// The to add. + void AddMessageTask(Task task) + { + async Task Wrap(Task originalTask) + { + await originalTask.ConfigureAwait(false); + try + { + await task.ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error in asynchronous chat message!"); + } + } + + lock (handlerCts) + messageSendTask = Wrap(messageSendTask); + } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/PullRequestsCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/PullRequestsCommand.cs index 2c645130b4..617a67f6ac 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Commands/PullRequestsCommand.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/PullRequestsCommand.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Globalization; @@ -21,7 +21,7 @@ namespace Tgstation.Server.Host.Components.Chat.Commands public string Name => "prs"; /// - public string HelpText => "Display live test merge pull request numbers. Add --repo to view repository test merges"; + public string HelpText => "Display live test merge numbers. Add --repo to view test merges in the repository as opposed to live."; /// public bool AdminOnly => false; @@ -87,7 +87,7 @@ namespace Tgstation.Server.Host.Components.Chat.Commands .Select(x => new Models.TestMerge { Number = x.Number, - PullRequestRevision = x.PullRequestRevision + TargetCommitSha = x.TargetCommitSha }) .ToListAsync(cancellationToken) .ConfigureAwait(false)) @@ -100,7 +100,7 @@ namespace Tgstation.Server.Host.Components.Chat.Commands results = watchdog.ActiveCompileJob?.RevisionInformation.ActiveTestMerges.Select(x => x.TestMerge).ToList() ?? new List(); } - return !results.Any() ? "None!" : String.Join(", ", results.Select(x => String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.PullRequestRevision.Substring(0, 7)))); + return !results.Any() ? "None!" : String.Join(", ", results.Select(x => String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.TargetCommitSha.Substring(0, 7)))); } #pragma warning restore CA1506 } diff --git a/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs index 3cfe424951..367274d8b2 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs @@ -44,21 +44,19 @@ namespace Tgstation.Server.Host.Components.Chat Task ChangeChannels(long connectionId, IEnumerable newChannels, CancellationToken cancellationToken); /// - /// Send a chat to a given set of + /// Queue a chat to a given set of . /// - /// The message being sent - /// The s of the s to send to - /// The for the operation - /// A representing the running operation - Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken); + /// The message being sent. + /// The s of the s to send to. + void QueueMessage(string message, IEnumerable channelIds); /// - /// Send a chat to configured watchdog channels + /// Queue a chat to configured watchdog channels. /// - /// The message being sent - /// The for the operation - /// A representing the running operation - Task SendWatchdogMessage(string message, CancellationToken cancellationToken); + /// The message being sent. + /// The for the operation. + /// A representing the running operation. + Task QueueWatchdogMessage(string message, CancellationToken cancellationToken); /// /// Send the message for a deployment to configured deployment channels. @@ -69,16 +67,14 @@ namespace Tgstation.Server.Host.Components.Chat /// The repository GitHub owner, if any. /// The repository GitHub name, if any. /// if the local deployment commit was pushed to the remote repository. - /// The for the operation. - /// A resulting in a to call to update the message at the deployment's conclusion. Parameters: Error message if any, DreamMaker output if any. - Task> SendDeploymentMessage( + /// An to call to update the message at the deployment's conclusion. Parameters: Error message if any, DreamMaker output if any. + Action QueueDeploymentMessage( Models.RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset? estimatedCompletionTime, string gitHubOwner, string gitHubRepo, - bool localCommitPushed, - CancellationToken cancellationToken); + bool localCommitPushed); /// /// Start tracking s and s. diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index e82c35b9d5..4b60ad50fb 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -417,7 +417,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers .Select(x => new EmbedFieldBuilder { Name = $"#{x.Number}", - Value = $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.PullRequestRevision.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.PullRequestRevision}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}" + Value = $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}" })); var builder = new EmbedBuilder @@ -461,7 +461,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers var completionString = errorMessage == null ? "Succeeded" : "Failed"; builder.Footer.Text = completionString; builder.Color = errorMessage == null ? Color.Green : Color.Red; - builder.Timestamp = DateTimeOffset.Now; + builder.Timestamp = DateTimeOffset.UtcNow; builder.Description = errorMessage == null ? "The deployment completed successfully and will be available at the next server reboot." : "The deployment failed."; diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index 911f95dc23..a554d12637 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -601,7 +601,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers var testmergeInsert = (revisionInformation.ActiveTestMerges?.Count ?? 0) == 0 ? String.Empty : String.Format(CultureInfo.InvariantCulture, " (Test Merges: {0})", String.Join(", ", revisionInformation.ActiveTestMerges.Select(x => x.TestMerge).Select(x => { - var result = String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.PullRequestRevision.Substring(0, 7)); + var result = String.Format(CultureInfo.InvariantCulture, "#{0} at {1}", x.Number, x.TargetCommitSha.Substring(0, 7)); if (x.Comment != null) result += String.Format(CultureInfo.InvariantCulture, " ({0})", x.Comment); return result; @@ -619,7 +619,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers ? byondVersion.ToString() : $"{byondVersion.Major}.{byondVersion.Minor}", estimatedCompletionTime.HasValue - ? $" ETA: {estimatedCompletionTime - DateTimeOffset.Now}" + ? $" ETA: {estimatedCompletionTime - DateTimeOffset.UtcNow}" : String.Empty), cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index 4a9df3d206..874de6b9a6 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Models; @@ -41,9 +42,9 @@ namespace Tgstation.Server.Host.Components.Deployment readonly IIOManager ioManager; /// - /// The for the . + /// The for the . /// - readonly IGitHubDeploymentManager gitHubDeploymentManager; + readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory; /// /// The for the @@ -53,7 +54,7 @@ namespace Tgstation.Server.Host.Components.Deployment /// /// The for the /// - readonly Api.Models.Instance instance; + readonly Api.Models.Instance metadata; /// /// The for @@ -90,21 +91,21 @@ namespace Tgstation.Server.Host.Components.Deployment /// /// The value of /// The value of - /// The value of . + /// The value of . /// The value of - /// The value of + /// The value of public DmbFactory( IDatabaseContextFactory databaseContextFactory, IIOManager ioManager, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, ILogger logger, - Api.Models.Instance instance) + Api.Models.Instance metadata) { this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); - this.gitHubDeploymentManager = gitHubDeploymentManager ?? throw new ArgumentNullException(nameof(gitHubDeploymentManager)); + this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); cleanupTask = Task.CompletedTask; newerDmbTcs = new TaskCompletionSource(); @@ -124,9 +125,12 @@ namespace Tgstation.Server.Host.Components.Deployment async Task HandleCleanup() { var deleteJob = ioManager.DeleteDirectory(job.DirectoryName.ToString(), cleanupCts.Token); + var remoteDeploymentManager = remoteDeploymentManagerFactory.CreateRemoteDeploymentManager( + metadata, + job); // DCT: None available - var deploymentJob = gitHubDeploymentManager.MarkInactive(job, default); + var deploymentJob = remoteDeploymentManager.MarkInactive(job, default); var otherTask = cleanupTask; await Task.WhenAll(otherTask, deleteJob, deploymentJob).ConfigureAwait(false); } @@ -157,10 +161,15 @@ namespace Tgstation.Server.Host.Components.Deployment // Do this first, because it's entirely possible when we set the tcs it will immediately need to be applied if (started) - await gitHubDeploymentManager.StageDeployment( - newProvider.CompileJob, - cancellationToken) - .ConfigureAwait(false); + { + var remoteDeploymentManager = remoteDeploymentManagerFactory.CreateRemoteDeploymentManager( + metadata, + job); + await remoteDeploymentManager.StageDeployment( + newProvider.CompileJob, + cancellationToken) + .ConfigureAwait(false); + } lock (jobLockCounts) { @@ -199,7 +208,7 @@ namespace Tgstation.Server.Host.Components.Deployment cj = await db .CompileJobs .AsQueryable() - .Where(x => x.Job.Instance.Id == instance.Id) + .Where(x => x.Job.Instance.Id == metadata.Id) .OrderByDescending(x => x.Job.StoppedAt) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); @@ -261,7 +270,7 @@ namespace Tgstation.Server.Host.Components.Deployment // It constitutes an API violation if it's returned by the DreamDaemonController so just set it here // Bit of a hack, but it works out to be nearly if not the same value that's put in the DB logger.LogTrace("Setting missing StoppedAt for CompileJob.Job #{0}...", compileJob.Job.Id); - compileJob.Job.StoppedAt = DateTimeOffset.Now; + compileJob.Job.StoppedAt = DateTimeOffset.UtcNow; } var providerSubmitted = false; @@ -356,7 +365,7 @@ namespace Tgstation.Server.Host.Components.Deployment .CompileJobs .AsQueryable() .Where( - x => x.Job.Instance.Id == instance.Id + x => x.Job.Instance.Id == metadata.Id && jobIdsToSkip.Contains(x.Id)) .Select(x => x.DirectoryName.Value) .ToListAsync(cancellationToken) diff --git a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs index 9f178ea8c0..7b2d5f1b07 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs @@ -1,9 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Octokit; using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text; using System.Threading; @@ -12,10 +10,10 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Session; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; @@ -78,20 +76,15 @@ namespace Tgstation.Server.Host.Components.Deployment /// readonly IRepositoryManager repositoryManager; - /// - /// The for . - /// - readonly IGitHubClientFactory gitHubClientFactory; - /// /// The for . /// readonly ICompileJobSink compileJobConsumer; /// - /// The for . + /// The for . /// - readonly IGitHubDeploymentManager gitHubDeploymentManager; + readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory; /// /// The for @@ -108,8 +101,14 @@ namespace Tgstation.Server.Host.Components.Deployment /// readonly object deploymentLock; - Func currentChatCallback; + /// + /// The active callback from . + /// + Action currentChatCallback; + /// + /// Cached for . + /// string currentDreamMakerOutput; /// @@ -137,10 +136,9 @@ namespace Tgstation.Server.Host.Components.Deployment /// The value of /// The value of /// The value of - /// The value of . /// The value of . /// The value of . - /// The value of . + /// The value of . /// The value of /// The value of . public DreamMaker( @@ -151,10 +149,9 @@ namespace Tgstation.Server.Host.Components.Deployment IEventConsumer eventConsumer, IChatManager chatManager, IProcessExecutor processExecutor, - IGitHubClientFactory gitHubClientFactory, ICompileJobSink compileJobConsumer, IRepositoryManager repositoryManager, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, ILogger logger, Api.Models.Instance metadata) { @@ -165,10 +162,9 @@ namespace Tgstation.Server.Host.Components.Deployment this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); this.chatManager = chatManager ?? throw new ArgumentNullException(nameof(chatManager)); this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor)); - this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.compileJobConsumer = compileJobConsumer ?? throw new ArgumentNullException(nameof(compileJobConsumer)); this.repositoryManager = repositoryManager ?? throw new ArgumentNullException(nameof(repositoryManager)); - this.gitHubDeploymentManager = gitHubDeploymentManager ?? throw new ArgumentNullException(nameof(gitHubDeploymentManager)); + this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); @@ -242,13 +238,13 @@ namespace Tgstation.Server.Host.Components.Deployment await controller.Lifetime.WithToken(cancellationToken).ConfigureAwait(false); if (!controller.Lifetime.IsCompleted) - { - if (requireValidate) - throw new JobException(ErrorCode.DreamMakerNeverValidated); await controller.DisposeAsync().ConfigureAwait(false); - } validationStatus = controller.ApiValidationStatus; + + if (requireValidate && validationStatus == ApiValidationStatus.NeverValidated) + throw new JobException(ErrorCode.DreamMakerNeverValidated); + logger.LogTrace("API validation status: {0}", validationStatus); job.DMApiVersion = controller.DMApiVersion; @@ -370,9 +366,10 @@ namespace Tgstation.Server.Host.Components.Deployment /// Cleans up a failed compile . /// /// The running . + /// The associated with the . /// The that was thrown. /// A representing the running operation - async Task CleanupFailedCompile(Models.CompileJob job, Exception exception) + async Task CleanupFailedCompile(Models.CompileJob job, IRemoteDeploymentManager remoteDeploymentManager, Exception exception) { async Task CleanDir() { @@ -392,7 +389,7 @@ namespace Tgstation.Server.Host.Components.Deployment // DCT: None available await Task.WhenAll( CleanDir(), - gitHubDeploymentManager.FailDeployment( + remoteDeploymentManager.FailDeployment( job, FormatExceptionForUsers(exception), default)) @@ -406,10 +403,18 @@ namespace Tgstation.Server.Host.Components.Deployment /// The settings to use /// The to use /// The to use + /// The to use. /// The timeout for validating the DMAPI /// The for the operation /// A representing the running operation - async Task RunCompileJob(Models.CompileJob job, Api.Models.DreamMaker dreamMakerSettings, IByondExecutableLock byondLock, IRepository repository, uint apiValidateTimeout, CancellationToken cancellationToken) + async Task RunCompileJob( + Models.CompileJob job, + Api.Models.DreamMaker dreamMakerSettings, + IByondExecutableLock byondLock, + IRepository repository, + IRemoteDeploymentManager remoteDeploymentManager, + uint apiValidateTimeout, + CancellationToken cancellationToken) { var outputDirectory = job.DirectoryName.ToString(); logger.LogTrace("Compile output GUID: {0}", outputDirectory); @@ -426,7 +431,15 @@ namespace Tgstation.Server.Host.Components.Deployment // repository closed now // run precompile scripts - await eventConsumer.HandleEvent(EventType.CompileStart, new List { resolvedOutputDirectory, repoOrigin }, cancellationToken).ConfigureAwait(false); + await eventConsumer.HandleEvent( + EventType.CompileStart, + new List + { + resolvedOutputDirectory, + repoOrigin.ToString() + }, + cancellationToken) + .ConfigureAwait(false); // determine the dme if (job.DmeName == null) @@ -499,7 +512,7 @@ namespace Tgstation.Server.Host.Components.Deployment } catch (Exception ex) { - await CleanupFailedCompile(job, ex).ConfigureAwait(false); + await CleanupFailedCompile(job, remoteDeploymentManager, ex).ConfigureAwait(false); throw; } } @@ -540,6 +553,7 @@ namespace Tgstation.Server.Host.Components.Deployment Models.DreamDaemonSettings ddSettings = null; DreamMakerSettings dreamMakerSettings = null; IRepository repo = null; + IRemoteDeploymentManager remoteDeploymentManager = null; Models.RevisionInformation revInfo = null; await databaseContextFactory.UseContext( async databaseContext => @@ -590,20 +604,19 @@ namespace Tgstation.Server.Host.Components.Deployment if (repo == null) throw new JobException(ErrorCode.RepoMissing); - if (repo.IsGitHubRepository) - { - repoOwner = repo.GitHubOwner; - repoName = repo.GitHubRepoName; - } + remoteDeploymentManager = remoteDeploymentManagerFactory + .CreateRemoteDeploymentManager(metadata, repo.RemoteGitProvider.Value); var repoSha = repo.Head; + repoOwner = repo.RemoteRepositoryOwner; + repoName = repo.RemoteRepositoryName; revInfo = await databaseContext .RevisionInformations .AsQueryable() .Where(x => x.CommitSha == repoSha && x.Instance.Id == metadata.Id) .Include(x => x.ActiveTestMerges) - .ThenInclude(x => x.TestMerge) - .ThenInclude(x => x.MergedBy) + .ThenInclude(x => x.TestMerge) + .ThenInclude(x => x.MergedBy) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); @@ -643,6 +656,7 @@ namespace Tgstation.Server.Host.Components.Deployment dreamMakerSettings, ddSettings.StartupTimeout.Value, repo, + remoteDeploymentManager, progressReporter, averageSpan, likelyPushedTestMergeCommit, @@ -655,10 +669,12 @@ namespace Tgstation.Server.Host.Components.Deployment await databaseContextFactory.UseContext( async databaseContext => { + var fullJob = compileJob.Job; compileJob.Job = new Models.Job { Id = job.Id }; + var fullRevInfo = compileJob.RevisionInformation; compileJob.RevisionInformation = new Models.RevisionInformation { Id = revInfo.Id @@ -684,16 +700,19 @@ namespace Tgstation.Server.Host.Components.Deployment await databaseContext.Save(default).ConfigureAwait(false); throw; } + + compileJob.Job = fullJob; + compileJob.RevisionInformation = fullRevInfo; }) .ConfigureAwait(false); } catch (Exception ex) { - await CleanupFailedCompile(compileJob, ex).ConfigureAwait(false); + await CleanupFailedCompile(compileJob, remoteDeploymentManager, ex).ConfigureAwait(false); throw; } - var commentsTask = PostDeploymentComments( + var commentsTask = remoteDeploymentManager.PostDeploymentComments( compileJob, activeCompileJob?.RevisionInformation, repositorySettings, @@ -703,25 +722,26 @@ namespace Tgstation.Server.Host.Components.Deployment var eventTask = eventConsumer.HandleEvent(EventType.DeploymentComplete, null, cancellationToken); - var chatTask = currentChatCallback(null, compileJob.Output); - currentChatCallback = null; - try { - await Task.WhenAll(commentsTask, eventTask, chatTask).ConfigureAwait(false); + currentChatCallback(null, compileJob.Output); + + await Task.WhenAll(commentsTask, eventTask).ConfigureAwait(false); } catch (Exception ex) { throw new JobException(ErrorCode.PostDeployFailure, ex); } + finally + { + currentChatCallback = null; + } } catch (Exception ex) { - if (currentChatCallback != null) - await currentChatCallback( - FormatExceptionForUsers(ex), - currentDreamMakerOutput) - .ConfigureAwait(false); + currentChatCallback?.Invoke( + FormatExceptionForUsers(ex), + currentDreamMakerOutput); throw; } @@ -771,6 +791,7 @@ namespace Tgstation.Server.Host.Components.Deployment Api.Models.DreamMaker dreamMakerSettings, uint apiValidateTimeout, IRepository repository, + IRemoteDeploymentManager remoteDeploymentManager, Action progressReporter, TimeSpan? estimatedDuration, bool localCommitExistsOnRemote, @@ -783,31 +804,38 @@ namespace Tgstation.Server.Host.Components.Deployment try { using var byondLock = await byond.UseExecutables(null, cancellationToken).ConfigureAwait(false); - currentChatCallback = await chatManager.SendDeploymentMessage( + currentChatCallback = chatManager.QueueDeploymentMessage( revisionInformation, byondLock.Version, - DateTimeOffset.Now + estimatedDuration, - repository.GitHubOwner, - repository.GitHubRepoName, - localCommitExistsOnRemote, - cancellationToken) - .ConfigureAwait(false); + DateTimeOffset.UtcNow + estimatedDuration, + repository.RemoteRepositoryOwner, + repository.RemoteRepositoryName, + localCommitExistsOnRemote); var job = new Models.CompileJob { DirectoryName = Guid.NewGuid(), DmeName = dreamMakerSettings.ProjectName, RevisionInformation = revisionInformation, - ByondVersion = byondLock.Version.ToString() + ByondVersion = byondLock.Version.ToString(), + RepositoryOrigin = repository.Origin.ToString(), }; - await gitHubDeploymentManager.StartDeployment( + await remoteDeploymentManager.StartDeployment( repository, job, cancellationToken) .ConfigureAwait(false); - await RunCompileJob(job, dreamMakerSettings, byondLock, repository, apiValidateTimeout, cancellationToken).ConfigureAwait(false); + await RunCompileJob( + job, + dreamMakerSettings, + byondLock, + repository, + remoteDeploymentManager, + apiValidateTimeout, + cancellationToken) + .ConfigureAwait(false); return job; } @@ -823,98 +851,5 @@ namespace Tgstation.Server.Host.Components.Deployment await progressTask.ConfigureAwait(false); } } - - /// - /// Post deployment GitHub comments. - /// - /// The deployed . - /// The of the previous deployment. - /// The . - /// The GitHub repostiory owner. - /// The GitHub repostiory name. - /// The for the operation. - /// A representing the running operation. - async Task PostDeploymentComments( - Models.CompileJob compileJob, - Models.RevisionInformation previousRevisionInformation, - Models.RepositorySettings repositorySettings, - string repoOwner, - string repoName, - CancellationToken cancellationToken) - { - if (repositorySettings?.AccessToken == null) - return; - - if ((previousRevisionInformation != null && previousRevisionInformation.CommitSha == previousRevisionInformation.CommitSha) - || !repositorySettings.PostTestMergeComment.Value) - return; - - previousRevisionInformation = new Models.RevisionInformation - { - ActiveTestMerges = new List() - }; - - var gitHubClient = gitHubClientFactory.CreateClient(repositorySettings.AccessToken); - - async Task CommentOnPR(int prNumber, string comment) - { - try - { - await gitHubClient.Issue.Comment.Create(repoOwner, repoName, prNumber, comment) - .WithToken(cancellationToken) - .ConfigureAwait(false); - } - catch (ApiException e) - { - logger.LogWarning(e, "Error posting GitHub comment!"); - } - } - - var tasks = new List(); - - var deployedRevisionInformation = compileJob.RevisionInformation; - string FormatTestMerge(Models.TestMerge testMerge, bool updated) => String.Format(CultureInfo.InvariantCulture, "#### Test Merge {4}{0}{0}##### Server Instance{0}{5}{1}{0}{0}##### Revision{0}Origin: {6}{0}Pull Request: {2}{0}Server: {7}{3}{8}", - Environment.NewLine, - repositorySettings.ShowTestMergeCommitters.Value ? String.Format(CultureInfo.InvariantCulture, "{0}{0}##### Merged By{0}{1}", Environment.NewLine, testMerge.MergedBy.Name) : String.Empty, - testMerge.PullRequestRevision, - testMerge.Comment != null ? String.Format(CultureInfo.InvariantCulture, "{0}{0}##### Comment{0}{1}", Environment.NewLine, testMerge.Comment) : String.Empty, - updated ? "Updated" : "Deployed", - metadata.Name, - deployedRevisionInformation.OriginCommitSha, - deployedRevisionInformation.CommitSha, - compileJob.GitHubDeploymentId.HasValue - ? $"{Environment.NewLine}[GitHub Deployments](https://github.com/{repoOwner}/{repoName}/deployments/activity_log?environment=TGS%3A%20{metadata.Name})" - : String.Empty); - - // added prs - foreach (var I in deployedRevisionInformation - .ActiveTestMerges - .Select(x => x.TestMerge) - .Where(x => !previousRevisionInformation - .ActiveTestMerges - .Any(y => y.TestMerge.Number == x.Number))) - tasks.Add(CommentOnPR(I.Number, FormatTestMerge(I, false))); - - // removed prs - foreach (var I in previousRevisionInformation - .ActiveTestMerges - .Select(x => x.TestMerge) - .Where(x => !deployedRevisionInformation - .ActiveTestMerges - .Any(y => y.TestMerge.Number == x.Number))) - tasks.Add(CommentOnPR(I.Number, "#### Test Merge Removed")); - - // updated prs - foreach (var I in deployedRevisionInformation - .ActiveTestMerges - .Select(x => x.TestMerge) - .Where(x => previousRevisionInformation - .ActiveTestMerges - .Any(y => y.TestMerge.Number == x.Number))) - tasks.Add(CommentOnPR(I.Number, FormatTestMerge(I, true))); - - if (tasks.Any()) - await Task.WhenAll(tasks).ConfigureAwait(false); - } } } diff --git a/src/Tgstation.Server.Host/Components/Deployment/GitHubDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/GitHubDeploymentManager.cs deleted file mode 100644 index 7b931405d5..0000000000 --- a/src/Tgstation.Server.Host/Components/Deployment/GitHubDeploymentManager.cs +++ /dev/null @@ -1,243 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Octokit; -using System; -using System.Collections.ObjectModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Host.Components.Repository; -using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; -using Tgstation.Server.Host.Models; - -namespace Tgstation.Server.Host.Components.Deployment -{ - /// - sealed class GitHubDeploymentManager : IGitHubDeploymentManager - { - /// - /// The for the . - /// - readonly IDatabaseContextFactory databaseContextFactory; - - /// - /// The for the . - /// - readonly IGitHubClientFactory gitHubClientFactory; - - /// - /// The for the . - /// - readonly ILogger logger; - - /// - /// The for the . - /// - readonly Api.Models.Instance metadata; - - /// - /// Initializes a new instance of the . - /// - /// The value of . - /// The value of . - /// The value of . - /// The value of . - public GitHubDeploymentManager( - IDatabaseContextFactory databaseContextFactory, - IGitHubClientFactory gitHubClientFactory, - ILogger logger, - Api.Models.Instance metadata) - { - this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); - this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); - } - - /// - public async Task StartDeployment(IRepository repository, CompileJob compileJob, CancellationToken cancellationToken) - { - if (repository == null) - throw new ArgumentNullException(nameof(repository)); - if (compileJob == null) - throw new ArgumentNullException(nameof(compileJob)); - - if (!repository.IsGitHubRepository) - { - logger.LogTrace("Not managing deployment as this is not a GitHub repo"); - return; - } - - logger.LogTrace("Starting deployment..."); - - RepositorySettings repositorySettings = null; - await databaseContextFactory.UseContext( - async databaseContext => - repositorySettings = await databaseContext - .RepositorySettings - .AsQueryable() - .Where(x => x.InstanceId == metadata.Id) - .FirstAsync(cancellationToken) - .ConfigureAwait(false)) - .ConfigureAwait(false); - - var gitHubClient = repositorySettings.AccessToken == null - ? gitHubClientFactory.CreateClient() - : gitHubClientFactory.CreateClient(repositorySettings.AccessToken); - - var repositoryTask = gitHubClient - .Repository - .Get( - repository.GitHubOwner, - repository.GitHubRepoName); - - if (repositorySettings.CreateGitHubDeployments.Value) - { - logger.LogTrace("Creating deployment..."); - var deployment = await gitHubClient - .Repository - .Deployment - .Create( - repository.GitHubOwner, - repository.GitHubRepoName, - new NewDeployment(compileJob.RevisionInformation.CommitSha) - { - AutoMerge = false, - Description = "TGS Game Deployment", - Environment = $"TGS: {metadata.Name}", - ProductionEnvironment = true, - RequiredContexts = new Collection() - }) - .WithToken(cancellationToken) - .ConfigureAwait(false); - - compileJob.GitHubDeploymentId = deployment.Id; - logger.LogDebug("Created deployment ID {0}", deployment.Id); - - await gitHubClient - .Repository - .Deployment - .Status - .Create( - repository.GitHubOwner, - repository.GitHubRepoName, - deployment.Id, - new NewDeploymentStatus(DeploymentState.InProgress) - { - Description = "The project is being deployed", - AutoInactive = false - }) - .WithToken(cancellationToken) - .ConfigureAwait(false); - - logger.LogTrace("In-progress deployment status created"); - } - else - logger.LogTrace("Not creating deployment"); - - try - { - var gitHubRepo = await repositoryTask - .WithToken(cancellationToken) - .ConfigureAwait(false); - - compileJob.GitHubRepoId = gitHubRepo.Id; - logger.LogTrace("Set GitHub ID as {0}", compileJob.GitHubRepoId); - } - catch (RateLimitExceededException ex) when (!repositorySettings.CreateGitHubDeployments.Value) - { - logger.LogWarning(ex, "Unable to set compile job repository ID!"); - } - } - - async Task UpdateDeployment( - CompileJob compileJob, - string description, - DeploymentState deploymentState, - CancellationToken cancellationToken) - { - if (compileJob == null) - throw new ArgumentNullException(nameof(compileJob)); - - if (!compileJob.GitHubRepoId.HasValue || !compileJob.GitHubDeploymentId.HasValue) - { - logger.LogTrace("Not updating deployment as it is missing a repo ID or deployment ID."); - return; - } - - logger.LogTrace("Updating deployment {0} to {1}...", compileJob.GitHubDeploymentId.Value, deploymentState); - - string gitHubAccessToken = null; - await databaseContextFactory.UseContext( - async databaseContext => - gitHubAccessToken = await databaseContext - .RepositorySettings - .AsQueryable() - .Where(x => x.InstanceId == metadata.Id) - .Select(x => x.AccessToken) - .FirstAsync(cancellationToken) - .ConfigureAwait(false)) - .ConfigureAwait(false); - - if (gitHubAccessToken == null) - { - logger.LogWarning( - "GitHub access token disappeared during deployment, can't update to {0}!", - deploymentState); - return; - } - - var gitHubClient = gitHubClientFactory.CreateClient(gitHubAccessToken); - - await gitHubClient - .Repository - .Deployment - .Status - .Create( - compileJob.GitHubRepoId.Value, - compileJob.GitHubDeploymentId.Value, - new NewDeploymentStatus(deploymentState) - { - Description = description - }) - .WithToken(cancellationToken) - .ConfigureAwait(false); - } - - /// - public Task StageDeployment( - CompileJob compileJob, - CancellationToken cancellationToken) - => UpdateDeployment( - compileJob, - "The deployment succeeded and will be applied a the next server reboot.", - DeploymentState.Pending, - cancellationToken); - - /// - public Task ApplyDeployment(CompileJob compileJob, CompileJob oldCompileJob, CancellationToken cancellationToken) - => UpdateDeployment( - compileJob, - "The deployment is now live on the server.", - DeploymentState.Success, - cancellationToken); - - /// - public Task FailDeployment(CompileJob compileJob, string errorMessage, CancellationToken cancellationToken) - => UpdateDeployment( - compileJob, - errorMessage, - DeploymentState.Error, - cancellationToken); - - /// - public Task MarkInactive(CompileJob compileJob, CancellationToken cancellationToken) - => UpdateDeployment( - compileJob, - "The deployment has been superceeded.", - DeploymentState.Inactive, - cancellationToken); - } -} diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/BaseRemoteDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/BaseRemoteDeploymentManager.cs new file mode 100644 index 0000000000..a9ea424e52 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/BaseRemoteDeploymentManager.cs @@ -0,0 +1,199 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + /// Base class for implementing s. + /// + abstract class BaseRemoteDeploymentManager : IRemoteDeploymentManager + { + /// + /// The for the . + /// + protected Api.Models.Instance Metadata { get; } + + /// + /// The for the . + /// + protected ILogger Logger { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + protected BaseRemoteDeploymentManager(ILogger logger, Api.Models.Instance metadata) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + /// + public async Task PostDeploymentComments( + CompileJob compileJob, + RevisionInformation previousRevisionInformation, + RepositorySettings repositorySettings, + string repoOwner, + string repoName, + CancellationToken cancellationToken) + { + if (repositorySettings?.AccessToken == null) + return; + + var deployedRevisionInformation = compileJob.RevisionInformation; + if ((previousRevisionInformation != null && previousRevisionInformation.CommitSha == deployedRevisionInformation.CommitSha) + || !repositorySettings.PostTestMergeComment.Value) + return; + + previousRevisionInformation ??= new RevisionInformation(); + previousRevisionInformation.ActiveTestMerges ??= new List(); + + deployedRevisionInformation.ActiveTestMerges ??= new List(); + var tasks = new List(); + + // added prs + var tmsAdded = deployedRevisionInformation + .ActiveTestMerges + .Select(x => x.TestMerge) + .Where(x => !previousRevisionInformation + .ActiveTestMerges + .Any(y => y.TestMerge.Number == x.Number)) + .ToList(); + var tmsRemoved = previousRevisionInformation + .ActiveTestMerges + .Select(x => x.TestMerge) + .Where(x => !deployedRevisionInformation + .ActiveTestMerges + .Any(y => y.TestMerge.Number == x.Number)) + .ToList(); + var tmsUpdated = deployedRevisionInformation + .ActiveTestMerges + .Select(x => x.TestMerge) + .Where(x => previousRevisionInformation + .ActiveTestMerges + .Any(y => y.TestMerge.Number == x.Number)) + .ToList(); + + if (!tmsAdded.Any() && !tmsRemoved.Any() && !tmsUpdated.Any()) + return; + + Logger.LogTrace( + "Commenting on {0} added, {1} removed, and {2} updated test merge sources...", + tmsAdded.Count, + tmsRemoved.Count, + tmsUpdated.Count); + foreach (var I in tmsAdded) + tasks.Add( + CommentOnTestMergeSource( + repositorySettings, + repoOwner, + repoName, + FormatTestMerge( + repositorySettings, + compileJob, + I, + repoOwner, + repoName, + false), + I.Number, + cancellationToken)); + + foreach (var I in tmsRemoved) + tasks.Add( + CommentOnTestMergeSource( + repositorySettings, + repoOwner, + repoName, + "#### Test Merge Removed", + I.Number, + cancellationToken)); + + foreach (var I in tmsUpdated) + tasks.Add( + CommentOnTestMergeSource( + repositorySettings, + repoOwner, + repoName, + FormatTestMerge( + repositorySettings, + compileJob, + I, + repoOwner, + repoName, + true), + I.Number, + cancellationToken)); + + if (tasks.Any()) + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + /// + /// Create a comment of a given 's source. + /// + /// The to use. + /// The . + /// The . + /// The comment to post. + /// The . + /// The for the operation. + /// A representing the running operation. + protected abstract Task CommentOnTestMergeSource( + RepositorySettings repositorySettings, + string remoteRepositoryOwner, + string remoteRepositoryName, + string comment, + int testMergeNumber, + CancellationToken cancellationToken); + + /// + /// Formats a comment for a given . + /// + /// The to use. + /// The test merge's . + /// The . + /// The . + /// The . + /// If is new, otherwise it has been updated to a different . + /// A representing the running operation. + protected abstract string FormatTestMerge( + RepositorySettings repositorySettings, + CompileJob compileJob, + TestMerge testMerge, + string remoteRepositoryOwner, + string remoteRepositoryName, + bool updated); + + /// + public abstract Task ApplyDeployment(CompileJob compileJob, CompileJob oldCompileJob, CancellationToken cancellationToken); + + /// + public abstract Task FailDeployment(CompileJob compileJob, string errorMessage, CancellationToken cancellationToken); + + /// + public abstract Task MarkInactive(CompileJob compileJob, CancellationToken cancellationToken); + + /// + public abstract Task> RemoveMergedTestMerges( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken); + + /// + public abstract Task StageDeployment(CompileJob compileJob, CancellationToken cancellationToken); + + /// + public abstract Task StartDeployment( + Api.Models.Internal.IGitRemoteInformation remoteInformation, + CompileJob compileJob, + CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/GitHubRemoteDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/GitHubRemoteDeploymentManager.cs new file mode 100644 index 0000000000..4ef0a40b6b --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/GitHubRemoteDeploymentManager.cs @@ -0,0 +1,358 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Octokit; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + /// for GitHub.com + /// + sealed class GitHubRemoteDeploymentManager : BaseRemoteDeploymentManager + { + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The for the . + /// The for the . + public GitHubRemoteDeploymentManager( + IDatabaseContextFactory databaseContextFactory, + IGitHubClientFactory gitHubClientFactory, + ILogger logger, + Api.Models.Instance metadata) + : base(logger, metadata) + { + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + } + + /// + public override async Task StartDeployment( + Api.Models.Internal.IGitRemoteInformation remoteInformation, + CompileJob compileJob, + CancellationToken cancellationToken) + { + if (remoteInformation == null) + throw new ArgumentNullException(nameof(remoteInformation)); + if (compileJob == null) + throw new ArgumentNullException(nameof(compileJob)); + + Logger.LogTrace("Starting deployment..."); + + RepositorySettings repositorySettings = null; + await databaseContextFactory.UseContext( + async databaseContext => + repositorySettings = await databaseContext + .RepositorySettings + .AsQueryable() + .Where(x => x.InstanceId == Metadata.Id) + .FirstAsync(cancellationToken) + .ConfigureAwait(false)) + .ConfigureAwait(false); + + var instanceAuthenticated = repositorySettings.AccessToken == null; + var gitHubClient = repositorySettings.AccessToken == null + ? gitHubClientFactory.CreateClient() + : gitHubClientFactory.CreateClient(repositorySettings.AccessToken); + + var repositoryTask = gitHubClient + .Repository + .Get( + remoteInformation.RemoteRepositoryOwner, + remoteInformation.RemoteRepositoryName); + + if (!repositorySettings.CreateGitHubDeployments.Value) + Logger.LogTrace("Not creating deployment"); + else if (!instanceAuthenticated) + Logger.LogWarning("Can't create GitHub deployment as no access token is set for repository!"); + else + { + Logger.LogTrace("Creating deployment..."); + var deployment = await gitHubClient + .Repository + .Deployment + .Create( + remoteInformation.RemoteRepositoryOwner, + remoteInformation.RemoteRepositoryName, + new NewDeployment(compileJob.RevisionInformation.CommitSha) + { + AutoMerge = false, + Description = "TGS Game Deployment", + Environment = $"TGS: {Metadata.Name}", + ProductionEnvironment = true, + RequiredContexts = new Collection() + }) + .WithToken(cancellationToken) + .ConfigureAwait(false); + + compileJob.GitHubDeploymentId = deployment.Id; + Logger.LogDebug("Created deployment ID {0}", deployment.Id); + + await gitHubClient + .Repository + .Deployment + .Status + .Create( + remoteInformation.RemoteRepositoryOwner, + remoteInformation.RemoteRepositoryName, + deployment.Id, + new NewDeploymentStatus(DeploymentState.InProgress) + { + Description = "The project is being deployed", + AutoInactive = false + }) + .WithToken(cancellationToken) + .ConfigureAwait(false); + + Logger.LogTrace("In-progress deployment status created"); + } + + try + { + var gitHubRepo = await repositoryTask + .WithToken(cancellationToken) + .ConfigureAwait(false); + + compileJob.GitHubRepoId = gitHubRepo.Id; + Logger.LogTrace("Set GitHub ID as {0}", compileJob.GitHubRepoId); + } + catch (RateLimitExceededException ex) when (!repositorySettings.CreateGitHubDeployments.Value) + { + Logger.LogWarning(ex, "Unable to set compile job repository ID!"); + } + } + + async Task UpdateDeployment( + CompileJob compileJob, + string description, + DeploymentState deploymentState, + CancellationToken cancellationToken) + { + if (compileJob == null) + throw new ArgumentNullException(nameof(compileJob)); + + if (!compileJob.GitHubRepoId.HasValue || !compileJob.GitHubDeploymentId.HasValue) + { + Logger.LogTrace("Not updating deployment as it is missing a repo ID or deployment ID."); + return; + } + + Logger.LogTrace("Updating deployment {0} to {1}...", compileJob.GitHubDeploymentId.Value, deploymentState); + + string gitHubAccessToken = null; + await databaseContextFactory.UseContext( + async databaseContext => + gitHubAccessToken = await databaseContext + .RepositorySettings + .AsQueryable() + .Where(x => x.InstanceId == Metadata.Id) + .Select(x => x.AccessToken) + .FirstAsync(cancellationToken) + .ConfigureAwait(false)) + .ConfigureAwait(false); + + if (gitHubAccessToken == null) + { + Logger.LogWarning( + "GitHub access token disappeared during deployment, can't update to {0}!", + deploymentState); + return; + } + + var gitHubClient = gitHubClientFactory.CreateClient(gitHubAccessToken); + + await gitHubClient + .Repository + .Deployment + .Status + .Create( + compileJob.GitHubRepoId.Value, + compileJob.GitHubDeploymentId.Value, + new NewDeploymentStatus(deploymentState) + { + Description = description + }) + .WithToken(cancellationToken) + .ConfigureAwait(false); + } + + /// + public override Task StageDeployment( + CompileJob compileJob, + CancellationToken cancellationToken) + => UpdateDeployment( + compileJob, + "The deployment succeeded and will be applied a the next server reboot.", + DeploymentState.Pending, + cancellationToken); + + /// + public override Task ApplyDeployment(CompileJob compileJob, CompileJob oldCompileJob, CancellationToken cancellationToken) + => UpdateDeployment( + compileJob, + "The deployment is now live on the server.", + DeploymentState.Success, + cancellationToken); + + /// + public override Task FailDeployment(CompileJob compileJob, string errorMessage, CancellationToken cancellationToken) + => UpdateDeployment( + compileJob, + errorMessage, + DeploymentState.Error, + cancellationToken); + + /// + public override Task MarkInactive(CompileJob compileJob, CancellationToken cancellationToken) + => UpdateDeployment( + compileJob, + "The deployment has been superceeded.", + DeploymentState.Inactive, + cancellationToken); + + /// + public override async Task> RemoveMergedTestMerges( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken) + { + if (repository == null) + throw new ArgumentNullException(nameof(repository)); + if (repositorySettings == null) + throw new ArgumentNullException(nameof(repositorySettings)); + if (revisionInformation == null) + throw new ArgumentNullException(nameof(revisionInformation)); + + if (revisionInformation.ActiveTestMerges?.Any() != true) + { + Logger.LogTrace("No test merges to remove."); + return Array.Empty(); + } + + var gitHubClient = repositorySettings.AccessToken != null + ? gitHubClientFactory.CreateClient(repositorySettings.AccessToken) + : gitHubClientFactory.CreateClient(); + + var tasks = revisionInformation + .ActiveTestMerges + .Select(x => gitHubClient + .PullRequest + .Get(repository.RemoteRepositoryOwner, repository.RemoteRepositoryName, x.TestMerge.Number) + .WithToken(cancellationToken)); + try + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logger.LogWarning(ex, "Pull requests update check failed!"); + } + + var newList = revisionInformation.ActiveTestMerges.ToList(); + + PullRequest lastMerged = null; + async Task CheckRemovePR(Task task) + { + var pr = await task.ConfigureAwait(false); + if (!pr.Merged) + return; + + // We don't just assume, actually check the repo contains the merge commit. + if (await repository.ShaIsParent(pr.MergeCommitSha, cancellationToken).ConfigureAwait(false)) + { + if (lastMerged == null || lastMerged.MergedAt < pr.MergedAt) + lastMerged = pr; + newList.Remove( + newList.First( + potential => potential.TestMerge.Number == pr.Number)); + } + } + + foreach (var prTask in tasks) + await CheckRemovePR(prTask).ConfigureAwait(false); + + return newList; + } + + /// + protected override async Task CommentOnTestMergeSource( + RepositorySettings repositorySettings, + string remoteRepositoryOwner, + string remoteRepositoryName, + string comment, + int testMergeNumber, + CancellationToken cancellationToken) + { + var gitHubClient = gitHubClientFactory.CreateClient(repositorySettings.AccessToken); + + try + { + await gitHubClient.Issue.Comment.Create(remoteRepositoryOwner, remoteRepositoryName, testMergeNumber, comment) + .WithToken(cancellationToken) + .ConfigureAwait(false); + } + catch (ApiException e) + { + Logger.LogWarning(e, "Error posting GitHub comment!"); + } + } + + /// + protected override string FormatTestMerge( + RepositorySettings repositorySettings, + CompileJob compileJob, + TestMerge testMerge, + string remoteRepositoryOwner, + string remoteRepositoryName, + bool updated) => String.Format( + CultureInfo.InvariantCulture, + "#### Test Merge {4}{0}{0}##### Server Instance{0}{5}{1}{0}{0}##### Revision{0}Origin: {6}{0}Pull Request: {2}{0}Server: {7}{3}{8}", + Environment.NewLine, + repositorySettings.ShowTestMergeCommitters.Value + ? String.Format( + CultureInfo.InvariantCulture, + "{0}{0}##### Merged By{0}{1}", + Environment.NewLine, + testMerge.MergedBy.Name) + : String.Empty, + testMerge.TargetCommitSha, + testMerge.Comment != null + ? String.Format( + CultureInfo.InvariantCulture, + "{0}{0}##### Comment{0}{1}", + Environment.NewLine, + testMerge.Comment) + : String.Empty, + updated ? "Updated" : "Deployed", + Metadata.Name, + compileJob.RevisionInformation.OriginCommitSha, + compileJob.RevisionInformation.CommitSha, + compileJob.GitHubDeploymentId.HasValue + ? $"{Environment.NewLine}[GitHub Deployments](https://github.com/{remoteRepositoryOwner}/{remoteRepositoryName}/deployments/activity_log?environment=TGS%3A%20{Metadata.Name})" + : String.Empty); + } +} diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/GitLabRemoteDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/GitLabRemoteDeploymentManager.cs new file mode 100644 index 0000000000..a81972fc87 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/GitLabRemoteDeploymentManager.cs @@ -0,0 +1,175 @@ +using GitLabApiClient; +using GitLabApiClient.Models.MergeRequests.Responses; +using GitLabApiClient.Models.Notes.Requests; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + /// for GitLab.com + /// + sealed class GitLabRemoteDeploymentManager : BaseRemoteDeploymentManager + { + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The for the . + public GitLabRemoteDeploymentManager(ILogger logger, Api.Models.Instance metadata) + : base(logger, metadata) + { + } + + /// + protected override Task CommentOnTestMergeSource( + RepositorySettings repositorySettings, + string remoteRepositoryOwner, + string remoteRepositoryName, + string comment, + int testMergeNumber, + CancellationToken cancellationToken) + { + var client = repositorySettings.AccessToken != null + ? new GitLabClient(GitLabRemoteFeatures.GitLabUrl, repositorySettings.AccessToken) + : new GitLabClient(GitLabRemoteFeatures.GitLabUrl); + + return client + .MergeRequests + .CreateNoteAsync( + $"{remoteRepositoryOwner}/{remoteRepositoryName}", + testMergeNumber, + new CreateMergeRequestNoteRequest(comment)) + .WithToken(cancellationToken); + } + + /// + protected override string FormatTestMerge( + RepositorySettings repositorySettings, + CompileJob compileJob, + TestMerge testMerge, + string remoteRepositoryOwner, + string remoteRepositoryName, + bool updated) => String.Format( + CultureInfo.InvariantCulture, + "#### Test Merge {4}{0}{0}##### Server Instance{0}{5}{1}{0}{0}##### Revision{0}Origin: {6}{0}Merge Request: {2}{0}Server: {7}{3}", + Environment.NewLine, + repositorySettings.ShowTestMergeCommitters.Value + ? String.Format( + CultureInfo.InvariantCulture, + "{0}{0}##### Merged By{0}{1}", + Environment.NewLine, + testMerge.MergedBy.Name) + : String.Empty, + testMerge.TargetCommitSha, + testMerge.Comment != null + ? String.Format( + CultureInfo.InvariantCulture, + "{0}{0}##### Comment{0}{1}", + Environment.NewLine, + testMerge.Comment) + : String.Empty, + updated ? "Updated" : "Deployed", + Metadata.Name, + compileJob.RevisionInformation.OriginCommitSha, + compileJob.RevisionInformation.CommitSha); + + /// + public override async Task> RemoveMergedTestMerges( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken) + { + if (repository == null) + throw new ArgumentNullException(nameof(repository)); + if (repositorySettings == null) + throw new ArgumentNullException(nameof(repositorySettings)); + if (revisionInformation == null) + throw new ArgumentNullException(nameof(revisionInformation)); + + if (revisionInformation.ActiveTestMerges?.Any() != true) + { + Logger.LogTrace("No test merges to remove."); + return Array.Empty(); + } + + var client = repositorySettings.AccessToken != null + ? new GitLabClient(GitLabRemoteFeatures.GitLabUrl, repositorySettings.AccessToken) + : new GitLabClient(GitLabRemoteFeatures.GitLabUrl); + + var tasks = revisionInformation + .ActiveTestMerges + .Select(x => client + .MergeRequests + .GetAsync( + $"{repository.RemoteRepositoryOwner}/{repository.RemoteRepositoryName}", + x.TestMerge.Number) + .WithToken(cancellationToken)); + try + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + Logger.LogWarning(ex, "Merge requests update check failed!"); + } + + var newList = revisionInformation.ActiveTestMerges.ToList(); + + MergeRequest lastMerged = null; + async Task CheckRemoveMR(Task task) + { + var mergeRequest = await task.ConfigureAwait(false); + if (mergeRequest.State != MergeRequestState.Merged) + return; + + // We don't just assume, actually check the repo contains the merge commit. + if (await repository.ShaIsParent(mergeRequest.MergeCommitSha, cancellationToken).ConfigureAwait(false)) + { + if (lastMerged == null || lastMerged.ClosedAt < mergeRequest.ClosedAt) + lastMerged = mergeRequest; + newList.Remove( + newList.First( + potential => potential.TestMerge.Number == mergeRequest.Id)); + } + } + + foreach (var prTask in tasks) + await CheckRemoveMR(prTask).ConfigureAwait(false); + + return newList; + } + + /// + public override Task ApplyDeployment( + CompileJob compileJob, + CompileJob oldCompileJob, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public override Task FailDeployment( + CompileJob compileJob, + string errorMessage, + CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public override Task MarkInactive(CompileJob compileJob, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public override Task StageDeployment(CompileJob compileJob, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public override Task StartDeployment( + Api.Models.Internal.IGitRemoteInformation remoteInformation, + CompileJob compileJob, + CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/Tgstation.Server.Host/Components/Deployment/IGitHubDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManager.cs similarity index 52% rename from src/Tgstation.Server.Host/Components/Deployment/IGitHubDeploymentManager.cs rename to src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManager.cs index 9b3a955310..26947bb659 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/IGitHubDeploymentManager.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManager.cs @@ -1,23 +1,27 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Models; -namespace Tgstation.Server.Host.Components.Deployment +namespace Tgstation.Server.Host.Components.Deployment.Remote { /// - /// Creates and updates GitHub deployments. + /// Creates and updates remote deployments. /// - interface IGitHubDeploymentManager + interface IRemoteDeploymentManager { /// /// Start a deployment for a given . /// - /// The being deployed. + /// The of the repository being deployed. /// The active . /// The for the operation. /// A representing the running operation. - Task StartDeployment(IRepository repository, CompileJob compileJob, CancellationToken cancellationToken); + Task StartDeployment( + Api.Models.Internal.IGitRemoteInformation remoteInformation, + CompileJob compileJob, + CancellationToken cancellationToken); /// /// Stage a given 's deployment. @@ -54,5 +58,37 @@ namespace Tgstation.Server.Host.Components.Deployment /// The for the operation. /// A representing the running operation. Task MarkInactive(CompileJob compileJob, CancellationToken cancellationToken); + + /// + /// Post deployment comments to the test merge ticket. + /// + /// The deployed . + /// The of the previous deployment. + /// The . + /// The GitHub repostiory owner. + /// The GitHub repostiory name. + /// The for the operation. + /// A representing the running operation. + Task PostDeploymentComments( + CompileJob compileJob, + RevisionInformation previousRevisionInformation, + RepositorySettings repositorySettings, + string repoOwner, + string repoName, + CancellationToken cancellationToken); + + /// + /// Get the updated list of s for an origin merge. + /// + /// The to use. + /// The . + /// The current . + /// The for the operation. + /// A resulting in the of s that should remain the new . + Task> RemoveMergedTestMerges( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManagerFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManagerFactory.cs new file mode 100644 index 0000000000..9ab84ef261 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/IRemoteDeploymentManagerFactory.cs @@ -0,0 +1,26 @@ +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + /// Factory for creating s. + /// + interface IRemoteDeploymentManagerFactory + { + /// + /// Creates a for a given . + /// + /// Current metadata. + /// The in use. + /// A new based on the . + IRemoteDeploymentManager CreateRemoteDeploymentManager(Api.Models.Instance metadata, RemoteGitProvider remoteGitProvider); + + /// + /// Create a for a given . + /// + /// Current metadata. + /// The containing the to use. + /// A new . + IRemoteDeploymentManager CreateRemoteDeploymentManager(Api.Models.Instance metadata, Models.CompileJob compileJob); + } +} diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs new file mode 100644 index 0000000000..4c84dc4a6f --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + /// No-op implementation of . + /// + sealed class NoOpRemoteDeploymentManager : IRemoteDeploymentManager + { + /// + public Task ApplyDeployment( + CompileJob compileJob, + CompileJob oldCompileJob, + CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task FailDeployment( + CompileJob compileJob, + string errorMessage, + CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task MarkInactive( + CompileJob compileJob, + CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task PostDeploymentComments( + CompileJob compileJob, + RevisionInformation previousRevisionInformation, + RepositorySettings repositorySettings, + string repoOwner, + string repoName, + CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task> RemoveMergedTestMerges( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken) => Task.FromResult>(Array.Empty()); + + /// + public Task StageDeployment(CompileJob compileJob, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task StartDeployment( + Api.Models.Internal.IGitRemoteInformation remoteInformation, + CompileJob compileJob, + CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/RemoteDeploymentManagerFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/RemoteDeploymentManagerFactory.cs new file mode 100644 index 0000000000..6bc60ab1fa --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/RemoteDeploymentManagerFactory.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging; +using System; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Database; + +namespace Tgstation.Server.Host.Components.Deployment.Remote +{ + /// + sealed class RemoteDeploymentManagerFactory : IRemoteDeploymentManagerFactory + { + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory; + + /// + /// The for the . + /// + readonly ILoggerFactory loggerFactory; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public RemoteDeploymentManagerFactory( + IDatabaseContextFactory databaseContextFactory, + IGitHubClientFactory gitHubClientFactory, + IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, + ILoggerFactory loggerFactory, + ILogger logger) + { + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.gitRemoteFeaturesFactory = gitRemoteFeaturesFactory ?? throw new ArgumentNullException(nameof(gitRemoteFeaturesFactory)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IRemoteDeploymentManager CreateRemoteDeploymentManager(Api.Models.Instance metadata, RemoteGitProvider remoteGitProvider) + { + if (metadata == null) + throw new ArgumentNullException(nameof(metadata)); + + logger.LogTrace("Creating remote deployment manager for remote git provider {0}...", remoteGitProvider); + return remoteGitProvider switch + { + RemoteGitProvider.GitHub => new GitHubRemoteDeploymentManager( + databaseContextFactory, + gitHubClientFactory, + loggerFactory.CreateLogger(), + metadata), + RemoteGitProvider.GitLab => new GitLabRemoteDeploymentManager( + loggerFactory.CreateLogger(), + metadata), + RemoteGitProvider.Unknown => new NoOpRemoteDeploymentManager(), + _ => throw new InvalidOperationException($"Invalid RemoteGitProvider: {remoteGitProvider}!"), + }; + } + + /// + public IRemoteDeploymentManager CreateRemoteDeploymentManager(Api.Models.Instance metadata, Models.CompileJob compileJob) + { + if (compileJob == null) + throw new ArgumentNullException(nameof(compileJob)); + + RemoteGitProvider remoteGitProvider; + + // Pre 4.7.X + if (compileJob.RepositoryOrigin == null) + remoteGitProvider = RemoteGitProvider.Unknown; + else + remoteGitProvider = gitRemoteFeaturesFactory.ParseRemoteGitProviderFromOrigin( + new Uri( + compileJob.RepositoryOrigin)); + + return CreateRemoteDeploymentManager(metadata, remoteGitProvider); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Events/EventType.cs b/src/Tgstation.Server.Host/Components/Events/EventType.cs index 9b648382c9..9085c6e215 100644 --- a/src/Tgstation.Server.Host/Components/Events/EventType.cs +++ b/src/Tgstation.Server.Host/Components/Events/EventType.cs @@ -1,4 +1,4 @@ -namespace Tgstation.Server.Host.Components.Events +namespace Tgstation.Server.Host.Components.Events { /// /// Types of events. Mirror in tgs.dm @@ -24,10 +24,10 @@ RepoFetch, /// - /// Parameters: Pull request number, pull request sha, merger message + /// Parameters: Test merge number, test merge target sha, merger message /// [EventScript("RepoMergePullRequest")] - RepoMergePullRequest, + RepoAddTestMerge, /// /// Parameters: Absolute path to repository root diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index c4f0ee24ec..3c4b3f437f 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Octokit; using Serilog.Context; using System; using System.Collections.Generic; @@ -11,13 +10,11 @@ using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Watchdog; -using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -66,9 +63,9 @@ namespace Tgstation.Server.Host.Components readonly IEventConsumer eventConsumer; /// - /// The for the . + /// The for the . /// - readonly IGitHubClientFactory gitHubClientFactory; + readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory; /// /// The for the @@ -80,11 +77,6 @@ namespace Tgstation.Server.Host.Components /// readonly Api.Models.Instance metadata; - /// - /// The for the . - /// - readonly GeneralConfiguration generalConfiguration; - /// /// for and . /// @@ -113,9 +105,8 @@ namespace Tgstation.Server.Host.Components /// The value of /// The value of /// The value of - /// The value of . + /// The value of . /// The value of - /// The value of . public Instance( Api.Models.Instance metadata, IRepositoryManager repositoryManager, @@ -128,9 +119,8 @@ namespace Tgstation.Server.Host.Components IDmbFactory dmbFactory, IJobManager jobManager, IEventConsumer eventConsumer, - IGitHubClientFactory gitHubClientFactory, - ILogger logger, - GeneralConfiguration generalConfiguration) + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, + ILogger logger) { this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); RepositoryManager = repositoryManager ?? throw new ArgumentNullException(nameof(repositoryManager)); @@ -142,9 +132,8 @@ namespace Tgstation.Server.Host.Components this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); - this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.generalConfiguration = generalConfiguration ?? throw new ArgumentNullException(nameof(generalConfiguration)); timerLock = new object(); } @@ -274,12 +263,14 @@ namespace Tgstation.Server.Host.Components } var preserveTestMerges = repositorySettings.AutoUpdatesKeepTestMerges.Value; - + var remoteDeploymentManager = remoteDeploymentManagerFactory.CreateRemoteDeploymentManager( + metadata, + repo.RemoteGitProvider.Value); if (result.HasValue) { currentRevInfo = await currentRevInfoTask.ConfigureAwait(false); - var updatedTestMerges = await RemoveMergedPullRequests( + var updatedTestMerges = await remoteDeploymentManager.RemoveMergedTestMerges( repo, repositorySettings, currentRevInfo, @@ -456,73 +447,6 @@ namespace Tgstation.Server.Host.Components } #pragma warning restore CA1502 - /// - /// Get the updated list of s for an origin merge. - /// - /// The to use. - /// The . - /// The current . - /// The for the operation. - /// A resulting in the of s that should remain the new . - async Task> RemoveMergedPullRequests( - IRepository repository, - RepositorySettings repositorySettings, - RevisionInformation revisionInformation, - CancellationToken cancellationToken) - { - if (revisionInformation.ActiveTestMerges?.Any() != true) - { - logger.LogTrace("No test merges to remove."); - return Array.Empty(); - } - - var gitHubClient = repositorySettings.AccessToken != null - ? gitHubClientFactory.CreateClient(repositorySettings.AccessToken) - : (String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) - ? gitHubClientFactory.CreateClient() - : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken)); - - var tasks = revisionInformation - .ActiveTestMerges - .Select(x => gitHubClient - .PullRequest - .Get(repository.GitHubOwner, repository.GitHubRepoName, x.TestMerge.Number) - .WithToken(cancellationToken)); - try - { - await Task.WhenAll(tasks).ConfigureAwait(false); - } - catch (Exception ex) when (!(ex is OperationCanceledException)) - { - logger.LogWarning(ex, "Pull requests update check failed!"); - } - - var newList = revisionInformation.ActiveTestMerges.ToList(); - - PullRequest lastMerged = null; - async Task CheckRemovePR(Task task) - { - var pr = await task.ConfigureAwait(false); - if (!pr.Merged) - return; - - // We don't just assume, actually check the repo contains the merge commit. - if (await repository.ShaIsParent(pr.MergeCommitSha, cancellationToken).ConfigureAwait(false)) - { - if (lastMerged == null || lastMerged.MergedAt < pr.MergedAt) - lastMerged = pr; - newList.Remove( - newList.First( - potential => potential.TestMerge.Number == pr.Number)); - } - } - - foreach (var prTask in tasks) - await CheckRemovePR(prTask).ConfigureAwait(false); - - return newList; - } - /// public Task InstanceRenamed(string newName, CancellationToken cancellationToken) { diff --git a/src/Tgstation.Server.Host/Components/InstanceFactory.cs b/src/Tgstation.Server.Host/Components/InstanceFactory.cs index 548410eeef..cd04474fb4 100644 --- a/src/Tgstation.Server.Host/Components/InstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/InstanceFactory.cs @@ -7,6 +7,7 @@ using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Chat.Commands; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Interop.Bridge; using Tgstation.Server.Host.Components.Repository; @@ -19,6 +20,7 @@ using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Components { @@ -100,11 +102,6 @@ namespace Tgstation.Server.Host.Components /// readonly INetworkPromptReaper networkPromptReaper; - /// - /// The for the - /// - readonly IGitHubClientFactory gitHubClientFactory; - /// /// The for the /// @@ -125,6 +122,21 @@ namespace Tgstation.Server.Host.Components /// readonly IServerPortProvider serverPortProvider; + /// + /// The for the . + /// + readonly IFileTransferTicketProvider fileTransferService; + + /// + /// The for the . + /// + readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory; + + /// + /// The for the . + /// + readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory; + /// /// The for the . /// @@ -148,11 +160,13 @@ namespace Tgstation.Server.Host.Components /// The value of /// The value of /// The value of - /// The value of /// The value of /// The value of . /// The value of . /// The value of . + /// The value of . + /// The value of . + /// The value of . /// The containing the value of . public InstanceFactory( IIOManager ioManager, @@ -170,11 +184,13 @@ namespace Tgstation.Server.Host.Components IWatchdogFactory watchdogFactory, IJobManager jobManager, INetworkPromptReaper networkPromptReaper, - IGitHubClientFactory gitHubClientFactory, IPlatformIdentifier platformIdentifier, ILibGit2RepositoryFactory repositoryFactory, ILibGit2Commands repositoryCommands, IServerPortProvider serverPortProvider, + IFileTransferTicketProvider fileTransferService, + IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, IOptions generalConfigurationOptions) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); @@ -192,11 +208,13 @@ namespace Tgstation.Server.Host.Components this.watchdogFactory = watchdogFactory ?? throw new ArgumentNullException(nameof(watchdogFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.networkPromptReaper = networkPromptReaper ?? throw new ArgumentNullException(nameof(networkPromptReaper)); - this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.repositoryFactory = repositoryFactory ?? throw new ArgumentNullException(nameof(repositoryFactory)); this.repositoryCommands = repositoryCommands ?? throw new ArgumentNullException(nameof(repositoryCommands)); this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); + this.gitRemoteFeaturesFactory = gitRemoteFeaturesFactory ?? throw new ArgumentNullException(nameof(gitRemoteFeaturesFactory)); + this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -214,13 +232,22 @@ namespace Tgstation.Server.Host.Components var diagnosticsIOManager = new ResolvingIOManager(instanceIoManager, "Diagnostics"); var configurationIoManager = new ResolvingIOManager(instanceIoManager, "Configuration"); - var configuration = new StaticFiles.Configuration(configurationIoManager, synchronousIOManager, symlinkFactory, processExecutor, postWriteHandler, platformIdentifier, loggerFactory.CreateLogger()); + var configuration = new StaticFiles.Configuration( + configurationIoManager, + synchronousIOManager, + symlinkFactory, + processExecutor, + postWriteHandler, + platformIdentifier, + fileTransferService, + loggerFactory.CreateLogger()); var eventConsumer = new EventConsumer(configuration); var repoManager = new RepositoryManager( repositoryFactory, repositoryCommands, repoIoManager, eventConsumer, + gitRemoteFeaturesFactory, loggerFactory.CreateLogger(), loggerFactory.CreateLogger()); try @@ -248,16 +275,10 @@ namespace Tgstation.Server.Host.Components loggerFactory.CreateLogger(), metadata.CloneMetadata()); - var gitHubDeploymentManager = new GitHubDeploymentManager( - databaseContextFactory, - gitHubClientFactory, - loggerFactory.CreateLogger(), - metadata.CloneMetadata()); - var dmbFactory = new DmbFactory( databaseContextFactory, gameIoManager, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, loggerFactory.CreateLogger(), metadata.CloneMetadata()); try @@ -276,7 +297,7 @@ namespace Tgstation.Server.Host.Components gameIoManager, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, metadata.CloneMetadata(), metadata.DreamDaemonSettings); eventConsumer.SetWatchdog(watchdog); @@ -292,10 +313,9 @@ namespace Tgstation.Server.Host.Components eventConsumer, chatManager, processExecutor, - gitHubClientFactory, dmbFactory, repoManager, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, loggerFactory.CreateLogger(), metadata.CloneMetadata()); @@ -310,9 +330,8 @@ namespace Tgstation.Server.Host.Components dmbFactory, jobManager, eventConsumer, - gitHubClientFactory, - loggerFactory.CreateLogger(), - generalConfiguration); + remoteDeploymentManagerFactory, + loggerFactory.CreateLogger()); return instance; } @@ -355,6 +374,6 @@ namespace Tgstation.Server.Host.Components /// /// Test that the is functional. /// - private void CheckSystemCompatibility() => repositoryFactory.CreateInMemory().Dispose(); + private void CheckSystemCompatibility() => repositoryFactory.CreateInMemory(); } } diff --git a/src/Tgstation.Server.Host/Components/InstanceManager.cs b/src/Tgstation.Server.Host/Components/InstanceManager.cs index 3684cdb799..f4526b2e09 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -18,6 +18,7 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Swarm; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components @@ -26,7 +27,6 @@ namespace Tgstation.Server.Host.Components sealed class InstanceManager : IInstanceManager, IInstanceCoreProvider, - IRestartHandler, IHostedService, IBridgeRegistrar, IAsyncDisposable @@ -34,11 +34,6 @@ namespace Tgstation.Server.Host.Components /// public Task Ready => readyTcs.Task; - /// - /// The for the ; - /// - readonly Lazy lazyRestartRegistration; - /// /// The for the /// @@ -79,16 +74,16 @@ namespace Tgstation.Server.Host.Components /// readonly IAsyncDelayer asyncDelayer; - /// - /// The for the - /// - readonly IDatabaseSeeder databaseSeeder; - /// /// The for the /// readonly IServerPortProvider serverPortProvider; + /// + /// The for the . + /// + readonly ISwarmService swarmService; + /// /// The for the /// @@ -114,16 +109,16 @@ namespace Tgstation.Server.Host.Components /// readonly GeneralConfiguration generalConfiguration; + /// + /// The for the . + /// + readonly SwarmConfiguration swarmConfiguration; + /// /// The for . /// readonly TaskCompletionSource readyTcs; - /// - /// Used in to determine if database downgrades must be made - /// - Version downgradeVersion; - /// /// If the has been 'd /// @@ -140,9 +135,10 @@ namespace Tgstation.Server.Host.Components /// The value of /// The value of . /// The value of . - /// The value of . /// The value of . + /// The value of . /// The containing the value of . + /// The containing the value of . /// The value of public InstanceManager( IInstanceFactory instanceFactory, @@ -153,9 +149,10 @@ namespace Tgstation.Server.Host.Components IServerControl serverControl, ISystemIdentityFactory systemIdentityFactory, IAsyncDelayer asyncDelayer, - IDatabaseSeeder databaseSeeder, IServerPortProvider serverPortProvider, + ISwarmService swarmService, IOptions generalConfigurationOptions, + IOptions swarmConfigurationOptions, ILogger logger) { this.instanceFactory = instanceFactory ?? throw new ArgumentNullException(nameof(instanceFactory)); @@ -166,13 +163,12 @@ namespace Tgstation.Server.Host.Components this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); - this.databaseSeeder = databaseSeeder ?? throw new ArgumentNullException(nameof(databaseSeeder)); this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider)); + this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - lazyRestartRegistration = new Lazy(() => serverControl.RegisterForRestart(this)); - instances = new Dictionary(); bridgeHandlers = new Dictionary(); readyTcs = new TaskCompletionSource(); @@ -192,7 +188,6 @@ namespace Tgstation.Server.Host.Components foreach (var I in instances) await I.Value.Instance.DisposeAsync().ConfigureAwait(false); - lazyRestartRegistration.Value.Dispose(); instanceStateChangeSemaphore.Dispose(); logger.LogInformation("Server shutdown"); @@ -208,7 +203,7 @@ namespace Tgstation.Server.Host.Components { if (!instances.TryGetValue(metadata.Id, out var instance)) { - logger.LogTrace("Cannot reference instance {0} as it is not online!", metadata.Id); + logger.LogTrace("Cannot reference instance {0} as it is not online or on this node!", metadata.Id); return null; } @@ -385,35 +380,37 @@ namespace Tgstation.Server.Host.Components } /// - #pragma warning disable CA1506 // TODO: Decomplexify - public Task StartAsync(CancellationToken cancellationToken) => databaseContextFactory.UseContext(async databaseContext => + public async Task StartAsync(CancellationToken cancellationToken) { - logger.LogInformation(assemblyInformationProvider.VersionString); - - // we do this here because making the restart registration triggers a trace log message - // The above log message should be the first one one startup - var _ = lazyRestartRegistration.Value; - try { + logger.LogInformation(assemblyInformationProvider.VersionString); generalConfiguration.CheckCompatibility(logger); CheckSystemCompatibility(); + + await InitializeSwarm(cancellationToken).ConfigureAwait(false); + + + List dbInstances = null; + var instanceEnumeration = databaseContextFactory.UseContext( + async databaseContext => dbInstances = await databaseContext + .Instances + .AsQueryable() + .Where(x => x.Online.Value && x.SwarmIdentifer == swarmConfiguration.Identifier) + .Include(x => x.RepositorySettings) + .Include(x => x.ChatSettings) + .ThenInclude(x => x.Channels) + .Include(x => x.DreamDaemonSettings) + .ToListAsync(cancellationToken) + .ConfigureAwait(false)); + var factoryStartup = instanceFactory.StartAsync(cancellationToken); - await databaseSeeder.Initialize(databaseContext, cancellationToken).ConfigureAwait(false); - await jobManager.StartAsync(cancellationToken).ConfigureAwait(false); - var dbInstances = await databaseContext - .Instances - .AsQueryable() - .Where(x => x.Online.Value) - .Include(x => x.RepositorySettings) - .Include(x => x.ChatSettings) - .ThenInclude(x => x.Channels) - .Include(x => x.DreamDaemonSettings) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - await factoryStartup.ConfigureAwait(false); - var tasks = dbInstances.Select( + var jobManagerStartup = jobManager.StartAsync(cancellationToken); + + await Task.WhenAll(instanceEnumeration, factoryStartup, jobManagerStartup).ConfigureAwait(false); + + var instanceOnliningTasks = dbInstances.Select( async metadata => { try @@ -424,18 +421,18 @@ namespace Tgstation.Server.Host.Components { logger.LogError(ex, "Failed to online instance {0}!"); } - }) - .ToList(); - await Task.WhenAll(tasks).ConfigureAwait(false); + }); + + await Task.WhenAll(instanceOnliningTasks).ConfigureAwait(false); jobManager.Activate(); logger.LogInformation("Server ready!"); readyTcs.SetResult(null); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogInformation("Cancelled instance manager initialization!"); + logger.LogInformation(ex, "Cancelled instance manager initialization!"); } catch (Exception e) { @@ -452,21 +449,19 @@ namespace Tgstation.Server.Host.Components throw; } - }); - #pragma warning restore CA1506 // TODO: Decomplexify + } /// public async Task StopAsync(CancellationToken cancellationToken) { try { + var instanceFactoryStopTask = instanceFactory.StopAsync(cancellationToken); await jobManager.StopAsync(cancellationToken).ConfigureAwait(false); await Task.WhenAll(instances.Select(x => x.Value.Instance.StopAsync(cancellationToken))).ConfigureAwait(false); - await instanceFactory.StopAsync(cancellationToken).ConfigureAwait(false); + await instanceFactoryStopTask.ConfigureAwait(false); - // downgrade the db if necessary - if (downgradeVersion != null) - await databaseContextFactory.UseContext(db => databaseSeeder.Downgrade(db, downgradeVersion, cancellationToken)).ConfigureAwait(false); + await swarmService.Shutdown(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -474,13 +469,6 @@ namespace Tgstation.Server.Host.Components } } - /// - public Task HandleRestart(Version updateVersion, CancellationToken cancellationToken) - { - downgradeVersion = updateVersion != null && updateVersion < assemblyInformationProvider.Version ? updateVersion : null; - return Task.CompletedTask; - } - /// /// Check we have a valid system identity. /// @@ -559,5 +547,29 @@ namespace Tgstation.Server.Host.Components return container?.Instance; } } + + /// + /// Initializes the connection to the TGS swarm. + /// + /// The for the operation. + /// A representing the running operation. + async Task InitializeSwarm(CancellationToken cancellationToken) + { + SwarmRegistrationResult registrationResult; + do + { + registrationResult = await swarmService.Initialize(cancellationToken).ConfigureAwait(false); + + if (registrationResult == SwarmRegistrationResult.Unauthorized) + throw new InvalidOperationException("Swarm private key does not match the swarm controller's!"); + + if (registrationResult == SwarmRegistrationResult.VersionMismatch) + throw new InvalidOperationException("Swarm controller's TGS version does not match our own!"); + + if (registrationResult != SwarmRegistrationResult.Success) + await asyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + while (registrationResult != SwarmRegistrationResult.Success && !cancellationToken.IsCancellationRequested); + } } } diff --git a/src/Tgstation.Server.Host/Components/Interop/Bridge/TestMergeInformation.cs b/src/Tgstation.Server.Host/Components/Interop/Bridge/TestMergeInformation.cs index 314a6d5ed3..d3dc664503 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Bridge/TestMergeInformation.cs +++ b/src/Tgstation.Server.Host/Components/Interop/Bridge/TestMergeInformation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using Tgstation.Server.Api.Models.Internal; @@ -14,6 +14,18 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge /// public string TimeMerged { get; set; } + /// + /// Backing field for needed to continue to support DMAPI 5. + /// + public string PullRequestRevision { get; set; } + + /// + public override string TargetCommitSha + { + get => PullRequestRevision; + set => PullRequestRevision = value; + } + /// /// The of the /// diff --git a/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs b/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs index 9f3100ffa3..c910a8a1e2 100644 --- a/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs +++ b/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using System; -using Tgstation.Server.Host.Components.Interop.Converters; +using Tgstation.Server.Host.Extensions.Converters; using Tgstation.Server.Host.Properties; namespace Tgstation.Server.Host.Components.Interop diff --git a/src/Tgstation.Server.Host/Components/Repository/DefaultGitRemoteFeatures.cs b/src/Tgstation.Server.Host/Components/Repository/DefaultGitRemoteFeatures.cs new file mode 100644 index 0000000000..3bdc876709 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/DefaultGitRemoteFeatures.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// The used for unknown providers. + /// + sealed class DefaultGitRemoteFeatures : IGitRemoteFeatures + { + /// + public string TestMergeRefSpecFormatter => throw new NotSupportedException(); + + /// + public string TestMergeLocalBranchNameFormatter => throw new NotSupportedException(); + + /// + public RemoteGitProvider? RemoteGitProvider => Api.Models.RemoteGitProvider.Unknown; + + /// + public string RemoteRepositoryOwner => null; + + /// + public string RemoteRepositoryName => null; + + /// + public Task GetTestMerge( + TestMergeParameters parameters, + Api.Models.Internal.RepositorySettings repositorySettings, + CancellationToken cancellationToken) => throw new NotSupportedException(); + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/GitHubRemoteFeatures.cs b/src/Tgstation.Server.Host/Components/Repository/GitHubRemoteFeatures.cs new file mode 100644 index 0000000000..260e64fa7e --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/GitHubRemoteFeatures.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Logging; +using Octokit; +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Extensions; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// GitHub . + /// + sealed class GitHubRemoteFeatures : GitRemoteFeaturesBase + { + /// + public override string TestMergeRefSpecFormatter => "pull/{0}/head:{1}"; + + /// + public override string TestMergeLocalBranchNameFormatter => "pull/{0}/headrefs/heads/{1}"; + + /// + public override RemoteGitProvider? RemoteGitProvider => Api.Models.RemoteGitProvider.GitHub; + + /// + public override string RemoteRepositoryOwner { get; } + + /// + public override string RemoteRepositoryName { get; } + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The for the . + /// The remote repository . + public GitHubRemoteFeatures(IGitHubClientFactory gitHubClientFactory, ILogger logger, Uri remoteUrl) + : base(logger, remoteUrl) + { + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + + if (remoteUrl == null) + throw new ArgumentNullException(nameof(remoteUrl)); + + RemoteRepositoryOwner = remoteUrl.Segments[1].TrimEnd('/'); + RemoteRepositoryName = remoteUrl.Segments[2].TrimEnd('/'); + } + + /// + protected override async Task GetTestMergeImpl( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken) + { + var gitHubClient = repositorySettings.AccessToken != null + ? gitHubClientFactory.CreateClient(repositorySettings.AccessToken) + : gitHubClientFactory.CreateClient(); + + PullRequest pr = null; + ApiException exception = null; + string errorMessage = null; + try + { + pr = await gitHubClient + .PullRequest + .Get(RemoteRepositoryOwner, RemoteRepositoryName, parameters.Number) + .WithToken(cancellationToken) + .ConfigureAwait(false); + } + catch (RateLimitExceededException ex) + { + // you look at your anonymous access and sigh + errorMessage = "GITHUB API ERROR: RATE LIMITED"; + exception = ex; + } + catch (AuthorizationException ex) + { + errorMessage = "GITHUB API ERROR: BAD CREDENTIALS"; + exception = ex; + } + catch (NotFoundException ex) + { + // you look at your shithub and sigh + errorMessage = "GITHUB API ERROR: PULL REQUEST NOT FOUND"; + exception = ex; + } + + if (exception != null) + Logger.LogWarning(exception, "Error retrieving pull request metadata!"); + + var revisionToUse = parameters.TargetCommitSha == null + || pr?.Head.Sha.StartsWith(parameters.TargetCommitSha, StringComparison.OrdinalIgnoreCase) == true + ? pr?.Head.Sha + : parameters.TargetCommitSha; + + var testMerge = new Models.TestMerge + { + Author = pr?.User.Login ?? errorMessage, + BodyAtMerge = pr?.Body ?? errorMessage ?? String.Empty, + TitleAtMerge = pr?.Title ?? errorMessage ?? String.Empty, + Comment = parameters.Comment, + Number = parameters.Number, + TargetCommitSha = revisionToUse, + Url = pr?.HtmlUrl ?? errorMessage + }; + + return testMerge; + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/GitLabRemoteFeatures.cs b/src/Tgstation.Server.Host/Components/Repository/GitLabRemoteFeatures.cs new file mode 100644 index 0000000000..cf5fd71149 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/GitLabRemoteFeatures.cs @@ -0,0 +1,101 @@ +using GitLabApiClient; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Extensions; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// GitLab . + /// + sealed class GitLabRemoteFeatures : GitRemoteFeaturesBase + { + /// + /// Url for main GitLab site. + /// + /// Eventually we'll derive this from a repo's origin when someone request custom GitHub/GitLab installation support. + public const string GitLabUrl = "https://gitlab.com"; + + /// + public override string TestMergeRefSpecFormatter => "merge-requests/{0}/head:{1}"; + + /// + public override string TestMergeLocalBranchNameFormatter => "merge-requests/{0}/headrefs/heads/{1}"; + + /// + public override RemoteGitProvider? RemoteGitProvider => Api.Models.RemoteGitProvider.GitLab; + + /// + public override string RemoteRepositoryOwner { get; } + + /// + public override string RemoteRepositoryName { get; } + + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The remote repository . + public GitLabRemoteFeatures(ILogger logger, Uri remoteUrl) + : base(logger, remoteUrl) + { + RemoteRepositoryOwner = remoteUrl.Segments[1].TrimEnd('/'); + RemoteRepositoryName = remoteUrl.Segments[2].TrimEnd('/'); + } + + /// + protected override async Task GetTestMergeImpl( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken) + { + var client = repositorySettings.AccessToken != null + ? new GitLabClient(GitLabUrl, repositorySettings.AccessToken) + : new GitLabClient(GitLabUrl); + + try + { + var mr = await client + .MergeRequests + .GetAsync($"{RemoteRepositoryOwner}/{RemoteRepositoryName}", parameters.Number) + .WithToken(cancellationToken) + .ConfigureAwait(false); + + var revisionToUse = parameters.TargetCommitSha == null + || mr.Sha.StartsWith(parameters.TargetCommitSha, StringComparison.OrdinalIgnoreCase) + ? mr.Sha + : parameters.TargetCommitSha; + + return new Models.TestMerge + { + Author = mr.Author.Username, + BodyAtMerge = mr.Description, + TitleAtMerge = mr.Title, + Comment = parameters.Comment, + Number = parameters.Number, + TargetCommitSha = mr.Sha, + Url = mr.WebUrl + }; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error retrieving merge request metadata!"); + + return new Models.TestMerge + { + Author = ex.Message, + BodyAtMerge = ex.Message, + TitleAtMerge = ex.Message, + Comment = parameters.Comment, + Number = parameters.Number, + TargetCommitSha = parameters.TargetCommitSha, + Url = ex.Message + }; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesBase.cs b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesBase.cs new file mode 100644 index 0000000000..92835dd7ad --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesBase.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// Base for implementing . + /// + abstract class GitRemoteFeaturesBase : IGitRemoteFeatures + { + /// + public abstract string TestMergeRefSpecFormatter { get; } + + /// + public abstract string TestMergeLocalBranchNameFormatter { get; } + + /// + public abstract RemoteGitProvider? RemoteGitProvider { get; } + + /// + public abstract string RemoteRepositoryOwner { get; } + + /// + public abstract string RemoteRepositoryName { get; } + + /// + /// The for the . + /// + protected ILogger Logger { get; } + + /// + /// Cache of created s. + /// + readonly Dictionary cachedLookups; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The remote repository . + public GitRemoteFeaturesBase(ILogger logger, Uri remoteUrl) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (remoteUrl == null) + throw new ArgumentNullException(nameof(remoteUrl)); + + cachedLookups = new Dictionary(); + } + + /// + public async Task GetTestMerge( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken) + { + if (parameters == null) + throw new ArgumentNullException(nameof(parameters)); + if (repositorySettings == null) + throw new ArgumentNullException(nameof(repositorySettings)); + + Models.TestMerge result; + lock (cachedLookups) + if (cachedLookups.TryGetValue(parameters, out result)) + Logger.LogTrace("Using cache for test merge #{0}", parameters.Number); + + if (result == null) + { + Logger.LogTrace("Retrieving metadata for test merge #{0}...", parameters.Number); + result = await GetTestMergeImpl(parameters, repositorySettings, cancellationToken).ConfigureAwait(false); + lock (cachedLookups) + if (!cachedLookups.TryAdd(parameters, result)) + Logger.LogError("Race condition on adding test merge #{0}!", parameters.Number); + } + + return result; + } + + /// + /// Implementation of + /// + /// The . + /// The . + /// The for the operation. + /// A resulting in the of the . + protected abstract Task GetTestMergeImpl( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs new file mode 100644 index 0000000000..2f864b258e --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/GitRemoteFeaturesFactory.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using System; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + sealed class GitRemoteFeaturesFactory : IGitRemoteFeaturesFactory + { + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly ILoggerFactory loggerFactory; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + public GitRemoteFeaturesFactory( + IGitHubClientFactory gitHubClientFactory, + ILoggerFactory loggerFactory, + ILogger logger) + { + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository) + { + if (repository == null) + throw new ArgumentNullException(nameof(repository)); + + var primaryRemote = repository.Origin; + var remoteGitProvider = ParseRemoteGitProviderFromOrigin(primaryRemote); + return remoteGitProvider switch + { + RemoteGitProvider.GitHub => new GitHubRemoteFeatures( + gitHubClientFactory, + loggerFactory.CreateLogger(), + primaryRemote), + RemoteGitProvider.GitLab => new GitLabRemoteFeatures( + loggerFactory.CreateLogger(), + primaryRemote), + RemoteGitProvider.Unknown => new DefaultGitRemoteFeatures(), + _ => throw new InvalidOperationException($"Unknown RemoteGitProvider: {remoteGitProvider}!"), + }; + } + + /// + public RemoteGitProvider ParseRemoteGitProviderFromOrigin(Uri origin) + { + if (origin == null) + throw new ArgumentNullException(nameof(origin)); + + switch (origin.Host.ToUpperInvariant()) + { + case "GITHUB.COM": + case "WWW.GITHUB.COM": + case "GIT.GITHUB.COM": + return RemoteGitProvider.GitHub; + case "GITLAB.COM": + case "WWW.GITLAB.COM": + case "GIT.GITLAB.COM": + return RemoteGitProvider.GitLab; + default: + logger.LogTrace("Unknown git remote: {0}", origin); + return RemoteGitProvider.Unknown; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteAdditionalInformation.cs b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteAdditionalInformation.cs new file mode 100644 index 0000000000..7b4de75b0e --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteAdditionalInformation.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// Additional information from git remotes. + /// + public interface IGitRemoteAdditionalInformation : IGitRemoteInformation + { + /// + /// Retrieve the representation of given test merge . + /// + /// The . + /// The . + /// The for the operation. + /// A resulting in the of the . + Task GetTestMerge( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs new file mode 100644 index 0000000000..d91dca020d --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeatures.cs @@ -0,0 +1,18 @@ +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// Provides features for remote git services + /// + interface IGitRemoteFeatures : IGitRemoteAdditionalInformation + { + /// + /// Gets a formatter string which creates the remote refspec for fetching the HEAD of passed in test merge number. + /// + string TestMergeRefSpecFormatter { get; } + + /// + /// Get + /// + string TestMergeLocalBranchNameFormatter { get; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs new file mode 100644 index 0000000000..b2c00aac6d --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Repository/IGitRemoteFeaturesFactory.cs @@ -0,0 +1,25 @@ +using System; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Components.Repository +{ + /// + /// Factory for creating . + /// + interface IGitRemoteFeaturesFactory + { + /// + /// Create the for a given . + /// + /// The to create for. + /// A new instance. + IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository); + + /// + /// Gets the for a given . + /// + /// The of the origin. + /// The of the . + RemoteGitProvider ParseRemoteGitProviderFromOrigin(Uri origin); + } +} diff --git a/src/Tgstation.Server.Host/Components/Repository/ILibGit2RepositoryFactory.cs b/src/Tgstation.Server.Host/Components/Repository/ILibGit2RepositoryFactory.cs index ac5ee07b91..047671181d 100644 --- a/src/Tgstation.Server.Host/Components/Repository/ILibGit2RepositoryFactory.cs +++ b/src/Tgstation.Server.Host/Components/Repository/ILibGit2RepositoryFactory.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using System; using System.Threading; using System.Threading.Tasks; @@ -11,17 +11,17 @@ namespace Tgstation.Server.Host.Components.Repository interface ILibGit2RepositoryFactory : ICredentialsProvider { /// - /// Create an in-memeory . + /// Create and destory an in-memeory . /// - /// A new in-memory . - LibGit2Sharp.IRepository CreateInMemory(); + /// Used as a test of the libgit2 native library. + void CreateInMemory(); /// /// Load a from a given . /// /// The full path to the . /// The for the operation. - /// A resulting in, the loaded . + /// A resulting in the loaded . Task CreateFromPath(string path, CancellationToken cancellationToken); /// @@ -34,4 +34,4 @@ namespace Tgstation.Server.Host.Components.Repository /// A representing the running operation. Task Clone(Uri url, CloneOptions cloneOptions, string path, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/Repository/IRepository.cs b/src/Tgstation.Server.Host/Components/Repository/IRepository.cs index 02cc7f7175..e2caa16611 100644 --- a/src/Tgstation.Server.Host/Components/Repository/IRepository.cs +++ b/src/Tgstation.Server.Host/Components/Repository/IRepository.cs @@ -8,23 +8,8 @@ namespace Tgstation.Server.Host.Components.Repository /// /// Represents an on-disk git repository /// - public interface IRepository : IDisposable + public interface IRepository : IGitRemoteAdditionalInformation, IDisposable { - /// - /// If the was cloned from GitHub.com - /// - bool IsGitHubRepository { get; } - - /// - /// The if this - /// - string GitHubOwner { get; } - - /// - /// The if this - /// - string GitHubRepoName { get; } - /// /// If tracks an upstream branch /// @@ -43,7 +28,7 @@ namespace Tgstation.Server.Host.Components.Repository /// /// The current origin remote the is using /// - string Origin { get; } + Uri Origin { get; } /// /// Checks if a given is a sha @@ -63,7 +48,7 @@ namespace Tgstation.Server.Host.Components.Repository Task CheckoutObject(string committish, Action progressReporter, CancellationToken cancellationToken); /// - /// Attempt to merge a GitHub pull request into HEAD + /// Attempt to merge the revision specified by a given set of into HEAD /// /// The of the pull request /// The name of the merge committer diff --git a/src/Tgstation.Server.Host/Components/Repository/LibGit2Commands.cs b/src/Tgstation.Server.Host/Components/Repository/LibGit2Commands.cs index 65c1f21049..58158078ab 100644 --- a/src/Tgstation.Server.Host/Components/Repository/LibGit2Commands.cs +++ b/src/Tgstation.Server.Host/Components/Repository/LibGit2Commands.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using System; using System.Collections.Generic; @@ -22,13 +22,10 @@ namespace Tgstation.Server.Host.Components.Repository if (libGit2Repo == null) throw new ArgumentNullException(nameof(libGit2Repo)); - if (!(libGit2Repo is LibGit2Sharp.Repository concreteRepo)) - throw new ArgumentException("libGit2Repo must be an instance of LibGit2Sharp.Repository!", nameof(libGit2Repo)); - if (remote == null) throw new ArgumentNullException(nameof(remote)); - Commands.Fetch(concreteRepo, remote.Name, refSpecs, fetchOptions, logMessage); + Commands.Fetch((LibGit2Sharp.Repository)libGit2Repo, remote.Name, refSpecs, fetchOptions, logMessage); } } } diff --git a/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs b/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs index b09fbc9c45..182ca3c3dc 100644 --- a/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs +++ b/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs @@ -28,19 +28,20 @@ namespace Tgstation.Server.Host.Components.Repository } /// - public LibGit2Sharp.IRepository CreateInMemory() + public void CreateInMemory() { logger.LogTrace("Creating in-memory libgit2 repository..."); - return new LibGit2Sharp.Repository(); + using var _ = new LibGit2Sharp.Repository(); + logger.LogTrace("Success"); } /// - public Task CreateFromPath(string path, CancellationToken cancellationToken) + public async Task CreateFromPath(string path, CancellationToken cancellationToken) { if (path == null) throw new ArgumentNullException(nameof(path)); - return Task.Factory.StartNew( + var repo = await Task.Factory.StartNew( () => { logger.LogTrace("Creating libgit2 repostory at {0}...", path); @@ -48,7 +49,10 @@ namespace Tgstation.Server.Host.Components.Repository }, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, - TaskScheduler.Current); + TaskScheduler.Current) + .ConfigureAwait(false); + + return repo; } /// diff --git a/src/Tgstation.Server.Host/Components/Repository/Repository.cs b/src/Tgstation.Server.Host/Components/Repository/Repository.cs index 53e7d5b50b..3ab698c381 100644 --- a/src/Tgstation.Server.Host/Components/Repository/Repository.cs +++ b/src/Tgstation.Server.Host/Components/Repository/Repository.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -17,11 +18,6 @@ namespace Tgstation.Server.Host.Components.Repository /// sealed class Repository : IRepository { - /// - /// Indication of a GitHub repository - /// - public const string GitHubUrl = "://github.com/"; - /// /// The default username for committers. /// @@ -42,16 +38,19 @@ namespace Tgstation.Server.Host.Components.Repository /// public const string RemoteTemporaryBranchName = "___TGSTempBranch"; + /// + /// Used when a reference cannot be determined + /// const string UnknownReference = ""; /// - public bool IsGitHubRepository { get; } + public RemoteGitProvider? RemoteGitProvider => gitRemoteFeatures.RemoteGitProvider; /// - public string GitHubOwner { get; } + public string RemoteRepositoryOwner => gitRemoteFeatures.RemoteRepositoryOwner; /// - public string GitHubRepoName { get; } + public string RemoteRepositoryName => gitRemoteFeatures.RemoteRepositoryName; /// public bool Tracking => Reference != null && libGitRepo.Head.IsTracking; @@ -63,7 +62,7 @@ namespace Tgstation.Server.Host.Components.Repository public string Reference => libGitRepo.Head.FriendlyName; /// - public string Origin => libGitRepo.Network.Remotes.First().Url; + public Uri Origin => new Uri(libGitRepo.Network.Remotes.First().Url); /// /// The for the @@ -90,6 +89,11 @@ namespace Tgstation.Server.Host.Components.Repository /// readonly ICredentialsProvider credentialsProvider; + /// + /// The for the . + /// + readonly IGitRemoteFeatures gitRemoteFeatures; + /// /// The for the /// @@ -120,6 +124,7 @@ namespace Tgstation.Server.Host.Components.Repository /// The value of /// The value of /// The value of + /// The to provide the value of . /// The value of /// The value if public Repository( @@ -128,6 +133,7 @@ namespace Tgstation.Server.Host.Components.Repository IIOManager ioMananger, IEventConsumer eventConsumer, ICredentialsProvider credentialsProvider, + IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, ILogger logger, Action onDispose) { @@ -136,17 +142,13 @@ namespace Tgstation.Server.Host.Components.Repository this.ioMananger = ioMananger ?? throw new ArgumentNullException(nameof(ioMananger)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); this.credentialsProvider = credentialsProvider ?? throw new ArgumentNullException(nameof(credentialsProvider)); + if (gitRemoteFeaturesFactory == null) + throw new ArgumentNullException(nameof(gitRemoteFeaturesFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.onDispose = onDispose ?? throw new ArgumentNullException(nameof(onDispose)); - IsGitHubRepository = Origin.Contains(GitHubUrl, StringComparison.InvariantCultureIgnoreCase); - - if (IsGitHubRepository) - { - GetRepositoryOwnerName(Origin, out var owner, out var name); - GitHubOwner = owner; - GitHubRepoName = name; - } + gitRemoteFeatures = gitRemoteFeaturesFactory.CreateGitRemoteFeatures(this); } /// @@ -165,21 +167,6 @@ namespace Tgstation.Server.Host.Components.Repository onDispose(); } - void GetRepositoryOwnerName(string remote, out string owner, out string name) - { - // Assume standard gh format: [(git)|(https)]://github.com/owner/repo(.git)[0-1] - // Yes use .git twice in case it was weird - var toRemove = new string[] { ".git", "/", ".git" }; - foreach (string item in toRemove) - if (remote.EndsWith(item, StringComparison.OrdinalIgnoreCase)) - remote = remote.Substring(0, remote.LastIndexOf(item, StringComparison.OrdinalIgnoreCase)); - var splits = remote.Split('/'); - name = splits.Last(); - owner = splits[^2].Split('.').First(); - - logger.LogTrace("GetRepositoryOwnerName({0}) => {1} / {2}", remote, owner, name); - } - /// /// Generate a standard set of /// @@ -278,35 +265,35 @@ namespace Tgstation.Server.Host.Components.Repository logger.LogDebug("Begin AddTestMerge: #{0} at {1} ({2}) by <{3} ({4})>", testMergeParameters.Number, - testMergeParameters.PullRequestRevision?.Substring(0, 7), + testMergeParameters.TargetCommitSha?.Substring(0, 7), testMergeParameters.Comment, committerName, committerEmail); - if (!IsGitHubRepository) - throw new JobException(ErrorCode.RepoTestMergeInvalidRemote); + if (RemoteGitProvider == Api.Models.RemoteGitProvider.Unknown) + throw new InvalidOperationException("Cannot test merge with an Unknown RemoteGitProvider!"); var commitMessage = String.Format( CultureInfo.InvariantCulture, - "Test merge of pull request #{0}{1}{2}", + "TGS Test merge #{0}{1}{2}", testMergeParameters.Number, testMergeParameters.Comment != null ? Environment.NewLine : String.Empty, testMergeParameters.Comment ?? String.Empty); - var prBranchName = String.Format(CultureInfo.InvariantCulture, "pr-{0}", testMergeParameters.Number); - var localBranchName = String.Format(CultureInfo.InvariantCulture, "pull/{0}/headrefs/heads/{1}", testMergeParameters.Number, prBranchName); + var testMergeBranchName = String.Format(CultureInfo.InvariantCulture, "tm-{0}", testMergeParameters.Number); + var localBranchName = String.Format(CultureInfo.InvariantCulture, gitRemoteFeatures.TestMergeLocalBranchNameFormatter, testMergeParameters.Number, testMergeBranchName); - var refSpec = String.Format(CultureInfo.InvariantCulture, "pull/{0}/head:{1}", testMergeParameters.Number, prBranchName); + var refSpec = String.Format(CultureInfo.InvariantCulture, gitRemoteFeatures.TestMergeRefSpecFormatter, testMergeParameters.Number, testMergeBranchName); var refSpecList = new List { refSpec }; - var logMessage = String.Format(CultureInfo.InvariantCulture, "Merge remote pull request #{0}", testMergeParameters.Number); + var logMessage = String.Format(CultureInfo.InvariantCulture, "Test merge #{0}", testMergeParameters.Number); var originalCommit = libGitRepo.Head; MergeResult result = null; - var sig = new Signature(new Identity(committerName, committerEmail), DateTimeOffset.Now); + var sig = new Signature(new Identity(committerName, committerEmail), DateTimeOffset.UtcNow); await Task.Factory.StartNew(() => { try @@ -344,13 +331,13 @@ namespace Tgstation.Server.Host.Components.Repository cancellationToken.ThrowIfCancellationRequested(); - testMergeParameters.PullRequestRevision = libGitRepo.Lookup(testMergeParameters.PullRequestRevision ?? localBranchName).Sha; + testMergeParameters.TargetCommitSha = libGitRepo.Lookup(testMergeParameters.TargetCommitSha ?? localBranchName).Sha; cancellationToken.ThrowIfCancellationRequested(); - logger.LogTrace("Merging {0} into {1}...", testMergeParameters.PullRequestRevision.Substring(0, 7), Reference); + logger.LogTrace("Merging {0} into {1}...", testMergeParameters.TargetCommitSha.Substring(0, 7), Reference); - result = libGitRepo.Merge(testMergeParameters.PullRequestRevision, sig, new MergeOptions + result = libGitRepo.Merge(testMergeParameters.TargetCommitSha, sig, new MergeOptions { CommitOnSuccess = commitMessage == null, FailOnConflict = true, @@ -379,7 +366,17 @@ namespace Tgstation.Server.Host.Components.Repository if (result.Status == MergeStatus.Conflicts) { - await eventConsumer.HandleEvent(EventType.RepoMergeConflict, new List { originalCommit.Tip.Sha, testMergeParameters.PullRequestRevision, originalCommit.FriendlyName ?? UnknownReference, prBranchName }, cancellationToken).ConfigureAwait(false); + await eventConsumer.HandleEvent( + EventType.RepoMergeConflict, + new List + { + originalCommit.Tip.Sha, + testMergeParameters.TargetCommitSha, + originalCommit.FriendlyName ?? UnknownReference, + testMergeBranchName + }, + cancellationToken) + .ConfigureAwait(false); return null; } @@ -393,11 +390,11 @@ namespace Tgstation.Server.Host.Components.Repository } await eventConsumer.HandleEvent( - EventType.RepoMergePullRequest, + EventType.RepoAddTestMerge, new List { testMergeParameters.Number.ToString(CultureInfo.InvariantCulture), - testMergeParameters.PullRequestRevision, + testMergeParameters.TargetCommitSha, testMergeParameters.Comment }, cancellationToken) @@ -579,7 +576,7 @@ namespace Tgstation.Server.Host.Components.Repository trackedBranch.FriendlyName, committerName, committerEmail); - result = libGitRepo.Merge(trackedBranch, new Signature(committerName, committerEmail, DateTimeOffset.Now), new MergeOptions + result = libGitRepo.Merge(trackedBranch, new Signature(committerName, committerEmail, DateTimeOffset.UtcNow), new MergeOptions { CommitOnSuccess = true, FailOnConflict = true, @@ -751,7 +748,7 @@ namespace Tgstation.Server.Host.Components.Repository new Signature( DefaultCommitterName, DefaultCommitterEmail, - DateTimeOffset.Now), + DateTimeOffset.UtcNow), new MergeOptions { FastForwardStrategy = FastForwardStrategy.FastForwardOnly, @@ -771,5 +768,14 @@ namespace Tgstation.Server.Host.Components.Repository return false; }, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current); + + /// + public Task GetTestMerge( + TestMergeParameters parameters, + RepositorySettings repositorySettings, + CancellationToken cancellationToken) => gitRemoteFeatures.GetTestMerge( + parameters, + repositorySettings, + cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs index b3a1b858b1..6e025f8b6f 100644 --- a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs +++ b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs @@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Repository /// readonly IEventConsumer eventConsumer; + /// + /// The for the + /// + readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory; + /// /// The created s /// @@ -62,6 +67,7 @@ namespace Tgstation.Server.Host.Components.Repository /// The value of . /// The value of /// The value of + /// The value of . /// The value of /// The value of public RepositoryManager( @@ -69,6 +75,7 @@ namespace Tgstation.Server.Host.Components.Repository ILibGit2Commands commands, IIOManager ioManager, IEventConsumer eventConsumer, + IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, ILogger repositoryLogger, ILogger logger) { @@ -76,6 +83,7 @@ namespace Tgstation.Server.Host.Components.Repository this.commands = commands ?? throw new ArgumentNullException(nameof(commands)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); + this.gitRemoteFeaturesFactory = gitRemoteFeaturesFactory ?? throw new ArgumentNullException(nameof(gitRemoteFeaturesFactory)); this.repositoryLogger = repositoryLogger ?? throw new ArgumentNullException(nameof(repositoryLogger)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); semaphore = new SemaphoreSlim(1); @@ -188,18 +196,21 @@ namespace Tgstation.Server.Host.Components.Repository { try { - var libGitRepo = await repositoryFactory.CreateFromPath(ioManager.ResolvePath(), cancellationToken).ConfigureAwait(false); + var libGit2Repo = await repositoryFactory.CreateFromPath(ioManager.ResolvePath(), cancellationToken).ConfigureAwait(false); + return new Repository( - libGitRepo, + libGit2Repo, commands, ioManager, eventConsumer, repositoryFactory, - repositoryLogger, () => - { - logger.LogTrace("Releasing semaphore due to Repository disposal..."); - semaphore.Release(); - }); + gitRemoteFeaturesFactory, + repositoryLogger, + () => + { + logger.LogTrace("Releasing semaphore due to Repository disposal..."); + semaphore.Release(); + }); } catch { diff --git a/src/Tgstation.Server.Host/Components/Session/LaunchResult.cs b/src/Tgstation.Server.Host/Components/Session/LaunchResult.cs index 10132f32ce..c55880b09a 100644 --- a/src/Tgstation.Server.Host/Components/Session/LaunchResult.cs +++ b/src/Tgstation.Server.Host/Components/Session/LaunchResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; namespace Tgstation.Server.Host.Components.Session @@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Components.Session public sealed class LaunchResult { /// - /// The time it took for to return. If the startup timed out + /// The time it took for to return or the initial bridge request to process. If the startup timed out /// public TimeSpan? StartupTime { get; set; } @@ -21,4 +21,4 @@ namespace Tgstation.Server.Host.Components.Session /// public override string ToString() => String.Format(CultureInfo.InvariantCulture, "Exit Code: {0}, Time {1}ms", ExitCode, StartupTime?.TotalMilliseconds); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index 79b17c930b..1462b6cd50 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -212,6 +212,17 @@ namespace Tgstation.Server.Host.Components.Session this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + portClosedForReboot = false; + disposed = false; + apiValidationStatus = ApiValidationStatus.NeverValidated; + released = false; + + rebootTcs = new TaskCompletionSource(); + primeTcs = new TaskCompletionSource(); + initialBridgeRequestTcs = new TaskCompletionSource(); + reattachTopicCts = new CancellationTokenSource(); + synchronizationLock = new object(); + if (apiValidate || DMApiAvailable) { bridgeRegistration = bridgeRegistrar.RegisterHandler(this); @@ -224,17 +235,6 @@ namespace Tgstation.Server.Host.Components.Session ? "no" : $"incompatible ({reattachInformation.Dmb.CompileJob.DMApiVersion})"); - portClosedForReboot = false; - disposed = false; - apiValidationStatus = ApiValidationStatus.NeverValidated; - released = false; - - rebootTcs = new TaskCompletionSource(); - primeTcs = new TaskCompletionSource(); - initialBridgeRequestTcs = new TaskCompletionSource(); - reattachTopicCts = new CancellationTokenSource(); - synchronizationLock = new object(); - async Task WrapLifetime() { var exitCode = await process.Lifetime.ConfigureAwait(false); @@ -300,21 +300,27 @@ namespace Tgstation.Server.Host.Components.Session bool reattached, bool apiValidate) { - var startTime = DateTimeOffset.Now; - var startupTask = !reattached && (apiValidate || DMApiAvailable) + var startTime = DateTimeOffset.UtcNow; + var useBridgeRequestForLaunchResult = !reattached && (apiValidate || DMApiAvailable); + var startupTask = useBridgeRequestForLaunchResult ? initialBridgeRequestTcs.Task : process.Startup; var toAwait = Task.WhenAny(startupTask, process.Lifetime); if (startupTimeout.HasValue) - toAwait = Task.WhenAny(toAwait, Task.Delay(startTime.AddSeconds(startupTimeout.Value) - startTime)); + toAwait = Task.WhenAny(toAwait, Task.Delay(TimeSpan.FromSeconds(startupTimeout.Value))); + + logger.LogTrace( + "Waiting for LaunchResult based on {0}{1}...", + useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup", + startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty); await toAwait.ConfigureAwait(false); var result = new LaunchResult { ExitCode = process.Lifetime.IsCompleted ? (int?)await process.Lifetime.ConfigureAwait(false) : null, - StartupTime = startupTask.IsCompleted ? (TimeSpan?)(DateTimeOffset.Now - startTime) : null + StartupTime = startupTask.IsCompleted ? (TimeSpan?)(DateTimeOffset.UtcNow - startTime) : null }; logger.LogTrace("Launch result: {0}", result); @@ -343,11 +349,16 @@ namespace Tgstation.Server.Host.Components.Session } /// - public async Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken) + public Task ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken) { if (parameters == null) throw new ArgumentNullException(nameof(parameters)); + static Task Error(string message) => Task.FromResult(new BridgeResponse + { + ErrorMessage = message + }); + using (LogContext.PushProperty("Instance", metadata.Id)) { logger.LogTrace("Handling bridge request..."); @@ -358,34 +369,20 @@ namespace Tgstation.Server.Host.Components.Session { case BridgeCommandType.ChatSend: if (parameters.ChatMessage == null) - return new BridgeResponse - { - ErrorMessage = "Missing chatMessage field!" - }; + return Error("Missing chatMessage field!"); if (parameters.ChatMessage.ChannelIds == null) - return new BridgeResponse - { - ErrorMessage = "Missing channelIds field in chatMessage!" - }; + return Error("Missing channelIds field in chatMessage!"); if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _))) - return new BridgeResponse - { - ErrorMessage = "Invalid channelIds in chatMessage!" - }; + return Error("Invalid channelIds in chatMessage!"); if (parameters.ChatMessage.Text == null) - return new BridgeResponse - { - ErrorMessage = "Missing message field in chatMessage!" - }; + return Error("Missing message field in chatMessage!"); - await chat.SendMessage( + chat.QueueMessage( parameters.ChatMessage.Text, - parameters.ChatMessage.ChannelIds.Select(UInt64.Parse), - cancellationToken) - .ConfigureAwait(false); + parameters.ChatMessage.ChannelIds.Select(UInt64.Parse)); break; case BridgeCommandType.Prime: var oldPrimeTcs = primeTcs; @@ -404,10 +401,7 @@ namespace Tgstation.Server.Host.Components.Session { /////UHHHH logger.LogWarning("DreamDaemon sent new port command without providing it's own!"); - return new BridgeResponse - { - ErrorMessage = "Missing stringified port as data parameter!" - }; + return Error("Missing stringified port as data parameter!"); } var currentPort = parameters.CurrentPort.Value; @@ -434,19 +428,13 @@ namespace Tgstation.Server.Host.Components.Session case BridgeCommandType.Startup: apiValidationStatus = ApiValidationStatus.BadValidationRequest; if (parameters.Version == null) - return new BridgeResponse - { - ErrorMessage = "Missing dmApiVersion field!" - }; + return Error("Missing dmApiVersion field!"); DMApiVersion = parameters.Version; if (DMApiVersion.Major != DMApiConstants.Version.Major) { apiValidationStatus = ApiValidationStatus.Incompatible; - return new BridgeResponse - { - ErrorMessage = "Incompatible dmApiVersion!" - }; + return Error("Incompatible dmApiVersion!"); } switch (parameters.MinimumSecurityLevel) @@ -461,17 +449,13 @@ namespace Tgstation.Server.Host.Components.Session apiValidationStatus = ApiValidationStatus.RequiresTrusted; break; case null: - return new BridgeResponse - { - ErrorMessage = "Missing minimumSecurityLevel field!" - }; + return Error("Missing minimumSecurityLevel field!"); default: - return new BridgeResponse - { - ErrorMessage = "Invalid minimumSecurityLevel!" - }; + return Error("Invalid minimumSecurityLevel!"); } + logger.LogTrace("ApiValidationStatus set to {0}", apiValidationStatus); + response.RuntimeInformation = new RuntimeInformation( chatTrackingContext, reattachInformation.Dmb, @@ -504,7 +488,7 @@ namespace Tgstation.Server.Host.Components.Session break; } - return response; + return Task.FromResult(response); } } diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index 5805339a58..427636e6b3 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs @@ -15,6 +15,7 @@ using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Components.StaticFiles { @@ -76,6 +77,11 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// readonly IPlatformIdentifier platformIdentifier; + /// + /// The for . + /// > + readonly IFileTransferTicketProvider fileTransferService; + /// /// The for /// @@ -86,6 +92,16 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// readonly SemaphoreSlim semaphore; + /// + /// The that is triggered when is called. + /// + readonly CancellationTokenSource disposeCts; + + /// + /// The culmination of all upload file transfer callbacks. + /// + Task uploadTasks; + /// /// Construct /// @@ -95,6 +111,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// The value of /// The value of /// The value of + /// The value of . /// The value of public Configuration( IIOManager ioManager, @@ -103,6 +120,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles IProcessExecutor processExecutor, IPostWriteHandler postWriteHandler, IPlatformIdentifier platformIdentifier, + IFileTransferTicketProvider fileTransferService, ILogger logger) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); @@ -111,13 +129,21 @@ namespace Tgstation.Server.Host.Components.StaticFiles this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor)); this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); semaphore = new SemaphoreSlim(1); + disposeCts = new CancellationTokenSource(); + uploadTasks = Task.CompletedTask; } /// - public void Dispose() => semaphore.Dispose(); + public void Dispose() + { + semaphore.Dispose(); + disposeCts.Cancel(); + disposeCts.Dispose(); + } /// /// Get the proper path to @@ -246,17 +272,55 @@ namespace Tgstation.Server.Host.Components.StaticFiles lock (semaphore) try { - var content = synchronousIOManager.ReadFile(path); - string sha1String; + string GetFileSha() + { + var content = synchronousIOManager.ReadFile(path); #pragma warning disable CA5350 // Do not use insecure cryptographic algorithm SHA1. - using (var sha1 = new SHA1Managed()) + using var sha1 = new SHA1Managed(); #pragma warning restore CA5350 // Do not use insecure cryptographic algorithm SHA1. - sha1String = String.Join(String.Empty, sha1.ComputeHash(content).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + return String.Join(String.Empty, sha1.ComputeHash(content).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))); + } + + var originalSha = GetFileSha(); + + var disposeToken = disposeCts.Token; + var fileTicket = fileTransferService.CreateDownload( + new FileDownloadProvider( + () => + { + if (disposeToken.IsCancellationRequested) + return ErrorCode.InstanceOffline; + + var newSha = GetFileSha(); + if (newSha != originalSha) + return ErrorCode.ConfigurationFileUpdated; + + return null; + }, + async cancellationToken => + { + FileStream result = null; + void GetFileStream() + { + result = ioManager.GetFileStream(path, false); + } + + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) + if (systemIdentity == null) + await Task.Factory.StartNew(GetFileStream, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current).ConfigureAwait(false); + else + await systemIdentity.RunImpersonated(GetFileStream, cancellationToken).ConfigureAwait(false); + + return result; + }, + path, + false)); + result = new ConfigurationFile { - Content = content, + FileTicket = fileTicket.FileTicket, IsDirectory = false, - LastReadHash = sha1String, + LastReadHash = originalSha, AccessDenied = false, Path = configurationRelativePath }; @@ -365,7 +429,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles } /// - public async Task Write(string configurationRelativePath, ISystemIdentity systemIdentity, byte[] data, string previousHash, CancellationToken cancellationToken) + public async Task Write(string configurationRelativePath, ISystemIdentity systemIdentity, string previousHash, CancellationToken cancellationToken) { await EnsureDirectories(cancellationToken).ConfigureAwait(false); var path = ValidateConfigRelativePath(configurationRelativePath); @@ -377,20 +441,47 @@ namespace Tgstation.Server.Host.Components.StaticFiles lock (semaphore) try { - var fileHash = previousHash; - var success = synchronousIOManager.WriteFileChecked(path, data, ref fileHash, cancellationToken); - if (!success) - return; - if (data != null) - postWriteHandler.HandleWrite(path); + var fileTicket = fileTransferService.CreateUpload(true); + var uploadCancellationToken = disposeCts.Token; + async Task UploadHandler() + { + using (fileTicket) + { + var fileHash = previousHash; + using var uploadStream = await fileTicket.GetResult(uploadCancellationToken).ConfigureAwait(false); + bool success = false; + void WriteCallback() + { + success = synchronousIOManager.WriteFileChecked(path, uploadStream, ref fileHash, cancellationToken); + } + + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) + if (systemIdentity == null) + await Task.Factory.StartNew(WriteCallback, cancellationToken, DefaultIOManager.BlockingTaskCreationOptions, TaskScheduler.Current).ConfigureAwait(false); + else + await systemIdentity.RunImpersonated(WriteCallback, cancellationToken).ConfigureAwait(false); + + if (!success) + fileTicket.SetErrorMessage(new ErrorMessage(ErrorCode.ConfigurationFileUpdated) + { + AdditionalData = fileHash + }); + else if(uploadStream.Length > 0) + postWriteHandler.HandleWrite(path); + } + } + result = new ConfigurationFile { - Content = data, + FileTicket = fileTicket.Ticket.FileTicket, + LastReadHash = previousHash, IsDirectory = false, - LastReadHash = fileHash, AccessDenied = false, Path = configurationRelativePath }; + + lock (disposeCts) + uploadTasks = Task.WhenAll(uploadTasks, UploadHandler()); } catch (UnauthorizedAccessException) { diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs index b57497a4d1..34a42d299e 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/IConfiguration.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using System; using System.Collections.Generic; using System.Threading; @@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// The relative path in the Configuration directory /// The for the operation. If , the operation will be performed as the user of the /// The for the operation - /// A resulting in the s for the items in the directory. and will both be + /// A resulting in the s for the items in the directory. and will both be Task> ListDirectory(string configurationRelativePath, ISystemIdentity systemIdentity, CancellationToken cancellationToken); /// @@ -72,10 +72,9 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// /// The relative path in the Configuration directory /// The for the operation. If , the operation will be performed as the user of the - /// The data to write. If , the file is deleted /// The hash any existing file must match in order for the write to succeed /// The for the operation. Usage may result in partial writes /// A resulting in the updated or if the write failed due to conflicts - Task Write(string configurationRelativePath, ISystemIdentity systemIdentity, byte[] data, string previousHash, CancellationToken cancellationToken); + Task Write(string configurationRelativePath, ISystemIdentity systemIdentity, string previousHash, CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs index faf935a4e1..c364a8cf65 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; @@ -47,7 +48,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The for the . @@ -62,7 +63,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IAsyncDelayer asyncDelayer, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, ILogger logger, DreamDaemonLaunchParameters initialLaunchParameters, Api.Models.Instance instance, @@ -77,7 +78,7 @@ namespace Tgstation.Server.Host.Components.Watchdog asyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, logger, initialLaunchParameters, instance, @@ -94,7 +95,7 @@ namespace Tgstation.Server.Host.Components.Watchdog if (Server.RebootState == Session.RebootState.Shutdown) { // the time for graceful shutdown is now - await Chat.SendWatchdogMessage( + await Chat.QueueWatchdogMessage( String.Format( CultureInfo.InvariantCulture, "Server {0}! Shutting down due to graceful termination request...", @@ -104,7 +105,7 @@ namespace Tgstation.Server.Host.Components.Watchdog return MonitorAction.Exit; } - await Chat.SendWatchdogMessage( + await Chat.QueueWatchdogMessage( String.Format( CultureInfo.InvariantCulture, "Server {0}! Rebooting...", @@ -131,7 +132,7 @@ namespace Tgstation.Server.Host.Components.Watchdog return MonitorAction.Restart; case Session.RebootState.Shutdown: // graceful shutdown time - await Chat.SendWatchdogMessage( + await Chat.QueueWatchdogMessage( "Active server rebooted! Shutting down due to graceful termination request...", cancellationToken) .ConfigureAwait(false); @@ -264,7 +265,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { gracefulRebootRequired = true; if (Server.CompileJob.DMApiVersion == null) - return Chat.SendWatchdogMessage( + return Chat.QueueWatchdogMessage( "A new deployment has been made but cannot be applied automatically as the currently running server has no DMAPI. Please manually reboot the server to apply the update.", cancellationToken); return Server.SetRebootState(Session.RebootState.Restart, cancellationToken); diff --git a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdogFactory.cs index 128361d841..b833c7812a 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdogFactory.cs @@ -1,6 +1,7 @@ using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.IO; @@ -22,7 +23,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The pointing to the Game directory for the . /// The pointing to the Diagnostics directory for the . /// The for the . - /// The for the . + /// The for the . /// The for the /// The initial for the /// A new @@ -34,7 +35,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager gameIOManager, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, Api.Models.Instance instance, DreamDaemonSettings settings); } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs index 9259b15253..a0ef483b82 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; @@ -35,7 +36,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . + /// The for the . /// The pointing to the game directory for the .. /// The for the . /// The for the . @@ -52,7 +53,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IAsyncDelayer asyncDelayer, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, IIOManager gameIOManager, ISymlinkFactory symlinkFactory, ILogger logger, @@ -69,7 +70,7 @@ namespace Tgstation.Server.Host.Components.Watchdog asyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, gameIOManager, symlinkFactory, logger, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs index 8a1575f89c..92aa8ed26b 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs @@ -4,6 +4,7 @@ using System; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; @@ -52,7 +53,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager gameIOManager, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, Api.Models.Instance instance, DreamDaemonSettings settings) => new PosixWatchdog( @@ -65,7 +66,7 @@ namespace Tgstation.Server.Host.Components.Watchdog AsyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, gameIOManager, SymlinkFactory, LoggerFactory.CreateLogger(), diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index 83ed8294c1..5a24b01076 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -10,6 +10,7 @@ using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Interop.Topic; using Tgstation.Server.Host.Components.Session; @@ -85,7 +86,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The for the . /// - readonly Api.Models.Instance instance; + readonly Api.Models.Instance metadata; /// /// The for the . @@ -123,9 +124,9 @@ namespace Tgstation.Server.Host.Components.Watchdog readonly IEventConsumer eventConsumer; /// - /// The for the . + /// The for the . /// - readonly IGitHubDeploymentManager gitHubDeploymentManager; + readonly IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory; /// /// If the should in @@ -179,10 +180,10 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The value of . /// The value of . /// The value of . - /// The value of . + /// The value of . /// The value of /// The initial value of . May be modified - /// The value of + /// The value of /// The value of protected WatchdogBase( IChatManager chat, @@ -194,10 +195,10 @@ namespace Tgstation.Server.Host.Components.Watchdog IAsyncDelayer asyncDelayer, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, ILogger logger, DreamDaemonLaunchParameters initialLaunchParameters, - Api.Models.Instance instance, + Api.Models.Instance metadata, bool autoStart) { Chat = chat ?? throw new ArgumentNullException(nameof(chat)); @@ -208,10 +209,10 @@ namespace Tgstation.Server.Host.Components.Watchdog AsyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.diagnosticsIOManager = diagnosticsIOManager ?? throw new ArgumentNullException(nameof(diagnosticsIOManager)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); - this.gitHubDeploymentManager = gitHubDeploymentManager ?? throw new ArgumentNullException(nameof(gitHubDeploymentManager)); + this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); ActiveLaunchParameters = initialLaunchParameters ?? throw new ArgumentNullException(nameof(initialLaunchParameters)); - this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); this.autoStart = autoStart; if (serverControl == null) @@ -268,7 +269,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { var eventTask = eventConsumer.HandleEvent(releaseServers ? EventType.WatchdogDetach : EventType.WatchdogShutdown, null, cancellationToken); - var chatTask = announce ? Chat.SendWatchdogMessage("Shutting down...", cancellationToken) : Task.CompletedTask; + var chatTask = announce ? Chat.QueueWatchdogMessage("Shutting down...", cancellationToken) : Task.CompletedTask; await eventTask.ConfigureAwait(false); @@ -313,7 +314,7 @@ namespace Tgstation.Server.Host.Components.Watchdog case 2: var message2 = "DEFCON 3: DreamDaemon has missed 2 heartbeats!"; Logger.LogInformation(message2); - await Chat.SendWatchdogMessage(message2, cancellationToken).ConfigureAwait(false); + await Chat.QueueWatchdogMessage(message2, cancellationToken).ConfigureAwait(false); break; case 3: var actionToTake = shouldShutdown @@ -321,7 +322,7 @@ namespace Tgstation.Server.Host.Components.Watchdog : "be restarted"; var message3 = $"DEFCON 2: DreamDaemon has missed 3 heartbeats! If it does not respond to the next one, the watchdog will {actionToTake}!"; Logger.LogWarning(message3); - await Chat.SendWatchdogMessage(message3, cancellationToken).ConfigureAwait(false); + await Chat.QueueWatchdogMessage(message3, cancellationToken).ConfigureAwait(false); break; case 4: var actionTaken = shouldShutdown @@ -329,7 +330,7 @@ namespace Tgstation.Server.Host.Components.Watchdog : "Restarting"; var message4 = $"DEFCON 1: Four heartbeats have been missed! {actionTaken}..."; Logger.LogWarning(message4); - await Chat.SendWatchdogMessage(message4, cancellationToken).ConfigureAwait(false); + await Chat.QueueWatchdogMessage(message4, cancellationToken).ConfigureAwait(false); await DisposeAndNullControllers(cancellationToken).ConfigureAwait(false); return shouldShutdown ? MonitorAction.Exit : MonitorAction.Restart; default: @@ -370,7 +371,7 @@ namespace Tgstation.Server.Host.Components.Watchdog Task announceTask; if (announce) { - announceTask = Chat.SendWatchdogMessage( + announceTask = Chat.QueueWatchdogMessage( reattachInfo == null ? "Launching..." : "Reattaching...", @@ -404,7 +405,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { await originalChatTask.ConfigureAwait(false); if (announceFailure) - await Chat.SendWatchdogMessage("Startup failed!", cancellationToken).ConfigureAwait(false); + await Chat.QueueWatchdogMessage("Startup failed!", cancellationToken).ConfigureAwait(false); } announceTask = ChainChatTaskWithErrorMessage(); @@ -486,7 +487,7 @@ namespace Tgstation.Server.Host.Components.Watchdog const string FailReattachMessage = "Unable to properly reattach to server! Restarting watchdog..."; Logger.LogWarning(FailReattachMessage); - var chatTask = Chat.SendWatchdogMessage(FailReattachMessage, cancellationToken); + var chatTask = Chat.QueueWatchdogMessage(FailReattachMessage, cancellationToken); await InitControllers(chatTask, null, cancellationToken).ConfigureAwait(false); } @@ -538,7 +539,10 @@ namespace Tgstation.Server.Host.Components.Watchdog return Task.CompletedTask; } - return gitHubDeploymentManager.ApplyDeployment(newCompileJob, ActiveCompileJob, cancellationToken); + var remoteDeploymentManager = remoteDeploymentManagerFactory.CreateRemoteDeploymentManager( + metadata, + newCompileJob); + return remoteDeploymentManager.ApplyDeployment(newCompileJob, ActiveCompileJob, cancellationToken); } /// @@ -587,7 +591,7 @@ namespace Tgstation.Server.Host.Components.Watchdog Math.Pow(2, retryAttempts)), TimeSpan.FromHours(1).TotalSeconds); // max of one hour, increasing by a power of 2 each time - chatTask = Chat.SendWatchdogMessage( + chatTask = Chat.QueueWatchdogMessage( $"Failed to restart (Attempt: {retryAttempts}), retrying in {retryDelay}s...", cancellationToken); @@ -729,7 +733,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var nextActionMessage = nextAction != MonitorAction.Exit ? "Recovering" : "Shutting down"; - var chatTask = Chat.SendWatchdogMessage( + var chatTask = Chat.QueueWatchdogMessage( $"Monitor crashed, this should NEVER happen! Please report this, full details in logs! {nextActionMessage}. Error: {e.Message}", cancellationToken); @@ -807,22 +811,19 @@ namespace Tgstation.Server.Host.Components.Watchdog .ConfigureAwait(false); if (result?.InteropResponse?.ChatResponses != null) - await Task.WhenAll( - result.InteropResponse.ChatResponses.Select( - x => Chat.SendMessage( - x.Text, - x.ChannelIds - .Select(channelIdString => - { - if (UInt64.TryParse(channelIdString, out var channelId)) - return (ulong?)channelId; + foreach (var response in result.InteropResponse.ChatResponses) + Chat.QueueMessage( + response.Text, + response.ChannelIds + .Select(channelIdString => + { + if (UInt64.TryParse(channelIdString, out var channelId)) + return (ulong?)channelId; - return null; - }) - .Where(nullableChannelId => nullableChannelId.HasValue) - .Select(nullableChannelId => nullableChannelId.Value), - cancellationToken))) - .ConfigureAwait(false); + return null; + }) + .Where(nullableChannelId => nullableChannelId.HasValue) + .Select(nullableChannelId => nullableChannelId.Value)); } /// @@ -884,7 +885,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { if (!graceful) { - var chatTask = Chat.SendWatchdogMessage("Manual restart triggered...", cancellationToken); + var chatTask = Chat.QueueWatchdogMessage("Manual restart triggered...", cancellationToken); await TerminateNoLock(false, false, cancellationToken).ConfigureAwait(false); await LaunchNoLock(true, false, true, null, cancellationToken).ConfigureAwait(false); await chatTask.ConfigureAwait(false); @@ -909,7 +910,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { Instance = new Models.Instance { - Id = instance.Id + Id = metadata.Id }, Description = $"Instance startup watchdog {(reattachInfo != null ? "reattach" : "launch")}", CancelRight = (ulong)DreamDaemonRights.Shutdown, @@ -962,7 +963,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { releaseServers = true; if (Status == WatchdogStatus.Online) - await Chat.SendWatchdogMessage("Detaching...", cancellationToken).ConfigureAwait(false); + await Chat.QueueWatchdogMessage("Detaching...", cancellationToken).ConfigureAwait(false); else Logger.LogTrace("Not sending detach chat message as status is: {0}", Status); } @@ -979,7 +980,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var dumpFileName = diagnosticsIOManager.ResolvePath( diagnosticsIOManager.ConcatPath( DumpDirectory, - $"DreamDaemon-{DateTimeOffset.Now.ToFileStamp()}.dmp")); + $"DreamDaemon-{DateTimeOffset.UtcNow.ToFileStamp()}.dmp")); var session = GetActiveController(); if (session?.Lifetime.IsCompleted != false) diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs index 3592b413cf..2185a78586 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs @@ -4,6 +4,7 @@ using System; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; @@ -72,7 +73,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager gameIOManager, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerfactory, Api.Models.Instance instance, DreamDaemonSettings settings) => new BasicWatchdog( @@ -85,7 +86,7 @@ namespace Tgstation.Server.Host.Components.Watchdog AsyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerfactory, LoggerFactory.CreateLogger(), settings, instance, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs index a59fa95ff0..1f14c243fc 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; @@ -55,7 +56,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . + /// The for the . /// The value of . /// The value of . /// The for the . @@ -72,7 +73,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IAsyncDelayer asyncDelayer, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, IIOManager gameIOManager, ISymlinkFactory symlinkFactory, ILogger logger, @@ -88,7 +89,7 @@ namespace Tgstation.Server.Host.Components.Watchdog asyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, logger, initialLaunchParameters, instance, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs index 98c7d1c2c2..0f7f140f6f 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs @@ -4,6 +4,7 @@ using System; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; @@ -58,7 +59,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager gameIOManager, IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, - IGitHubDeploymentManager gitHubDeploymentManager, + IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, Api.Models.Instance instance, DreamDaemonSettings settings) => new WindowsWatchdog( @@ -71,7 +72,7 @@ namespace Tgstation.Server.Host.Components.Watchdog AsyncDelayer, diagnosticsIOManager, eventConsumer, - gitHubDeploymentManager, + remoteDeploymentManagerFactory, gameIOManager, SymlinkFactory, LoggerFactory.CreateLogger(), diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index e58c8d036e..766e4d4cd4 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -43,6 +43,11 @@ namespace Tgstation.Server.Host.Configuration /// const uint DefaultUserLimit = 100; + /// + /// The default value for . + /// + const uint DefaultUserGroupLimit = 25; + /// /// The default value for /// @@ -102,6 +107,7 @@ namespace Tgstation.Server.Host.Configuration MinimumPasswordLength = DefaultMinimumPasswordLength; InstanceLimit = DefaultInstanceLimit; UserLimit = DefaultUserLimit; + UserGroupLimit = DefaultUserGroupLimit; } /// diff --git a/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs b/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs new file mode 100644 index 0000000000..b12e239911 --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/OAuthConfiguration.cs @@ -0,0 +1,20 @@ +using System; + +namespace Tgstation.Server.Host.Configuration +{ + /// + /// OAuth configuration options. + /// + sealed class OAuthConfiguration : OAuthConfigurationBase + { + /// + /// The client redirect URL. Not used by all providers. + /// + public Uri ServerUrl { get; set; } + + /// + /// The authentication server URL. Not used by all providers. + /// + public Uri RedirectUrl { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs new file mode 100644 index 0000000000..6fb4fda5fb --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs @@ -0,0 +1,37 @@ +using System; + +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Base OAuth options. + /// + abstract class OAuthConfigurationBase + { + /// + /// The client ID. + /// + public string ClientId { get; set; } + + /// + /// The client secret. + /// + public string ClientSecret { get; set; } + + /// + /// Initializes a new instance of the . + /// + public OAuthConfigurationBase() { } + + /// + /// Initializes a new instance of the . + /// + /// The to copy settings from. + public OAuthConfigurationBase(OAuthConfigurationBase oAuthConfiguration) + { + if (oAuthConfiguration == null) + throw new ArgumentNullException(nameof(oAuthConfiguration)); + ClientId = oAuthConfiguration.ClientId; + ClientSecret = oAuthConfiguration.ClientSecret; + } + } +} diff --git a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs index c0101eeca9..1957a3b56d 100644 --- a/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/SecurityConfiguration.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using Tgstation.Server.Api.Models; + namespace Tgstation.Server.Host.Configuration { /// @@ -15,6 +18,11 @@ namespace Tgstation.Server.Host.Configuration /// const uint DefaultTokenExpiryMinutes = 15; + /// + /// Default value of . + /// + const uint DefaultOAuthTokenExpiryMinutes = 60 * 24; // 1 day + /// /// Default value of . /// @@ -26,12 +34,12 @@ namespace Tgstation.Server.Host.Configuration const uint DefaultTokenSigningKeyByteAmount = 256; /// - /// Amount of minutes until generated s expire. + /// Amount of minutes until s generated from passwords expire. /// public uint TokenExpiryMinutes { get; set; } = DefaultTokenExpiryMinutes; /// - /// Amount of minutes to skew the clock for validation. + /// Amount of minutes to skew the clock for validation. /// public uint TokenClockSkewMinutes { get; set; } = DefaultTokenClockSkewMinutes; @@ -40,9 +48,19 @@ namespace Tgstation.Server.Host.Configuration /// public uint TokenSigningKeyByteCount { get; set; } = DefaultTokenSigningKeyByteAmount; + /// + /// Amount of minutes until s generated from OAuth logins expire. + /// + public uint OAuthTokenExpiryMinutes { get; set; } = DefaultOAuthTokenExpiryMinutes; + /// /// A custom token signing key. Overrides . /// public string CustomTokenSigningKeyBase64 { get; set; } + + /// + /// OAuth provider settings. + /// + public IDictionary OAuth { get; set; } } } diff --git a/src/Tgstation.Server.Host/Configuration/SwarmConfiguration.cs b/src/Tgstation.Server.Host/Configuration/SwarmConfiguration.cs new file mode 100644 index 0000000000..96a14283bc --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/SwarmConfiguration.cs @@ -0,0 +1,26 @@ +using System; +using Tgstation.Server.Api.Models.Internal; + +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Configuration for the server swarm system. + /// + public sealed class SwarmConfiguration : SwarmServer + { + /// + /// The key for the the resides in. + /// + public const string Section = "Swarm"; + + /// + /// The of the swarm controller. If , the current server is considered the controller. + /// + public Uri ControllerAddress { get; set; } + + /// + /// The private key used for swarm communication. + /// + public string PrivateKey { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index c35de94da2..7c2f709fd3 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -1,11 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using Octokit; using System; -using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -22,6 +19,7 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Controllers { @@ -41,7 +39,12 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the /// - readonly IServerControl serverUpdater; + readonly IServerControl serverControl; + + /// + /// The for the + /// + readonly IServerUpdateInitiator serverUpdater; /// /// The for the @@ -58,16 +61,16 @@ namespace Tgstation.Server.Host.Controllers /// readonly IPlatformIdentifier platformIdentifier; + /// + /// The for the . + /// + readonly IFileTransferTicketProvider fileTransferService; + /// /// The for the /// readonly UpdatesConfiguration updatesConfiguration; - /// - /// The for the - /// - readonly GeneralConfiguration generalConfiguration; - /// /// The for the /// @@ -79,25 +82,27 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The for the /// The value of - /// The value of + /// The value of . + /// The value of . /// The value of /// The value of /// The value of + /// The value of . /// The for the /// The containing value of - /// The containing value of /// The containing value of public AdministrationController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, - IServerControl serverUpdater, + IServerControl serverControl, + IServerUpdateInitiator serverUpdater, IAssemblyInformationProvider assemblyInformationProvider, IIOManager ioManager, IPlatformIdentifier platformIdentifier, + IFileTransferTicketProvider fileTransferService, ILogger logger, IOptions updatesConfigurationOptions, - IOptions generalConfigurationOptions, IOptions fileLoggingConfigurationOptions) : base( databaseContext, @@ -106,82 +111,16 @@ namespace Tgstation.Server.Host.Controllers true) { this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } - ObjectResult RateLimit(RateLimitExceededException exception) - { - Logger.LogWarning(exception, "Exceeded GitHub rate limit!"); - var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture); - Response.Headers.Add("Retry-After", new StringValues(secondsString)); - return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit)); - } - - /// - /// Try to download and apply an update with a given . - /// - /// The version of the server to update to. - /// The for the operation. - /// A resulting in the of the operation. - async Task CheckReleasesAndApplyUpdate(Version newVersion, CancellationToken cancellationToken) - { - Logger.LogDebug("Looking for GitHub releases version {0}...", newVersion); - IEnumerable releases; - try - { - var gitHubClient = GetGitHubClient(); - releases = await gitHubClient - .Repository - .Release - .GetAll(updatesConfiguration.GitHubRepositoryId) - .WithToken(cancellationToken) - .ConfigureAwait(false); - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return StatusCode(HttpStatusCode.FailedDependency); - } - - releases = releases.Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture)); - - Logger.LogTrace("Release query complete!"); - - foreach (var release in releases) - if (Version.TryParse( - release.TagName.Replace( - updatesConfiguration.GitTagPrefix, String.Empty, StringComparison.Ordinal), - out var version) - && version == newVersion) - { - var asset = release.Assets.Where(x => x.Name.Equals(updatesConfiguration.UpdatePackageAssetName, StringComparison.Ordinal)).FirstOrDefault(); - if (asset == default) - continue; - - if (!serverUpdater.ApplyUpdate(version, new Uri(asset.BrowserDownloadUrl), ioManager)) - return Conflict(new ErrorMessage(ErrorCode.ServerUpdateInProgress)); - return Accepted(new Administration - { - WindowsHost = platformIdentifier.IsWindows, - NewVersion = newVersion - }); // gtfo of here before all the cancellation tokens fire - } - - return Gone(); - } - - IGitHubClient GetGitHubClient() => String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) ? gitHubClientFactory.CreateClient() : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken); - /// /// Get server information. /// @@ -191,7 +130,7 @@ namespace Tgstation.Server.Host.Controllers /// The GitHub API rate limit was hit. See response header Retry-After. /// A GitHub API error occurred. See error message for details. [HttpGet] - [TgsAuthorize] + [TgsAuthorize(AdministrationRights.ChangeVersion)] [ProducesResponseType(typeof(Administration), 200)] [ProducesResponseType(typeof(ErrorMessage), 424)] [ProducesResponseType(typeof(ErrorMessage), 429)] @@ -203,7 +142,7 @@ namespace Tgstation.Server.Host.Controllers Uri repoUrl = null; try { - var gitHubClient = GetGitHubClient(); + var gitHubClient = gitHubClientFactory.CreateClient(); var repositoryTask = gitHubClient .Repository .Get(updatesConfiguration.GitHubRepositoryId) @@ -234,7 +173,6 @@ namespace Tgstation.Server.Host.Controllers { LatestVersion = greatestVersion, TrackedRepositoryUrl = repoUrl, - WindowsHost = platformIdentifier.IsWindows }); } catch (RateLimitExceededException e) @@ -244,7 +182,7 @@ namespace Tgstation.Server.Host.Controllers catch (ApiException e) { Logger.LogWarning(e, OctokitException); - return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.GitHubApiError) + return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.RemoteApiError) { AdditionalData = e.Message }); @@ -283,10 +221,35 @@ namespace Tgstation.Server.Host.Controllers if (model.NewVersion.Major != assemblyInformationProvider.Version.Major) return BadRequest(new ErrorMessage(ErrorCode.CannotChangeServerSuite)); - if (!serverUpdater.WatchdogPresent) + if (!serverControl.WatchdogPresent) return UnprocessableEntity(new ErrorMessage(ErrorCode.MissingHostWatchdog)); - return await CheckReleasesAndApplyUpdate(model.NewVersion, cancellationToken).ConfigureAwait(false); + try + { + var updateResult = await serverUpdater.BeginUpdate(model.NewVersion, cancellationToken).ConfigureAwait(false); + if (updateResult == ServerUpdateResult.ReleaseMissing) + return Gone(); + + if (updateResult == ServerUpdateResult.UpdateInProgress) + return BadRequest(new ErrorMessage(ErrorCode.ServerUpdateInProgress)); + + return Accepted(new Administration + { + NewVersion = model.NewVersion + }); + } + catch (RateLimitExceededException e) + { + return RateLimit(e); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message + }); + } } /// @@ -303,13 +266,13 @@ namespace Tgstation.Server.Host.Controllers { try { - if (!serverUpdater.WatchdogPresent) + if (!serverControl.WatchdogPresent) { Logger.LogDebug("Restart request failed due to lack of host watchdog!"); return UnprocessableEntity(new ErrorMessage(ErrorCode.MissingHostWatchdog)); } - await serverUpdater.Restart().ConfigureAwait(false); + await serverControl.Restart().ConfigureAwait(false); return NoContent(); } catch (InvalidOperationException) @@ -321,48 +284,59 @@ namespace Tgstation.Server.Host.Controllers /// /// List s present. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Listed logs successfully. /// An IO error occurred while listing. [HttpGet(Routes.Logs)] [TgsAuthorize(AdministrationRights.DownloadLogs)] - [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(Paginated), 200)] [ProducesResponseType(typeof(ErrorMessage), 409)] - public async Task ListLogs(CancellationToken cancellationToken) - { - var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier); - try - { - var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false); - var tasks = files.Select( - async file => new LogFile - { - Name = ioManager.GetFileName(file), - LastModified = await ioManager.GetLastModified( - ioManager.ConcatPath(path, file), - cancellationToken) - .ConfigureAwait(false) - }) - .ToList(); - - await Task.WhenAll(tasks).ConfigureAwait(false); - - var result = tasks - .Select(x => x.Result) - .OrderByDescending(x => x.Name) - .ToList(); - - return Ok(result); - } - catch (IOException ex) - { - return Conflict(new ErrorMessage(ErrorCode.IOError) + public Task ListLogs([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + async () => { - AdditionalData = ex.ToString() - }); - } - } + var path = fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier); + try + { + var files = await ioManager.GetFiles(path, cancellationToken).ConfigureAwait(false); + var tasks = files.Select( + async file => new LogFile + { + Name = ioManager.GetFileName(file), + LastModified = await ioManager + .GetLastModified( + ioManager.ConcatPath(path, file), + cancellationToken) + .ConfigureAwait(false) + }) + .ToList(); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + var result = tasks + .Select(x => x.Result) + .OrderByDescending(x => x.Name) + .ToList(); + + return new PaginatableResult( + result.AsQueryable()); + } + catch (IOException ex) + { + return new PaginatableResult( + Conflict(new ErrorMessage(ErrorCode.IOError) + { + AdditionalData = ex.ToString() + })); + } + }, + null, + page, + pageSize, + cancellationToken); /// /// Download a . @@ -393,13 +367,20 @@ namespace Tgstation.Server.Host.Controllers path); try { + var fileTransferTicket = fileTransferService.CreateDownload( + new FileDownloadProvider( + () => null, + null, + fullPath, + true)); + var readTask = ioManager.ReadAllBytes(fullPath, cancellationToken); return Ok(new LogFile { Name = path, LastModified = await ioManager.GetLastModified(fullPath, cancellationToken).ConfigureAwait(false), - Content = await readTask.ConfigureAwait(false) + FileTicket = fileTransferTicket.FileTicket }); } catch (IOException ex) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 90a5c5f448..0c37a75142 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,9 +1,15 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using Octokit; using Serilog.Context; using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Mime; @@ -12,6 +18,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers @@ -23,6 +30,16 @@ namespace Tgstation.Server.Host.Controllers [ApiController] public abstract class ApiController : Controller { + /// + /// Default size of results. + /// + private const ushort DefaultPageSize = 10; + + /// + /// Maximum size of results. + /// + private const ushort MaximumPageSize = 100; + /// /// The for the operation /// @@ -60,7 +77,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The value of /// The value of - public ApiController( + protected ApiController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger, @@ -71,7 +88,7 @@ namespace Tgstation.Server.Host.Controllers throw new ArgumentNullException(nameof(authenticationContextFactory)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext; - Instance = AuthenticationContext?.InstanceUser?.Instance; + Instance = AuthenticationContext?.InstancePermissionSet?.Instance; this.requireHeaders = requireHeaders; } @@ -115,6 +132,22 @@ namespace Tgstation.Server.Host.Controllers /// A with the given . protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); + /// + /// 429 response for a given . + /// + /// The that occurred. + /// A . + protected ObjectResult RateLimit(RateLimitExceededException rateLimitException) + { + if (rateLimitException == null) + throw new ArgumentNullException(nameof(rateLimitException)); + + Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!"); + var secondsString = Math.Ceiling((rateLimitException.Reset - DateTimeOffset.UtcNow).TotalSeconds).ToString(CultureInfo.InvariantCulture); + Response.Headers.Add(HeaderNames.RetryAfter, secondsString); + return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit)); + } + /// /// Performs validation a request. /// @@ -126,13 +159,14 @@ namespace Tgstation.Server.Host.Controllers /// /// Response for missing/Invalid headers. /// + /// Whether or not errors due to missing should be thrown. /// The appropriate . - protected IActionResult HeadersIssue() + protected IActionResult HeadersIssue(bool ignoreMissingAuth) { HeadersException headersException; try { - var _ = new ApiHeaders(Request.GetTypedHeaders()); + var _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth); throw new InvalidOperationException("Expected a header parse exception!"); } catch (HeadersException ex) @@ -196,7 +230,7 @@ namespace Tgstation.Server.Host.Controllers { if (requireHeaders) { - await HeadersIssue() + await HeadersIssue(false) .ExecuteResultAsync(context) .ConfigureAwait(false); return; @@ -239,12 +273,148 @@ namespace Tgstation.Server.Host.Controllers { if (ApiHeaders != null) Logger.LogDebug( - "Starting API Request: Version: {0}. User-Agent: {1}", + "Starting API request: Version: {0}. {1}: {2}", ApiHeaders.ApiVersion.Semver(), + HeaderNames.UserAgent, ApiHeaders.RawUserAgent); + else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents)) + Logger.LogDebug( + "Starting unauthorized API request. {0}: {1}", + HeaderNames.UserAgent, + userAgents); + else + Logger.LogDebug( + "Starting unauthorized API request. No {0}!", + HeaderNames.UserAgent); await base.OnActionExecutionAsync(context, next).ConfigureAwait(false); } } - #pragma warning restore CA1506 +#pragma warning restore CA1506 + + /// + /// Generates a paginated response. + /// + /// The of model being generated and returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + protected Task Paginated( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) => PaginatedImpl( + queryGenerator, + resultTransformer, + pageQuery, + pageSizeQuery, + cancellationToken); + + /// + /// Generates a paginated response. + /// + /// The of model being generated. + /// The of model being returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + protected Task Paginated( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) + where TModel : IApiTransformable + => PaginatedImpl( + queryGenerator, + resultTransformer, + pageQuery, + pageSizeQuery, + cancellationToken); + + /// + /// Generates a paginated response. + /// + /// The of model being generated. If different from , must implement for . + /// The of model being returned. + /// A resulting in a resulting in the generated . + /// An to transform the s after being queried. + /// The requested page from the query. + /// The requested page size from the query. + /// The for the operation. + /// A resulting in the for the operation. + async Task PaginatedImpl( + Func>> queryGenerator, + Action resultTransformer, + int? pageQuery, + int? pageSizeQuery, + CancellationToken cancellationToken) + { + if (queryGenerator == null) + throw new ArgumentNullException(nameof(queryGenerator)); + + if (pageQuery <= 0 || pageSizeQuery <= 0) + return BadRequest(new ErrorMessage(ErrorCode.ApiInvalidPageOrPageSize)); + + var pageSize = pageSizeQuery ?? DefaultPageSize; + if (pageSize > MaximumPageSize) + return BadRequest(new ErrorMessage(ErrorCode.ApiPageTooLarge) + { + AdditionalData = $"Maximum page size: {MaximumPageSize}" + }); + + var page = pageQuery ?? 1; + + var paginationResult = await queryGenerator().ConfigureAwait(false); + if (paginationResult.EarlyOut != null) + return paginationResult.EarlyOut; + + var queriedResults = paginationResult + .Results + .Skip((page - 1) * pageSize) + .Take(pageSize); + + int totalResults; + List pagedResults; + if (queriedResults.Provider is IAsyncQueryProvider) + { + totalResults = await paginationResult.Results.CountAsync(cancellationToken).ConfigureAwait(false); + pagedResults = await queriedResults + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + totalResults = paginationResult.Results.Count(); + pagedResults = queriedResults.ToList(); + } + + if (resultTransformer != null) + foreach (var I in pagedResults) + resultTransformer(I); + + ICollection finalResults; + if (typeof(TModel) == typeof(TResultModel)) + finalResults = (List)(object)pagedResults; // clearly a safe cast + else + finalResults = pagedResults + .OfType>() + .Select(x => x.ToApi()) + .ToList(); + + return Json( + new Paginated + { + Content = finalResults, + PageSize = pageSize, + TotalPages = (ushort)((totalResults / pageSize) + 1) + }); + } } } diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index d4adbe54a9..8bb613ff4d 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -16,8 +16,9 @@ namespace Tgstation.Server.Host.Controllers /// /// for recieving DMAPI requests from DreamDaemon. /// - [Route("Bridge")] + [Route("/Bridge")] [Produces(MediaTypeNames.Application.Json)] + [ApiController] public class BridgeController : Controller { /// diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index cab2dd1fb2..2b62765f39 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -1,7 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -13,6 +13,7 @@ using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Controllers { @@ -27,6 +28,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IJobManager jobManager; + /// + /// The for the . + /// + readonly IFileTransferTicketProvider fileTransferService; + /// /// Construct a /// @@ -34,12 +40,14 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The for the . /// The value of + /// The value of . /// The for the public ByondController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, + IFileTransferTicketProvider fileTransferService, ILogger logger) : base( instanceManager, @@ -48,6 +56,7 @@ namespace Tgstation.Server.Host.Controllers logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); } /// @@ -69,21 +78,31 @@ namespace Tgstation.Server.Host.Controllers /// /// Lists installed versions. /// + /// The current page. + /// The page size. + /// The for the operation. /// A resulting in the for the operation. /// Retrieved version information successfully. [HttpGet(Routes.List)] [TgsAuthorize(ByondRights.ListInstalled)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public Task List() - => WithComponentInstance(instance => - Task.FromResult( - Json(instance - .ByondManager - .InstalledVersions - .Select(x => new Api.Models.Byond - { - Version = x - })))); + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => WithComponentInstance( + instance => Paginated( + () => Task.FromResult( + new PaginatableResult( + instance + .ByondManager + .InstalledVersions + .Select(x => new Api.Models.Byond + { + Version = x + }) + .AsQueryable())), + null, + page, + pageSize, + cancellationToken)); /// /// Changes the active BYOND version to the one specified in a given . @@ -104,14 +123,16 @@ namespace Tgstation.Server.Host.Controllers if (model == null) throw new ArgumentNullException(nameof(model)); + var uploadingZip = model.UploadCustomZip == true; + if (model.Version == null || model.Version.Revision != -1 - || (model.Content != null && model.Version.Build > 0)) + || (uploadingZip && model.Version.Build > 0)) return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); - var userByondRights = AuthenticationContext.InstanceUser.ByondRights.Value; - if ((!userByondRights.HasFlag(ByondRights.InstallOfficialOrChangeActiveVersion) && model.Content == null) - || (!userByondRights.HasFlag(ByondRights.InstallCustomVersion) && model.Content != null)) + var userByondRights = AuthenticationContext.InstancePermissionSet.ByondRights.Value; + if ((!userByondRights.HasFlag(ByondRights.InstallOfficialOrChangeActiveVersion) && !uploadingZip) + || (!userByondRights.HasFlag(ByondRights.InstallCustomVersion) && uploadingZip)) return Forbid(); // remove cruff fields @@ -120,7 +141,7 @@ namespace Tgstation.Server.Host.Controllers async instance => { var byondManager = instance.ByondManager; - if (model.Content == null && byondManager.InstalledVersions.Any(x => x == model.Version)) + if (!uploadingZip && byondManager.InstalledVersions.Any(x => x == model.Version)) { Logger.LogInformation( "User ID {0} changing instance ID {1} BYOND version to {2}", @@ -146,21 +167,61 @@ namespace Tgstation.Server.Host.Controllers // run the install through the job manager var job = new Models.Job { - Description = $"Install {(model.Content == null ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}", + Description = $"Install {(!uploadingZip ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}", StartedBy = AuthenticationContext.User, CancelRightsType = RightsType.Byond, CancelRight = (ulong)ByondRights.CancelInstall, Instance = Instance }; - await jobManager.RegisterOperation( - job, - (core, databaseContextFactory, paramJob, progressHandler, jobCancellationToken) => core.ByondManager.ChangeVersion( - model.Version, - model.Content, - jobCancellationToken), - cancellationToken) - .ConfigureAwait(false); - result.InstallJob = job.ToApi(); + + IFileUploadTicket fileUploadTicket = null; + if (uploadingZip) + fileUploadTicket = fileTransferService.CreateUpload(false); + + try + { + await jobManager.RegisterOperation( + job, + async (core, databaseContextFactory, paramJob, progressHandler, jobCancellationToken) => + { + Stream zipFileStream = null; + if (fileUploadTicket != null) + using (fileUploadTicket) + { + var uploadStream = await fileUploadTicket.GetResult(jobCancellationToken).ConfigureAwait(false); + if (uploadStream == null) + throw new JobException(ErrorCode.FileUploadExpired); + + zipFileStream = new MemoryStream(); + try + { + await uploadStream.CopyToAsync(zipFileStream, jobCancellationToken).ConfigureAwait(false); + } + catch + { + await zipFileStream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + using (zipFileStream) + await core.ByondManager.ChangeVersion( + model.Version, + zipFileStream, + jobCancellationToken) + .ConfigureAwait(false); + }, + cancellationToken) + .ConfigureAwait(false); + + result.InstallJob = job.ToApi(); + result.FileTicket = fileUploadTicket?.Ticket.FileTicket; + } + catch + { + fileUploadTicket?.Dispose(); + throw; + } } if ((AuthenticationContext.GetRight(RightsType.Byond) & (ulong)ByondRights.ReadActive) != 0) diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index 45c2db5d74..d328a300eb 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -168,29 +168,33 @@ namespace Tgstation.Server.Host.Controllers /// /// List s. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the for the operation. /// Listed chat bots successfully. [HttpGet(Routes.List)] [TgsAuthorize(ChatBotRights.Read)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) { - var query = DatabaseContext - .ChatBots - .AsQueryable() - .Where(x => x.InstanceId == Instance.Id) - .Include(x => x.Channels); - - var results = await query.ToListAsync(cancellationToken).ConfigureAwait(false); - var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0; - - if (!connectionStrings) - foreach (var I in results) - I.ConnectionString = null; - - return Json(results.Select(x => x.ToApi())); + return Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .ChatBots + .AsQueryable() + .Where(x => x.InstanceId == Instance.Id) + .Include(x => x.Channels))), + chatBot => + { + if (connectionStrings) + chatBot.ConnectionString = null; + }, + page, + pageSize, + cancellationToken); } /// diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 3a76b8bc5b..f4688a200c 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -77,11 +77,11 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. /// File updated successfully. - /// File created successfully. + /// File upload ticket created successfully. [HttpPost] [TgsAuthorize(ConfigurationRights.Write)] [ProducesResponseType(typeof(ConfigurationFile), 200)] - [ProducesResponseType(typeof(ConfigurationFile), 201)] + [ProducesResponseType(typeof(ConfigurationFile), 202)] public async Task Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken) { if (model == null) @@ -99,16 +99,11 @@ namespace Tgstation.Server.Host.Controllers .Write( model.Path, systemIdentity, - model.Content, model.LastReadHash, cancellationToken) .ConfigureAwait(false); - if (newFile == null) - return Conflict(new ErrorMessage(ErrorCode.ConfigurationFileUpdated)); - newFile.Content = null; - - return model.LastReadHash == null ? (IActionResult)Created(newFile) : Json(newFile); + return model.LastReadHash == null ? (IActionResult)Accepted(newFile) : Json(newFile); }) .ConfigureAwait(false); } @@ -177,54 +172,70 @@ namespace Tgstation.Server.Host.Controllers /// Get the contents of a directory at a /// /// The path of the directory to get + /// The current page. + /// The page size. /// The for the operation /// A resulting in the for the operation /// Directory listed successfully.> /// Directory does not currently exist. [HttpGet(Routes.List + "/{*directoryPath}")] [TgsAuthorize(ConfigurationRights.List)] - [ProducesResponseType(typeof(IReadOnlyList), 200)] + [ProducesResponseType(typeof(Paginated), 200)] [ProducesResponseType(typeof(ErrorMessage), 410)] - public async Task Directory(string directoryPath, CancellationToken cancellationToken) - { - if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity)) - return Forbid(); - - try - { - return await WithComponentInstance( - async instance => + public Task Directory( + string directoryPath, + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) + => WithComponentInstance( + instance => Paginated( + async () => { - var result = await instance - .Configuration - .ListDirectory(directoryPath, systemIdentity, cancellationToken) - .ConfigureAwait(false); - if (result == null) - return Gone(); + if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity)) + return new PaginatableResult( + Forbid()); - return Json(result); - }) - .ConfigureAwait(false); - } - catch (NotImplementedException) - { - return RequiresPosixSystemIdentity(); - } - catch (UnauthorizedAccessException) - { - return Forbid(); - } - } + try + { + var result = await instance + .Configuration + .ListDirectory(directoryPath, systemIdentity, cancellationToken) + .ConfigureAwait(false); + if (result == null) + return new PaginatableResult(Gone()); + + return new PaginatableResult(result.AsQueryable()); + } + catch (NotImplementedException) + { + return new PaginatableResult( + RequiresPosixSystemIdentity()); + } + catch (UnauthorizedAccessException) + { + return new PaginatableResult( + Forbid()); + } + }, + null, + page, + pageSize, + cancellationToken)); /// /// Get the contents of the root configuration directory. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the for the operation. [HttpGet(Routes.List)] [TgsAuthorize(ConfigurationRights.List)] - [ProducesResponseType(typeof(IReadOnlyList), 200)] - public Task List(CancellationToken cancellationToken) => Directory(null, cancellationToken); + [ProducesResponseType(typeof(Paginated), 200)] + public Task List( + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) => Directory(null, page, pageSize, cancellationToken); /// /// Create a configuration directory. diff --git a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs index 506d9cc12d..275acbe91a 100644 --- a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs +++ b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs @@ -41,9 +41,7 @@ namespace Tgstation.Server.Host.Controllers { var contentTypeProvider = new FileExtensionContentTypeProvider(); if (!contentTypeProvider.TryGetContentType(fileInfo.Name, out var contentType)) - { - contentType = "application/octet-stream"; - } + contentType = MediaTypeNames.Application.Octet; return File(appRoute, contentType); } diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 0170f9003e..41ffc0228d 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -270,8 +270,8 @@ namespace Tgstation.Server.Host.Controllers || CheckModified(x => x.AutoStart, DreamDaemonRights.SetAutoStart) || CheckModified(x => x.Port, DreamDaemonRights.SetPort) || CheckModified(x => x.SecurityLevel, DreamDaemonRights.SetSecurity) - || (model.SoftRestart.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftRestart)) - || (model.SoftShutdown.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftShutdown)) + || (model.SoftRestart.HasValue && !AuthenticationContext.InstancePermissionSet.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftRestart)) + || (model.SoftShutdown.HasValue && !AuthenticationContext.InstancePermissionSet.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftShutdown)) || CheckModified(x => x.StartupTimeout, DreamDaemonRights.SetStartupTimeout) || CheckModified(x => x.HeartbeatSeconds, DreamDaemonRights.SetHeartbeatInterval) || CheckModified(x => x.TopicRequestTimeout, DreamDaemonRights.SetTopicTimeout) diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index b1bc9b6432..40223ad242 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -95,43 +94,53 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(ErrorMessage), 404)] public async Task GetId(long id, CancellationToken cancellationToken) { - var compileJob = await DatabaseContext - .CompileJobs - .AsQueryable() - .Where(x => x.Id == id && x.Job.Instance.Id == Instance.Id) - .Include(x => x.Job).ThenInclude(x => x.StartedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.PrimaryTestMerge).ThenInclude(x => x.MergedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).ThenInclude(x => x.MergedBy) + var compileJob = await BaseCompileJobsQuery() + .Where(x => x.Id == id) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (compileJob == default) return NotFound(); return Json(compileJob.ToApi()); } + /// + /// Base query for pulling in all required fields. + /// + /// An of with all the inclusions. + IQueryable BaseCompileJobsQuery() => DatabaseContext + .CompileJobs + .AsQueryable() + .Include(x => x.Job) + .ThenInclude(x => x.StartedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.PrimaryTestMerge) + .ThenInclude(x => x.MergedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.ActiveTestMerges) + .ThenInclude(x => x.TestMerge) + .ThenInclude(x => x.MergedBy) + .Where(x => x.Job.Instance.Id == Instance.Id); + /// /// List all s for the instance. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(DreamMakerRights.CompileJobs)] - [ProducesResponseType(typeof(List), 200)] - public async Task List(CancellationToken cancellationToken) - { - var compileJobs = await DatabaseContext - .CompileJobs - .AsQueryable() - .Where(x => x.Job.Instance.Id == Instance.Id) - .OrderByDescending(x => x.Job.StoppedAt) - .Select(x => new EntityId - { - Id = x.Id - }) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(compileJobs); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + BaseCompileJobsQuery() + .OrderByDescending(x => x.Job.StoppedAt))), + null, + page, + pageSize, + cancellationToken); /// /// Begin deploying repository code. @@ -199,7 +208,7 @@ namespace Tgstation.Server.Host.Controllers if (model.ProjectName != null) { - if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetDme)) + if (!AuthenticationContext.InstancePermissionSet.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetDme)) return Forbid(); if (model.ProjectName.Length == 0) hostModel.ProjectName = null; @@ -209,7 +218,7 @@ namespace Tgstation.Server.Host.Controllers if (model.ApiValidationPort.HasValue) { - if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationPort)) + if (!AuthenticationContext.InstancePermissionSet.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationPort)) return Forbid(); if (model.ApiValidationPort.Value != hostModel.ApiValidationPort.Value) @@ -229,14 +238,14 @@ namespace Tgstation.Server.Host.Controllers if (model.ApiValidationSecurityLevel.HasValue) { - if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetSecurityLevel)) + if (!AuthenticationContext.InstancePermissionSet.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetSecurityLevel)) return Forbid(); hostModel.ApiValidationSecurityLevel = model.ApiValidationSecurityLevel; } if (model.RequireDMApiValidation.HasValue) { - if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationRequirement)) + if (!AuthenticationContext.InstancePermissionSet.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationRequirement)) return Forbid(); hostModel.RequireDMApiValidation = model.RequireDMApiValidation; } diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 234f5299f7..e08e0cde15 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,12 +1,15 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; +using Octokit; using System; using System.Linq; +using System.Net; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -17,6 +20,8 @@ using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; +using Tgstation.Server.Host.Swarm; using Tgstation.Server.Host.System; using Wangkanai.Detection; @@ -53,6 +58,26 @@ namespace Tgstation.Server.Host.Controllers /// readonly IIdentityCache identityCache; + /// + /// The for the . + /// + readonly IOAuthProviders oAuthProviders; + + /// + /// The for the . + /// + readonly IPlatformIdentifier platformIdentifier; + + /// + /// The for the . + /// + readonly ISwarmService swarmService; + + /// + /// The for the . + /// + readonly IServerControl serverControl; + /// /// The for the /// @@ -78,7 +103,11 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of + /// The value of . + /// The value of . /// The value of + /// The value of . + /// The value of . /// The containing the value of . /// The containing the value of /// The for the @@ -90,7 +119,11 @@ namespace Tgstation.Server.Host.Controllers ICryptographySuite cryptographySuite, IAssemblyInformationProvider assemblyInformationProvider, IIdentityCache identityCache, + IOAuthProviders oAuthProviders, + IPlatformIdentifier platformIdentifier, IBrowserResolver browserResolver, + ISwarmService swarmService, + IServerControl serverControl, IOptions generalConfigurationOptions, IOptions controlPanelConfigurationOptions, ILogger logger) @@ -98,15 +131,18 @@ namespace Tgstation.Server.Host.Controllers databaseContext, authenticationContextFactory, logger, - (browserResolver ?? throw new ArgumentNullException(nameof(browserResolver))).Browser.Type != BrowserType.Generic - && !(controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions))).Enable) + false) { this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); - this.browserResolver = browserResolver; + this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); + this.oAuthProviders = oAuthProviders ?? throw new ArgumentNullException(nameof(oAuthProviders)); + this.browserResolver = browserResolver ?? throw new ArgumentNullException(nameof(browserResolver)); + this.swarmService = swarmService ?? throw new ArgumentNullException(nameof(swarmService)); + this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions)); } @@ -114,37 +150,56 @@ namespace Tgstation.Server.Host.Controllers /// /// Main page of the /// + /// The for the operation. /// - /// The of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise. + /// A resuting in the containing of the if a properly authenticated API request, the web control panel if on a browser and enabled, otherwise. /// /// retrieved successfully. [HttpGet] - [TgsAuthorize] [AllowAnonymous] [ProducesResponseType(typeof(ServerInformation), 200)] - public IActionResult Home() + #pragma warning disable CA1506 + public async Task Home(CancellationToken cancellationToken) { - if (AuthenticationContext != null) - return Json(new ServerInformation - { - Version = assemblyInformationProvider.Version, - ApiVersion = ApiHeaders.Version, - DMApiVersion = DMApiConstants.Version, - MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, - InstanceLimit = generalConfiguration.InstanceLimit, - UserLimit = generalConfiguration.UserLimit, - ValidInstancePaths = generalConfiguration.ValidInstancePaths - }); - // if we are using a browser and the control panel, soft redirect to the app page if (controlPanelConfiguration.Enable && browserResolver.Browser.Type != BrowserType.Generic) { Logger.LogDebug("Unauthorized browser request (User-Agent: \"{0}\"), redirecting to control panel...", browserResolver.UserAgent); - return Redirect(Application.ControlPanelRoute); + return Redirect(Core.Application.ControlPanelRoute); } - return ApiHeaders == null ? HeadersIssue() : Unauthorized(); + // we only allow authorization header issues + if (ApiHeaders == null) + try + { + var headers = new ApiHeaders(Request.GetTypedHeaders(), true); + if (!headers.Compatible()) + return StatusCode( + HttpStatusCode.UpgradeRequired, + new ErrorMessage(ErrorCode.ApiMismatch)); + } + catch (HeadersException) + { + return HeadersIssue(true); + } + + return Json(new ServerInformation + { + Version = assemblyInformationProvider.Version, + ApiVersion = ApiHeaders.Version, + DMApiVersion = DMApiConstants.Version, + MinimumPasswordLength = generalConfiguration.MinimumPasswordLength, + InstanceLimit = generalConfiguration.InstanceLimit, + UserLimit = generalConfiguration.UserLimit, + UserGroupLimit = generalConfiguration.UserGroupLimit, + ValidInstancePaths = generalConfiguration.ValidInstancePaths, + WindowsHost = platformIdentifier.IsWindows, + SwarmServers = swarmService.GetSwarmServers(), + OAuthProviderInfos = await oAuthProviders.ProviderInfos(cancellationToken).ConfigureAwait(false), + UpdateInProgress = serverControl.UpdateInProgress, + }); } + #pragma warning restore CA1506 /// /// Attempt to authenticate a using @@ -154,40 +209,77 @@ namespace Tgstation.Server.Host.Controllers /// User logged in and generated successfully. /// User authentication failed. /// User authenticated but is disabled by an administrator. + /// OAuth authentication failed due to rate limiting. [HttpPost] [ProducesResponseType(typeof(Token), 200)] - #pragma warning disable CA1506 // TODO: Decomplexify + [ProducesResponseType(typeof(ErrorMessage), 429)] +#pragma warning disable CA1506 // TODO: Decomplexify public async Task CreateToken(CancellationToken cancellationToken) { if (ApiHeaders == null) { Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS4 bearer token\"")); - return HeadersIssue(); + return HeadersIssue(false); } if (ApiHeaders.IsTokenAuthentication) return BadRequest(new ErrorMessage(ErrorCode.TokenWithToken)); - ISystemIdentity systemIdentity; - try - { - // trust the system over the database because a user's name can change while still having the same SID - systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken).ConfigureAwait(false); - } - catch (NotImplementedException) - { - systemIdentity = null; - } + var oAuthLogin = ApiHeaders.OAuthProvider.HasValue; + + ISystemIdentity systemIdentity = null; + if (!oAuthLogin) + try + { + // trust the system over the database because a user's name can change while still having the same SID + systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken).ConfigureAwait(false); + } + catch (NotImplementedException ex) + { + Logger.LogTrace(ex, "System identities not implemented!"); + } using (systemIdentity) { // Get the user from the database IQueryable query = DatabaseContext.Users.AsQueryable(); - string canonicalName = Models.User.CanonicalizeName(ApiHeaders.Username); - if (systemIdentity == null) - query = query.Where(x => x.CanonicalName == canonicalName); + if (oAuthLogin) + { + string externalUserId; + try + { + var validator = oAuthProviders + .GetValidator(ApiHeaders.OAuthProvider.Value); + + if (validator == null) + return BadRequest(new ErrorMessage(ErrorCode.OAuthProviderDisabled)); + + externalUserId = await validator + .ValidateResponseCode(ApiHeaders.Token, cancellationToken) + .ConfigureAwait(false); + } + catch (RateLimitExceededException ex) + { + return RateLimit(ex); + } + + if (externalUserId == null) + return Unauthorized(); + + query = query.Where( + x => x.OAuthConnections.Any( + y => y.Provider == ApiHeaders.OAuthProvider.Value + && y.ExternalUserId == externalUserId)); + } else - query = query.Where(x => x.CanonicalName == canonicalName || x.SystemIdentifier == systemIdentity.Uid); + { + string canonicalName = Models.User.CanonicalizeName(ApiHeaders.Username); + if (systemIdentity == null) + query = query.Where(x => x.CanonicalName == canonicalName); + else + query = query.Where(x => x.CanonicalName == canonicalName || x.SystemIdentifier == systemIdentity.Uid); + } + var users = await query.Select(x => new Models.User { Id = x.Id, @@ -213,32 +305,33 @@ namespace Tgstation.Server.Host.Controllers var originalHash = user.PasswordHash; var isDbUser = originalHash != null; bool usingSystemIdentity = systemIdentity != null && !isDbUser; - if (!usingSystemIdentity) - { - // DB User password check and update - if (!cryptographySuite.CheckUserPassword(user, ApiHeaders.Password)) - return Unauthorized(); - if (user.PasswordHash != originalHash) + if (!oAuthLogin) + if (!usingSystemIdentity) { - Logger.LogDebug("User ID {0}'s password hash needs a refresh, updating database.", user.Id); - var updatedUser = new Models.User + // DB User password check and update + if (!cryptographySuite.CheckUserPassword(user, ApiHeaders.Password)) + return Unauthorized(); + if (user.PasswordHash != originalHash) { - Id = user.Id - }; - DatabaseContext.Users.Attach(updatedUser); - updatedUser.PasswordHash = user.PasswordHash; + Logger.LogDebug("User ID {0}'s password hash needs a refresh, updating database.", user.Id); + var updatedUser = new Models.User + { + Id = user.Id + }; + DatabaseContext.Users.Attach(updatedUser); + updatedUser.PasswordHash = user.PasswordHash; + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + } + } + else if (systemIdentity.Username != user.Name) + { + // System identity username change update + Logger.LogDebug("User ID {0}'s system identity needs a refresh, updating database.", user.Id); + DatabaseContext.Users.Attach(user); + user.Name = systemIdentity.Username; + user.CanonicalName = Models.User.CanonicalizeName(user.Name); await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); } - } - else if (systemIdentity.Username != user.Name) - { - // System identity username change update - Logger.LogDebug("User ID {0}'s system identity needs a refresh, updating database.", user.Id); - DatabaseContext.Users.Attach(user); - user.Name = systemIdentity.Username; - user.CanonicalName = Models.User.CanonicalizeName(user.Name); - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - } // Now that the bookeeping is done, tell them to fuck off if necessary if (!user.Enabled.Value) @@ -247,7 +340,7 @@ namespace Tgstation.Server.Host.Controllers return Forbid(); } - var token = await tokenFactory.CreateToken(user, cancellationToken).ConfigureAwait(false); + var token = await tokenFactory.CreateToken(user, oAuthLogin, cancellationToken).ConfigureAwait(false); if (usingSystemIdentity) { // expire the identity slightly after the auth token in case of lag @@ -262,6 +355,6 @@ namespace Tgstation.Server.Host.Controllers return Json(token); } } - #pragma warning restore CA1506 +#pragma warning restore CA1506 } } diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index d5926406e6..8a6c9c62ba 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -72,6 +72,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly GeneralConfiguration generalConfiguration; + /// + /// The for the . + /// + readonly SwarmConfiguration swarmConfiguration; + /// /// Construct a /// @@ -83,6 +88,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of . /// The containing the value of . + /// The containing the value of . /// The for the . public InstanceController( IDatabaseContext databaseContext, @@ -93,6 +99,7 @@ namespace Tgstation.Server.Host.Controllers IPortAllocator portAllocator, IPlatformIdentifier platformIdentifier, IOptions generalConfigurationOptions, + IOptions swarmConfigurationOptions, ILogger logger) : base( databaseContext, @@ -106,6 +113,7 @@ namespace Tgstation.Server.Host.Controllers this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); } async Task CreateDefaultInstance(Api.Models.Instance initialSettings, CancellationToken cancellationToken) @@ -169,10 +177,11 @@ namespace Tgstation.Server.Host.Controllers PostTestMergeComment = false, CreateGitHubDeployments = false }, - InstanceUsers = new List // give this user full privileges on the instance + InstancePermissionSets = new List // give this user full privileges on the instance { - InstanceAdminUser(null) - } + InstanceAdminPermissionSet(null) + }, + SwarmIdentifer = swarmConfiguration.Identifier, }; } @@ -188,21 +197,21 @@ namespace Tgstation.Server.Host.Controllers return path; } - Models.InstanceUser InstanceAdminUser(Models.InstanceUser userToModify) + Models.InstancePermissionSet InstanceAdminPermissionSet(Models.InstancePermissionSet permissionSetToModify) { - if (userToModify == null) - userToModify = new Models.InstanceUser() + if (permissionSetToModify == null) + permissionSetToModify = new Models.InstancePermissionSet() { - UserId = AuthenticationContext.User.Id.Value + PermissionSetId = AuthenticationContext.PermissionSet.Id.Value }; - userToModify.ByondRights = RightsHelper.AllRights(); - userToModify.ChatBotRights = RightsHelper.AllRights(); - userToModify.ConfigurationRights = RightsHelper.AllRights(); - userToModify.DreamDaemonRights = RightsHelper.AllRights(); - userToModify.DreamMakerRights = RightsHelper.AllRights(); - userToModify.RepositoryRights = RightsHelper.AllRights(); - userToModify.InstanceUserRights = RightsHelper.AllRights(); - return userToModify; + permissionSetToModify.ByondRights = RightsHelper.AllRights(); + permissionSetToModify.ChatBotRights = RightsHelper.AllRights(); + permissionSetToModify.ConfigurationRights = RightsHelper.AllRights(); + permissionSetToModify.DreamDaemonRights = RightsHelper.AllRights(); + permissionSetToModify.DreamMakerRights = RightsHelper.AllRights(); + permissionSetToModify.RepositoryRights = RightsHelper.AllRights(); + permissionSetToModify.InstancePermissionSetRights = RightsHelper.AllRights(); + return permissionSetToModify; } /// @@ -257,6 +266,7 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext .Instances .AsQueryable() + .Where(x => x.SwarmIdentifer == swarmConfiguration.Identifier) .Select(x => new Models.Instance { Path = x.Path @@ -368,7 +378,7 @@ namespace Tgstation.Server.Host.Controllers var originalModel = await DatabaseContext .Instances .AsQueryable() - .Where(x => x.Id == id) + .Where(x => x.Id == id && x.SwarmIdentifer == swarmConfiguration.Identifier) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (originalModel == default) return Gone(); @@ -416,13 +426,13 @@ namespace Tgstation.Server.Host.Controllers IQueryable InstanceQuery() => DatabaseContext .Instances .AsQueryable() - .Where(x => x.Id == model.Id); + .Where(x => x.Id == model.Id && x.SwarmIdentifer == swarmConfiguration.Identifier); var moveJob = await InstanceQuery() .SelectMany(x => x.Jobs). -#pragma warning disable CA1307 // Specify StringComparison +#pragma warning disable CA1310 // Specify StringComparison Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix)) -#pragma warning restore CA1307 // Specify StringComparison +#pragma warning restore CA1310 // Specify StringComparison .Select(x => new Models.Job { Id = x.Id @@ -431,7 +441,7 @@ namespace Tgstation.Server.Host.Controllers if (moveJob != default) { // don't allow them to cancel it if they can't start it. - if (!AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.Relocate)) + if (!AuthenticationContext.PermissionSet.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.Relocate)) return Forbid(); await jobManager.CancelJob(moveJob, AuthenticationContext.User, true, cancellationToken).ConfigureAwait(false); // cancel it now } @@ -586,27 +596,35 @@ namespace Tgstation.Server.Host.Controllers /// /// List s. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) + [ProducesResponseType(typeof(Paginated), 200)] + public async Task List( + [FromQuery] int? page, + [FromQuery] int? pageSize, + CancellationToken cancellationToken) { IQueryable GetBaseQuery() { - IQueryable query = DatabaseContext.Instances; - if (!AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List)) + IQueryable query = DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.SwarmIdentifer == swarmConfiguration.Identifier); + if (!AuthenticationContext.PermissionSet.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List)) query = query - .Where(x => x.InstanceUsers.Any(y => y.UserId == AuthenticationContext.User.Id)) - .Where(x => x.InstanceUsers.Any(instanceUser => + .Where(x => x.InstancePermissionSets.Any(y => y.PermissionSetId == AuthenticationContext.PermissionSet.Id.Value)) + .Where(x => x.InstancePermissionSets.Any(instanceUser => instanceUser.ByondRights != ByondRights.None || instanceUser.ChatBotRights != ChatBotRights.None || instanceUser.ConfigurationRights != ConfigurationRights.None || instanceUser.DreamDaemonRights != DreamDaemonRights.None || instanceUser.DreamMakerRights != DreamMakerRights.None || - instanceUser.InstanceUserRights != InstanceUserRights.None)); + instanceUser.InstancePermissionSetRights != InstancePermissionSetRights.None)); // Hack for EF IAsyncEnumerable BS return query.Select(x => x); @@ -614,29 +632,33 @@ namespace Tgstation.Server.Host.Controllers var moveJobs = await GetBaseQuery() .SelectMany(x => x.Jobs) -#pragma warning disable CA1307 // Specify StringComparison +#pragma warning disable CA1310 // Specify StringComparison .Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix)) -#pragma warning restore CA1307 // Specify StringComparison +#pragma warning restore CA1310 // Specify StringComparison .Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy) .Include(x => x.Instance) .ToListAsync(cancellationToken) .ConfigureAwait(false); - var instances = await GetBaseQuery() - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - var needsUpdate = false; - foreach (var instance in instances) - needsUpdate |= InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance); + var result = await Paginated( + () => Task.FromResult( + new PaginatableResult( + GetBaseQuery())), + instance => + { + needsUpdate |= InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance); + instance.MoveJob = moveJobs.FirstOrDefault(x => x.Instance.Id == instance.Id)?.ToApi(); + }, + page, + pageSize, + cancellationToken) + .ConfigureAwait(false); if (needsUpdate) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - var apis = instances.Select(x => x.ToApi()); - foreach(var I in moveJobs) - apis.Where(x => x.Id == I.Instance.Id).First().MoveJob = I.ToApi(); // if this .First() fails i will personally murder kevinz000 because I just know he is somehow responsible - return Json(apis); + return result; } /// @@ -653,16 +675,16 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(ErrorMessage), 410)] public async Task GetId(long id, CancellationToken cancellationToken) { - var cantList = !AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List); + var cantList = !AuthenticationContext.PermissionSet.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List); IQueryable QueryForUser() { var query = DatabaseContext .Instances .AsQueryable() - .Where(x => x.Id == id); + .Where(x => x.Id == id && x.SwarmIdentifer == swarmConfiguration.Identifier); if (cantList) - query = query.Include(x => x.InstanceUsers); + query = query.Include(x => x.InstancePermissionSets); return query; } @@ -674,22 +696,22 @@ namespace Tgstation.Server.Host.Controllers if (InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance)) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - if (cantList && !instance.InstanceUsers.Any(instanceUser => instanceUser.UserId == AuthenticationContext.User.Id && + if (cantList && !instance.InstancePermissionSets.Any(instanceUser => instanceUser.PermissionSetId == AuthenticationContext.PermissionSet.Id.Value && (instanceUser.ByondRights != ByondRights.None || instanceUser.ChatBotRights != ChatBotRights.None || instanceUser.ConfigurationRights != ConfigurationRights.None || instanceUser.DreamDaemonRights != DreamDaemonRights.None || instanceUser.DreamMakerRights != DreamMakerRights.None || - instanceUser.InstanceUserRights != InstanceUserRights.None))) + instanceUser.InstancePermissionSetRights != InstancePermissionSetRights.None))) return Forbid(); var api = instance.ToApi(); var moveJob = await QueryForUser() .SelectMany(x => x.Jobs) -#pragma warning disable CA1307 // Specify StringComparison +#pragma warning disable CA1310 // Specify StringComparison .Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix)) -#pragma warning restore CA1307 // Specify StringComparison +#pragma warning restore CA1310 // Specify StringComparison .Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); @@ -710,22 +732,22 @@ namespace Tgstation.Server.Host.Controllers public async Task GrantPermissions(long id, CancellationToken cancellationToken) { // ensure the current user has write privilege on the instance - var usersInstanceUser = await DatabaseContext + var usersInstancePermissionSet = await DatabaseContext .Instances .AsQueryable() - .Where(x => x.Id == id) - .SelectMany(x => x.InstanceUsers) - .Where(x => x.UserId == AuthenticationContext.User.Id) + .Where(x => x.Id == id && x.SwarmIdentifer == swarmConfiguration.Identifier) + .SelectMany(x => x.InstancePermissionSets) + .Where(x => x.PermissionSetId == AuthenticationContext.PermissionSet.Id.Value) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); - if (usersInstanceUser == default) + if (usersInstancePermissionSet == default) { - var instanceAdminUser = InstanceAdminUser(null); + var instanceAdminUser = InstanceAdminPermissionSet(null); instanceAdminUser.InstanceId = id; - DatabaseContext.InstanceUsers.Add(instanceAdminUser); + DatabaseContext.InstancePermissionSets.Add(instanceAdminUser); } else - InstanceAdminUser(usersInstanceUser); + InstanceAdminPermissionSet(usersInstancePermissionSet); await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs new file mode 100644 index 0000000000..58b95e35a4 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -0,0 +1,247 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Z.EntityFramework.Plus; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for managing s. + /// + [Route(Routes.InstancePermissionSet)] + public sealed class InstancePermissionSetController : InstanceRequiredController + { + /// + /// Construct a + /// + /// The for the . + /// The for the + /// The for the + /// The for the + public InstancePermissionSetController( + IInstanceManager instanceManager, + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + ILogger logger) + : base( + instanceManager, + databaseContext, + authenticationContextFactory, + logger) + { } + + /// + /// Create an . + /// + /// The to create. + /// The for the operation. + /// A resulting in the of the request. + /// created successfully. + /// The does not exist. + [HttpPut] + [TgsAuthorize(InstancePermissionSetRights.Create)] + [ProducesResponseType(typeof(Api.Models.InstancePermissionSet), 201)] + [ProducesResponseType(typeof(ErrorMessage), 410)] +#pragma warning disable CA1506 + public async Task Create([FromBody] Api.Models.InstancePermissionSet model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + var existingPermissionSet = await DatabaseContext + .PermissionSets + .AsQueryable() + .Where(x => x.Id == model.PermissionSetId) + .Select(x => new Models.PermissionSet + { + UserId = x.UserId + }) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (existingPermissionSet == default) + return Gone(); + + if (existingPermissionSet.UserId.HasValue) + { + var userCanonicalName = await DatabaseContext + .Users + .AsQueryable() + .Where(x => x.Id == existingPermissionSet.UserId.Value) + .Select(x => x.CanonicalName) + .FirstAsync(cancellationToken) + .ConfigureAwait(false); + + if (userCanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return Forbid(); + } + + var dbUser = new Models.InstancePermissionSet + { + ByondRights = RightsHelper.Clamp(model.ByondRights ?? ByondRights.None), + ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? ChatBotRights.None), + ConfigurationRights = RightsHelper.Clamp(model.ConfigurationRights ?? ConfigurationRights.None), + DreamDaemonRights = RightsHelper.Clamp(model.DreamDaemonRights ?? DreamDaemonRights.None), + DreamMakerRights = RightsHelper.Clamp(model.DreamMakerRights ?? DreamMakerRights.None), + RepositoryRights = RightsHelper.Clamp(model.RepositoryRights ?? RepositoryRights.None), + InstancePermissionSetRights = RightsHelper.Clamp(model.InstancePermissionSetRights ?? InstancePermissionSetRights.None), + PermissionSetId = model.PermissionSetId, + InstanceId = Instance.Id + }; + + DatabaseContext.InstancePermissionSets.Add(dbUser); + + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + return Created(dbUser.ToApi()); + } + #pragma warning restore CA1506 + + /// + /// Update the permissions for an . + /// + /// The updated . + /// The for the operation. + /// A resulting in the of the request. + /// updated successfully. + /// The requested does not currently exist. + [HttpPost] + [TgsAuthorize(InstancePermissionSetRights.Write)] + [ProducesResponseType(typeof(Api.Models.InstancePermissionSet), 200)] + [ProducesResponseType(typeof(ErrorMessage), 410)] + #pragma warning disable CA1506 // TODO: Decomplexify + public async Task Update([FromBody] Api.Models.InstancePermissionSet model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + var originalPermissionSet = await DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .SelectMany(x => x.InstancePermissionSets) + .Where(x => x.PermissionSetId == model.PermissionSetId) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (originalPermissionSet == null) + return Gone(); + + originalPermissionSet.ByondRights = RightsHelper.Clamp(model.ByondRights ?? originalPermissionSet.ByondRights.Value); + originalPermissionSet.RepositoryRights = RightsHelper.Clamp(model.RepositoryRights ?? originalPermissionSet.RepositoryRights.Value); + originalPermissionSet.InstancePermissionSetRights = RightsHelper.Clamp(model.InstancePermissionSetRights ?? originalPermissionSet.InstancePermissionSetRights.Value); + originalPermissionSet.ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? originalPermissionSet.ChatBotRights.Value); + originalPermissionSet.ConfigurationRights = RightsHelper.Clamp(model.ConfigurationRights ?? originalPermissionSet.ConfigurationRights.Value); + originalPermissionSet.DreamDaemonRights = RightsHelper.Clamp(model.DreamDaemonRights ?? originalPermissionSet.DreamDaemonRights.Value); + originalPermissionSet.DreamMakerRights = RightsHelper.Clamp(model.DreamMakerRights ?? originalPermissionSet.DreamMakerRights.Value); + + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + var showFullPermissionSet = originalPermissionSet.PermissionSetId == AuthenticationContext.PermissionSet.Id.Value + || (AuthenticationContext.GetRight(RightsType.InstancePermissionSet) & (ulong)InstancePermissionSetRights.Read) != 0; + return Json( + showFullPermissionSet + ? originalPermissionSet.ToApi() + : new Api.Models.InstancePermissionSet + { + PermissionSetId = originalPermissionSet.PermissionSetId + }); + } +#pragma warning restore CA1506 + /// + /// Read the active . + /// + /// The of the request. + /// retrieved successfully. + [HttpGet] + [TgsAuthorize] + [ProducesResponseType(typeof(Api.Models.InstancePermissionSet), 200)] + public IActionResult Read() => Json(AuthenticationContext.InstancePermissionSet.ToApi()); + + /// + /// Lists s for the instance. + /// + /// The current page. + /// The page size. + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] + [TgsAuthorize(InstancePermissionSetRights.Read)] + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .SelectMany(x => x.InstancePermissionSets))), + null, + page, + pageSize, + cancellationToken); + + /// + /// Gets a specific . + /// + /// The . + /// The for the operation. + /// A resulting in the of the request. + /// Retrieve successfully. + /// The requested does not currently exist. + [HttpGet("{id}")] + [TgsAuthorize(InstancePermissionSetRights.Read)] + [ProducesResponseType(typeof(Api.Models.InstancePermissionSet), 200)] + [ProducesResponseType(typeof(ErrorMessage), 410)] + public async Task GetId(long id, CancellationToken cancellationToken) + { + // this functions as userId + var permissionSet = await DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .SelectMany(x => x.InstancePermissionSets) + .Where(x => x.PermissionSetId == id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (permissionSet == default) + return Gone(); + return Json(permissionSet.ToApi()); + } + + /// + /// Delete an . + /// + /// The to delete. + /// The for the operation. + /// A resulting in the of the request. + /// Target deleted. + /// Target or no longer exists. + [HttpDelete("{id}")] + [TgsAuthorize(InstancePermissionSetRights.Write)] + [ProducesResponseType(204)] + [ProducesResponseType(typeof(ErrorMessage), 410)] + public async Task Delete(long id, CancellationToken cancellationToken) + { + var numDeleted = await DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .SelectMany(x => x.InstancePermissionSets) + .Where(x => x.PermissionSetId == id) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + return numDeleted > 0 ? (IActionResult)NoContent() : Gone(); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs index 2ec482ef59..1005c5fcb2 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs @@ -47,7 +47,7 @@ namespace Tgstation.Server.Host.Controllers { if (!ApiHeaders.InstanceId.HasValue) return BadRequest(new ErrorMessage(ErrorCode.InstanceHeaderRequired)); - if (AuthenticationContext.InstanceUser == null) + if (AuthenticationContext.InstancePermissionSet == null) return Forbid(); if (ValidateInstanceOnlineStatus(instanceManager, Logger, Instance)) diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs deleted file mode 100644 index 34586ea5cf..0000000000 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ /dev/null @@ -1,219 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; -using Tgstation.Server.Host.Components; -using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Models; -using Tgstation.Server.Host.Security; -using Z.EntityFramework.Plus; - -namespace Tgstation.Server.Host.Controllers -{ - /// - /// for managing s. - /// - [Route(Routes.InstanceUser)] - public sealed class InstanceUserController : InstanceRequiredController - { - /// - /// Construct a - /// - /// The for the . - /// The for the - /// The for the - /// The for the - public InstanceUserController( - IInstanceManager instanceManager, - IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, - ILogger logger) - : base( - instanceManager, - databaseContext, - authenticationContextFactory, - logger) - { } - - /// - /// Create an . - /// - /// The to create. - /// The for the operation. - /// A resulting in the of the request. - /// created successfully. - [HttpPut] - [TgsAuthorize(InstanceUserRights.CreateUsers)] - [ProducesResponseType(typeof(Api.Models.InstanceUser), 201)] - public async Task Create([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) - { - if (model == null) - throw new ArgumentNullException(nameof(model)); - - var userCanonicalName = await DatabaseContext - .Users - .AsQueryable() - .Where(x => x.Id == model.UserId) - .Select(x => x.CanonicalName) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - if (userCanonicalName == default) - return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); - - if (userCanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - return Forbid(); - - var dbUser = new Models.InstanceUser - { - ByondRights = RightsHelper.Clamp(model.ByondRights ?? ByondRights.None), - ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? ChatBotRights.None), - ConfigurationRights = RightsHelper.Clamp(model.ConfigurationRights ?? ConfigurationRights.None), - DreamDaemonRights = RightsHelper.Clamp(model.DreamDaemonRights ?? DreamDaemonRights.None), - DreamMakerRights = RightsHelper.Clamp(model.DreamMakerRights ?? DreamMakerRights.None), - RepositoryRights = RightsHelper.Clamp(model.RepositoryRights ?? RepositoryRights.None), - InstanceUserRights = RightsHelper.Clamp(model.InstanceUserRights ?? InstanceUserRights.None), - UserId = model.UserId, - InstanceId = Instance.Id - }; - - DatabaseContext.InstanceUsers.Add(dbUser); - - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return Created(dbUser.ToApi()); - } - - /// - /// Update the permissions for an . - /// - /// The updated . - /// The for the operation. - /// A resulting in the of the request. - /// updated successfully. - /// The requested does not currently exist. - [HttpPost] - [TgsAuthorize(InstanceUserRights.WriteUsers)] - [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] - [ProducesResponseType(typeof(ErrorMessage), 410)] - #pragma warning disable CA1506 // TODO: Decomplexify - public async Task Update([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) - { - if (model == null) - throw new ArgumentNullException(nameof(model)); - - var originalUser = await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .SelectMany(x => x.InstanceUsers) - .Where(x => x.UserId == model.UserId) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - if (originalUser == null) - return Gone(); - - originalUser.ByondRights = RightsHelper.Clamp(model.ByondRights ?? originalUser.ByondRights.Value); - originalUser.RepositoryRights = RightsHelper.Clamp(model.RepositoryRights ?? originalUser.RepositoryRights.Value); - originalUser.InstanceUserRights = RightsHelper.Clamp(model.InstanceUserRights ?? originalUser.InstanceUserRights.Value); - originalUser.ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? originalUser.ChatBotRights.Value); - originalUser.ConfigurationRights = RightsHelper.Clamp(model.ConfigurationRights ?? originalUser.ConfigurationRights.Value); - originalUser.DreamDaemonRights = RightsHelper.Clamp(model.DreamDaemonRights ?? originalUser.DreamDaemonRights.Value); - originalUser.DreamMakerRights = RightsHelper.Clamp(model.DreamMakerRights ?? originalUser.DreamMakerRights.Value); - - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return Json(originalUser.UserId == AuthenticationContext.User.Id || (AuthenticationContext.GetRight(RightsType.InstanceUser) & (ulong)InstanceUserRights.ReadUsers) != 0 ? originalUser.ToApi() : new Api.Models.InstanceUser - { - UserId = originalUser.UserId - }); - } -#pragma warning restore CA1506 - /// - /// Read the active . - /// - /// The of the request. - /// retrieved successfully. - [HttpGet] - [TgsAuthorize] - [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] - public IActionResult Read() => Json(AuthenticationContext.InstanceUser.ToApi()); - - /// - /// Lists s for the instance. - /// - /// The for the operation. - /// A resulting in the of the request. - /// Retrieved s successfully. - [HttpGet(Routes.List)] - [TgsAuthorize(InstanceUserRights.ReadUsers)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) - { - var users = await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .SelectMany(x => x.InstanceUsers) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(users.Select(x => x.ToApi())); - } - - /// - /// Gets a specific . - /// - /// The . - /// The for the operation. - /// A resulting in the of the request. - /// Retrieve successfully. - /// The requested does not currently exist. - [HttpGet("{id}")] - [TgsAuthorize(InstanceUserRights.ReadUsers)] - [ProducesResponseType(typeof(Api.Models.InstanceUser), 200)] - [ProducesResponseType(typeof(ErrorMessage), 410)] - public async Task GetId(long id, CancellationToken cancellationToken) - { - // this functions as userId - var user = await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .SelectMany(x => x.InstanceUsers) - .Where(x => x.UserId == id) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - if (user == default) - return Gone(); - return Json(user.ToApi()); - } - - /// - /// Delete an . - /// - /// The to delete. - /// The for the operation. - /// A resulting in the of the request. - /// deleted or no longer exists. - [HttpDelete("{id}")] - [TgsAuthorize(InstanceUserRights.WriteUsers)] - [ProducesResponseType(204)] - public async Task Delete(long id, CancellationToken cancellationToken) - { - await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .SelectMany(x => x.InstanceUsers) - .Where(x => x.UserId == id) - .DeleteAsync(cancellationToken) - .ConfigureAwait(false); - return NoContent(); - } - } -} diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 6347617699..62f657d9e3 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -1,8 +1,7 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -53,50 +52,54 @@ namespace Tgstation.Server.Host.Controllers /// /// Get active s for the instance. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved active s successfully. [HttpGet] [TgsAuthorize] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task Read(CancellationToken cancellationToken) - { - var result = await DatabaseContext - .Jobs - .AsQueryable() - .Include(x => x.StartedBy) - .Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue) - .OrderByDescending(x => x.StartedAt) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(result.Select(x => x.ToApi())); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task Read([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Jobs + .AsQueryable() + .Include(x => x.StartedBy) + .Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue) + .OrderByDescending(x => x.StartedAt))), + null, + page, + pageSize, + cancellationToken); /// /// List all s for the instance in reverse creation order. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the request. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize] - [ProducesResponseType(typeof(List), 200)] - public async Task List(CancellationToken cancellationToken) - { - // you KNOW this will need pagination eventually right? - var jobs = await DatabaseContext - .Jobs - .AsQueryable() - .Where(x => x.Instance.Id == Instance.Id) - .OrderByDescending(x => x.StartedAt) - .Select(x => new EntityId - { - Id = x.Id - }) - .ToListAsync(cancellationToken) - .ConfigureAwait(false); - return Json(jobs); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Jobs + .AsQueryable() + .Include(x => x.StartedBy) + .Where(x => x.Instance.Id == Instance.Id) + .OrderByDescending(x => x.StartedAt))), + null, + page, + pageSize, + cancellationToken); /// /// Cancel a running . diff --git a/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResult.cs b/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResult.cs new file mode 100644 index 0000000000..eefafb2aa6 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResult.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.IO; +using System.Net.Mime; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Very similar to except it's contains a fix for https://github.com/dotnet/aspnetcore/issues/28189. + /// + public sealed class LimitedFileStreamResult : FileResult + { + /// + /// The representing the file to download. + /// + public FileStream FileStream { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public LimitedFileStreamResult(FileStream stream) + : base(MediaTypeNames.Application.Octet) + { + FileStream = stream ?? throw new ArgumentNullException(nameof(stream)); + } + + /// + public override Task ExecuteResultAsync(ActionContext context) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var executor = context + .HttpContext + .RequestServices + .GetRequiredService>(); + return executor.ExecuteAsync(context, this); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResultExecutor.cs b/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResultExecutor.cs new file mode 100644 index 0000000000..19df00ae1e --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/LimitedFileStreamResultExecutor.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for s. + /// + public class LimitedFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor + { + /// + /// Initializes a new instance of the . + /// + /// The for the . + public LimitedFileStreamResultExecutor(ILogger logger) + : base(logger) + { + } + + /// + public async Task ExecuteAsync(ActionContext context, LimitedFileStreamResult result) + { + if (context == null) + throw new ArgumentNullException(nameof(context)); + + if (result == null) + throw new ArgumentNullException(nameof(result)); + + using (result.FileStream) + { + var contentLength = result.FileStream.Length; + var (range, rangeLength, serveBody) = SetHeadersAndLog(context, result, contentLength, result.EnableRangeProcessing); + if (!serveBody) + return; + + try + { + var cancellationToken = context.HttpContext.RequestAborted; + var outputStream = context.HttpContext.Response.Body; + if (range == null) + { + await StreamCopyOperation.CopyToAsync( + result.FileStream, + outputStream, + contentLength, + BufferSize, + cancellationToken) + .ConfigureAwait(false); + } + else + { + result.FileStream.Seek(range.From.Value, SeekOrigin.Begin); + await StreamCopyOperation.CopyToAsync( + result.FileStream, + outputStream, + rangeLength, + BufferSize, + cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Don't throw this exception, it's most likely caused by the client disconnecting. + // However, if it was cancelled for any other reason we need to prevent empty responses. + context.HttpContext.Abort(); + } + } + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs b/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs new file mode 100644 index 0000000000..4efdb614a5 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Helper for returning paginated models. + /// + /// The of model intended to be returned. + public sealed class PaginatableResult + { + /// + /// The results. + /// + public IQueryable Results { get; } + + /// + /// An to return immediately. + /// + public IActionResult EarlyOut { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatableResult(IQueryable results) + { + Results = results ?? throw new ArgumentNullException(nameof(results)); + } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public PaginatableResult(IActionResult earlyOut) + { + EarlyOut = earlyOut ?? throw new ArgumentNullException(nameof(earlyOut)); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 880a8efc96..c76b40682b 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Globalization; @@ -14,10 +13,8 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; -using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -41,11 +38,6 @@ namespace Tgstation.Server.Host.Controllers /// readonly IJobManager jobManager; - /// - /// The for the - /// - readonly GeneralConfiguration generalConfiguration; - /// /// Construct a /// @@ -55,15 +47,13 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The for the - /// The containing value of public RepositoryController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IGitHubClientFactory gitHubClientFactory, IJobManager jobManager, - ILogger logger, - IOptions generalConfigurationOptions) + ILogger logger) : base( instanceManager, databaseContext, @@ -72,7 +62,6 @@ namespace Tgstation.Server.Host.Controllers { this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); - generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } async Task LoadRevisionInformation(Components.Repository.IRepository repository, IDatabaseContext databaseContext, Models.Instance instance, string lastOriginCommitSha, Action revInfoSink, CancellationToken cancellationToken) @@ -123,11 +112,9 @@ namespace Tgstation.Server.Host.Controllers async Task PopulateApi(Repository model, Components.Repository.IRepository repository, IDatabaseContext databaseContext, Models.Instance instance, CancellationToken cancellationToken) { - if (repository.IsGitHubRepository) - { - model.GitHubOwner = repository.GitHubOwner; - model.GitHubName = repository.GitHubRepoName; - } + model.RemoteGitProvider = repository.RemoteGitProvider; + model.RemoteRepositoryOwner = repository.RemoteRepositoryOwner; + model.RemoteRepositoryName = repository.RemoteRepositoryName; model.Origin = repository.Origin; model.Reference = repository.Reference; @@ -172,14 +159,6 @@ namespace Tgstation.Server.Host.Controllers if (currentModel == default) return Gone(); - // normalize github urls - const string BadGitHubUrl = "://www.github.com/"; - var uiOrigin = model.Origin.ToUpperInvariant(); - var uiBad = BadGitHubUrl.ToUpperInvariant(); - var uiGitHub = Components.Repository.Repository.GitHubUrl.ToUpperInvariant(); - if (uiOrigin.Contains(uiBad, StringComparison.Ordinal)) - model.Origin = uiOrigin.Replace(uiBad, uiGitHub, StringComparison.Ordinal); - currentModel.AccessToken = model.AccessToken; currentModel.AccessUser = model.AccessUser; // intentionally only these fields, user not allowed to change anything else atm var cloneBranch = model.Reference; @@ -215,7 +194,7 @@ namespace Tgstation.Server.Host.Controllers { var repoManager = core.RepositoryManager; using var repos = await repoManager.CloneRepository( - new Uri(origin), + origin, cloneBranch, currentModel.AccessUser, currentModel.AccessToken, @@ -426,7 +405,7 @@ namespace Tgstation.Server.Host.Controllers || (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch))) return Forbid(); - if (currentModel.AccessToken?.Length == 0 && currentModel.AccessUser?.Length == 0) + if (model.AccessToken?.Length == 0 && model.AccessUser?.Length == 0) { // setting an empty string clears everything currentModel.AccessUser = null; @@ -480,11 +459,11 @@ namespace Tgstation.Server.Host.Controllers description = String.Format(CultureInfo.InvariantCulture, "Checkout repository {0} {1}", model.Reference != null ? "reference" : "SHA", model.Reference ?? model.CheckoutSha); if (newTestMerges) - description = String.Format(CultureInfo.InvariantCulture, "{0}est merge pull request(s) {1}{2}", + description = String.Format(CultureInfo.InvariantCulture, "{0}est merge(s) {1}{2}", description != null ? String.Format(CultureInfo.InvariantCulture, "{0} and t", description) : "T", String.Join(", ", model.NewTestMerges.Select(x => String.Format(CultureInfo.InvariantCulture, "#{0}{1}", x.Number, - x.PullRequestRevision != null ? String.Format(CultureInfo.InvariantCulture, " at {0}", x.PullRequestRevision.Substring(0, 7)) : String.Empty))), + x.TargetCommitSha != null ? String.Format(CultureInfo.InvariantCulture, " at {0}", x.TargetCommitSha.Substring(0, 7)) : String.Empty))), description != null ? String.Empty : " in repository"); if (description == null) @@ -507,7 +486,7 @@ namespace Tgstation.Server.Host.Controllers var startSha = repo.Head; string postUpdateSha = null; - if (newTestMerges && !repo.IsGitHubRepository) + if (newTestMerges && repo.RemoteGitProvider == RemoteGitProvider.Unknown) throw new JobException(ErrorCode.RepoUnsupportedTestMergeRemote); var committerName = currentModel.ShowTestMergeCommitters.Value @@ -652,47 +631,42 @@ namespace Tgstation.Server.Host.Controllers } // test merging - Dictionary prMap = null; if (newTestMerges) { + if (repo.RemoteGitProvider == RemoteGitProvider.Unknown) + throw new JobException(ErrorCode.RepoTestMergeInvalidRemote); + // bit of sanitization - foreach (var I in model.NewTestMerges.Where(x => String.IsNullOrWhiteSpace(x.PullRequestRevision))) - I.PullRequestRevision = null; + foreach (var I in model.NewTestMerges.Where(x => String.IsNullOrWhiteSpace(x.TargetCommitSha))) + I.TargetCommitSha = null; var gitHubClient = currentModel.AccessToken != null ? gitHubClientFactory.CreateClient(currentModel.AccessToken) - : (String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) - ? gitHubClientFactory.CreateClient() - : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken)); + : gitHubClientFactory.CreateClient(); - var repoOwner = repo.GitHubOwner; - var repoName = repo.GitHubRepoName; + var repoOwner = repo.RemoteRepositoryOwner; + var repoName = repo.RemoteRepositoryName; // optimization: if we've already merged these exact same commits in this fashion before, just find the rev info for it and check it out Models.RevisionInformation revInfoWereLookingFor = null; bool needToApplyRemainingPrs = true; if (lastRevisionInfo.OriginCommitSha == lastRevisionInfo.CommitSha) { - // In order for this to work though we need the shas of all the commits - if (model.NewTestMerges.Any(x => x.PullRequestRevision == null)) - prMap = new Dictionary(); - bool cantSearch = false; foreach (var I in model.NewTestMerges) { - if (I.PullRequestRevision != null) + if (I.TargetCommitSha != null) #pragma warning disable CA1308 // Normalize strings to uppercase - I.PullRequestRevision = I.PullRequestRevision?.ToLowerInvariant(); // ala libgit2 + I.TargetCommitSha = I.TargetCommitSha?.ToLowerInvariant(); // ala libgit2 #pragma warning restore CA1308 // Normalize strings to uppercase else try { // retrieve the latest sha - var pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number) - .WithToken(ct) - .ConfigureAwait(false); - prMap.Add(I.Number, pr); - I.PullRequestRevision = pr.Head.Sha; + var pr = await repo.GetTestMerge(I, currentModel, ct).ConfigureAwait(false); + + // we want to take the earliest truth possible to prevent RCEs, if this fails AddTestMerge will set it + I.TargetCommitSha = pr.TargetCommitSha; } catch { @@ -725,7 +699,7 @@ namespace Tgstation.Server.Host.Controllers && x.ActiveTestMerges.Select(y => y.TestMerge) .All(y => model.NewTestMerges.Any(z => y.Number == z.Number - && y.PullRequestRevision.StartsWith(z.PullRequestRevision, StringComparison.Ordinal) + && y.TargetCommitSha.StartsWith(z.TargetCommitSha, StringComparison.Ordinal) && (y.Comment?.Trim().ToUpperInvariant() == z.Comment?.Trim().ToUpperInvariant() || z.Comment == null)))) .FirstOrDefault(); @@ -753,8 +727,8 @@ namespace Tgstation.Server.Host.Controllers if (!numberMatch) return false; - var shaMatch = testRevInfo.PrimaryTestMerge.PullRequestRevision.StartsWith( - testTestMerge.PullRequestRevision, + var shaMatch = testRevInfo.PrimaryTestMerge.TargetCommitSha.StartsWith( + testTestMerge.TargetCommitSha, StringComparison.Ordinal); if (!shaMatch) return false; @@ -808,47 +782,10 @@ namespace Tgstation.Server.Host.Controllers { foreach (var I in model.NewTestMerges) { - Octokit.PullRequest pr = null; - string errorMessage = null; - if (lastRevisionInfo.ActiveTestMerges.Any(x => x.TestMerge.Number == I.Number)) throw new JobException(ErrorCode.RepoDuplicateTestMerge); - Exception exception = null; - try - { - // load from cache if possible - if (prMap == null || !prMap.TryGetValue(I.Number, out pr)) - pr = await gitHubClient - .PullRequest - .Get(repoOwner, repoName, I.Number) - .WithToken(ct) - .ConfigureAwait(false); - } - catch (Octokit.RateLimitExceededException ex) - { - // you look at your anonymous access and sigh - errorMessage = "REMOTE API ERROR: RATE LIMITED"; - exception = ex; - } - catch (Octokit.AuthorizationException ex) - { - errorMessage = "REMOTE API ERROR: BAD CREDENTIALS"; - exception = ex; - } - catch (Octokit.NotFoundException ex) - { - // you look at your shithub and sigh - errorMessage = "REMOTE API ERROR: PULL REQUEST NOT FOUND"; - exception = ex; - } - - if (exception != null) - Logger.LogWarning(exception, "Error retrieving pull request metadata!"); - - // we want to take the earliest truth possible to prevent RCEs, if this fails AddTestMerge will set it - if (I.PullRequestRevision == null && pr != null) - I.PullRequestRevision = pr.Head.Sha; + var fullTestMergeTask = repo.GetTestMerge(I, currentModel, ct); var mergeResult = await repo.AddTestMerge( I, @@ -859,28 +796,40 @@ namespace Tgstation.Server.Host.Controllers NextProgressReporter(), ct).ConfigureAwait(false); - if (!mergeResult.HasValue) + if (mergeResult == null) throw new JobException( ErrorCode.RepoTestMergeConflict, new JobException( - $"Merge of PR #{I.Number} at {I.PullRequestRevision.Substring(0, 7)} conflicted!")); + $"Test Merge #{I.Number} at {I.TargetCommitSha.Substring(0, 7)} conflicted!")); - ++doneSteps; + Models.TestMerge fullTestMerge; + try + { + fullTestMerge = await fullTestMergeTask.ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning("Error retrieving metadata for test merge #{0}!", I.Number); + + fullTestMerge = new Models.TestMerge + { + Author = ex.Message, + BodyAtMerge = ex.Message, + MergedAt = DateTimeOffset.UtcNow, + TitleAtMerge = ex.Message, + Comment = I.Comment, + Number = I.Number, + Url = ex.Message + }; + } + + // Ensure we're getting the full sha from git itself + fullTestMerge.TargetCommitSha = I.TargetCommitSha; // MergedBy will be set later - var tm = new Models.TestMerge - { - Author = pr?.User.Login ?? errorMessage, - BodyAtMerge = pr?.Body ?? errorMessage ?? String.Empty, - MergedAt = DateTimeOffset.Now, - TitleAtMerge = pr?.Title ?? errorMessage ?? String.Empty, - Comment = I.Comment, - Number = I.Number, - PullRequestRevision = I.PullRequestRevision, - Url = pr?.HtmlUrl ?? errorMessage - }; + ++doneSteps; - await UpdateRevInfo(tm).ConfigureAwait(false); + await UpdateRevInfo(fullTestMerge).ConfigureAwait(false); } } } diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs new file mode 100644 index 0000000000..1c2409dd8a --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs @@ -0,0 +1,242 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Serilog.Context; +using System; +using System.Linq; +using System.Net; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Swarm; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// For swarm server communication. + /// + [Route(SwarmConstants.ControllerRoute)] + [Produces(MediaTypeNames.Application.Json)] + [ApiController] + public sealed class SwarmController : Controller + { + /// + /// Get the current registration from the . + /// + Guid RequestRegistrationId => Guid.Parse(Request.Headers[SwarmConstants.RegistrationIdHeader].First()); + + /// + /// The for the . + /// + readonly ISwarmOperations swarmOperations; + + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly SwarmConfiguration swarmConfiguration; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The containing the value of . + /// The value of . + public SwarmController( + ISwarmOperations swarmOperations, + IAssemblyInformationProvider assemblyInformationProvider, + IOptions swarmConfigurationOptions, + ILogger logger) + { + this.swarmOperations = swarmOperations ?? throw new ArgumentNullException(nameof(swarmOperations)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); + this.logger = logger; + } + + /// + /// Check that the is valid. + /// + /// if the is valid, otherwise. + bool ValidateRegistration() => swarmOperations.ValidateRegistration(RequestRegistrationId); + + /// + /// Registration endpoint. + /// + /// The . + /// The of the operation. + [HttpPost(SwarmConstants.RegisterRoute)] + public IActionResult Register([FromBody]SwarmRegistrationRequest registrationRequest) + { + if (registrationRequest == null) + throw new ArgumentNullException(nameof(registrationRequest)); + + if (registrationRequest.ServerVersion != assemblyInformationProvider.Version) + return StatusCode((int)HttpStatusCode.UpgradeRequired); + + var registrationResult = swarmOperations.RegisterNode(registrationRequest, RequestRegistrationId); + if (!registrationResult) + return Conflict(); + return NoContent(); + } + + /// + /// Deregistration endpoint. + /// + /// The for the operation. + /// A resulting in the of the operation. + [HttpDelete(SwarmConstants.RegisterRoute)] + public async Task UnregisterNode(CancellationToken cancellationToken) + { + if (!ValidateRegistration()) + return Forbid(); + + await swarmOperations.UnregisterNode(RequestRegistrationId, cancellationToken).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Health check endpoint. + /// + /// The of the operation. + [HttpGet] + public IActionResult HealthCheck() + { + if (!ValidateRegistration()) + return Forbid(); + + return NoContent(); + } + + /// + /// Node list update endpoint. + /// + /// The . + /// The of the operation. + [HttpPost] + public IActionResult UpdateNodeList([FromBody]SwarmServersUpdateRequest serversUpdateRequest) + { + if (serversUpdateRequest == null) + throw new ArgumentNullException(nameof(serversUpdateRequest)); + + if (!ValidateRegistration()) + return Forbid(); + + swarmOperations.UpdateSwarmServersList(serversUpdateRequest.SwarmServers); + return NoContent(); + } + + /// + /// Update initiation endpoint. + /// + /// The . + /// The for the operation. + /// A resulting in the of the operation. + [HttpPut(SwarmConstants.UpdateRoute)] + public async Task PrepareUpdate([FromBody]SwarmUpdateRequest updateRequest, CancellationToken cancellationToken) + { + if (updateRequest == null) + throw new ArgumentNullException(nameof(updateRequest)); + + if (!ValidateRegistration()) + return Forbid(); + + var prepareResult = await swarmOperations.PrepareUpdateFromController(updateRequest.UpdateVersion, cancellationToken).ConfigureAwait(false); + if (!prepareResult) + return Conflict(); + + return NoContent(); + } + + /// + /// Update commit endpoint. + /// + /// The for the operation. + /// A resulting in the of the operation. + [HttpPost(SwarmConstants.UpdateRoute)] + public async Task CommitUpdate(CancellationToken cancellationToken) + { + if (!ValidateRegistration()) + return Forbid(); + + var result = await swarmOperations.RemoteCommitRecieved(RequestRegistrationId, cancellationToken).ConfigureAwait(false); + if (!result) + return Conflict(); + return NoContent(); + } + + /// + /// Update abort endpoint. + /// + /// The for the operation. + /// A resulting in the of the operation. + [HttpDelete(SwarmConstants.UpdateRoute)] + public async Task AbortUpdate(CancellationToken cancellationToken) + { + if (!ValidateRegistration()) + return Forbid(); + + await swarmOperations.RemoteAbortUpdate(cancellationToken).ConfigureAwait(false); + return NoContent(); + } + + /// + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + using var _ = LogContext.PushProperty("Request", $"{Request.Method} {Request.Path}"); + logger.LogTrace("Swarm request from {0}...", Request.HttpContext.Connection.RemoteIpAddress); + if (swarmConfiguration.PrivateKey == null) + { + logger.LogDebug("Attempted swarm request without private key configured!"); + await Forbid().ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + + if (!(Request.Headers.TryGetValue(SwarmConstants.ApiKeyHeader, out var apiKeyHeaderValues) + && apiKeyHeaderValues.Count == 1 + && apiKeyHeaderValues.First() == swarmConfiguration.PrivateKey)) + { + logger.LogDebug("Unauthorized swarm request!"); + await Unauthorized().ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + + if (!(Request.Headers.TryGetValue(SwarmConstants.RegistrationIdHeader, out var registrationHeaderValues) + && registrationHeaderValues.Count == 1 + && Guid.TryParse(registrationHeaderValues.First(), out var registrationId))) + { + logger.LogDebug("Swarm request without registration ID!"); + await BadRequest().ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + + // we validate the registration itself on a case-by-case basis + if (ModelState?.IsValid == false) + { + var errors = ModelState + .SelectMany(x => x.Value.Errors) + .Select(x => x.Exception); + + logger.LogDebug(new AggregateException(errors), "Swarm request model validation failed!"); + await BadRequest().ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + + logger.LogDebug("Starting swarm request processing..."); + await base.OnActionExecutionAsync(context, next).ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs index 4501a9783f..b7e5f74ec1 100644 --- a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs @@ -102,13 +102,13 @@ namespace Tgstation.Server.Host.Controllers } /// - /// Construct a for + /// Construct a for /// /// The rights required - public TgsAuthorizeAttribute(InstanceUserRights requiredRights) + public TgsAuthorizeAttribute(InstancePermissionSetRights requiredRights) { Roles = RightsHelper.RoleNames(requiredRights); - RightsType = Api.Rights.RightsType.InstanceUser; + RightsType = Api.Rights.RightsType.InstancePermissionSet; } } } diff --git a/src/Tgstation.Server.Host/Controllers/TransferController.cs b/src/Tgstation.Server.Host/Controllers/TransferController.cs new file mode 100644 index 0000000000..26521a7159 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/TransferController.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Transfer; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for file streaming. + /// + [Route(Routes.Transfer)] + [RequestSizeLimit(Limits.MaximumFileTransferSize)] + public sealed class TransferController : ApiController + { + /// + /// The for the . + /// + readonly IFileTransferStreamHandler fileTransferService; + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The value of . + /// The for the + public TransferController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IFileTransferStreamHandler fileTransferService, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) + { + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); + } + + /// + /// Downloads a file with a given . + /// + /// The for the download. + /// The for the operation. + /// A resulting in the of the method. + /// Started streaming download successfully. + /// The was no longer or was never valid. + [TgsAuthorize] + [HttpGet] + [ProducesResponseType(200, Type = typeof(LimitedFileStreamResult))] + [ProducesResponseType(410, Type = typeof(ErrorMessage))] + public async Task Download([Required, FromQuery] string ticket, CancellationToken cancellationToken) + { + if (ticket == null) + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); + + var streamAccept = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); + if (!Request.GetTypedHeaders().Accept.Any(x => streamAccept.IsSubsetOf(x))) + return StatusCode(HttpStatusCode.NotAcceptable, new ErrorMessage(ErrorCode.BadHeaders) + { + AdditionalData = $"File downloads must accept both {MediaTypeNames.Application.Octet} and {MediaTypeNames.Application.Json}!" + }); + + var fileTicketResult = new FileTicketResult + { + FileTicket = ticket + }; + + var tuple = await fileTransferService.RetrieveDownloadStream(fileTicketResult, cancellationToken).ConfigureAwait(false); + var stream = tuple.Item1; + try + { + if (tuple.Item2 != null) + return Conflict(tuple.Item2); + + if (stream == null) + return Gone(); + + return new LimitedFileStreamResult(stream); + } + catch + { + stream.Dispose(); + throw; + } + } + + /// + /// Uploads a file with a given . + /// + /// The for the upload. + /// The for the operation. + /// A resulting in the of the method. + /// Uploaded file successfully. + /// An error occurred during the upload. + /// The was no longer or was never valid. + [TgsAuthorize] + [HttpPut] + [ProducesResponseType(204)] + [ProducesResponseType(410, Type = typeof(ErrorMessage))] + public async Task Upload([Required, FromQuery] string ticket, CancellationToken cancellationToken) + { + if (ticket == null) + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); + + var fileTicketResult = new FileTicketResult + { + FileTicket = ticket + }; + + var result = await fileTransferService.SetUploadStream(fileTicketResult, Request.Body, cancellationToken).ConfigureAwait(false); + if (result != null) + return result.ErrorCode == ErrorCode.ResourceNotPresent + ? Gone() + : Conflict(result); + + return NoContent(); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 485216faa4..f4c46de26a 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -66,7 +66,7 @@ namespace Tgstation.Server.Host.Controllers } /// - /// Check if a given has a valid specified. + /// Check if a given has a valid specified. /// /// The to check. /// If this is a new . @@ -103,7 +103,7 @@ namespace Tgstation.Server.Host.Controllers } /// - /// Create a . + /// Create a new . /// /// The to create. /// The for the operation. @@ -113,14 +113,22 @@ namespace Tgstation.Server.Host.Controllers [HttpPut] [TgsAuthorize(AdministrationRights.WriteUsers)] [ProducesResponseType(typeof(Api.Models.User), 201)] +#pragma warning disable CA1502, CA1506 public async Task Create([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); - if (!(model.Password == null ^ model.SystemIdentifier == null)) + if (model.OAuthConnections?.Any(x => x == null) == true) + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); + + if ((model.Password != null && model.SystemIdentifier != null) + || (model.Password == null && model.SystemIdentifier == null && model.OAuthConnections?.Any() != true)) return BadRequest(new ErrorMessage(ErrorCode.UserMismatchPasswordSid)); + if (model.Group != null && model.PermissionSet != null) + return BadRequest(new ErrorMessage(ErrorCode.UserGroupAndPermissionSet)); + model.Name = model.Name?.Trim(); if (model.Name?.Length == 0) model.Name = null; @@ -132,17 +140,17 @@ namespace Tgstation.Server.Host.Controllers if (fail != null) return fail; - var dbUser = new Models.User - { - AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? AdministrationRights.None), - CreatedAt = DateTimeOffset.Now, - CreatedBy = AuthenticationContext.User, - Enabled = model.Enabled ?? false, - InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? InstanceManagerRights.None), - Name = model.Name, - SystemIdentifier = model.SystemIdentifier, - InstanceUsers = new List() - }; + var totalUsers = await DatabaseContext + .Users + .AsQueryable() + .CountAsync(cancellationToken) + .ConfigureAwait(false); + if (totalUsers >= generalConfiguration.UserLimit) + return Conflict(new ErrorMessage(ErrorCode.UserLimitReached)); + + var dbUser = await CreateNewUserFromModel(model, cancellationToken).ConfigureAwait(false); + if (dbUser == null) + return Gone(); if (model.SystemIdentifier != null) try @@ -157,7 +165,7 @@ namespace Tgstation.Server.Host.Controllers { return RequiresPosixSystemIdentity(); } - else + else if (!(model.Password?.Length == 0 && model.OAuthConnections?.Any() == true)) { var result = TrySetPassword(dbUser, model.Password, true); if (result != null) @@ -170,8 +178,11 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + Logger.LogInformation("Created new user {0} ({1})", dbUser.Name, dbUser.Id); + return Created(dbUser.ToApi(true)); } +#pragma warning restore CA1502, CA1506 /// /// Update a . @@ -180,31 +191,42 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the of the operation. /// updated successfully. - /// Requested does not exist. + /// Requested does not exist. + /// Requested does not exist. [HttpPost] - [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)] + [TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword | AdministrationRights.EditOwnOAuthConnections)] [ProducesResponseType(typeof(Api.Models.User), 200)] [ProducesResponseType(typeof(ErrorMessage), 404)] - #pragma warning disable CA1502 // TODO: Decomplexify - #pragma warning disable CA1506 + [ProducesResponseType(typeof(ErrorMessage), 410)] +#pragma warning disable CA1502 // TODO: Decomplexify +#pragma warning disable CA1506 public async Task Update([FromBody] UserUpdate model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); - if (!model.Id.HasValue) + if (!model.Id.HasValue || model.OAuthConnections?.Any(x => x == null) == true) return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); - var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); - var passwordEditOnly = !callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); + if (model.Group != null && model.PermissionSet != null) + return BadRequest(new ErrorMessage(ErrorCode.UserGroupAndPermissionSet)); - var originalUser = passwordEditOnly + var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); + var canEditAllUsers = callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); + var passwordEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnPassword); + var oAuthEdit = canEditAllUsers || callerAdministrationRights.HasFlag(AdministrationRights.EditOwnOAuthConnections); + + var originalUser = !canEditAllUsers ? AuthenticationContext.User : await DatabaseContext .Users .AsQueryable() .Where(x => x.Id == model.Id) .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.Group) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); @@ -214,13 +236,15 @@ namespace Tgstation.Server.Host.Controllers if (originalUser.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) return Forbid(); - // Ensure they are only trying to edit password (system identity change will trigger a bad request) - if (passwordEditOnly + // Ensure they are only trying to edit things they have perms for (system identity change will trigger a bad request) + if ((!canEditAllUsers && (model.Id != originalUser.Id - || model.InstanceManagerRights.HasValue - || model.AdministrationRights.HasValue || model.Enabled.HasValue + || model.Group != null + || model.PermissionSet != null || model.Name != null)) + || (!passwordEdit && model.Password != null) + || (!oAuthEdit && model.OAuthConnections != null)) return Forbid(); if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier) @@ -236,16 +260,69 @@ namespace Tgstation.Server.Host.Controllers if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName) return BadRequest(new ErrorMessage(ErrorCode.UserNameChange)); - originalUser.InstanceManagerRights = RightsHelper.Clamp(model.InstanceManagerRights ?? originalUser.InstanceManagerRights.Value); - originalUser.AdministrationRights = RightsHelper.Clamp(model.AdministrationRights ?? originalUser.AdministrationRights.Value); if (model.Enabled.HasValue) { if (originalUser.Enabled.Value && !model.Enabled.Value) - originalUser.LastPasswordUpdate = DateTimeOffset.Now; + originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; originalUser.Enabled = model.Enabled.Value; } + if (model.OAuthConnections != null + && (model.OAuthConnections.Count != originalUser.OAuthConnections.Count + || !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId)))) + { + if (originalUser.CanonicalName == Models.User.CanonicalizeName(Api.Models.User.AdminName)) + return BadRequest(new ErrorMessage(ErrorCode.AdminUserCannotOAuth)); + + if (model.OAuthConnections.Count == 0 && originalUser.PasswordHash == null && originalUser.SystemIdentifier == null) + return BadRequest(new ErrorMessage(ErrorCode.CannotRemoveLastAuthenticationOption)); + + originalUser.OAuthConnections.Clear(); + foreach (var updatedConnection in model.OAuthConnections) + originalUser.OAuthConnections.Add(new Models.OAuthConnection + { + Provider = updatedConnection.Provider, + ExternalUserId = updatedConnection.ExternalUserId + }); + } + + if (model.Group != null) + { + originalUser.Group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (originalUser.Group == default) + return Gone(); + + DatabaseContext.Groups.Attach(originalUser.Group); + if (originalUser.PermissionSet != null) + { + Logger.LogInformation("Deleting permission set {0}...", originalUser.PermissionSet.Id); + DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); + originalUser.PermissionSet = null; + } + } + else if (model.PermissionSet != null) + { + if (originalUser.PermissionSet == null) + { + Logger.LogTrace("Creating new permission set..."); + originalUser.PermissionSet = new Models.PermissionSet(); + } + + originalUser.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? AdministrationRights.None; + originalUser.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? InstanceManagerRights.None; + + originalUser.Group = null; + originalUser.GroupId = null; + } + var fail = CheckValidName(model, false); if (fail != null) return fail; @@ -254,9 +331,11 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + Logger.LogInformation("Updated user {0} ({1})", originalUser.Name, originalUser.Id); + // return id only if not a self update and cannot read users return Json( - model.Id == originalUser.Id + AuthenticationContext.User.Id == originalUser.Id || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers) ? originalUser.ToApi(true) : new Api.Models.User @@ -264,8 +343,8 @@ namespace Tgstation.Server.Host.Controllers Id = originalUser.Id }); } - #pragma warning restore CA1506 - #pragma warning restore CA1502 +#pragma warning restore CA1506 +#pragma warning restore CA1502 /// /// Get information about the current . @@ -280,27 +359,36 @@ namespace Tgstation.Server.Host.Controllers /// /// List all s in the server. /// + /// The current page. + /// The page size. /// The for the operation. /// A resulting in the of the operation. /// Retrieved s successfully. [HttpGet(Routes.List)] [TgsAuthorize(AdministrationRights.ReadUsers)] - [ProducesResponseType(typeof(IEnumerable), 200)] - public async Task List(CancellationToken cancellationToken) - { - var users = await DatabaseContext - .Users - .AsQueryable() - .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) - .Include(x => x.CreatedBy) - .ToListAsync(cancellationToken).ConfigureAwait(false); - return Json(users.Select(x => x.ToApi(true))); - } + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Users + .AsQueryable() + .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + .Include(x => x.CreatedBy) + .Include(x => x.PermissionSet) + .Include(x => x.OAuthConnections) + .Include(x => x.Group) + .ThenInclude(x => x.PermissionSet))), + null, + page, + pageSize, + cancellationToken); /// /// Get a specific . /// - /// The to retrieve. + /// The to retrieve. /// The for the operation. /// A resulting in the of the operation. /// The was retrieved successfully. @@ -321,6 +409,10 @@ namespace Tgstation.Server.Host.Controllers .AsQueryable() .Where(x => x.Id == id) .Include(x => x.CreatedBy) + .Include(x => x.OAuthConnections) + .Include(x => x.Group) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.PermissionSet) .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (user == default) return NotFound(); @@ -330,5 +422,51 @@ namespace Tgstation.Server.Host.Controllers return Json(user.ToApi(true)); } + + /// + /// Creates a new from a given . + /// + /// The to use as a template. + /// The for the operation. + /// A resulting in a new on success, if the requested did not exist. + async Task CreateNewUserFromModel(Api.Models.User model, CancellationToken cancellationToken) + { + Models.PermissionSet permissionSet = null; + Models.UserGroup group = null; + if (model.Group != null) + group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Group.Id) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + else + permissionSet = new Models.PermissionSet + { + AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None, + }; + + return new Models.User + { + CreatedAt = DateTimeOffset.UtcNow, + CreatedBy = AuthenticationContext.User, + Enabled = model.Enabled ?? false, + PermissionSet = permissionSet, + Group = group, + Name = model.Name, + SystemIdentifier = model.SystemIdentifier, + OAuthConnections = model + .OAuthConnections + ?.Select(x => new Models.OAuthConnection + { + Provider = x.Provider, + ExternalUserId = x.ExternalUserId + }) + .ToList() + ?? new List(), + }; + } } } diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs new file mode 100644 index 0000000000..5fde888df0 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -0,0 +1,240 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; +using Z.EntityFramework.Plus; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for managing s. + /// + [Route(Routes.UserGroup)] + public class UserGroupController : ApiController + { + /// + /// The for the . + /// + readonly GeneralConfiguration generalConfiguration; + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The containing the value of . + /// The for the . + public UserGroupController( + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + IOptions generalConfigurationOptions, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true) + { + generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + /// Create a new . + /// + /// The to create. + /// The for the operation. + /// A resulting in the of the operation. + /// created successfully. + [HttpPut] + [TgsAuthorize(AdministrationRights.WriteUsers)] + [ProducesResponseType(typeof(UserGroup), 201)] + public async Task Create([FromBody] UserGroup model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + if (model.Name == null) + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); + + var totalGroups = await DatabaseContext + .Groups + .AsQueryable() + .CountAsync(cancellationToken) + .ConfigureAwait(false); + if (totalGroups >= generalConfiguration.UserGroupLimit) + return Conflict(new ErrorMessage(ErrorCode.UserGroupLimitReached)); + + var permissionSet = new Models.PermissionSet + { + AdministrationRights = model.PermissionSet?.AdministrationRights ?? AdministrationRights.None, + InstanceManagerRights = model.PermissionSet?.InstanceManagerRights ?? InstanceManagerRights.None + }; + + var dbGroup = new Models.UserGroup + { + Name = model.Name, + PermissionSet = permissionSet, + }; + + DatabaseContext.Groups.Add(dbGroup); + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + Logger.LogInformation("Created new user group {0} ({1})", dbGroup.Name, dbGroup.Id); + + return Created(dbGroup.ToApi(true)); + } + + /// + /// Update a . + /// + /// The to update. + /// The for the operation. + /// A resulting in the of the operation. + /// updated successfully. + /// The requested does not currently exist. + [HttpPost] + [TgsAuthorize(AdministrationRights.WriteUsers)] + [ProducesResponseType(typeof(UserGroup), 200)] + public async Task Update([FromBody] UserGroup model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + // For my sanity, I'm not allowing user management here + // Use the UserController for that + if (model.Users != null) + return BadRequest(new ErrorMessage(ErrorCode.UserGroupControllerCantEditMembers)); + + var currentGroup = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == model.Id) + .Include(x => x.PermissionSet) + .Include(x => x.Users) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (currentGroup == default) + return Gone(); + + if (model.PermissionSet != null) + { + currentGroup.PermissionSet.AdministrationRights = model.PermissionSet.AdministrationRights ?? currentGroup.PermissionSet.AdministrationRights; + currentGroup.PermissionSet.InstanceManagerRights = model.PermissionSet.InstanceManagerRights ?? currentGroup.PermissionSet.InstanceManagerRights; + } + + currentGroup.Name = model.Name ?? currentGroup.Name; + + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + + if (!AuthenticationContext.PermissionSet.AdministrationRights.Value.HasFlag(AdministrationRights.ReadUsers)) + return Json(new UserGroup + { + Id = currentGroup.Id + }); + + return Json(currentGroup.ToApi(true)); + } + + /// + /// Gets a specific . + /// + /// The of the . + /// The for the operation. + /// A resulting in the of the request. + /// Retrieve successfully. + /// The requested does not currently exist. + [HttpGet("{id}")] + [TgsAuthorize(AdministrationRights.ReadUsers)] + [ProducesResponseType(typeof(UserGroup), 200)] + [ProducesResponseType(typeof(ErrorMessage), 410)] + public async Task GetId(long id, CancellationToken cancellationToken) + { + // this functions as userId + var group = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .Include(x => x.Users) + .Include(x => x.PermissionSet) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (group == default) + return Gone(); + return Json(group.ToApi(true)); + } + + /// + /// Lists all s. + /// + /// The current page. + /// The page size. + /// The for the operation. + /// A resulting in the of the request. + /// Retrieved s successfully. + [HttpGet(Routes.List)] + [TgsAuthorize(AdministrationRights.ReadUsers)] + [ProducesResponseType(typeof(Paginated), 200)] + public Task List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken) + => Paginated( + () => Task.FromResult( + new PaginatableResult( + DatabaseContext + .Groups + .AsQueryable() + .Include(x => x.Users) + .Include(x => x.PermissionSet))), + null, + page, + pageSize, + cancellationToken); + + /// + /// Delete an . + /// + /// The of the to delete. + /// The for the operation. + /// A resulting in the of the request. + /// was deleted. + /// The is not empty. + /// The didn't exist. + [HttpDelete("{id}")] + [TgsAuthorize(AdministrationRights.WriteUsers)] + [ProducesResponseType(204)] + [ProducesResponseType(typeof(ErrorMessage), 409)] + [ProducesResponseType(typeof(ErrorMessage), 410)] + public async Task Delete(long id, CancellationToken cancellationToken) + { + var numDeleted = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id && x.Users.Count == 0) + .DeleteAsync(cancellationToken) + .ConfigureAwait(false); + + if (numDeleted > 0) + return NoContent(); + + // find out how we failed + var groupExists = await DatabaseContext + .Groups + .AsQueryable() + .Where(x => x.Id == id) + .AnyAsync(cancellationToken) + .ConfigureAwait(false); + + return groupExists + ? Conflict(new ErrorMessage(ErrorCode.UserGroupNotEmpty)) + : Gone(); + } + } +} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 3511b8248f..8d0a203961 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -15,6 +16,7 @@ using Serilog; using Serilog.Events; using Serilog.Formatting.Display; using System; +using System.Collections.Generic; using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Linq; @@ -25,20 +27,25 @@ using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; using Tgstation.Server.Host.Components.Chat.Providers; +using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Interop.Bridge; -using Tgstation.Server.Host.Components.Interop.Converters; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Extensions.Converters; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Properties; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Security.OAuth; using Tgstation.Server.Host.Setup; +using Tgstation.Server.Host.Swarm; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Core { @@ -100,7 +107,7 @@ namespace Tgstation.Server.Host.Core // configure configuration services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); - services.UseStandardConfig(Configuration); + services.UseStandardConfig(Configuration); // enable options which give us config reloading services.AddOptions(); @@ -127,7 +134,10 @@ namespace Tgstation.Server.Host.Core config => { if (microsoftEventLevel.HasValue) + { config.MinimumLevel.Override("Microsoft", microsoftEventLevel.Value); + config.MinimumLevel.Override("System.Net.Http.HttpClient", microsoftEventLevel.Value); + } }, sinkConfig => { @@ -159,24 +169,26 @@ namespace Tgstation.Server.Host.Core }); // configure bearer token validation - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(jwtBearerOptions => - { - // this line isn't actually run until the first request is made - // at that point tokenFactory will be populated - jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; - jwtBearerOptions.Events = new JwtBearerEvents + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwtBearerOptions => { - // Application is our composition root so this monstrosity of a line is okay - // At least, that's what I tell myself to sleep at night - OnTokenValidated = ctx => ctx - .HttpContext - .RequestServices - .GetRequiredService() - .InjectClaimsIntoContext( - ctx, - ctx.HttpContext.RequestAborted) - }; - }); + // this line isn't actually run until the first request is made + // at that point tokenFactory will be populated + jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; + jwtBearerOptions.Events = new JwtBearerEvents + { + // Application is our composition root so this monstrosity of a line is okay + // At least, that's what I tell myself to sleep at night + OnTokenValidated = ctx => ctx + .HttpContext + .RequestServices + .GetRequiredService() + .InjectClaimsIntoContext( + ctx, + ctx.HttpContext.RequestAborted) + }; + }); // WARNING: STATIC CODE // fucking prevents converting 'sub' to M$ bs @@ -198,7 +210,10 @@ namespace Tgstation.Server.Host.Core options.SerializerSettings.CheckAdditionalContent = true; options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error; options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - options.SerializerSettings.Converters = new[] { new VersionConverter() }; + options.SerializerSettings.Converters = new List + { + new VersionConverter() + }; }); if (postSetupServices.GeneralConfiguration.HostApiDocumentation) @@ -216,6 +231,9 @@ namespace Tgstation.Server.Host.Core // CORS conditionally enabled later services.AddCors(); + // Enable managed HTTP clients + services.AddHttpClient(); + void AddTypedContext() where TContext : DatabaseContext { var configureAction = DatabaseContext.GetConfigureAction(); @@ -260,6 +278,7 @@ namespace Tgstation.Server.Host.Core // configure security services services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -294,20 +313,28 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); } - // configure misc services - services.AddSingleton(); + // configure component/misc services + services.AddScoped(); + services.AddTransient, LimitedFileStreamResultExecutor>(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddScoped(); - - // configure component services + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(); // configure root services services.AddSingleton(); @@ -344,6 +371,7 @@ namespace Tgstation.Server.Host.Core /// The for the /// The value of /// The . + /// The . /// The containing the to use /// The containing the to use /// The for the @@ -352,6 +380,7 @@ namespace Tgstation.Server.Host.Core IServerControl serverControl, ITokenFactory tokenFactory, IInstanceManager instanceManager, + IServerPortProvider serverPortProvider, IOptions controlPanelConfigurationOptions, IOptions generalConfigurationOptions, ILogger logger) @@ -428,7 +457,10 @@ namespace Tgstation.Server.Host.Core var originalBuilder = corsBuilder; corsBuilder = builder => { - builder.AllowAnyHeader().AllowAnyMethod(); + builder + .AllowAnyHeader() + .AllowAnyMethod() + .SetPreflightMaxAge(TimeSpan.FromDays(1)); originalBuilder?.Invoke(builder); }; applicationBuilder.UseCors(corsBuilder); @@ -463,7 +495,7 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("DMAPI version: {0}", masterVersionsAttribute.RawDMApiVersion); logger.LogTrace("Web control panel version: {0}", masterVersionsAttribute.RawControlPanelVersion); - logger.LogDebug("Starting hosting..."); + logger.LogDebug("Starting hosting on port {0}...", serverPortProvider.HttpApiPort); } } } diff --git a/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs b/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs index e2bc292b18..218a19bf4f 100644 --- a/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs +++ b/src/Tgstation.Server.Host/Core/GitHubClientFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using Octokit; using Tgstation.Server.Host.System; @@ -25,7 +25,10 @@ namespace Tgstation.Server.Host.Core /// Create a /// /// A new - GitHubClient CreateBaseClient() => new GitHubClient(new ProductHeaderValue(assemblyInformationProvider.VersionPrefix, assemblyInformationProvider.Version.ToString())); + GitHubClient CreateBaseClient() => new GitHubClient( + new ProductHeaderValue( + assemblyInformationProvider.ProductInfoHeaderValue.Product.Name, + assemblyInformationProvider.ProductInfoHeaderValue.Product.Version)); /// public IGitHubClient CreateClient() => CreateBaseClient(); diff --git a/src/Tgstation.Server.Host/Core/IGitHubClientFactory.cs b/src/Tgstation.Server.Host/Core/IGitHubClientFactory.cs index 7066ed1d29..68cf07d9e7 100644 --- a/src/Tgstation.Server.Host/Core/IGitHubClientFactory.cs +++ b/src/Tgstation.Server.Host/Core/IGitHubClientFactory.cs @@ -1,4 +1,4 @@ -using Octokit; +using Octokit; namespace Tgstation.Server.Host.Core { @@ -8,9 +8,9 @@ namespace Tgstation.Server.Host.Core public interface IGitHubClientFactory { /// - /// Create a client with anonymous authentication. Low rate limit + /// Create a client with anonymous authentication or general authentica. Low rate limit. Attempts to use the server's token to bypass this. /// - /// A new + /// A new . IGitHubClient CreateClient(); /// @@ -20,4 +20,4 @@ namespace Tgstation.Server.Host.Core /// A new IGitHubClient CreateClient(string accessToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Core/IServerControl.cs b/src/Tgstation.Server.Host/Core/IServerControl.cs index 03b5b10a67..a7ca77c1b5 100644 --- a/src/Tgstation.Server.Host/Core/IServerControl.cs +++ b/src/Tgstation.Server.Host/Core/IServerControl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host.IO; @@ -15,6 +15,11 @@ namespace Tgstation.Server.Host.Core /// bool WatchdogPresent { get; } + /// + /// Whether or not the server is currently updating + /// + bool UpdateInProgress { get; } + /// /// Run a new assembly and stop the current one. This will likely trigger all active s /// diff --git a/src/Tgstation.Server.Host/Core/IServerUpdateInitiator.cs b/src/Tgstation.Server.Host/Core/IServerUpdateInitiator.cs new file mode 100644 index 0000000000..9dfc307cca --- /dev/null +++ b/src/Tgstation.Server.Host/Core/IServerUpdateInitiator.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Core +{ + /// + /// Initiates server self updates. + /// + public interface IServerUpdateInitiator + { + /// + /// Start the process of downloading and applying an update to a new server . + /// + /// The TGS to update to. + /// The for the operation. + /// A resulting in the . + Task BeginUpdate(Version version, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Core/ServerUpdateInitiator.cs b/src/Tgstation.Server.Host/Core/ServerUpdateInitiator.cs new file mode 100644 index 0000000000..eda60adc04 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/ServerUpdateInitiator.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Octokit; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.IO; + +namespace Tgstation.Server.Host.Core +{ + /// + sealed class ServerUpdateInitiator : IServerUpdateInitiator + { + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly IIOManager ioManager; + + /// + /// The for the . + /// + readonly IServerControl serverControl; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly UpdatesConfiguration updatesConfiguration; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The containing the value of . + public ServerUpdateInitiator( + IGitHubClientFactory gitHubClientFactory, + IIOManager ioManager, + IServerControl serverControl, + ILogger logger, + IOptions updatesConfigurationOptions) + { + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions)); + } + + /// + public async Task BeginUpdate(Version newVersion, CancellationToken cancellationToken) + { + logger.LogDebug("Looking for GitHub releases version {0}...", newVersion); + IEnumerable releases; + var gitHubClient = gitHubClientFactory.CreateClient(); + releases = await gitHubClient + .Repository + .Release + .GetAll(updatesConfiguration.GitHubRepositoryId) + .WithToken(cancellationToken) + .ConfigureAwait(false); + + releases = releases.Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture)); + + logger.LogTrace("Release query complete!"); + + foreach (var release in releases) + if (Version.TryParse( + release.TagName.Replace( + updatesConfiguration.GitTagPrefix, String.Empty, StringComparison.Ordinal), + out var version) + && version == newVersion) + { + var asset = release.Assets.Where(x => x.Name.Equals(updatesConfiguration.UpdatePackageAssetName, StringComparison.Ordinal)).FirstOrDefault(); + if (asset == default) + continue; + + if (!serverControl.ApplyUpdate(version, new Uri(asset.BrowserDownloadUrl), ioManager)) + return ServerUpdateResult.UpdateInProgress; + return ServerUpdateResult.Started; + } + + return ServerUpdateResult.ReleaseMissing; + } + } +} diff --git a/src/Tgstation.Server.Host/Core/ServerUpdateResult.cs b/src/Tgstation.Server.Host/Core/ServerUpdateResult.cs new file mode 100644 index 0000000000..0fe3d71303 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/ServerUpdateResult.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Core +{ + /// + /// The result of a call to start a server update. + /// + public enum ServerUpdateResult + { + /// + /// The update process was started successfully. + /// + Started, + + /// + /// The requested release version was not found. + /// + ReleaseMissing, + + /// + /// Another update is already in progress. + /// + UpdateInProgress, + } +} diff --git a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs index b928dd6f2a..eec48ee126 100644 --- a/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Core/SwaggerConfiguration.cs @@ -25,6 +25,11 @@ namespace Tgstation.Server.Host.Core /// const string PasswordSecuritySchemeId = "Password_Login_Scheme"; + /// + /// The name for OAuth 2.0 authentication. + /// + const string OAuthSecuritySchemeId = "OAuth_Login_Scheme"; + /// /// The name for token authentication. /// @@ -156,8 +161,13 @@ namespace Tgstation.Server.Host.Core swaggerGenOptions.CustomSchemaIds(type => { - if (type == typeof(Api.Models.Internal.User)) + if (type == typeof(Api.Models.Internal.UserBase)) return "ShallowUser"; + if (type == typeof(Api.Models.Internal.UserGroup)) + return "ShallowUserGroup"; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Paginated<>)) + return $"Paginated{type.GenericTypeArguments.First().Name}"; return type.Name; }); @@ -170,13 +180,21 @@ namespace Tgstation.Server.Host.Core Scheme = ApiHeaders.BasicAuthenticationScheme }); + swaggerGenOptions.AddSecurityDefinition(OAuthSecuritySchemeId, new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Name = HeaderNames.Authorization, + Scheme = ApiHeaders.OAuthAuthenticationScheme + }); + swaggerGenOptions.AddSecurityDefinition(TokenSecuritySchemeId, new OpenApiSecurityScheme { BearerFormat = "JWT", In = ParameterLocation.Header, Type = SecuritySchemeType.Http, Name = HeaderNames.Authorization, - Scheme = ApiHeaders.JwtAuthenticationScheme + Scheme = ApiHeaders.BearerAuthenticationScheme }); } @@ -228,7 +246,7 @@ namespace Tgstation.Server.Host.Core }; if (typeof(InstanceRequiredController).IsAssignableFrom(context.MethodInfo.DeclaringType)) - operation.Parameters.Add(new OpenApiParameter + operation.Parameters.Insert(0, new OpenApiParameter { Reference = new OpenApiReference { @@ -236,10 +254,35 @@ namespace Tgstation.Server.Host.Core Id = ApiHeaders.InstanceIdHeader } }); + else if (typeof(TransferController).IsAssignableFrom(context.MethodInfo.DeclaringType)) + if (context.MethodInfo.Name == nameof(TransferController.Upload)) + operation.RequestBody = new OpenApiRequestBody + { + Content = new Dictionary + { + { + MediaTypeNames.Application.Octet, + new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "string", + Format = "binary" + } + } + } + } + }; + else if (context.MethodInfo.Name == nameof(TransferController.Download)) + { + var twoHundredResponseContents = operation.Responses["200"].Content; + var fileContent = twoHundredResponseContents[MediaTypeNames.Application.Json]; + twoHundredResponseContents.Remove(MediaTypeNames.Application.Json); + twoHundredResponseContents.Add(MediaTypeNames.Application.Octet, fileContent); + } } - else + else if (context.MethodInfo.Name == nameof(HomeController.CreateToken)) { - // HomeController.CreateToken var passwordScheme = new OpenApiSecurityScheme { Reference = new OpenApiReference @@ -249,6 +292,28 @@ namespace Tgstation.Server.Host.Core } }; + var oAuthScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = OAuthSecuritySchemeId + } + }; + + operation.Parameters.Add(new OpenApiParameter + { + In = ParameterLocation.Header, + Name = ApiHeaders.OAuthProviderHeader, + Description = "The external OAuth service provider.", + Style = ParameterStyle.Simple, + Example = new OpenApiString("Discord"), + Schema = new OpenApiSchema + { + Type = "string" + } + }); + operation.Security = new List { new OpenApiSecurityRequirement @@ -256,6 +321,10 @@ namespace Tgstation.Server.Host.Core { passwordScheme, new List() + }, + { + oAuthScheme, + new List() } } }; @@ -311,17 +380,25 @@ namespace Tgstation.Server.Host.Core Schema = productHeaderSchema }); - string bridgeOperationPath = null; + var pathsToRemove = new List(); + var filteredControllers = new string[] + { + nameof(BridgeController), + nameof(ControlPanelController), + nameof(SwarmController), + }; + foreach (var path in swaggerDoc.Paths) foreach (var operation in path.Value.Operations.Select(x => x.Value)) { - if (operation.OperationId.Equals($"{nameof(BridgeController)}.{nameof(BridgeController.Process)}", StringComparison.Ordinal)) + if (filteredControllers.Any( + x => operation.OperationId.StartsWith(x, StringComparison.Ordinal))) { - bridgeOperationPath = path.Key; + pathsToRemove.Add(path.Key); continue; } - operation.Parameters.Add(new OpenApiParameter + operation.Parameters.Insert(0, new OpenApiParameter { Reference = new OpenApiReference { @@ -330,7 +407,7 @@ namespace Tgstation.Server.Host.Core }, }); - operation.Parameters.Add(new OpenApiParameter + operation.Parameters.Insert(1, new OpenApiParameter { Reference = new OpenApiReference { @@ -340,7 +417,8 @@ namespace Tgstation.Server.Host.Core }); } - swaggerDoc.Paths.Remove(bridgeOperationPath); + foreach (var filteredPath in pathsToRemove) + swaggerDoc.Paths.Remove(filteredPath); AddDefaultResponses(swaggerDoc); } diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index 23d07da4e2..9de0ffa3f2 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -62,9 +62,9 @@ namespace Tgstation.Server.Host.Database public DbSet RepositorySettings { get; set; } /// - /// The s in the . + /// The s in the . /// - public DbSet InstanceUsers { get; set; } + public DbSet InstancePermissionSets { get; set; } /// /// The s in the . @@ -87,10 +87,25 @@ namespace Tgstation.Server.Host.Database public DbSet TestMerges { get; set; } /// - /// The s om the + /// The s in the /// public DbSet RevInfoTestMerges { get; set; } + /// + /// The s in the + /// + public DbSet OAuthConnections { get; set; } + + /// + /// The s in the + /// + public DbSet PermissionSets { get; set; } + + /// + /// The s in the + /// + public DbSet Groups { get; set; } + /// /// The for the / foreign key. /// @@ -103,7 +118,7 @@ namespace Tgstation.Server.Host.Database IDatabaseCollection IDatabaseContext.Instances => instancesCollection; /// - IDatabaseCollection IDatabaseContext.InstanceUsers => instanceUsersCollection; + IDatabaseCollection IDatabaseContext.InstancePermissionSets => instancePermissionSetsCollection; /// IDatabaseCollection IDatabaseContext.Jobs => jobsCollection; @@ -132,6 +147,15 @@ namespace Tgstation.Server.Host.Database /// IDatabaseCollection IDatabaseContext.ReattachInformations => reattachInformationsCollection; + /// + IDatabaseCollection IDatabaseContext.OAuthConnections => oAuthConnections; + + /// + IDatabaseCollection IDatabaseContext.Groups => groups; + + /// + IDatabaseCollection IDatabaseContext.PermissionSets => permissionSets; + /// /// Backing field for . /// @@ -148,9 +172,9 @@ namespace Tgstation.Server.Host.Database readonly IDatabaseCollection compileJobsCollection; /// - /// Backing field for . + /// Backing field for . /// - readonly IDatabaseCollection instanceUsersCollection; + readonly IDatabaseCollection instancePermissionSetsCollection; /// /// Backing field for . @@ -192,6 +216,21 @@ namespace Tgstation.Server.Host.Database /// readonly IDatabaseCollection reattachInformationsCollection; + /// + /// Backing field for . + /// + readonly IDatabaseCollection oAuthConnections; + + /// + /// Backing field for . + /// + readonly IDatabaseCollection groups; + + /// + /// Backing field for . + /// + readonly IDatabaseCollection permissionSets; + /// /// Gets the configure action for a given . /// @@ -216,11 +255,11 @@ namespace Tgstation.Server.Host.Database /// Construct a /// /// The for the . - public DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + protected DatabaseContext(DbContextOptions dbContextOptions) : base(dbContextOptions) { usersCollection = new DatabaseCollection(Users); instancesCollection = new DatabaseCollection(Instances); - instanceUsersCollection = new DatabaseCollection(InstanceUsers); + instancePermissionSetsCollection = new DatabaseCollection(InstancePermissionSets); compileJobsCollection = new DatabaseCollection(CompileJobs); repositorySettingsCollection = new DatabaseCollection(RepositorySettings); dreamMakerSettingsCollection = new DatabaseCollection(DreamMakerSettings); @@ -230,6 +269,9 @@ namespace Tgstation.Server.Host.Database revisionInformationsCollection = new DatabaseCollection(RevisionInformations); jobsCollection = new DatabaseCollection(Jobs); reattachInformationsCollection = new DatabaseCollection(ReattachInformations); + oAuthConnections = new DatabaseCollection(OAuthConnections); + groups = new DatabaseCollection(Groups); + permissionSets = new DatabaseCollection(PermissionSets); } /// @@ -244,8 +286,20 @@ namespace Tgstation.Server.Host.Database userModel.HasIndex(x => x.CanonicalName).IsUnique(); userModel.HasIndex(x => x.SystemIdentifier).IsUnique(); userModel.HasMany(x => x.TestMerges).WithOne(x => x.MergedBy).OnDelete(DeleteBehavior.Restrict); + userModel.HasMany(x => x.OAuthConnections).WithOne(x => x.User).OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().HasIndex(x => new { x.UserId, x.InstanceId }).IsUnique(); + modelBuilder.Entity().HasIndex(x => new { x.Provider, x.ExternalUserId }).IsUnique(); + + var groupsModel = modelBuilder.Entity(); + groupsModel.HasIndex(x => x.Name).IsUnique(); + groupsModel.HasMany(x => x.Users).WithOne(x => x.Group).OnDelete(DeleteBehavior.ClientSetNull); + + var permissionSetModel = modelBuilder.Entity(); + permissionSetModel.HasOne(x => x.Group).WithOne(x => x.PermissionSet).OnDelete(DeleteBehavior.Cascade); + permissionSetModel.HasOne(x => x.User).WithOne(x => x.PermissionSet).OnDelete(DeleteBehavior.Cascade); + permissionSetModel.HasMany(x => x.InstancePermissionSets).WithOne(x => x.PermissionSet).OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasIndex(x => new { x.PermissionSetId, x.InstanceId }).IsUnique(); var revInfo = modelBuilder.Entity(); revInfo.HasMany(x => x.ActiveTestMerges).WithOne(x => x.RevisionInformation).OnDelete(DeleteBehavior.Cascade); @@ -278,13 +332,13 @@ namespace Tgstation.Server.Host.Database modelBuilder.Entity().HasIndex(x => new { x.InstanceId, x.Name }).IsUnique(); var instanceModel = modelBuilder.Entity(); - instanceModel.HasIndex(x => x.Path).IsUnique(); + instanceModel.HasIndex(x => new { x.Path, x.SwarmIdentifer }).IsUnique(); instanceModel.HasMany(x => x.ChatSettings).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); instanceModel.HasOne(x => x.DreamDaemonSettings).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); instanceModel.HasOne(x => x.DreamMakerSettings).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); instanceModel.HasOne(x => x.RepositorySettings).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); instanceModel.HasMany(x => x.RevisionInformations).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); - instanceModel.HasMany(x => x.InstanceUsers).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); + instanceModel.HasMany(x => x.InstancePermissionSets).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); instanceModel.HasMany(x => x.Jobs).WithOne(x => x.Instance).OnDelete(DeleteBehavior.Cascade); } @@ -315,30 +369,33 @@ namespace Tgstation.Server.Host.Database return wasEmpty; } -#if DEBUG - /// - /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. - /// - public static readonly Type MSLatestMigration = typeof(MSAddAdditionalDDParameters); + // HEY YOU + // IF YOU HAVE A TEST THAT'S CREATING ERRORS BECAUSE THESE VALUES AREN'T SET CORRECTLY THERE'S MORE TO FIXING IT THAN JUST UPDATING THEM + // IN THE FUNCTION BELOW YOU ALSO NEED TO CORRECTLY SET THE RIGHT MIGRATION TO DOWNLOAD TO FOR THE LAST TGS VERSION + // IF THIS BREAKS AGAIN I WILL PERSONALLY HAUNT YOUR ASS WHEN I DIE /// /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. /// - public static readonly Type MYLatestMigration = typeof(MYAddAdditionalDDParameters); + internal static readonly Type MSLatestMigration = typeof(MSAddSwarmIdentifer); /// - /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. + /// Used by unit tests to remind us to setup the correct MYSQL migration downgrades. /// - public static readonly Type PGLatestMigration = typeof(PGAddAdditionalDDParameters); + internal static readonly Type MYLatestMigration = typeof(MYAddSwarmIdentifer); /// - /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. + /// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades. /// - public static readonly Type SLLatestMigration = typeof(SLAddAdditionalDDParameters); -#endif + internal static readonly Type PGLatestMigration = typeof(PGAddSwarmIdentifer); + + /// + /// Used by unit tests to remind us to setup the correct SQLite migration downgrades. + /// + internal static readonly Type SLLatestMigration = typeof(SLAddSwarmIdentifer); /// -#pragma warning disable CA1502 // TODO: Decomplexify +#pragma warning disable CA1502 // Cyclomatic complexity public async Task SchemaDowngradeForServerVersion( ILogger logger, Version targetVersion, @@ -355,91 +412,50 @@ namespace Tgstation.Server.Host.Database if (currentDatabaseType == DatabaseType.PostgresSql && targetVersion < new Version(4, 3, 0)) throw new NotSupportedException("Cannot migrate below version 4.3.0 with PostgresSql!"); + if (currentDatabaseType == DatabaseType.MariaDB) + currentDatabaseType = DatabaseType.MySql; // Keeping switch expressions while avoiding `or` syntax from C#9 + if (targetVersion < new Version(4, 1, 0)) throw new NotSupportedException("Cannot migrate below version 4.1.0!"); // Update this with new migrations as they are made string targetMigration = null; if (targetVersion < new Version(4, 7, 0)) - switch (currentDatabaseType) + targetMigration = currentDatabaseType switch { - case DatabaseType.MariaDB: - case DatabaseType.MySql: - targetMigration = nameof(MYAddAdditionalDDParameters); - break; - case DatabaseType.PostgresSql: - targetMigration = nameof(PGAddAdditionalDDParameters); - break; - case DatabaseType.SqlServer: - targetMigration = nameof(MSAddAdditionalDDParameters); - break; - case DatabaseType.Sqlite: - targetMigration = nameof(SLAddAdditionalDDParameters); - break; - default: - throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); - } - + DatabaseType.MySql => nameof(MYAddAdditionalDDParameters), + DatabaseType.PostgresSql => nameof(PGAddAdditionalDDParameters), + DatabaseType.SqlServer => nameof(MSAddAdditionalDDParameters), + DatabaseType.Sqlite => nameof(SLAddAdditionalDDParameters), + _ => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)), + }; if (targetVersion < new Version(4, 6, 0)) - switch (currentDatabaseType) + targetMigration = currentDatabaseType switch { - case DatabaseType.MariaDB: - case DatabaseType.MySql: - targetMigration = nameof(MYAddDeploymentColumns); - break; - case DatabaseType.PostgresSql: - targetMigration = nameof(PGAddDeploymentColumns); - break; - case DatabaseType.SqlServer: - targetMigration = nameof(MSAddDeploymentColumns); - break; - case DatabaseType.Sqlite: - targetMigration = nameof(SLAddDeploymentColumns); - break; - default: - throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); - } - + DatabaseType.MySql => nameof(MYAddDeploymentColumns), + DatabaseType.PostgresSql => nameof(PGAddDeploymentColumns), + DatabaseType.SqlServer => nameof(MSAddDeploymentColumns), + DatabaseType.Sqlite => nameof(SLAddDeploymentColumns), + _ => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)), + }; if (targetVersion < new Version(4, 5, 0)) - switch (currentDatabaseType) + targetMigration = currentDatabaseType switch { - case DatabaseType.MariaDB: - case DatabaseType.MySql: - targetMigration = nameof(MYAllowNullDMApi); - break; - case DatabaseType.PostgresSql: - targetMigration = nameof(PGAllowNullDMApi); - break; - case DatabaseType.SqlServer: - targetMigration = nameof(MSAllowNullDMApi); - break; - case DatabaseType.Sqlite: - targetMigration = nameof(SLAllowNullDMApi); - break; - default: - throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); - } - + DatabaseType.MySql => nameof(MYAllowNullDMApi), + DatabaseType.PostgresSql => nameof(PGAllowNullDMApi), + DatabaseType.SqlServer => nameof(MSAllowNullDMApi), + DatabaseType.Sqlite => nameof(SLAllowNullDMApi), + _ => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)), + }; if (targetVersion < new Version(4, 4, 0)) - switch (currentDatabaseType) + targetMigration = currentDatabaseType switch { - case DatabaseType.MariaDB: - case DatabaseType.MySql: - targetMigration = nameof(MYFixForeignKey); - break; - case DatabaseType.PostgresSql: - targetMigration = nameof(PGCreate); - break; - case DatabaseType.SqlServer: - targetMigration = nameof(MSRemoveSoftColumns); - break; - case DatabaseType.Sqlite: - targetMigration = nameof(SLRemoveSoftColumns); - break; - default: - throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); - } - + DatabaseType.MySql => nameof(MYFixForeignKey), + DatabaseType.PostgresSql => nameof(PGCreate), + DatabaseType.SqlServer => nameof(MSRemoveSoftColumns), + DatabaseType.Sqlite => nameof(SLRemoveSoftColumns), + _ => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)), + }; if (targetVersion < new Version(4, 2, 0)) targetMigration = currentDatabaseType == DatabaseType.Sqlite ? nameof(SLRebuild) : nameof(MSFixCascadingDelete); @@ -449,29 +465,18 @@ namespace Tgstation.Server.Host.Database return; } - string migrationSubstitution; - switch (currentDatabaseType) + // already setup + var migrationSubstitution = currentDatabaseType switch { - case DatabaseType.SqlServer: - // already setup - migrationSubstitution = null; - break; - case DatabaseType.MySql: - case DatabaseType.MariaDB: - migrationSubstitution = "MY{0}"; - break; - case DatabaseType.Sqlite: - migrationSubstitution = "SL{0}"; - break; - case DatabaseType.PostgresSql: - migrationSubstitution = "PG{0}"; - break; - default: - throw new InvalidOperationException($"Invalid DatabaseType: {currentDatabaseType}"); - } + DatabaseType.SqlServer => null,// already setup + DatabaseType.MySql => "MY{0}", + DatabaseType.Sqlite => "SL{0}", + DatabaseType.PostgresSql => "PG{0}", + _ => throw new InvalidOperationException($"Invalid DatabaseType: {currentDatabaseType}"), + }; if (migrationSubstitution != null) - targetMigration = String.Format(CultureInfo.InvariantCulture, migrationSubstitution, targetMigration.Substring(2)); + targetMigration = String.Format(CultureInfo.InvariantCulture, migrationSubstitution, targetMigration[2..]); // even though it clearly implements it in the DatabaseFacade definition this won't work without casting (╯ಠ益ಠ)╯︵ ┻━┻ var dbServiceProvider = ((IInfrastructure)Database).Instance; @@ -487,6 +492,6 @@ namespace Tgstation.Server.Host.Database logger.LogCritical(e, "Failed to migrate!"); } } -#pragma warning restore CA1502 +#pragma warning restore CA1502 // Cyclomatic complexity } } diff --git a/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs b/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs index 44b598ca8e..9e1c6afd79 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs @@ -83,15 +83,14 @@ namespace Tgstation.Server.Host.Database bool alreadyExists = tgsUser != null; tgsUser ??= new User() { - CreatedAt = DateTimeOffset.Now, + CreatedAt = DateTimeOffset.UtcNow, CanonicalName = User.CanonicalizeName(User.TgsSystemUserName), }; + // intentionally not giving a group or permissionset tgsUser.Name = User.TgsSystemUserName; tgsUser.PasswordHash = "_"; // This can't be hashed tgsUser.Enabled = false; - tgsUser.InstanceManagerRights = InstanceManagerRights.None; - tgsUser.AdministrationRights = AdministrationRights.None; if (!alreadyExists) databaseContext.Users.Add(tgsUser); @@ -107,9 +106,12 @@ namespace Tgstation.Server.Host.Database { var admin = new User { - AdministrationRights = RightsHelper.AllRights(), - CreatedAt = DateTimeOffset.Now, - InstanceManagerRights = RightsHelper.AllRights(), + PermissionSet = new PermissionSet + { + AdministrationRights = RightsHelper.AllRights(), + InstanceManagerRights = RightsHelper.AllRights(), + }, + CreatedAt = DateTimeOffset.UtcNow, Name = Api.Models.User.AdminName, CanonicalName = User.CanonicalizeName(Api.Models.User.AdminName), Enabled = true, @@ -149,11 +151,14 @@ namespace Tgstation.Server.Host.Database var admin = await GetAdminUser(databaseContext, cancellationToken).ConfigureAwait(false); if (admin != null) { - // Fix the issue with ulong enums - // https://github.com/tgstation/tgstation-server/commit/db341d43b3dab74fe3681f5172ca9bfeaafa6b6d#diff-09f06ec4584665cf89bb77b97f5ccfb9R36-R39 - // https://github.com/JamesNK/Newtonsoft.Json/issues/2301 - admin.AdministrationRights &= RightsHelper.AllRights(); - admin.InstanceManagerRights &= RightsHelper.AllRights(); + if (admin.PermissionSet != null) + { + // Fix the issue with ulong enums + // https://github.com/tgstation/tgstation-server/commit/db341d43b3dab74fe3681f5172ca9bfeaafa6b6d#diff-09f06ec4584665cf89bb77b97f5ccfb9R36-R39 + // https://github.com/JamesNK/Newtonsoft.Json/issues/2301 + admin.PermissionSet.AdministrationRights &= RightsHelper.AllRights(); + admin.PermissionSet.InstanceManagerRights &= RightsHelper.AllRights(); + } if (admin.CreatedBy == null) { @@ -229,7 +234,20 @@ namespace Tgstation.Server.Host.Database if (admin != null) { admin.Enabled = true; - admin.AdministrationRights |= AdministrationRights.WriteUsers; + + // force the user out of any groups + if (admin.PermissionSet == null) + { + admin.Group = null; + admin.GroupId = null; + admin.PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.None, + AdministrationRights = AdministrationRights.WriteUsers + }; + } + else + admin.PermissionSet.AdministrationRights |= AdministrationRights.WriteUsers; cryptographySuite.SetUserPassword(admin, Api.Models.User.DefaultAdminPassword, false); } @@ -249,6 +267,8 @@ namespace Tgstation.Server.Host.Database .AsQueryable() .Where(x => x.CanonicalName == User.CanonicalizeName(Api.Models.User.AdminName)) .Include(x => x.CreatedBy) + .Include(x => x.PermissionSet) + .Include(x => x.Group) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); if (admin == default) diff --git a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs index b5f6809d79..ab122b7e89 100644 --- a/src/Tgstation.Server.Host/Database/IDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/IDatabaseContext.cs @@ -14,65 +14,80 @@ namespace Tgstation.Server.Host.Database public interface IDatabaseContext { /// - /// The s in the + /// The s in the . /// IDatabaseCollection Users { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection Instances { get; } /// - /// The s in the + /// The s in the . /// - IDatabaseCollection InstanceUsers { get; } + IDatabaseCollection InstancePermissionSets { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection Jobs { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection CompileJobs { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection RevisionInformations { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection DreamMakerSettings { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection DreamDaemonSettings { get; } /// - /// The s in the + /// The s in the . /// IDatabaseCollection ChatBots { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection ChatChannels { get; } /// - /// The in the + /// The in the . /// IDatabaseCollection RepositorySettings { get; } /// - /// The for s + /// The for s. /// IDatabaseCollection ReattachInformations { get; } + /// + /// The for s. + /// + IDatabaseCollection OAuthConnections { get; } + + /// + /// The for s. + /// + IDatabaseCollection Groups { get; } + + /// + /// The for s. + /// + IDatabaseCollection PermissionSets { get; } + /// /// Saves changes made to the /// diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..2a705bfa38 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.Designer.cs @@ -0,0 +1,822 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20201122231219_MSAddOAuthConnections")] + partial class MSAddOAuthConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("decimal(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs new file mode 100644 index 0000000000..f965ae37de --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231219_MSAddOAuthConnections.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for MSSQL. + /// + public partial class MSAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthConnections", x => x.Id); + table.ForeignKey( + name: "FK_OAuthConnections_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_UserId", + table: "OAuthConnections", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_Provider_ExternalUserId", + table: "OAuthConnections", + columns: new[] { "Provider", "ExternalUserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..9b2bac237d --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.Designer.cs @@ -0,0 +1,811 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20201122231327_MYAddOAuthConnections")] + partial class MYAddOAuthConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("bigint unsigned"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("SystemIdentifier") + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs new file mode 100644 index 0000000000..d2741b785b --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231327_MYAddOAuthConnections.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for MYSQL. + /// + public partial class MYAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthConnections", x => x.Id); + table.ForeignKey( + name: "FK_OAuthConnections_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_UserId", + table: "OAuthConnections", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_Provider_ExternalUserId", + table: "OAuthConnections", + columns: new[] { "Provider", "ExternalUserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..48cbdcf3a7 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.Designer.cs @@ -0,0 +1,819 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20201122231443_PGAddOAuthConnections")] + partial class PGAddOAuthConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("numeric(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs new file mode 100644 index 0000000000..b660fe83e0 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231443_PGAddOAuthConnections.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for PostgresSQL. + /// + public partial class PGAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthConnections", x => x.Id); + table.ForeignKey( + name: "FK_OAuthConnections_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_UserId", + table: "OAuthConnections", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_Provider_ExternalUserId", + table: "OAuthConnections", + columns: new[] { "Provider", "ExternalUserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs new file mode 100644 index 0000000000..3a45f3b0a3 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.Designer.cs @@ -0,0 +1,810 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20201122231546_SLAddOAuthConnections")] + partial class SLAddOAuthConnections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10"); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstanceUserRights") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("StoppedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("TestMergeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PullRequestRevision") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs new file mode 100644 index 0000000000..9c8cdfb567 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201122231546_SLAddOAuthConnections.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the OAuthConnections table for SQLite. + /// + public partial class SLAddOAuthConnections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "OAuthConnections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Provider = table.Column(nullable: false), + ExternalUserId = table.Column(nullable: false, maxLength: 100), + UserId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthConnections", x => x.Id); + table.ForeignKey( + name: "FK_OAuthConnections_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_UserId", + table: "OAuthConnections", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthConnections_Provider_ExternalUserId", + table: "OAuthConnections", + columns: new[] { "Provider", "ExternalUserId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropTable( + name: "OAuthConnections"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.Designer.cs new file mode 100644 index 0000000000..aee2d7f748 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.Designer.cs @@ -0,0 +1,825 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20201209194250_MSGenericTestMergingUpdate")] + partial class MSGenericTestMergingUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RepositoryOrigin") + .HasColumnType("nvarchar(max)"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("decimal(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.cs new file mode 100644 index 0000000000..00413a1d03 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194250_MSGenericTestMergingUpdate.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Renames the PullRequestRevision and adds the RepositoryOrigin columns for MSSQL. + /// + public partial class MSGenericTestMergingUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "PullRequestRevision", + table: "TestMerges", + newName: "TargetCommitSha"); + + migrationBuilder.AddColumn( + name: "RepositoryOrigin", + table: "CompileJobs", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "TargetCommitSha", + table: "TestMerges", + newName: "PullRequestRevision"); + + migrationBuilder.DropColumn( + name: "RepositoryOrigin", + table: "CompileJobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.Designer.cs new file mode 100644 index 0000000000..b4a2731e53 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.Designer.cs @@ -0,0 +1,814 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20201209194348_MYGenericTestMergingUpdate")] + partial class MYGenericTestMergingUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RepositoryOrigin") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("bigint unsigned"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("SystemIdentifier") + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.cs new file mode 100644 index 0000000000..d8cf64d164 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194348_MYGenericTestMergingUpdate.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Renames the PullRequestRevision and adds the RepositoryOrigin columns for MYSQL. + /// + public partial class MYGenericTestMergingUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "PullRequestRevision", + table: "TestMerges", + newName: "TargetCommitSha"); + + migrationBuilder.AddColumn( + name: "RepositoryOrigin", + table: "CompileJobs", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "TargetCommitSha", + table: "TestMerges", + newName: "PullRequestRevision"); + + migrationBuilder.DropColumn( + name: "RepositoryOrigin", + table: "CompileJobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.Designer.cs new file mode 100644 index 0000000000..95b582de35 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.Designer.cs @@ -0,0 +1,822 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20201209194500_PGGenericTestMergingUpdate")] + partial class PGGenericTestMergingUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("RepositoryOrigin") + .HasColumnType("text"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstanceUserRights") + .HasColumnType("numeric(20,0)"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.cs new file mode 100644 index 0000000000..33d3005c57 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194500_PGGenericTestMergingUpdate.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Renames the PullRequestRevision and adds the RepositoryOrigin columns for PostgresSQL. + /// + public partial class PGGenericTestMergingUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "PullRequestRevision", + table: "TestMerges", + newName: "TargetCommitSha"); + + migrationBuilder.AddColumn( + name: "RepositoryOrigin", + table: "CompileJobs", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "TargetCommitSha", + table: "TestMerges", + newName: "PullRequestRevision"); + + migrationBuilder.DropColumn( + name: "RepositoryOrigin", + table: "CompileJobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.Designer.cs new file mode 100644 index 0000000000..7634e5c21f --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.Designer.cs @@ -0,0 +1,813 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20201209194554_SLGenericTestMergingUpdate")] + partial class SLGenericTestMergingUpdate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10"); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RepositoryOrigin") + .HasColumnType("TEXT"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstanceUserRights") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("UserId", "InstanceId") + .IsUnique(); + + b.ToTable("InstanceUsers"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("StoppedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("TestMergeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstanceUsers") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", null) + .WithMany("InstanceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.cs b/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.cs new file mode 100644 index 0000000000..0e88449ff8 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201209194554_SLGenericTestMergingUpdate.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Renames the PullRequestRevision and adds the RepositoryOrigin columns for SQLite. + /// + public partial class SLGenericTestMergingUpdate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "PullRequestRevision", + table: "TestMerges", + newName: "TargetCommitSha"); + + migrationBuilder.AddColumn( + name: "RepositoryOrigin", + table: "CompileJobs", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameColumn( + name: "TargetCommitSha", + table: "TestMerges", + newName: "PullRequestRevision"); + + migrationBuilder.RenameTable( + name: "CompileJobs", + newName: "CompileJobs_down"); + + migrationBuilder.CreateTable( + name: "CompileJobs", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + DmeName = table.Column(nullable: false), + Output = table.Column(nullable: false), + DirectoryName = table.Column(nullable: false), + MinimumSecurityLevel = table.Column(nullable: true), + JobId = table.Column(nullable: false), + RevisionInformationId = table.Column(nullable: false), + ByondVersion = table.Column(nullable: false), + DMApiMajorVersion = table.Column(nullable: true), + DMApiMinorVersion = table.Column(nullable: true), + DMApiPatchVersion = table.Column(nullable: true), + GitHubDeploymentId = table.Column(nullable: true), + GitHubRepoId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CompileJobs", x => x.Id); + table.ForeignKey( + name: "FK_CompileJobs_Jobs_JobId", + column: x => x.JobId, + principalTable: "Jobs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CompileJobs_RevisionInformations_RevisionInformationId", + column: x => x.RevisionInformationId, + principalTable: "RevisionInformations", + principalColumn: "Id"); + }); + + migrationBuilder.Sql( + $"INSERT INTO CompileJobs SELECT Id,DmeName,Output,DirectoryName,MinimumSecurityLevel,JobId,RevisionInformationId,ByondVersion,DMApiMajorVersion,DMApiMinorVersion,DMApiPatchVersion,GitHubDeploymentId,GitHubRepoId FROM CompileJobs_down"); + + migrationBuilder.DropTable( + name: "CompileJobs_down"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.Designer.cs new file mode 100644 index 0000000000..0f406197eb --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.Designer.cs @@ -0,0 +1,895 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20201214181824_MSAddUserGroups")] + partial class MSAddUserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RepositoryOrigin") + .HasColumnType("nvarchar(max)"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("decimal(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique() + .HasFilter("[GroupId] IS NOT NULL"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.cs new file mode 100644 index 0000000000..3f41b592f4 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214181824_MSAddUserGroups.cs @@ -0,0 +1,308 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds UserGroups for MSSQL. + /// + public partial class MSAddUserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + + migrationBuilder.AddColumn( + name: "GroupId", + table: "Users", + nullable: true); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AdministrationRights = table.Column(nullable: false), + InstanceManagerRights = table.Column(nullable: false), + UserId = table.Column(nullable: true), + GroupId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_PermissionSets_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PermissionSets_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InstancePermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PermissionSetId = table.Column(nullable: false), + InstancePermissionSetRights = table.Column(nullable: false), + ByondRights = table.Column(nullable: false), + DreamDaemonRights = table.Column(nullable: false), + DreamMakerRights = table.Column(nullable: false), + RepositoryRights = table.Column(nullable: false), + ChatBotRights = table.Column(nullable: false), + ConfigurationRights = table.Column(nullable: false), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstancePermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_InstancePermissionSets_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstancePermissionSets_PermissionSets_PermissionSetId", + column: x => x.PermissionSetId, + principalTable: "PermissionSets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_GroupId", + table: "Users", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + table: "Groups", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_InstanceId", + table: "InstancePermissionSets", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_PermissionSetId_InstanceId", + table: "InstancePermissionSets", + columns: new[] { "PermissionSetId", "InstanceId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_GroupId", + table: "PermissionSets", + column: "GroupId", + unique: true, + filter: "[GroupId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_UserId", + table: "PermissionSets", + column: "UserId", + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users", + column: "GroupId", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql( + "INSERT INTO PermissionSets (UserId, AdministrationRights, InstanceManagerRights) SELECT Id, AdministrationRights, InstanceManagerRights FROM Users"); + + migrationBuilder.Sql( + "INSERT INTO InstancePermissionSets (PermissionSetId, InstanceId, InstancePermissionSetRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.Id, iu.InstanceId, iu.InstanceUserRights, iu.ByondRights, iu.DreamDaemonRights, iu.DreamMakerRights, iu.RepositoryRights, iu.ChatBotRights, iu.ConfigurationRights FROM InstanceUsers iu JOIN PermissionSets p ON iu.UserId = p.UserId"); + + migrationBuilder.DropTable( + name: "InstanceUsers"); + + migrationBuilder.DropColumn( + name: "AdministrationRights", + table: "Users"); + + migrationBuilder.DropColumn( + name: "InstanceManagerRights", + table: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + type: "nvarchar(450)", + nullable: true, + oldClrType: typeof(string), + oldMaxLength: 100, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + type: "nvarchar(max)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "AdministrationRights", + table: "Users", + type: "decimal(20,0)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "InstanceManagerRights", + table: "Users", + type: "decimal(20,0)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.CreateTable( + name: "InstanceUsers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ByondRights = table.Column(type: "decimal(20,0)", nullable: false), + ChatBotRights = table.Column(type: "decimal(20,0)", nullable: false), + ConfigurationRights = table.Column(type: "decimal(20,0)", nullable: false), + DreamDaemonRights = table.Column(type: "decimal(20,0)", nullable: false), + DreamMakerRights = table.Column(type: "decimal(20,0)", nullable: false), + InstanceId = table.Column(type: "bigint", nullable: false), + InstanceUserRights = table.Column(type: "decimal(20,0)", nullable: false), + RepositoryRights = table.Column(type: "decimal(20,0)", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstanceUsers", x => x.Id); + table.ForeignKey( + name: "FK_InstanceUsers_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstanceUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_InstanceId", + table: "InstanceUsers", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_UserId_InstanceId", + table: "InstanceUsers", + columns: new[] { "UserId", "InstanceId" }, + unique: true); + + migrationBuilder.Sql( + "UPDATE Users SET AdministrationRights = p.AdministrationRights, InstanceManagerRights = p.InstanceManagerRights FROM Users u JOIN PermissionSets p ON u.Id = p.UserId WHERE p.UserId != NULL"); + + migrationBuilder.Sql( + "UPDATE Users SET AdministrationRights = p.AdministrationRights, InstanceManagerRights = p.InstanceManagerRights FROM Users u JOIN PermissionSets p ON u.GroupId = p.GroupId WHERE p.GroupId != NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.UserId, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id WHERE p.UserId != NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT u.Id, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id JOIN Users u ON p.GroupId = u.GroupId WHERE p.GroupId != NULL"); + + migrationBuilder.DropForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users"); + + migrationBuilder.DropTable( + name: "InstancePermissionSets"); + + migrationBuilder.DropTable( + name: "PermissionSets"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropIndex( + name: "IX_Users_GroupId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "GroupId", + table: "Users"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.Designer.cs new file mode 100644 index 0000000000..2adb2e47d4 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.Designer.cs @@ -0,0 +1,880 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20201214181914_MYAddUserGroups")] + partial class MYAddUserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RepositoryOrigin") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("bigint unsigned"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("SystemIdentifier") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.cs new file mode 100644 index 0000000000..d6957a88e5 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214181914_MYAddUserGroups.cs @@ -0,0 +1,307 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds UserGroups for MySQL. + /// + public partial class MYAddUserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "varchar(255) CHARACTER SET utf8mb4", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "longtext CHARACTER SET utf8mb4", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(255) CHARACTER SET utf8mb4"); + + migrationBuilder.AddColumn( + name: "GroupId", + table: "Users", + nullable: true); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Name = table.Column(maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + AdministrationRights = table.Column(nullable: false), + InstanceManagerRights = table.Column(nullable: false), + UserId = table.Column(nullable: true), + GroupId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_PermissionSets_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PermissionSets_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InstancePermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + PermissionSetId = table.Column(nullable: false), + InstancePermissionSetRights = table.Column(nullable: false), + ByondRights = table.Column(nullable: false), + DreamDaemonRights = table.Column(nullable: false), + DreamMakerRights = table.Column(nullable: false), + RepositoryRights = table.Column(nullable: false), + ChatBotRights = table.Column(nullable: false), + ConfigurationRights = table.Column(nullable: false), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstancePermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_InstancePermissionSets_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstancePermissionSets_PermissionSets_PermissionSetId", + column: x => x.PermissionSetId, + principalTable: "PermissionSets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_GroupId", + table: "Users", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + table: "Groups", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_InstanceId", + table: "InstancePermissionSets", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_PermissionSetId_InstanceId", + table: "InstancePermissionSets", + columns: new[] { "PermissionSetId", "InstanceId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_GroupId", + table: "PermissionSets", + column: "GroupId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_UserId", + table: "PermissionSets", + column: "UserId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users", + column: "GroupId", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql( + "INSERT INTO PermissionSets (UserId, AdministrationRights, InstanceManagerRights) SELECT Id, AdministrationRights, InstanceManagerRights FROM Users"); + + migrationBuilder.Sql( + "INSERT INTO InstancePermissionSets (PermissionSetId, InstanceId, InstancePermissionSetRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.Id, iu.InstanceId, iu.InstanceUserRights, iu.ByondRights, iu.DreamDaemonRights, iu.DreamMakerRights, iu.RepositoryRights, iu.ChatBotRights, iu.ConfigurationRights FROM InstanceUsers iu JOIN PermissionSets p ON iu.UserId = p.UserId"); + + migrationBuilder.DropTable( + name: "InstanceUsers"); + + migrationBuilder.DropColumn( + name: "AdministrationRights", + table: "Users"); + + migrationBuilder.DropColumn( + name: "InstanceManagerRights", + table: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + type: "varchar(255) CHARACTER SET utf8mb4", + nullable: true, + oldClrType: typeof(string), + oldMaxLength: 100, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + type: "longtext CHARACTER SET utf8mb4", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + type: "varchar(255) CHARACTER SET utf8mb4", + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "AdministrationRights", + table: "Users", + type: "bigint unsigned", + nullable: false, + defaultValue: 0ul); + + migrationBuilder.AddColumn( + name: "InstanceManagerRights", + table: "Users", + type: "bigint unsigned", + nullable: false, + defaultValue: 0ul); + + migrationBuilder.CreateTable( + name: "InstanceUsers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ByondRights = table.Column(type: "bigint unsigned", nullable: false), + ChatBotRights = table.Column(type: "bigint unsigned", nullable: false), + ConfigurationRights = table.Column(type: "bigint unsigned", nullable: false), + DreamDaemonRights = table.Column(type: "bigint unsigned", nullable: false), + DreamMakerRights = table.Column(type: "bigint unsigned", nullable: false), + InstanceId = table.Column(type: "bigint", nullable: false), + InstanceUserRights = table.Column(type: "bigint unsigned", nullable: false), + RepositoryRights = table.Column(type: "bigint unsigned", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstanceUsers", x => x.Id); + table.ForeignKey( + name: "FK_InstanceUsers_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstanceUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_InstanceId", + table: "InstanceUsers", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_UserId_InstanceId", + table: "InstanceUsers", + columns: new[] { "UserId", "InstanceId" }, + unique: true); + + migrationBuilder.Sql( + "UPDATE Users AS u JOIN PermissionSets p ON u.Id = p.UserId SET u.AdministrationRights = p.AdministrationRights, u.InstanceManagerRights = p.InstanceManagerRights WHERE p.UserId IS NOT NULL"); + + migrationBuilder.Sql( + "UPDATE Users AS u JOIN PermissionSets p ON u.GroupId = p.GroupId SET u.AdministrationRights = p.AdministrationRights, u.InstanceManagerRights = p.InstanceManagerRights WHERE p.GroupId IS NOT NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.UserId, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id WHERE p.UserId IS NOT NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT u.Id, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id JOIN Users u ON p.GroupId = u.GroupId WHERE p.GroupId IS NOT NULL"); + + migrationBuilder.DropForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users"); + + migrationBuilder.DropTable( + name: "InstancePermissionSets"); + + migrationBuilder.DropTable( + name: "PermissionSets"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropIndex( + name: "IX_Users_GroupId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "GroupId", + table: "Users"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.Designer.cs new file mode 100644 index 0000000000..595d5a17db --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.Designer.cs @@ -0,0 +1,890 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20201214182008_PGAddUserGroups")] + partial class PGAddUserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("RepositoryOrigin") + .HasColumnType("text"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("numeric(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.cs new file mode 100644 index 0000000000..72586ccabd --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214182008_PGAddUserGroups.cs @@ -0,0 +1,307 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds UserGroups for PostgresSql. + /// + public partial class PGAddUserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + maxLength: 100, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn( + name: "GroupId", + table: "Users", + nullable: true); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AdministrationRights = table.Column(nullable: false), + InstanceManagerRights = table.Column(nullable: false), + UserId = table.Column(nullable: true), + GroupId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_PermissionSets_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PermissionSets_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InstancePermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PermissionSetId = table.Column(nullable: false), + InstancePermissionSetRights = table.Column(nullable: false), + ByondRights = table.Column(nullable: false), + DreamDaemonRights = table.Column(nullable: false), + DreamMakerRights = table.Column(nullable: false), + RepositoryRights = table.Column(nullable: false), + ChatBotRights = table.Column(nullable: false), + ConfigurationRights = table.Column(nullable: false), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstancePermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_InstancePermissionSets_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstancePermissionSets_PermissionSets_PermissionSetId", + column: x => x.PermissionSetId, + principalTable: "PermissionSets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_GroupId", + table: "Users", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + table: "Groups", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_InstanceId", + table: "InstancePermissionSets", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_PermissionSetId_InstanceId", + table: "InstancePermissionSets", + columns: new[] { "PermissionSetId", "InstanceId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_GroupId", + table: "PermissionSets", + column: "GroupId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_UserId", + table: "PermissionSets", + column: "UserId", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users", + column: "GroupId", + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.Sql( + "INSERT INTO \"PermissionSets\" (\"UserId\", \"AdministrationRights\", \"InstanceManagerRights\") SELECT \"Id\", \"AdministrationRights\", \"InstanceManagerRights\" FROM \"Users\""); + + migrationBuilder.Sql( + "INSERT INTO \"InstancePermissionSets\" (\"PermissionSetId\", \"InstanceId\", \"InstancePermissionSetRights\", \"ByondRights\", \"DreamDaemonRights\", \"DreamMakerRights\", \"RepositoryRights\", \"ChatBotRights\", \"ConfigurationRights\") SELECT p.\"Id\", iu.\"InstanceId\", iu.\"InstanceUserRights\", iu.\"ByondRights\", iu.\"DreamDaemonRights\", iu.\"DreamMakerRights\", iu.\"RepositoryRights\", iu.\"ChatBotRights\", iu.\"ConfigurationRights\" FROM \"InstanceUsers\" iu JOIN \"PermissionSets\" p ON iu.\"UserId\" = p.\"UserId\""); + + migrationBuilder.DropTable( + name: "InstanceUsers"); + + migrationBuilder.DropColumn( + name: "AdministrationRights", + table: "Users"); + + migrationBuilder.DropColumn( + name: "InstanceManagerRights", + table: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AlterColumn( + name: "SystemIdentifier", + table: "Users", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldMaxLength: 100, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Name", + table: "Users", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "CanonicalName", + table: "Users", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldMaxLength: 100); + + migrationBuilder.AddColumn( + name: "AdministrationRights", + table: "Users", + type: "numeric(20,0)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "InstanceManagerRights", + table: "Users", + type: "numeric(20,0)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.CreateTable( + name: "InstanceUsers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ByondRights = table.Column(type: "numeric(20,0)", nullable: false), + ChatBotRights = table.Column(type: "numeric(20,0)", nullable: false), + ConfigurationRights = table.Column(type: "numeric(20,0)", nullable: false), + DreamDaemonRights = table.Column(type: "numeric(20,0)", nullable: false), + DreamMakerRights = table.Column(type: "numeric(20,0)", nullable: false), + InstanceId = table.Column(type: "bigint", nullable: false), + InstanceUserRights = table.Column(type: "numeric(20,0)", nullable: false), + RepositoryRights = table.Column(type: "numeric(20,0)", nullable: false), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstanceUsers", x => x.Id); + table.ForeignKey( + name: "FK_InstanceUsers_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstanceUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_InstanceId", + table: "InstanceUsers", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_UserId_InstanceId", + table: "InstanceUsers", + columns: new[] { "UserId", "InstanceId" }, + unique: true); + + migrationBuilder.Sql( + "UPDATE \"Users\" SET \"AdministrationRights\" = p.\"AdministrationRights\", \"InstanceManagerRights\" = p.\"InstanceManagerRights\" FROM \"Users\" u JOIN \"PermissionSets\" p ON u.\"Id\" = p.\"UserId\" WHERE p.\"UserId\" != NULL"); + + migrationBuilder.Sql( + "UPDATE \"Users\" SET \"AdministrationRights\" = p.\"AdministrationRights\", \"InstanceManagerRights\" = p.\"InstanceManagerRights\" FROM \"Users\" u JOIN \"PermissionSets\" p ON u.\"GroupId\" = p.\"GroupId\" WHERE p.\"GroupId\" != NULL"); + + migrationBuilder.Sql( + "INSERT INTO \"InstanceUsers\" (\"UserId\", \"InstanceId\", \"InstanceUserRights\", \"ByondRights\", \"DreamDaemonRights\", \"DreamMakerRights\", \"RepositoryRights\", \"ChatBotRights\", \"ConfigurationRights\") SELECT p.\"UserId\", ips.\"InstanceId\", ips.\"InstancePermissionSetRights\", ips.\"ByondRights\", ips.\"DreamDaemonRights\", ips.\"DreamMakerRights\", ips.\"RepositoryRights\", ips.\"ChatBotRights\", ips.\"ConfigurationRights\" FROM \"InstancePermissionSets\" ips JOIN \"PermissionSets\" p ON ips.\"PermissionSetId\" = p.\"Id\" WHERE p.\"UserId\" != NULL"); + + migrationBuilder.Sql( + "INSERT INTO \"InstanceUsers\" (\"UserId\", \"InstanceId\", \"InstanceUserRights\", \"ByondRights\", \"DreamDaemonRights\", \"DreamMakerRights\", \"RepositoryRights\", \"ChatBotRights\", \"ConfigurationRights\") SELECT u.\"Id\", ips.\"InstanceId\", ips.\"InstancePermissionSetRights\", ips.\"ByondRights\", ips.\"DreamDaemonRights\", ips.\"DreamMakerRights\", ips.\"RepositoryRights\", ips.\"ChatBotRights\", ips.\"ConfigurationRights\" FROM \"InstancePermissionSets\" ips JOIN \"PermissionSets\" p ON ips.\"PermissionSetId\" = p.\"Id\" JOIN \"Users\" u ON p.\"GroupId\" = u.\"GroupId\" WHERE p.\"GroupId\" != NULL"); + + migrationBuilder.DropForeignKey( + name: "FK_Users_Groups_GroupId", + table: "Users"); + + migrationBuilder.DropTable( + name: "InstancePermissionSets"); + + migrationBuilder.DropTable( + name: "PermissionSets"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropIndex( + name: "IX_Users_GroupId", + table: "Users"); + + migrationBuilder.DropColumn( + name: "GroupId", + table: "Users"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.Designer.cs new file mode 100644 index 0000000000..0714682d93 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.Designer.cs @@ -0,0 +1,879 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20201214182101_SLAddUserGroups")] + partial class SLAddUserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10"); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RepositoryOrigin") + .HasColumnType("TEXT"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("INTEGER"); + + b.Property("PermissionSetId") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("StoppedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("TestMergeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.cs b/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.cs new file mode 100644 index 0000000000..ef40a990e2 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201214182101_SLAddUserGroups.cs @@ -0,0 +1,308 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds UserGroups for SQLite. + /// + public partial class SLAddUserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.AddColumn( + name: "GroupId", + table: "Users", + nullable: true); + + migrationBuilder.CreateTable( + name: "Groups", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Groups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "PermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AdministrationRights = table.Column(nullable: false), + InstanceManagerRights = table.Column(nullable: false), + UserId = table.Column(nullable: true), + GroupId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_PermissionSets_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PermissionSets_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InstancePermissionSets", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + PermissionSetId = table.Column(nullable: false), + InstancePermissionSetRights = table.Column(nullable: false), + ByondRights = table.Column(nullable: false), + DreamDaemonRights = table.Column(nullable: false), + DreamMakerRights = table.Column(nullable: false), + RepositoryRights = table.Column(nullable: false), + ChatBotRights = table.Column(nullable: false), + ConfigurationRights = table.Column(nullable: false), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstancePermissionSets", x => x.Id); + table.ForeignKey( + name: "FK_InstancePermissionSets_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstancePermissionSets_PermissionSets_PermissionSetId", + column: x => x.PermissionSetId, + principalTable: "PermissionSets", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.RenameTable( + name: "Users", + newName: "Users_up"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(nullable: false), + CreatedAt = table.Column(nullable: false), + SystemIdentifier = table.Column(nullable: true), + Name = table.Column(maxLength: 10000, nullable: false), + PasswordHash = table.Column(nullable: true), + CreatedById = table.Column(nullable: true), + GroupId = table.Column(nullable: true), + CanonicalName = table.Column(nullable: false), + LastPasswordUpdate = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Users_CreatedById", + column: x => x.CreatedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Users_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_GroupId", + table: "Users", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_Groups_Name", + table: "Groups", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_InstanceId", + table: "InstancePermissionSets", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstancePermissionSets_PermissionSetId_InstanceId", + table: "InstancePermissionSets", + columns: new[] { "PermissionSetId", "InstanceId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_GroupId", + table: "PermissionSets", + column: "GroupId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PermissionSets_UserId", + table: "PermissionSets", + column: "UserId", + unique: true); + + migrationBuilder.Sql( + "INSERT INTO Users (Id, Enabled, CreatedAt, SystemIdentifier, Name, PasswordHash, CreatedById, CanonicalName, LastPasswordUpdate) SELECT Id, Enabled, CreatedAt, SystemIdentifier, Name, PasswordHash, CreatedById, CanonicalName, LastPasswordUpdate FROM Users_up"); + + migrationBuilder.Sql( + "INSERT INTO PermissionSets (UserId, AdministrationRights, InstanceManagerRights) SELECT Id, AdministrationRights, InstanceManagerRights FROM Users_up"); + + migrationBuilder.Sql( + "INSERT INTO InstancePermissionSets (PermissionSetId, InstanceId, InstancePermissionSetRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.Id, iu.InstanceId, iu.InstanceUserRights, iu.ByondRights, iu.DreamDaemonRights, iu.DreamMakerRights, iu.RepositoryRights, iu.ChatBotRights, iu.ConfigurationRights FROM InstanceUsers iu JOIN PermissionSets p ON iu.UserId = p.UserId"); + + migrationBuilder.DropTable( + name: "InstanceUsers"); + + migrationBuilder.DropTable( + name: "Users_up"); + + // Had to do this in SLAllowNullDMApi too, renames confuse the fuck out of the ORM + migrationBuilder.RenameTable( + name: "Users", + newName: "Users_up"); + + migrationBuilder.RenameTable( + name: "Users_up", + newName: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.CreateTable( + name: "InstanceUsers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ByondRights = table.Column(type: "INTEGER", nullable: false), + ChatBotRights = table.Column(type: "INTEGER", nullable: false), + ConfigurationRights = table.Column(type: "INTEGER", nullable: false), + DreamDaemonRights = table.Column(type: "INTEGER", nullable: false), + DreamMakerRights = table.Column(type: "INTEGER", nullable: false), + InstanceId = table.Column(type: "INTEGER", nullable: false), + InstanceUserRights = table.Column(type: "INTEGER", nullable: false), + RepositoryRights = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstanceUsers", x => x.Id); + table.ForeignKey( + name: "FK_InstanceUsers_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InstanceUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_InstanceId", + table: "InstanceUsers", + column: "InstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_InstanceUsers_UserId_InstanceId", + table: "InstanceUsers", + columns: new[] { "UserId", "InstanceId" }, + unique: true); + + migrationBuilder.RenameTable( + name: "Users", + newName: "Users_down"); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(nullable: false), + CreatedAt = table.Column(nullable: false), + SystemIdentifier = table.Column(nullable: true), + Name = table.Column(maxLength: 100, nullable: false), + AdministrationRights = table.Column(nullable: false), + InstanceManagerRights = table.Column(nullable: false), + PasswordHash = table.Column(nullable: true), + CreatedById = table.Column(nullable: true), + CanonicalName = table.Column(maxLength: 100, nullable: false), + LastPasswordUpdate = table.Column(nullable: true), + GroupId = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Users_CreatedById", + column: x => x.CreatedById, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.Sql( + "INSERT INTO Users (Id, Enabled, CreatedAt, SystemIdentifier, Name, PasswordHash, CreatedById, CanonicalName, LastPasswordUpdate, AdministrationRights, InstanceManagerRights) SELECT u.Id, u.Enabled, u.CreatedAt, u.SystemIdentifier, u.Name, u.PasswordHash, u.CreatedById, u.CanonicalName, u.LastPasswordUpdate, p.AdministrationRights, p.InstanceManagerRights FROM Users_down u JOIN PermissionSets p ON p.UserId = u.Id WHERE u.GroupId = NULL"); + + migrationBuilder.Sql( + "INSERT INTO Users (Id, Enabled, CreatedAt, SystemIdentifier, Name, PasswordHash, CreatedById, CanonicalName, LastPasswordUpdate, AdministrationRights, InstanceManagerRights) SELECT u.Id, u.Enabled, u.CreatedAt, u.SystemIdentifier, u.Name, u.PasswordHash, u.CreatedById, u.CanonicalName, u.LastPasswordUpdate, p.AdministrationRights, p.InstanceManagerRights FROM Users_down u JOIN PermissionSets p ON p.GroupId = u.GroupId WHERE u.GroupId != NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT p.UserId, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id WHERE p.UserId != NULL"); + + migrationBuilder.Sql( + "INSERT INTO InstanceUsers (UserId, InstanceId, InstanceUserRights, ByondRights, DreamDaemonRights, DreamMakerRights, RepositoryRights, ChatBotRights, ConfigurationRights) SELECT u.Id, ips.InstanceId, ips.InstancePermissionSetRights, ips.ByondRights, ips.DreamDaemonRights, ips.DreamMakerRights, ips.RepositoryRights, ips.ChatBotRights, ips.ConfigurationRights FROM InstancePermissionSets ips JOIN PermissionSets p ON ips.PermissionSetId = p.Id JOIN Users_down u ON p.GroupId = u.GroupId WHERE p.GroupId != NULL"); + + migrationBuilder.DropTable( + name: "InstancePermissionSets"); + + migrationBuilder.DropTable( + name: "PermissionSets"); + + migrationBuilder.DropTable( + name: "Groups"); + + migrationBuilder.DropTable( + name: "Users_down"); + + migrationBuilder.RenameTable( + name: "Users", + newName: "Users_down"); + + migrationBuilder.RenameTable( + name: "Users_down", + newName: "Users"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.Designer.cs new file mode 100644 index 0000000000..5ceffde807 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.Designer.cs @@ -0,0 +1,899 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20201222175310_MSAddSwarmIdentifer")] + partial class MSAddSwarmIdentifer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128) + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RepositoryOrigin") + .HasColumnType("nvarchar(max)"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("SwarmIdentifer") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique() + .HasFilter("[SwarmIdentifer] IS NOT NULL"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("decimal(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique() + .HasFilter("[GroupId] IS NOT NULL"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.cs new file mode 100644 index 0000000000..70e154ffd9 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175310_MSAddSwarmIdentifer.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the swarm identifier column for MSSQL. + /// + public partial class MSAddSwarmIdentifer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path", + table: "Instances"); + + migrationBuilder.AddColumn( + name: "SwarmIdentifer", + table: "Instances", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances", + columns: new[] { "Path", "SwarmIdentifer" }, + unique: true, + filter: "[SwarmIdentifer] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "SwarmIdentifer", + table: "Instances"); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path", + table: "Instances", + column: "Path", + unique: true); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.Designer.cs new file mode 100644 index 0000000000..61060d15ec --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.Designer.cs @@ -0,0 +1,883 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20201222175357_MYAddSwarmIdentifer")] + partial class MYAddSwarmIdentifer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RepositoryOrigin") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.Property("SwarmIdentifer") + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("bigint unsigned"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("SystemIdentifier") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.cs new file mode 100644 index 0000000000..643ffd516c --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175357_MYAddSwarmIdentifer.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the swarm identifier column for MySQL. + /// + public partial class MYAddSwarmIdentifer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path", + table: "Instances"); + + migrationBuilder.AddColumn( + name: "SwarmIdentifer", + table: "Instances", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances", + columns: new[] { "Path", "SwarmIdentifer" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "SwarmIdentifer", + table: "Instances"); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path", + table: "Instances", + column: "Path", + unique: true); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.Designer.cs new file mode 100644 index 0000000000..bd76fc40c7 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.Designer.cs @@ -0,0 +1,893 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20201222175444_PGAddSwarmIdentifer")] + partial class PGAddSwarmIdentifer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("RepositoryOrigin") + .HasColumnType("text"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("SwarmIdentifer") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("numeric(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.cs new file mode 100644 index 0000000000..f052cf0609 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175444_PGAddSwarmIdentifer.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the swarm identifier column for PostgresSQL. + /// + public partial class PGAddSwarmIdentifer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path", + table: "Instances"); + + migrationBuilder.AddColumn( + name: "SwarmIdentifer", + table: "Instances", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances", + columns: new[] { "Path", "SwarmIdentifer" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "SwarmIdentifer", + table: "Instances"); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path", + table: "Instances", + column: "Path", + unique: true); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.Designer.cs new file mode 100644 index 0000000000..21719bc857 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.Designer.cs @@ -0,0 +1,882 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20201222175532_SLAddSwarmIdentifer")] + partial class SLAddSwarmIdentifer + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.10"); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RepositoryOrigin") + .HasColumnType("TEXT"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SwarmIdentifer") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("INTEGER"); + + b.Property("PermissionSetId") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("StoppedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("TestMergeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.cs b/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.cs new file mode 100644 index 0000000000..5d6c11f937 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20201222175532_SLAddSwarmIdentifer.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + /// Adds the swarm identifier column for SQLite. + /// + public partial class SLAddSwarmIdentifer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.DropIndex( + name: "IX_Instances_Path", + table: "Instances"); + + migrationBuilder.AddColumn( + name: "SwarmIdentifer", + table: "Instances", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Instances_Path_SwarmIdentifer", + table: "Instances", + columns: new[] { "Path", "SwarmIdentifer" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + if (migrationBuilder == null) + throw new ArgumentNullException(nameof(migrationBuilder)); + + migrationBuilder.RenameTable( + name: "Instances", + newName: "Instances_down"); + + migrationBuilder.CreateTable( + name: "Instances", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(maxLength: 10000, nullable: false), + Path = table.Column(nullable: false), + Online = table.Column(nullable: false), + ConfigurationType = table.Column(nullable: false), + AutoUpdateInterval = table.Column(nullable: false), + ChatBotLimit = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Instances", x => x.Id); + }); + + migrationBuilder.Sql("INSERT INTO Instances (Id, Name, Path, Online, ConfigurationType, AutoUpdateInterval, ChatBotLimit) SELECT Id, Name, Path, Online, ConfigurationType, AutoUpdateInterval, ChatBotLimit FROM Instances_down"); + + migrationBuilder.DropTable( + name: "Instances_down"); + + migrationBuilder.RenameTable( + name: "Instances", + newName: "Instances_down"); + + migrationBuilder.RenameTable( + name: "Instances_down", + newName: "Instances"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs index e16999c281..8707171228 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs @@ -13,761 +13,868 @@ namespace Tgstation.Server.Host.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "3.1.7") + .HasAnnotation("ProductVersion", "3.1.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("ChannelLimit") - .IsRequired() - .HasColumnType("smallint unsigned"); + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("Enabled") - .HasColumnType("tinyint(1)"); + b.Property("Enabled") + .HasColumnType("tinyint(1)"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Name") - .IsRequired() - .HasColumnType("varchar(100) CHARACTER SET utf8mb4") - .HasMaxLength(100); + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); - b.Property("Provider") - .HasColumnType("int"); + b.Property("Provider") + .HasColumnType("int"); - b.Property("ReconnectionInterval") - .IsRequired() - .HasColumnType("int unsigned"); + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "Name") - .IsUnique(); + b.HasIndex("InstanceId", "Name") + .IsUnique(); - b.ToTable("ChatBots"); - }); + b.ToTable("ChatBots"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("ChatSettingsId") - .HasColumnType("bigint"); + b.Property("ChatSettingsId") + .HasColumnType("bigint"); - b.Property("DiscordChannelId") - .HasColumnType("bigint unsigned"); + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); - b.Property("IrcChannel") - .HasColumnType("varchar(100) CHARACTER SET utf8mb4") - .HasMaxLength(100); + b.Property("IrcChannel") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); - b.Property("IsAdminChannel") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("IsUpdatesChannel") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("IsWatchdogChannel") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("Tag") - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("Tag") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("ChatSettingsId", "DiscordChannelId") - .IsUnique(); + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); - b.HasIndex("ChatSettingsId", "IrcChannel") - .IsUnique(); + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); - b.ToTable("ChatChannels"); - }); + b.ToTable("ChatChannels"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("ByondVersion") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("DMApiMajorVersion") - .HasColumnType("int"); + b.Property("DMApiMajorVersion") + .HasColumnType("int"); - b.Property("DMApiMinorVersion") - .HasColumnType("int"); + b.Property("DMApiMinorVersion") + .HasColumnType("int"); - b.Property("DMApiPatchVersion") - .HasColumnType("int"); + b.Property("DMApiPatchVersion") + .HasColumnType("int"); - b.Property("DirectoryName") - .IsRequired() - .HasColumnType("char(36)"); + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); - b.Property("DmeName") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("GitHubDeploymentId") - .HasColumnType("int"); + b.Property("GitHubDeploymentId") + .HasColumnType("int"); - b.Property("GitHubRepoId") - .HasColumnType("bigint"); + b.Property("GitHubRepoId") + .HasColumnType("bigint"); - b.Property("JobId") - .HasColumnType("bigint"); + b.Property("JobId") + .HasColumnType("bigint"); - b.Property("MinimumSecurityLevel") - .HasColumnType("int"); + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); - b.Property("Output") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("Output") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RepositoryOrigin") + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.HasKey("Id"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.HasIndex("DirectoryName"); + b.HasKey("Id"); - b.HasIndex("JobId") - .IsUnique(); + b.HasIndex("DirectoryName"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("JobId") + .IsUnique(); - b.ToTable("CompileJobs"); - }); + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("AdditionalParameters") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("AllowWebClient") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("AutoStart") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("HeartbeatSeconds") - .IsRequired() - .HasColumnType("int unsigned"); + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("int unsigned"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Port") - .IsRequired() - .HasColumnType("smallint unsigned"); + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); - b.Property("SecurityLevel") - .HasColumnType("int"); + b.Property("SecurityLevel") + .HasColumnType("int"); - b.Property("StartupTimeout") - .IsRequired() - .HasColumnType("int unsigned"); + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); - b.Property("TopicRequestTimeout") - .IsRequired() - .HasColumnType("int unsigned"); + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamDaemonSettings"); - }); + b.ToTable("DreamDaemonSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("ApiValidationPort") - .IsRequired() - .HasColumnType("smallint unsigned"); + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); - b.Property("ApiValidationSecurityLevel") - .HasColumnType("int"); + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("ProjectName") - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("ProjectName") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("RequireDMApiValidation") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamMakerSettings"); - }); + b.ToTable("DreamMakerSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("AutoUpdateInterval") - .IsRequired() - .HasColumnType("int unsigned"); + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); - b.Property("ChatBotLimit") - .IsRequired() - .HasColumnType("smallint unsigned"); + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); - b.Property("ConfigurationType") - .HasColumnType("int"); + b.Property("ConfigurationType") + .HasColumnType("int"); - b.Property("Name") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("Name") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("Online") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("Path") - .IsRequired() - .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); - b.HasKey("Id"); + b.Property("SwarmIdentifer") + .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); - b.HasIndex("Path") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("Instances"); - }); + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + b.ToTable("Instances"); + }); - b.Property("ByondRights") - .HasColumnType("bigint unsigned"); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("ChatBotRights") - .HasColumnType("bigint unsigned"); + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); - b.Property("ConfigurationRights") - .HasColumnType("bigint unsigned"); + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); - b.Property("DreamDaemonRights") - .HasColumnType("bigint unsigned"); + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); - b.Property("DreamMakerRights") - .HasColumnType("bigint unsigned"); + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); - b.Property("InstanceUserRights") - .HasColumnType("bigint unsigned"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("RepositoryRights") - .HasColumnType("bigint unsigned"); + b.Property("InstancePermissionSetRights") + .HasColumnType("bigint unsigned"); - b.Property("UserId") - .HasColumnType("bigint"); + b.Property("PermissionSetId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); - b.HasIndex("InstanceId"); + b.HasKey("Id"); - b.HasIndex("UserId", "InstanceId") - .IsUnique(); + b.HasIndex("InstanceId"); - b.ToTable("InstanceUsers"); - }); + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("CancelRight") - .HasColumnType("bigint unsigned"); + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); - b.Property("CancelRightsType") - .HasColumnType("bigint unsigned"); + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); - b.Property("Cancelled") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("CancelledById") - .HasColumnType("bigint"); + b.Property("CancelledById") + .HasColumnType("bigint"); - b.Property("Description") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("Description") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("ErrorCode") - .HasColumnType("int unsigned"); + b.Property("ErrorCode") + .HasColumnType("int unsigned"); - b.Property("ExceptionDetails") - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("ExceptionDetails") + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("StartedAt") - .IsRequired() - .HasColumnType("datetime(6)"); + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); - b.Property("StartedById") - .HasColumnType("bigint"); + b.Property("StartedById") + .HasColumnType("bigint"); - b.Property("StoppedAt") - .HasColumnType("datetime(6)"); + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CancelledById"); + b.HasIndex("CancelledById"); - b.HasIndex("InstanceId"); + b.HasIndex("InstanceId"); - b.HasIndex("StartedById"); + b.HasIndex("StartedById"); - b.ToTable("Jobs"); - }); + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("AccessIdentifier") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("CompileJobId") - .HasColumnType("bigint"); + b.Property("CompileJobId") + .HasColumnType("bigint"); - b.Property("LaunchSecurityLevel") - .HasColumnType("int"); + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); - b.Property("Port") - .HasColumnType("smallint unsigned"); + b.Property("Port") + .HasColumnType("smallint unsigned"); - b.Property("ProcessId") - .HasColumnType("int"); + b.Property("ProcessId") + .HasColumnType("int"); - b.Property("RebootState") - .HasColumnType("int"); + b.Property("RebootState") + .HasColumnType("int"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CompileJobId"); + b.HasIndex("CompileJobId"); - b.ToTable("ReattachInformations"); - }); + b.ToTable("ReattachInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("AccessToken") - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("AccessToken") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("AccessUser") - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("AccessUser") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("AutoUpdatesKeepTestMerges") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("AutoUpdatesSynchronize") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("CommitterEmail") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("CommitterName") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("CommitterName") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("CreateGitHubDeployments") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("PostTestMergeComment") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("PushTestMergeCommits") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("ShowTestMergeCommitters") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("RepositorySettings"); - }); + b.ToTable("RepositorySettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.Property("TestMergeId") - .HasColumnType("bigint"); + b.Property("TestMergeId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("RevisionInformationId"); - b.HasIndex("TestMergeId"); + b.HasIndex("TestMergeId"); - b.ToTable("RevInfoTestMerges"); - }); + b.ToTable("RevInfoTestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("CommitSha") - .IsRequired() - .HasColumnType("varchar(40) CHARACTER SET utf8mb4") - .HasMaxLength(40); + b.Property("CommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("OriginCommitSha") - .IsRequired() - .HasColumnType("varchar(40) CHARACTER SET utf8mb4") - .HasMaxLength(40); + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "CommitSha") - .IsUnique(); + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); - b.ToTable("RevisionInformations"); - }); + b.ToTable("RevisionInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("Author") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("Author") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("BodyAtMerge") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("Comment") - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("Comment") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(10000); - b.Property("MergedAt") - .HasColumnType("datetime(6)"); + b.Property("MergedAt") + .HasColumnType("datetime(6)"); - b.Property("MergedById") - .HasColumnType("bigint"); + b.Property("MergedById") + .HasColumnType("bigint"); - b.Property("Number") - .HasColumnType("int"); + b.Property("Number") + .HasColumnType("int"); - b.Property("PrimaryRevisionInformationId") - .IsRequired() - .HasColumnType("bigint"); + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); - b.Property("PullRequestRevision") - .IsRequired() - .HasColumnType("varchar(40) CHARACTER SET utf8mb4") - .HasMaxLength(40); + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("varchar(40) CHARACTER SET utf8mb4") + .HasMaxLength(40); - b.Property("TitleAtMerge") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("Url") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("Url") + .IsRequired() + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("MergedById"); + b.HasIndex("MergedById"); - b.HasIndex("PrimaryRevisionInformationId") - .IsUnique(); + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); - b.ToTable("TestMerges"); - }); + b.ToTable("TestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); - b.Property("AdministrationRights") - .HasColumnType("bigint unsigned"); + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); - b.Property("CanonicalName") - .IsRequired() - .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); - b.Property("CreatedAt") - .IsRequired() - .HasColumnType("datetime(6)"); + b.Property("CreatedById") + .HasColumnType("bigint"); - b.Property("CreatedById") - .HasColumnType("bigint"); + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); - b.Property("Enabled") - .IsRequired() - .HasColumnType("tinyint(1)"); + b.Property("GroupId") + .HasColumnType("bigint"); - b.Property("InstanceManagerRights") - .HasColumnType("bigint unsigned"); + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); - b.Property("LastPasswordUpdate") - .HasColumnType("datetime(6)"); + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); - b.Property("Name") - .IsRequired() - .HasColumnType("longtext CHARACTER SET utf8mb4") - .HasMaxLength(10000); + b.Property("PasswordHash") + .HasColumnType("longtext CHARACTER SET utf8mb4"); - b.Property("PasswordHash") - .HasColumnType("longtext CHARACTER SET utf8mb4"); + b.Property("SystemIdentifier") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); - b.Property("SystemIdentifier") - .HasColumnType("varchar(255) CHARACTER SET utf8mb4"); + b.HasKey("Id"); - b.HasKey("Id"); + b.HasIndex("CanonicalName") + .IsUnique(); - b.HasIndex("CanonicalName") - .IsUnique(); + b.HasIndex("CreatedById"); - b.HasIndex("CreatedById"); + b.HasIndex("GroupId"); - b.HasIndex("SystemIdentifier") - .IsUnique(); + b.HasIndex("SystemIdentifier") + .IsUnique(); - b.ToTable("Users"); - }); + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("ChatSettings") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") - .WithMany("Channels") - .HasForeignKey("ChatSettingsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.HasOne("Tgstation.Server.Host.Models.Job", "Job") - .WithOne() - .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("CompileJobs") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamDaemonSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamMakerSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("InstanceUsers") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", null) - .WithMany("InstanceUsers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") - .WithMany() - .HasForeignKey("CancelledById"); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("Jobs") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") - .WithMany() - .HasForeignKey("StartedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") - .WithMany() - .HasForeignKey("CompileJobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("RepositorySettings") - .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("ActiveTestMerges") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") - .WithMany("RevisonInformations") - .HasForeignKey("TestMergeId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("RevisionInformations") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") - .WithMany("TestMerges") - .HasForeignKey("MergedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") - .WithOne("PrimaryTestMerge") - .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") - .WithMany("CreatedUsers") - .HasForeignKey("CreatedById"); - }); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs index b19d291ae9..a4d189000e 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs @@ -15,766 +15,876 @@ namespace Tgstation.Server.Host.Database.Migrations #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) - .HasAnnotation("ProductVersion", "3.1.7") + .HasAnnotation("ProductVersion", "3.1.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("ChannelLimit") - .HasColumnType("integer"); + b.Property("ChannelLimit") + .HasColumnType("integer"); - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("Enabled") - .HasColumnType("boolean"); + b.Property("Enabled") + .HasColumnType("boolean"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Name") - .IsRequired() - .HasColumnType("character varying(100)") - .HasMaxLength(100); + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); - b.Property("Provider") - .HasColumnType("integer"); + b.Property("Provider") + .HasColumnType("integer"); - b.Property("ReconnectionInterval") - .HasColumnType("bigint"); + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "Name") - .IsUnique(); + b.HasIndex("InstanceId", "Name") + .IsUnique(); - b.ToTable("ChatBots"); - }); + b.ToTable("ChatBots"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("ChatSettingsId") - .HasColumnType("bigint"); + b.Property("ChatSettingsId") + .HasColumnType("bigint"); - b.Property("DiscordChannelId") - .HasColumnType("numeric(20,0)"); + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); - b.Property("IrcChannel") - .HasColumnType("character varying(100)") - .HasMaxLength(100); + b.Property("IrcChannel") + .HasColumnType("character varying(100)") + .HasMaxLength(100); - b.Property("IsAdminChannel") - .IsRequired() - .HasColumnType("boolean"); + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); - b.Property("IsUpdatesChannel") - .IsRequired() - .HasColumnType("boolean"); + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); - b.Property("IsWatchdogChannel") - .IsRequired() - .HasColumnType("boolean"); + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); - b.Property("Tag") - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("Tag") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("ChatSettingsId", "DiscordChannelId") - .IsUnique(); + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); - b.HasIndex("ChatSettingsId", "IrcChannel") - .IsUnique(); + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); - b.ToTable("ChatChannels"); - }); + b.ToTable("ChatChannels"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("ByondVersion") - .IsRequired() - .HasColumnType("text"); + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); - b.Property("DMApiMajorVersion") - .HasColumnType("integer"); + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); - b.Property("DMApiMinorVersion") - .HasColumnType("integer"); + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); - b.Property("DMApiPatchVersion") - .HasColumnType("integer"); + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); - b.Property("DirectoryName") - .IsRequired() - .HasColumnType("uuid"); + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); - b.Property("DmeName") - .IsRequired() - .HasColumnType("text"); + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); - b.Property("GitHubDeploymentId") - .HasColumnType("integer"); + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); - b.Property("GitHubRepoId") - .HasColumnType("bigint"); + b.Property("GitHubRepoId") + .HasColumnType("bigint"); - b.Property("JobId") - .HasColumnType("bigint"); + b.Property("JobId") + .HasColumnType("bigint"); - b.Property("MinimumSecurityLevel") - .HasColumnType("integer"); + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); - b.Property("Output") - .IsRequired() - .HasColumnType("text"); + b.Property("Output") + .IsRequired() + .HasColumnType("text"); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RepositoryOrigin") + .HasColumnType("text"); - b.HasKey("Id"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.HasIndex("DirectoryName"); + b.HasKey("Id"); - b.HasIndex("JobId") - .IsUnique(); + b.HasIndex("DirectoryName"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("JobId") + .IsUnique(); - b.ToTable("CompileJobs"); - }); + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("AdditionalParameters") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("AllowWebClient") - .IsRequired() - .HasColumnType("boolean"); + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); - b.Property("AutoStart") - .IsRequired() - .HasColumnType("boolean"); + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); - b.Property("HeartbeatSeconds") - .HasColumnType("bigint"); + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Port") - .HasColumnType("integer"); + b.Property("Port") + .HasColumnType("integer"); - b.Property("SecurityLevel") - .HasColumnType("integer"); + b.Property("SecurityLevel") + .HasColumnType("integer"); - b.Property("StartupTimeout") - .HasColumnType("bigint"); + b.Property("StartupTimeout") + .HasColumnType("bigint"); - b.Property("TopicRequestTimeout") - .HasColumnType("bigint"); + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamDaemonSettings"); - }); + b.ToTable("DreamDaemonSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("ApiValidationPort") - .HasColumnType("integer"); + b.Property("ApiValidationPort") + .HasColumnType("integer"); - b.Property("ApiValidationSecurityLevel") - .HasColumnType("integer"); + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("ProjectName") - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("ProjectName") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("RequireDMApiValidation") - .IsRequired() - .HasColumnType("boolean"); + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamMakerSettings"); - }); + b.ToTable("DreamMakerSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("AutoUpdateInterval") - .HasColumnType("bigint"); + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); - b.Property("ChatBotLimit") - .HasColumnType("integer"); + b.Property("ChatBotLimit") + .HasColumnType("integer"); - b.Property("ConfigurationType") - .HasColumnType("integer"); + b.Property("ConfigurationType") + .HasColumnType("integer"); - b.Property("Name") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("Online") - .IsRequired() - .HasColumnType("boolean"); + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); - b.Property("Path") - .IsRequired() - .HasColumnType("text"); + b.Property("Path") + .IsRequired() + .HasColumnType("text"); - b.HasKey("Id"); + b.Property("SwarmIdentifer") + .HasColumnType("text"); - b.HasIndex("Path") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("Instances"); - }); + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + b.ToTable("Instances"); + }); - b.Property("ByondRights") - .HasColumnType("numeric(20,0)"); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("ChatBotRights") - .HasColumnType("numeric(20,0)"); + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); - b.Property("ConfigurationRights") - .HasColumnType("numeric(20,0)"); + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); - b.Property("DreamDaemonRights") - .HasColumnType("numeric(20,0)"); + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); - b.Property("DreamMakerRights") - .HasColumnType("numeric(20,0)"); + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); - b.Property("InstanceUserRights") - .HasColumnType("numeric(20,0)"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("RepositoryRights") - .HasColumnType("numeric(20,0)"); + b.Property("InstancePermissionSetRights") + .HasColumnType("numeric(20,0)"); - b.Property("UserId") - .HasColumnType("bigint"); + b.Property("PermissionSetId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); - b.HasIndex("InstanceId"); + b.HasKey("Id"); - b.HasIndex("UserId", "InstanceId") - .IsUnique(); + b.HasIndex("InstanceId"); - b.ToTable("InstanceUsers"); - }); + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("CancelRight") - .HasColumnType("numeric(20,0)"); + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); - b.Property("CancelRightsType") - .HasColumnType("numeric(20,0)"); + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); - b.Property("Cancelled") - .IsRequired() - .HasColumnType("boolean"); + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); - b.Property("CancelledById") - .HasColumnType("bigint"); + b.Property("CancelledById") + .HasColumnType("bigint"); - b.Property("Description") - .IsRequired() - .HasColumnType("text"); + b.Property("Description") + .IsRequired() + .HasColumnType("text"); - b.Property("ErrorCode") - .HasColumnType("bigint"); + b.Property("ErrorCode") + .HasColumnType("bigint"); - b.Property("ExceptionDetails") - .HasColumnType("text"); + b.Property("ExceptionDetails") + .HasColumnType("text"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("StartedAt") - .IsRequired() - .HasColumnType("timestamp with time zone"); + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); - b.Property("StartedById") - .HasColumnType("bigint"); + b.Property("StartedById") + .HasColumnType("bigint"); - b.Property("StoppedAt") - .HasColumnType("timestamp with time zone"); + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CancelledById"); + b.HasIndex("CancelledById"); - b.HasIndex("InstanceId"); + b.HasIndex("InstanceId"); - b.HasIndex("StartedById"); + b.HasIndex("StartedById"); - b.ToTable("Jobs"); - }); + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("AccessIdentifier") - .IsRequired() - .HasColumnType("text"); + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); - b.Property("CompileJobId") - .HasColumnType("bigint"); + b.Property("CompileJobId") + .HasColumnType("bigint"); - b.Property("LaunchSecurityLevel") - .HasColumnType("integer"); + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); - b.Property("Port") - .HasColumnType("integer"); + b.Property("Port") + .HasColumnType("integer"); - b.Property("ProcessId") - .HasColumnType("integer"); + b.Property("ProcessId") + .HasColumnType("integer"); - b.Property("RebootState") - .HasColumnType("integer"); + b.Property("RebootState") + .HasColumnType("integer"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CompileJobId"); + b.HasIndex("CompileJobId"); - b.ToTable("ReattachInformations"); - }); + b.ToTable("ReattachInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("AccessToken") - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("AccessToken") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("AccessUser") - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("AccessUser") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("AutoUpdatesKeepTestMerges") - .IsRequired() - .HasColumnType("boolean"); + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); - b.Property("AutoUpdatesSynchronize") - .IsRequired() - .HasColumnType("boolean"); + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); - b.Property("CommitterEmail") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("CommitterName") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("CommitterName") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("CreateGitHubDeployments") - .IsRequired() - .HasColumnType("boolean"); + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("PostTestMergeComment") - .IsRequired() - .HasColumnType("boolean"); + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); - b.Property("PushTestMergeCommits") - .IsRequired() - .HasColumnType("boolean"); + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); - b.Property("ShowTestMergeCommitters") - .IsRequired() - .HasColumnType("boolean"); + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("RepositorySettings"); - }); + b.ToTable("RepositorySettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.Property("TestMergeId") - .HasColumnType("bigint"); + b.Property("TestMergeId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("RevisionInformationId"); - b.HasIndex("TestMergeId"); + b.HasIndex("TestMergeId"); - b.ToTable("RevInfoTestMerges"); - }); + b.ToTable("RevInfoTestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("CommitSha") - .IsRequired() - .HasColumnType("character varying(40)") - .HasMaxLength(40); + b.Property("CommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("OriginCommitSha") - .IsRequired() - .HasColumnType("character varying(40)") - .HasMaxLength(40); + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "CommitSha") - .IsUnique(); + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); - b.ToTable("RevisionInformations"); - }); + b.ToTable("RevisionInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("Author") - .IsRequired() - .HasColumnType("text"); + b.Property("Author") + .IsRequired() + .HasColumnType("text"); - b.Property("BodyAtMerge") - .IsRequired() - .HasColumnType("text"); + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); - b.Property("Comment") - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("Comment") + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); - b.Property("MergedAt") - .HasColumnType("timestamp with time zone"); + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); - b.Property("MergedById") - .HasColumnType("bigint"); + b.Property("MergedById") + .HasColumnType("bigint"); - b.Property("Number") - .HasColumnType("integer"); + b.Property("Number") + .HasColumnType("integer"); - b.Property("PrimaryRevisionInformationId") - .IsRequired() - .HasColumnType("bigint"); + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); - b.Property("PullRequestRevision") - .IsRequired() - .HasColumnType("character varying(40)") - .HasMaxLength(40); + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("character varying(40)") + .HasMaxLength(40); - b.Property("TitleAtMerge") - .IsRequired() - .HasColumnType("text"); + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); - b.Property("Url") - .IsRequired() - .HasColumnType("text"); + b.Property("Url") + .IsRequired() + .HasColumnType("text"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("MergedById"); + b.HasIndex("MergedById"); - b.HasIndex("PrimaryRevisionInformationId") - .IsUnique(); + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); - b.ToTable("TestMerges"); - }); + b.ToTable("TestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); - b.Property("AdministrationRights") - .HasColumnType("numeric(20,0)"); + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); - b.Property("CanonicalName") - .IsRequired() - .HasColumnType("text"); + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); - b.Property("CreatedAt") - .IsRequired() - .HasColumnType("timestamp with time zone"); + b.Property("CreatedById") + .HasColumnType("bigint"); - b.Property("CreatedById") - .HasColumnType("bigint"); + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); - b.Property("Enabled") - .IsRequired() - .HasColumnType("boolean"); + b.Property("GroupId") + .HasColumnType("bigint"); - b.Property("InstanceManagerRights") - .HasColumnType("numeric(20,0)"); + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); - b.Property("LastPasswordUpdate") - .HasColumnType("timestamp with time zone"); + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); - b.Property("Name") - .IsRequired() - .HasColumnType("character varying(10000)") - .HasMaxLength(10000); + b.Property("PasswordHash") + .HasColumnType("text"); - b.Property("PasswordHash") - .HasColumnType("text"); + b.Property("SystemIdentifier") + .HasColumnType("character varying(100)") + .HasMaxLength(100); - b.Property("SystemIdentifier") - .HasColumnType("text"); + b.HasKey("Id"); - b.HasKey("Id"); + b.HasIndex("CanonicalName") + .IsUnique(); - b.HasIndex("CanonicalName") - .IsUnique(); + b.HasIndex("CreatedById"); - b.HasIndex("CreatedById"); + b.HasIndex("GroupId"); - b.HasIndex("SystemIdentifier") - .IsUnique(); + b.HasIndex("SystemIdentifier") + .IsUnique(); - b.ToTable("Users"); - }); + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("ChatSettings") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") - .WithMany("Channels") - .HasForeignKey("ChatSettingsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.HasOne("Tgstation.Server.Host.Models.Job", "Job") - .WithOne() - .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("CompileJobs") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamDaemonSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamMakerSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("InstanceUsers") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", null) - .WithMany("InstanceUsers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") - .WithMany() - .HasForeignKey("CancelledById"); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("Jobs") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") - .WithMany() - .HasForeignKey("StartedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") - .WithMany() - .HasForeignKey("CompileJobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("RepositorySettings") - .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("ActiveTestMerges") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") - .WithMany("RevisonInformations") - .HasForeignKey("TestMergeId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("RevisionInformations") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") - .WithMany("TestMerges") - .HasForeignKey("MergedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") - .WithOne("PrimaryTestMerge") - .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") - .WithMany("CreatedUsers") - .HasForeignKey("CreatedById"); - }); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs index 2c468c069a..5f77127198 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs @@ -14,770 +14,883 @@ namespace Tgstation.Server.Host.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "3.1.7") + .HasAnnotation("ProductVersion", "3.1.10") .HasAnnotation("Relational:MaxIdentifierLength", 128) .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("ChannelLimit") - .HasColumnType("int"); + b.Property("ChannelLimit") + .HasColumnType("int"); - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("Enabled") - .HasColumnType("bit"); + b.Property("Enabled") + .HasColumnType("bit"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(100)") - .HasMaxLength(100); + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); - b.Property("Provider") - .HasColumnType("int"); + b.Property("Provider") + .HasColumnType("int"); - b.Property("ReconnectionInterval") - .HasColumnType("bigint"); + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "Name") - .IsUnique(); + b.HasIndex("InstanceId", "Name") + .IsUnique(); - b.ToTable("ChatBots"); - }); + b.ToTable("ChatBots"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("ChatSettingsId") - .HasColumnType("bigint"); + b.Property("ChatSettingsId") + .HasColumnType("bigint"); - b.Property("DiscordChannelId") - .HasColumnType("decimal(20,0)"); + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); - b.Property("IrcChannel") - .HasColumnType("nvarchar(100)") - .HasMaxLength(100); + b.Property("IrcChannel") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); - b.Property("IsAdminChannel") - .IsRequired() - .HasColumnType("bit"); + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); - b.Property("IsUpdatesChannel") - .IsRequired() - .HasColumnType("bit"); + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); - b.Property("IsWatchdogChannel") - .IsRequired() - .HasColumnType("bit"); + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); - b.Property("Tag") - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("Tag") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("ChatSettingsId", "DiscordChannelId") - .IsUnique() - .HasFilter("[DiscordChannelId] IS NOT NULL"); + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); - b.HasIndex("ChatSettingsId", "IrcChannel") - .IsUnique() - .HasFilter("[IrcChannel] IS NOT NULL"); + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); - b.ToTable("ChatChannels"); - }); + b.ToTable("ChatChannels"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("ByondVersion") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("DMApiMajorVersion") - .HasColumnType("int"); + b.Property("DMApiMajorVersion") + .HasColumnType("int"); - b.Property("DMApiMinorVersion") - .HasColumnType("int"); + b.Property("DMApiMinorVersion") + .HasColumnType("int"); - b.Property("DMApiPatchVersion") - .HasColumnType("int"); + b.Property("DMApiPatchVersion") + .HasColumnType("int"); - b.Property("DirectoryName") - .IsRequired() - .HasColumnType("uniqueidentifier"); + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); - b.Property("DmeName") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("GitHubDeploymentId") - .HasColumnType("int"); + b.Property("GitHubDeploymentId") + .HasColumnType("int"); - b.Property("GitHubRepoId") - .HasColumnType("bigint"); + b.Property("GitHubRepoId") + .HasColumnType("bigint"); - b.Property("JobId") - .HasColumnType("bigint"); + b.Property("JobId") + .HasColumnType("bigint"); - b.Property("MinimumSecurityLevel") - .HasColumnType("int"); + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); - b.Property("Output") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RepositoryOrigin") + .HasColumnType("nvarchar(max)"); - b.HasKey("Id"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.HasIndex("DirectoryName"); + b.HasKey("Id"); - b.HasIndex("JobId") - .IsUnique(); + b.HasIndex("DirectoryName"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("JobId") + .IsUnique(); - b.ToTable("CompileJobs"); - }); + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("AdditionalParameters") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("AllowWebClient") - .IsRequired() - .HasColumnType("bit"); + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); - b.Property("AutoStart") - .IsRequired() - .HasColumnType("bit"); + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); - b.Property("HeartbeatSeconds") - .HasColumnType("bigint"); + b.Property("HeartbeatSeconds") + .HasColumnType("bigint"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("Port") - .HasColumnType("int"); + b.Property("Port") + .HasColumnType("int"); - b.Property("SecurityLevel") - .HasColumnType("int"); + b.Property("SecurityLevel") + .HasColumnType("int"); - b.Property("StartupTimeout") - .HasColumnType("bigint"); + b.Property("StartupTimeout") + .HasColumnType("bigint"); - b.Property("TopicRequestTimeout") - .HasColumnType("bigint"); + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamDaemonSettings"); - }); + b.ToTable("DreamDaemonSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("ApiValidationPort") - .HasColumnType("int"); + b.Property("ApiValidationPort") + .HasColumnType("int"); - b.Property("ApiValidationSecurityLevel") - .HasColumnType("int"); + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("ProjectName") - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("ProjectName") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("RequireDMApiValidation") - .IsRequired() - .HasColumnType("bit"); + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamMakerSettings"); - }); + b.ToTable("DreamMakerSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("AutoUpdateInterval") - .HasColumnType("bigint"); + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); - b.Property("ChatBotLimit") - .HasColumnType("int"); + b.Property("ChatBotLimit") + .HasColumnType("int"); - b.Property("ConfigurationType") - .HasColumnType("int"); + b.Property("ConfigurationType") + .HasColumnType("int"); - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("Online") - .IsRequired() - .HasColumnType("bit"); + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); - b.Property("Path") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("Id"); + b.Property("SwarmIdentifer") + .HasColumnType("nvarchar(450)"); - b.HasIndex("Path") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("Instances"); - }); + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique() + .HasFilter("[SwarmIdentifer] IS NOT NULL"); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + b.ToTable("Instances"); + }); - b.Property("ByondRights") - .HasColumnType("decimal(20,0)"); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("ChatBotRights") - .HasColumnType("decimal(20,0)"); + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); - b.Property("ConfigurationRights") - .HasColumnType("decimal(20,0)"); + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); - b.Property("DreamDaemonRights") - .HasColumnType("decimal(20,0)"); + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); - b.Property("DreamMakerRights") - .HasColumnType("decimal(20,0)"); + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); - b.Property("InstanceUserRights") - .HasColumnType("decimal(20,0)"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("RepositoryRights") - .HasColumnType("decimal(20,0)"); + b.Property("InstancePermissionSetRights") + .HasColumnType("decimal(20,0)"); - b.Property("UserId") - .HasColumnType("bigint"); + b.Property("PermissionSetId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); - b.HasIndex("InstanceId"); + b.HasKey("Id"); - b.HasIndex("UserId", "InstanceId") - .IsUnique(); + b.HasIndex("InstanceId"); - b.ToTable("InstanceUsers"); - }); + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("CancelRight") - .HasColumnType("decimal(20,0)"); + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); - b.Property("CancelRightsType") - .HasColumnType("decimal(20,0)"); + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); - b.Property("Cancelled") - .IsRequired() - .HasColumnType("bit"); + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); - b.Property("CancelledById") - .HasColumnType("bigint"); + b.Property("CancelledById") + .HasColumnType("bigint"); - b.Property("Description") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("ErrorCode") - .HasColumnType("bigint"); + b.Property("ErrorCode") + .HasColumnType("bigint"); - b.Property("ExceptionDetails") - .HasColumnType("nvarchar(max)"); + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("StartedAt") - .IsRequired() - .HasColumnType("datetimeoffset"); + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); - b.Property("StartedById") - .HasColumnType("bigint"); + b.Property("StartedById") + .HasColumnType("bigint"); - b.Property("StoppedAt") - .HasColumnType("datetimeoffset"); + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CancelledById"); + b.HasIndex("CancelledById"); - b.HasIndex("InstanceId"); + b.HasIndex("InstanceId"); - b.HasIndex("StartedById"); + b.HasIndex("StartedById"); - b.ToTable("Jobs"); - }); + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique() + .HasFilter("[GroupId] IS NOT NULL"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("PermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("AccessIdentifier") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("CompileJobId") - .HasColumnType("bigint"); + b.Property("CompileJobId") + .HasColumnType("bigint"); - b.Property("LaunchSecurityLevel") - .HasColumnType("int"); + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); - b.Property("Port") - .HasColumnType("int"); + b.Property("Port") + .HasColumnType("int"); - b.Property("ProcessId") - .HasColumnType("int"); + b.Property("ProcessId") + .HasColumnType("int"); - b.Property("RebootState") - .HasColumnType("int"); + b.Property("RebootState") + .HasColumnType("int"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CompileJobId"); + b.HasIndex("CompileJobId"); - b.ToTable("ReattachInformations"); - }); + b.ToTable("ReattachInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("AccessToken") - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("AccessToken") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("AccessUser") - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("AccessUser") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("AutoUpdatesKeepTestMerges") - .IsRequired() - .HasColumnType("bit"); + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); - b.Property("AutoUpdatesSynchronize") - .IsRequired() - .HasColumnType("bit"); + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); - b.Property("CommitterEmail") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("CommitterName") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("CommitterName") + .IsRequired() + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("CreateGitHubDeployments") - .IsRequired() - .HasColumnType("bit"); + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("PostTestMergeComment") - .IsRequired() - .HasColumnType("bit"); + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); - b.Property("PushTestMergeCommits") - .IsRequired() - .HasColumnType("bit"); + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); - b.Property("ShowTestMergeCommitters") - .IsRequired() - .HasColumnType("bit"); + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("RepositorySettings"); - }); + b.ToTable("RepositorySettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("RevisionInformationId") - .HasColumnType("bigint"); + b.Property("RevisionInformationId") + .HasColumnType("bigint"); - b.Property("TestMergeId") - .HasColumnType("bigint"); + b.Property("TestMergeId") + .HasColumnType("bigint"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("RevisionInformationId"); - b.HasIndex("TestMergeId"); + b.HasIndex("TestMergeId"); - b.ToTable("RevInfoTestMerges"); - }); + b.ToTable("RevInfoTestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("CommitSha") - .IsRequired() - .HasColumnType("nvarchar(40)") - .HasMaxLength(40); + b.Property("CommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); - b.Property("InstanceId") - .HasColumnType("bigint"); + b.Property("InstanceId") + .HasColumnType("bigint"); - b.Property("OriginCommitSha") - .IsRequired() - .HasColumnType("nvarchar(40)") - .HasMaxLength(40); + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "CommitSha") - .IsUnique(); + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); - b.ToTable("RevisionInformations"); - }); + b.ToTable("RevisionInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("Author") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("BodyAtMerge") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("Comment") - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("Comment") + .HasColumnType("nvarchar(max)") + .HasMaxLength(10000); - b.Property("MergedAt") - .HasColumnType("datetimeoffset"); + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); - b.Property("MergedById") - .HasColumnType("bigint"); + b.Property("MergedById") + .HasColumnType("bigint"); - b.Property("Number") - .HasColumnType("int"); + b.Property("Number") + .HasColumnType("int"); - b.Property("PrimaryRevisionInformationId") - .IsRequired() - .HasColumnType("bigint"); + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); - b.Property("PullRequestRevision") - .IsRequired() - .HasColumnType("nvarchar(40)") - .HasMaxLength(40); + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("nvarchar(40)") + .HasMaxLength(40); - b.Property("TitleAtMerge") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("MergedById"); + b.HasIndex("MergedById"); - b.HasIndex("PrimaryRevisionInformationId") - .IsUnique(); + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); - b.ToTable("TestMerges"); - }); + b.ToTable("TestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - b.Property("AdministrationRights") - .HasColumnType("decimal(20,0)"); + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); - b.Property("CanonicalName") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); - b.Property("CreatedAt") - .IsRequired() - .HasColumnType("datetimeoffset"); + b.Property("CreatedById") + .HasColumnType("bigint"); - b.Property("CreatedById") - .HasColumnType("bigint"); + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); - b.Property("Enabled") - .IsRequired() - .HasColumnType("bit"); + b.Property("GroupId") + .HasColumnType("bigint"); - b.Property("InstanceManagerRights") - .HasColumnType("decimal(20,0)"); + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); - b.Property("LastPasswordUpdate") - .HasColumnType("datetimeoffset"); + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)") - .HasMaxLength(10000); + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); + b.Property("SystemIdentifier") + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); - b.Property("SystemIdentifier") - .HasColumnType("nvarchar(450)"); + b.HasKey("Id"); - b.HasKey("Id"); + b.HasIndex("CanonicalName") + .IsUnique(); - b.HasIndex("CanonicalName") - .IsUnique(); + b.HasIndex("CreatedById"); - b.HasIndex("CreatedById"); + b.HasIndex("GroupId"); - b.HasIndex("SystemIdentifier") - .IsUnique() - .HasFilter("[SystemIdentifier] IS NOT NULL"); + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); - b.ToTable("Users"); - }); + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(100)") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("ChatSettings") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") - .WithMany("Channels") - .HasForeignKey("ChatSettingsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.HasOne("Tgstation.Server.Host.Models.Job", "Job") - .WithOne() - .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("CompileJobs") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamDaemonSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamMakerSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("InstanceUsers") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", null) - .WithMany("InstanceUsers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") - .WithMany() - .HasForeignKey("CancelledById"); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("Jobs") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") - .WithMany() - .HasForeignKey("StartedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") - .WithMany() - .HasForeignKey("CompileJobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("RepositorySettings") - .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("ActiveTestMerges") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") - .WithMany("RevisonInformations") - .HasForeignKey("TestMergeId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("RevisionInformations") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") - .WithMany("TestMerges") - .HasForeignKey("MergedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") - .WithOne("PrimaryTestMerge") - .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") - .WithMany("CreatedUsers") - .HasForeignKey("CreatedById"); - }); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs index de2eca1c74..ca51e0514a 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs @@ -13,760 +13,867 @@ namespace Tgstation.Server.Host.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "3.1.7"); + .HasAnnotation("ProductVersion", "3.1.10"); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ChannelLimit") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("ConnectionString") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("ConnectionString") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("Enabled") - .HasColumnType("INTEGER"); + b.Property("Enabled") + .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(100); + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); - b.Property("Provider") - .HasColumnType("INTEGER"); + b.Property("Provider") + .HasColumnType("INTEGER"); - b.Property("ReconnectionInterval") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "Name") - .IsUnique(); + b.HasIndex("InstanceId", "Name") + .IsUnique(); - b.ToTable("ChatBots"); - }); + b.ToTable("ChatBots"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ChatSettingsId") - .HasColumnType("INTEGER"); + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); - b.Property("DiscordChannelId") - .HasColumnType("INTEGER"); + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); - b.Property("IrcChannel") - .HasColumnType("TEXT") - .HasMaxLength(100); + b.Property("IrcChannel") + .HasColumnType("TEXT") + .HasMaxLength(100); - b.Property("IsAdminChannel") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("IsUpdatesChannel") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("IsWatchdogChannel") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("Tag") - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("Tag") + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("ChatSettingsId", "DiscordChannelId") - .IsUnique(); + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); - b.HasIndex("ChatSettingsId", "IrcChannel") - .IsUnique(); + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); - b.ToTable("ChatChannels"); - }); + b.ToTable("ChatChannels"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ByondVersion") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("DMApiMajorVersion") - .HasColumnType("INTEGER"); + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); - b.Property("DMApiMinorVersion") - .HasColumnType("INTEGER"); + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); - b.Property("DMApiPatchVersion") - .HasColumnType("INTEGER"); + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); - b.Property("DirectoryName") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("DmeName") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("GitHubDeploymentId") - .HasColumnType("INTEGER"); + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); - b.Property("GitHubRepoId") - .HasColumnType("INTEGER"); + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); - b.Property("JobId") - .HasColumnType("INTEGER"); + b.Property("JobId") + .HasColumnType("INTEGER"); - b.Property("MinimumSecurityLevel") - .HasColumnType("INTEGER"); + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); - b.Property("Output") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("RevisionInformationId") - .HasColumnType("INTEGER"); + b.Property("RepositoryOrigin") + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); - b.HasIndex("DirectoryName"); + b.HasKey("Id"); - b.HasIndex("JobId") - .IsUnique(); + b.HasIndex("DirectoryName"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("JobId") + .IsUnique(); - b.ToTable("CompileJobs"); - }); + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AdditionalParameters") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("AdditionalParameters") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("AllowWebClient") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("AutoStart") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("HeartbeatSeconds") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("HeartbeatSeconds") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("Port") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("SecurityLevel") - .HasColumnType("INTEGER"); + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); - b.Property("StartupTimeout") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("TopicRequestTimeout") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamDaemonSettings"); - }); + b.ToTable("DreamDaemonSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ApiValidationPort") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("ApiValidationSecurityLevel") - .HasColumnType("INTEGER"); + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("ProjectName") - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("ProjectName") + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("RequireDMApiValidation") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("DreamMakerSettings"); - }); + b.ToTable("DreamMakerSettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AutoUpdateInterval") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("ChatBotLimit") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("ConfigurationType") - .HasColumnType("INTEGER"); + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("Online") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("Path") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.Property("SwarmIdentifer") + .HasColumnType("TEXT"); - b.HasIndex("Path") - .IsUnique(); + b.HasKey("Id"); - b.ToTable("Instances"); - }); + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + b.ToTable("Instances"); + }); - b.Property("ByondRights") - .HasColumnType("INTEGER"); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("ChatBotRights") - .HasColumnType("INTEGER"); + b.Property("ByondRights") + .HasColumnType("INTEGER"); - b.Property("ConfigurationRights") - .HasColumnType("INTEGER"); + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); - b.Property("DreamDaemonRights") - .HasColumnType("INTEGER"); + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); - b.Property("DreamMakerRights") - .HasColumnType("INTEGER"); + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); - b.Property("InstanceUserRights") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("RepositoryRights") - .HasColumnType("INTEGER"); + b.Property("InstancePermissionSetRights") + .HasColumnType("INTEGER"); - b.Property("UserId") - .HasColumnType("INTEGER"); + b.Property("PermissionSetId") + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); - b.HasIndex("InstanceId"); + b.HasKey("Id"); - b.HasIndex("UserId", "InstanceId") - .IsUnique(); + b.HasIndex("InstanceId"); - b.ToTable("InstanceUsers"); - }); + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("CancelRight") - .HasColumnType("INTEGER"); + b.Property("CancelRight") + .HasColumnType("INTEGER"); - b.Property("CancelRightsType") - .HasColumnType("INTEGER"); + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); - b.Property("Cancelled") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("CancelledById") - .HasColumnType("INTEGER"); + b.Property("CancelledById") + .HasColumnType("INTEGER"); - b.Property("Description") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("ErrorCode") - .HasColumnType("INTEGER"); + b.Property("ErrorCode") + .HasColumnType("INTEGER"); - b.Property("ExceptionDetails") - .HasColumnType("TEXT"); + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("StartedAt") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("StartedById") - .HasColumnType("INTEGER"); + b.Property("StartedById") + .HasColumnType("INTEGER"); - b.Property("StoppedAt") - .HasColumnType("TEXT"); + b.Property("StoppedAt") + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CancelledById"); + b.HasIndex("CancelledById"); - b.HasIndex("InstanceId"); + b.HasIndex("InstanceId"); - b.HasIndex("StartedById"); + b.HasIndex("StartedById"); - b.ToTable("Jobs"); - }); + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AccessIdentifier") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("CompileJobId") - .HasColumnType("INTEGER"); + b.Property("CompileJobId") + .HasColumnType("INTEGER"); - b.Property("LaunchSecurityLevel") - .HasColumnType("INTEGER"); + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); - b.Property("Port") - .HasColumnType("INTEGER"); + b.Property("Port") + .HasColumnType("INTEGER"); - b.Property("ProcessId") - .HasColumnType("INTEGER"); + b.Property("ProcessId") + .HasColumnType("INTEGER"); - b.Property("RebootState") - .HasColumnType("INTEGER"); + b.Property("RebootState") + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("CompileJobId"); + b.HasIndex("CompileJobId"); - b.ToTable("ReattachInformations"); - }); + b.ToTable("ReattachInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AccessToken") - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("AccessToken") + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("AccessUser") - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("AccessUser") + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("AutoUpdatesKeepTestMerges") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("AutoUpdatesSynchronize") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("CommitterEmail") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("CommitterEmail") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("CommitterName") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("CommitterName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("CreateGitHubDeployments") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("PostTestMergeComment") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("PushTestMergeCommits") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("ShowTestMergeCommitters") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId") - .IsUnique(); + b.HasIndex("InstanceId") + .IsUnique(); - b.ToTable("RepositorySettings"); - }); + b.ToTable("RepositorySettings"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("RevisionInformationId") - .HasColumnType("INTEGER"); + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); - b.Property("TestMergeId") - .HasColumnType("INTEGER"); + b.Property("TestMergeId") + .HasColumnType("INTEGER"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RevisionInformationId"); + b.HasIndex("RevisionInformationId"); - b.HasIndex("TestMergeId"); + b.HasIndex("TestMergeId"); - b.ToTable("RevInfoTestMerges"); - }); + b.ToTable("RevInfoTestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("CommitSha") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(40); + b.Property("CommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); - b.Property("InstanceId") - .HasColumnType("INTEGER"); + b.Property("InstanceId") + .HasColumnType("INTEGER"); - b.Property("OriginCommitSha") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(40); + b.Property("OriginCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("InstanceId", "CommitSha") - .IsUnique(); + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); - b.ToTable("RevisionInformations"); - }); + b.ToTable("RevisionInformations"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("Author") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("BodyAtMerge") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("Comment") - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("Comment") + .HasColumnType("TEXT") + .HasMaxLength(10000); - b.Property("MergedAt") - .HasColumnType("TEXT"); + b.Property("MergedAt") + .HasColumnType("TEXT"); - b.Property("MergedById") - .HasColumnType("INTEGER"); + b.Property("MergedById") + .HasColumnType("INTEGER"); - b.Property("Number") - .HasColumnType("INTEGER"); + b.Property("Number") + .HasColumnType("INTEGER"); - b.Property("PrimaryRevisionInformationId") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("PullRequestRevision") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(40); + b.Property("TargetCommitSha") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(40); - b.Property("TitleAtMerge") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("Url") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("MergedById"); + b.HasIndex("MergedById"); - b.HasIndex("PrimaryRevisionInformationId") - .IsUnique(); + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); - b.ToTable("TestMerges"); - }); + b.ToTable("TestMerges"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); - b.Property("AdministrationRights") - .HasColumnType("INTEGER"); + b.Property("CanonicalName") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); - b.Property("CanonicalName") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); - b.Property("CreatedAt") - .IsRequired() - .HasColumnType("TEXT"); + b.Property("CreatedById") + .HasColumnType("INTEGER"); - b.Property("CreatedById") - .HasColumnType("INTEGER"); + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); - b.Property("Enabled") - .IsRequired() - .HasColumnType("INTEGER"); + b.Property("GroupId") + .HasColumnType("INTEGER"); - b.Property("InstanceManagerRights") - .HasColumnType("INTEGER"); + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); - b.Property("LastPasswordUpdate") - .HasColumnType("TEXT"); + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasMaxLength(10000); + b.Property("PasswordHash") + .HasColumnType("TEXT"); - b.Property("PasswordHash") - .HasColumnType("TEXT"); + b.Property("SystemIdentifier") + .HasColumnType("TEXT") + .HasMaxLength(100); - b.Property("SystemIdentifier") - .HasColumnType("TEXT"); + b.HasKey("Id"); - b.HasKey("Id"); + b.HasIndex("CanonicalName") + .IsUnique(); - b.HasIndex("CanonicalName") - .IsUnique(); + b.HasIndex("CreatedById"); - b.HasIndex("CreatedById"); + b.HasIndex("GroupId"); - b.HasIndex("SystemIdentifier") - .IsUnique(); + b.HasIndex("SystemIdentifier") + .IsUnique(); - b.ToTable("Users"); - }); + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("ChatSettings") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => - { - b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") - .WithMany("Channels") - .HasForeignKey("ChatSettingsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => - { - b.HasOne("Tgstation.Server.Host.Models.Job", "Job") - .WithOne() - .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("CompileJobs") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamDaemonSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("DreamMakerSettings") - .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); - modelBuilder.Entity("Tgstation.Server.Host.Models.InstanceUser", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("InstanceUsers") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", null) - .WithMany("InstanceUsers") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") - .WithMany() - .HasForeignKey("CancelledById"); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("Jobs") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") - .WithMany() - .HasForeignKey("StartedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") - .WithMany() - .HasForeignKey("CompileJobId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithOne("RepositorySettings") - .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") - .WithMany("ActiveTestMerges") - .HasForeignKey("RevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") - .WithMany("RevisonInformations") - .HasForeignKey("TestMergeId") - .OnDelete(DeleteBehavior.ClientNoAction) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => - { - b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") - .WithMany("RevisionInformations") - .HasForeignKey("InstanceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") - .WithMany("TestMerges") - .HasForeignKey("MergedById") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); - b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") - .WithOne("PrimaryTestMerge") - .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => - { - b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") - .WithMany("CreatedUsers") - .HasForeignKey("CreatedById"); - }); + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Tgstation.Server.Host/Database/SqliteDatabaseContext.cs b/src/Tgstation.Server.Host/Database/SqliteDatabaseContext.cs index f23aedca95..a22872e935 100644 --- a/src/Tgstation.Server.Host/Database/SqliteDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/SqliteDatabaseContext.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System; using System.Linq; diff --git a/src/Tgstation.Server.Host/Components/Interop/Converters/BoolConverter.cs b/src/Tgstation.Server.Host/Extensions/Converters/BoolConverter.cs similarity index 87% rename from src/Tgstation.Server.Host/Components/Interop/Converters/BoolConverter.cs rename to src/Tgstation.Server.Host/Extensions/Converters/BoolConverter.cs index 864127890a..3ce617cdb9 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Converters/BoolConverter.cs +++ b/src/Tgstation.Server.Host/Extensions/Converters/BoolConverter.cs @@ -1,7 +1,7 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using System; -namespace Tgstation.Server.Host.Components.Interop.Converters +namespace Tgstation.Server.Host.Extensions.Converters { /// /// for decoding s returned by BYOND. diff --git a/src/Tgstation.Server.Host/Components/Interop/Converters/VersionConverter.cs b/src/Tgstation.Server.Host/Extensions/Converters/VersionConverter.cs similarity index 93% rename from src/Tgstation.Server.Host/Components/Interop/Converters/VersionConverter.cs rename to src/Tgstation.Server.Host/Extensions/Converters/VersionConverter.cs index 5e4c307b86..9d5ebeec77 100644 --- a/src/Tgstation.Server.Host/Components/Interop/Converters/VersionConverter.cs +++ b/src/Tgstation.Server.Host/Extensions/Converters/VersionConverter.cs @@ -1,8 +1,8 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using System; using Tgstation.Server.Api; -namespace Tgstation.Server.Host.Components.Interop.Converters +namespace Tgstation.Server.Host.Extensions.Converters { /// /// for serializing s for BYOND. diff --git a/src/Tgstation.Server.Host/Extensions/WebHostBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/WebHostBuilderExtensions.cs index 09f3b4bb51..e1fafbddc5 100644 --- a/src/Tgstation.Server.Host/Extensions/WebHostBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/WebHostBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -43,6 +43,7 @@ namespace Tgstation.Server.Host.Extensions applicationBuilder.ApplicationServices.GetRequiredService(), applicationBuilder.ApplicationServices.GetRequiredService(), applicationBuilder.ApplicationServices.GetRequiredService(), + applicationBuilder.ApplicationServices.GetRequiredService(), applicationBuilder.ApplicationServices.GetRequiredService>(), applicationBuilder.ApplicationServices.GetRequiredService>(), applicationBuilder.ApplicationServices.GetRequiredService>())); diff --git a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs index d63c32753c..b79fa1640a 100644 --- a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs +++ b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs @@ -122,7 +122,7 @@ namespace Tgstation.Server.Host.IO /// public async Task CopyDirectory(string src, string dest, IEnumerable ignore, CancellationToken cancellationToken) { - if (dest == null) + if (src == null) throw new ArgumentNullException(nameof(src)); if (dest == null) throw new ArgumentNullException(nameof(src)); @@ -134,12 +134,7 @@ namespace Tgstation.Server.Host.IO } /// - public string ConcatPath(params string[] paths) - { - if (paths == null) - throw new ArgumentNullException(nameof(paths)); - return Path.Combine(paths); - } + public string ConcatPath(params string[] paths) => Path.Combine(paths); /// public async Task CopyFile(string src, string dest, CancellationToken cancellationToken) @@ -150,6 +145,8 @@ namespace Tgstation.Server.Host.IO throw new ArgumentNullException(nameof(dest)); using var srcStream = new FileStream(ResolvePath(src), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, DefaultBufferSize, true); using var destStream = new FileStream(ResolvePath(dest), FileMode.Create, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true); + + // value taken from documentation await srcStream.CopyToAsync(destStream, 81920, cancellationToken).ConfigureAwait(false); } @@ -308,14 +305,13 @@ namespace Tgstation.Server.Host.IO } /// - public Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + public Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken) => Task.Factory.StartNew(() => { path = ResolvePath(path); - if (zipFileBytes == null) - throw new ArgumentNullException(nameof(zipFileBytes)); + if (zipFile == null) + throw new ArgumentNullException(nameof(zipFile)); - using var ms = new MemoryStream(zipFileBytes); - using var archive = new ZipArchive(ms, ZipArchiveMode.Read); + using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read); archive.ExtractToDirectory(path); }, cancellationToken, BlockingTaskCreationOptions, TaskScheduler.Current); @@ -329,5 +325,8 @@ namespace Tgstation.Server.Host.IO var fileInfo = new FileInfo(path); return new DateTimeOffset(fileInfo.LastWriteTimeUtc); }, cancellationToken, BlockingTaskCreationOptions, TaskScheduler.Current); + + /// + public FileStream GetFileStream(string path, bool shareWrite) => new FileStream(ResolvePath(path), FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete | (shareWrite ? FileShare.Write : FileShare.None), DefaultBufferSize, true); } } diff --git a/src/Tgstation.Server.Host/IO/IIOManager.cs b/src/Tgstation.Server.Host/IO/IIOManager.cs index d18e117864..90c13b8cdb 100644 --- a/src/Tgstation.Server.Host/IO/IIOManager.cs +++ b/src/Tgstation.Server.Host/IO/IIOManager.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -187,13 +188,13 @@ namespace Tgstation.Server.Host.IO Task DownloadFile(Uri url, CancellationToken cancellationToken); /// - /// Extract a set of to a given + /// Extract a set of to a given /// /// The path to unzip to - /// The s of the + /// The of the /// The for the operation /// A representing the running operation - Task ZipToDirectory(string path, byte[] zipFileBytes, CancellationToken cancellationToken); + Task ZipToDirectory(string path, Stream zipFile, CancellationToken cancellationToken); /// /// Get the of when a given was last modified. @@ -202,5 +203,14 @@ namespace Tgstation.Server.Host.IO /// The for the operation. /// A resulting in the of when the file was last modified. Task GetLastModified(string path, CancellationToken cancellationToken); + + /// + /// Gets the for a given file . + /// + /// The path of the file. + /// If should be used. + /// The of the file. + /// This function is sychronous. + FileStream GetFileStream(string path, bool shareWrite); } } diff --git a/src/Tgstation.Server.Host/IO/ISynchronousIOManager.cs b/src/Tgstation.Server.Host/IO/ISynchronousIOManager.cs index 69f80050b5..20e6656aec 100644 --- a/src/Tgstation.Server.Host/IO/ISynchronousIOManager.cs +++ b/src/Tgstation.Server.Host/IO/ISynchronousIOManager.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using System.IO; using System.Threading; namespace Tgstation.Server.Host.IO @@ -50,11 +51,11 @@ namespace Tgstation.Server.Host.IO /// Write to a file at a given /// /// The path to the file to write - /// The new contents of the file + /// A containing the new contents of the file /// The function only succeeds if this parameter matches the SHA-1 hash of the contents of the current file. Contains the SHA1 of the file on disk once the function returns /// The for the operation /// on success, if the operation failed due to not matching the file's contents - bool WriteFileChecked(string path, byte[] data, ref string sha1InOut, CancellationToken cancellationToken); + bool WriteFileChecked(string path, Stream data, ref string sha1InOut, CancellationToken cancellationToken); /// /// Checks if a given is a directory diff --git a/src/Tgstation.Server.Host/IO/SynchronousIOManager.cs b/src/Tgstation.Server.Host/IO/SynchronousIOManager.cs index 2a6530faa0..c3c66bf92e 100644 --- a/src/Tgstation.Server.Host/IO/SynchronousIOManager.cs +++ b/src/Tgstation.Server.Host/IO/SynchronousIOManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -74,30 +74,24 @@ namespace Tgstation.Server.Host.IO } /// - public bool WriteFileChecked(string path, byte[] data, ref string sha1InOut, CancellationToken cancellationToken) + public bool WriteFileChecked(string path, Stream data, ref string sha1InOut, CancellationToken cancellationToken) { if (path == null) throw new ArgumentNullException(nameof(path)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + cancellationToken.ThrowIfCancellationRequested(); var directory = Path.GetDirectoryName(path); + Directory.CreateDirectory(directory); cancellationToken.ThrowIfCancellationRequested(); using (var file = File.Open(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) { cancellationToken.ThrowIfCancellationRequested(); - // as nice as it would be to not have to arrayify the memory stream, we have to - // because, oddly enough sha1(memorystream) != sha1(memorystream.ToArray()) - // vOv - byte[] originalBytes; - using (var readMs = new MemoryStream()) - { - file.CopyTo(readMs); - originalBytes = readMs.ToArray(); - } - // no sha1? no write - if (originalBytes.Length != 0 && sha1InOut == null) + if (file.Length != 0 && sha1InOut == null) return false; // suppressed due to only using for consistency checks @@ -105,8 +99,8 @@ namespace Tgstation.Server.Host.IO using (var sha1 = new SHA1Managed()) #pragma warning restore CA5350 // Do not use insecure cryptographic algorithm SHA1. { - string GetSha1(byte[] dataToHash) => dataToHash != null && dataToHash.Length != 0 ? String.Join(String.Empty, sha1.ComputeHash(dataToHash).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))) : null; - var originalSha1 = GetSha1(originalBytes); + string GetSha1(Stream dataToHash) => dataToHash != null && dataToHash.Length != 0 ? String.Join(String.Empty, sha1.ComputeHash(dataToHash).Select(b => b.ToString("x2", CultureInfo.InvariantCulture))) : null; + var originalSha1 = GetSha1(file); if (originalSha1 != sha1InOut) { sha1InOut = originalSha1; @@ -118,17 +112,18 @@ namespace Tgstation.Server.Host.IO cancellationToken.ThrowIfCancellationRequested(); - if (data != null) + if (data.Length != 0) { file.Seek(0, SeekOrigin.Begin); + data.Seek(0, SeekOrigin.Begin); cancellationToken.ThrowIfCancellationRequested(); file.SetLength(data.Length); - file.Write(data, 0, data.Length); + data.CopyTo(file); } } - if (data == null) + if (data.Length == 0) File.Delete(path); return true; } diff --git a/src/Tgstation.Server.Host/Jobs/JobManager.cs b/src/Tgstation.Server.Host/Jobs/JobManager.cs index 891d4bc409..afd47a3b6b 100644 --- a/src/Tgstation.Server.Host/Jobs/JobManager.cs +++ b/src/Tgstation.Server.Host/Jobs/JobManager.cs @@ -147,7 +147,7 @@ namespace Tgstation.Server.Host.Jobs }; databaseContext.Jobs.Attach(attachedJob); - attachedJob.StoppedAt = DateTimeOffset.Now; + attachedJob.StoppedAt = DateTimeOffset.UtcNow; attachedJob.ExceptionDetails = job.ExceptionDetails; attachedJob.ErrorCode = job.ErrorCode; attachedJob.Cancelled = job.Cancelled; @@ -177,7 +177,7 @@ namespace Tgstation.Server.Host.Jobs if (operation == null) throw new ArgumentNullException(nameof(operation)); - job.StartedAt = DateTimeOffset.Now; + job.StartedAt = DateTimeOffset.UtcNow; job.Cancelled = false; job.Instance = new Models.Instance @@ -238,7 +238,7 @@ namespace Tgstation.Server.Host.Jobs var job = new Job { Id = I }; databaseContext.Jobs.Attach(job); job.Cancelled = true; - job.StoppedAt = DateTimeOffset.Now; + job.StoppedAt = DateTimeOffset.UtcNow; } await databaseContext.Save(cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Models/ChatBot.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs index e3565be4e8..f190f2f043 100644 --- a/src/Tgstation.Server.Host/Models/ChatBot.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class ChatBot : Api.Models.Internal.ChatBot + public sealed class ChatBot : Api.Models.Internal.ChatBot, IApiTransformable { /// /// Default for . @@ -28,10 +28,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection Channels { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.ChatBot ToApi() => new Api.Models.ChatBot { Channels = Channels.Select(x => x.ToApi()).ToList(), diff --git a/src/Tgstation.Server.Host/Models/ChatChannel.cs b/src/Tgstation.Server.Host/Models/ChatChannel.cs index d4b7bca702..e92eb59884 100644 --- a/src/Tgstation.Server.Host/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Host/Models/ChatChannel.cs @@ -1,7 +1,7 @@ -namespace Tgstation.Server.Host.Models +namespace Tgstation.Server.Host.Models { /// - public sealed class ChatChannel : Api.Models.ChatChannel + public sealed class ChatChannel : Api.Models.ChatChannel, IApiTransformable { /// /// The row Id @@ -18,10 +18,7 @@ /// public ChatBot ChatSettings { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.ChatChannel ToApi() => new Api.Models.ChatChannel { DiscordChannelId = DiscordChannelId, diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index c74291520d..5a45c0a85d 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -1,10 +1,11 @@ using System; using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class CompileJob : Api.Models.Internal.CompileJob + public sealed class CompileJob : Api.Models.Internal.CompileJob, IApiTransformable { /// /// See @@ -13,7 +14,7 @@ namespace Tgstation.Server.Host.Models public Job Job { get; set; } /// - /// The of + /// The of /// public long JobId { get; set; } @@ -44,6 +45,11 @@ namespace Tgstation.Server.Host.Models /// public int? DMApiPatchVersion { get; set; } + /// + /// The origin of the repository the compile job was built from. + /// + public string RepositoryOrigin { get; set; } + /// /// The source GitHub repository the deployment came from if any. /// @@ -72,10 +78,7 @@ namespace Tgstation.Server.Host.Models } } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.CompileJob ToApi() => new Api.Models.CompileJob { DirectoryName = DirectoryName, @@ -86,7 +89,8 @@ namespace Tgstation.Server.Host.Models RevisionInformation = RevisionInformation.ToApi(), ByondVersion = Version.Parse(ByondVersion), MinimumSecurityLevel = MinimumSecurityLevel, - DMApiVersion = DMApiVersion + DMApiVersion = DMApiVersion, + RepositoryOrigin = new Uri(RepositoryOrigin), }; } } diff --git a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs index 7ae2ef5c79..eb3ed1bb19 100644 --- a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs +++ b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs @@ -1,9 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class DreamMakerSettings : Api.Models.DreamMaker + public sealed class DreamMakerSettings : Api.Models.DreamMaker, IApiTransformable { /// /// The row Id @@ -21,10 +21,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.DreamMaker ToApi() => new Api.Models.DreamMaker { ProjectName = ProjectName, diff --git a/src/Tgstation.Server.Host/Models/IApiTransformable.cs b/src/Tgstation.Server.Host/Models/IApiTransformable.cs new file mode 100644 index 0000000000..282e0d2442 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IApiTransformable.cs @@ -0,0 +1,15 @@ +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents a host-side model that may be transformed into a . + /// + /// The API form of the model. + public interface IApiTransformable + { + /// + /// Convert the to it's . + /// + /// A new based on the . + TApiModel ToApi(); + } +} diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 1827a9511d..3c721a12f1 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Tgstation.Server.Host.Models { /// /// Represents an in the database /// - public sealed class Instance : Api.Models.Instance + public sealed class Instance : Api.Models.Instance, IApiTransformable { /// /// Default for . @@ -28,9 +28,14 @@ namespace Tgstation.Server.Host.Models public RepositorySettings RepositorySettings { get; set; } /// - /// The s in the + /// The of the the server in the swarm this instance belongs to. /// - public ICollection InstanceUsers { get; set; } + public string SwarmIdentifer { get; set; } + + /// + /// The s in the + /// + public ICollection InstancePermissionSets { get; set; } /// /// The s for the @@ -47,10 +52,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection Jobs { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.Instance ToApi() => new Api.Models.Instance { AutoUpdateInterval = AutoUpdateInterval, @@ -59,7 +61,8 @@ namespace Tgstation.Server.Host.Models Name = Name, Path = Path, Online = Online, - ChatBotLimit = ChatBotLimit + ChatBotLimit = ChatBotLimit, + MoveJob = MoveJob, }; } } diff --git a/src/Tgstation.Server.Host/Models/InstanceUser.cs b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs similarity index 50% rename from src/Tgstation.Server.Host/Models/InstanceUser.cs rename to src/Tgstation.Server.Host/Models/InstancePermissionSet.cs index 2aa74e5042..c52c553b68 100644 --- a/src/Tgstation.Server.Host/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs @@ -1,9 +1,9 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class InstanceUser : Api.Models.InstanceUser + public sealed class InstancePermissionSet : Api.Models.InstancePermissionSet, IApiTransformable { /// /// The row Id @@ -16,16 +16,19 @@ namespace Tgstation.Server.Host.Models public long InstanceId { get; set; } /// - /// The the belongs to + /// The the belongs to. /// [Required] public Instance Instance { get; set; } /// - /// Convert the to it's API form + /// The the belongs to. /// - /// A new - public Api.Models.InstanceUser ToApi() => new Api.Models.InstanceUser + [Required] + public PermissionSet PermissionSet { get; set; } + + /// + public Api.Models.InstancePermissionSet ToApi() => new Api.Models.InstancePermissionSet { ByondRights = ByondRights, ChatBotRights = ChatBotRights, @@ -33,8 +36,8 @@ namespace Tgstation.Server.Host.Models DreamDaemonRights = DreamDaemonRights, DreamMakerRights = DreamMakerRights, RepositoryRights = RepositoryRights, - InstanceUserRights = InstanceUserRights, - UserId = UserId + InstancePermissionSetRights = InstancePermissionSetRights, + PermissionSetId = PermissionSetId }; } } diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index 134993f14c..861981efda 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -1,9 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class Job : Api.Models.Internal.Job + #pragma warning disable CA1724 // naming conflict with gitlab package + public sealed class Job : Api.Models.Internal.Job, IApiTransformable + #pragma warning restore CA1724 { /// /// See @@ -22,10 +24,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.Job ToApi() => new Api.Models.Job { Id = Id, diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs new file mode 100644 index 0000000000..441ac0e312 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class OAuthConnection : Api.Models.OAuthConnection, IApiTransformable + { + /// + /// The row Id. + /// + public long Id { get; set; } + + /// + /// The owning . + /// + public User User { get; set; } + + /// + public Api.Models.OAuthConnection ToApi() => new Api.Models.OAuthConnection + { + Provider = Provider, + ExternalUserId = ExternalUserId + }; + } +} diff --git a/src/Tgstation.Server.Host/Models/PermissionSet.cs b/src/Tgstation.Server.Host/Models/PermissionSet.cs new file mode 100644 index 0000000000..ad5c18ffc6 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/PermissionSet.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class PermissionSet : Api.Models.PermissionSet + { + /// + /// The of . + /// + public long? UserId { get; set; } + + /// + /// The of . + /// + public long? GroupId { get; set; } + + /// + /// The the belongs to, if it is for a . + /// + public User User { get; set; } + + /// + /// The the belongs to, if it is for a . + /// + public UserGroup Group { get; set; } + + /// + /// The s associated with the . + /// + public ICollection InstancePermissionSets { get; set; } + + /// + /// Convert the to it's API form. + /// + /// A new . + public Api.Models.PermissionSet ToApi() => new Api.Models.PermissionSet + { + Id = Id, + AdministrationRights = AdministrationRights, + InstanceManagerRights = InstanceManagerRights, + }; + } +} diff --git a/src/Tgstation.Server.Host/Models/RepositorySettings.cs b/src/Tgstation.Server.Host/Models/RepositorySettings.cs index 6cc2cf436e..25fb37edd7 100644 --- a/src/Tgstation.Server.Host/Models/RepositorySettings.cs +++ b/src/Tgstation.Server.Host/Models/RepositorySettings.cs @@ -4,7 +4,7 @@ using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class RepositorySettings : Api.Models.Internal.RepositorySettings + public sealed class RepositorySettings : Api.Models.Internal.RepositorySettings, IApiTransformable { /// /// The row Id @@ -22,10 +22,7 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Repository ToApi() => new Repository { // AccessToken = AccessToken, // never show this diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs index 6c28d3864a..3b99e8c26e 100644 --- a/src/Tgstation.Server.Host/Models/RevisionInformation.cs +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; namespace Tgstation.Server.Host.Models { /// - public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation + public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation, IApiTransformable { /// /// The row Id @@ -38,10 +38,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection CompileJobs { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.RevisionInformation ToApi() => new Api.Models.RevisionInformation { CommitSha = CommitSha, diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs index e44b557cb4..3ef25c0e91 100644 --- a/src/Tgstation.Server.Host/Models/TestMerge.cs +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { /// - public sealed class TestMerge : Api.Models.Internal.TestMerge + public sealed class TestMerge : Api.Models.Internal.TestMerge, IApiTransformable { /// /// See @@ -28,10 +28,7 @@ namespace Tgstation.Server.Host.Models /// public ICollection RevisonInformations { get; set; } - /// - /// Convert the to it's API form - /// - /// A new + /// public Api.Models.TestMerge ToApi() => new Api.Models.TestMerge { Author = Author, @@ -42,7 +39,7 @@ namespace Tgstation.Server.Host.Models Id = Id, MergedBy = MergedBy.ToApi(false), Number = Number, - PullRequestRevision = PullRequestRevision, + TargetCommitSha = TargetCommitSha, Url = Url }; } diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index ed1ec273aa..20b3dd6d2a 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class User : Api.Models.Internal.User + public sealed class User : Api.Models.Internal.User, IApiTransformable { /// /// Username used when creating jobs automatically. @@ -23,9 +25,25 @@ namespace Tgstation.Server.Host.Models public User CreatedBy { get; set; } /// - /// The uppercase invariant of + /// The the belongs to, if any. + /// + public UserGroup Group { get; set; } + + /// + /// The ID of the 's . + /// + public long? GroupId { get; set; } + + /// + /// The the has, if any. + /// + public PermissionSet PermissionSet { get; set; } + + /// + /// The uppercase invariant of /// [Required] + [StringLength(Limits.MaximumIndexableStringLength, MinimumLength = 1)] public string CanonicalName { get; set; } /// @@ -38,20 +56,20 @@ namespace Tgstation.Server.Host.Models /// public ICollection CreatedUsers { get; set; } - /// - /// The s for the - /// - public ICollection InstanceUsers { get; set; } - /// /// The s made by the /// public ICollection TestMerges { get; set; } /// - /// Change a into a . + /// The s made by the /// - /// The . + public ICollection OAuthConnections { get; set; } + + /// + /// Change a into a . + /// + /// The . /// The . public static string CanonicalizeName(string name) => name?.ToUpperInvariant() ?? throw new ArgumentNullException(nameof(name)); @@ -63,21 +81,29 @@ namespace Tgstation.Server.Host.Models /// A new Api.Models.User ToApi(bool recursive, bool showDetails) => new Api.Models.User { - AdministrationRights = showDetails ? AdministrationRights : null, - CreatedAt = CreatedAt, - CreatedBy = recursive ? CreatedBy?.ToApi(false, false) : null, - Enabled = Enabled, + CreatedAt = showDetails ? CreatedAt : null, + CreatedBy = showDetails && recursive ? CreatedBy?.ToApi(false, false) : null, + Enabled = showDetails ? Enabled : null, Id = Id, - InstanceManagerRights = showDetails ? InstanceManagerRights : null, Name = Name, - SystemIdentifier = showDetails ? SystemIdentifier : null + SystemIdentifier = showDetails ? SystemIdentifier : null, + OAuthConnections = showDetails + ? OAuthConnections + ?.Select(x => x.ToApi()) + .ToList() + : null, + Group = showDetails ? Group?.ToApi(false) : null, + PermissionSet = showDetails ? PermissionSet?.ToApi() : null, }; /// /// Convert the to it's API form /// - /// If rights and system identifier should be shown + /// If system identifier, oauth connections, and group/permission set should be shown. /// A new public Api.Models.User ToApi(bool showDetails) => ToApi(true, showDetails); + + /// + public Api.Models.User ToApi() => ToApi(true); } } diff --git a/src/Tgstation.Server.Host/Models/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs new file mode 100644 index 0000000000..2253b8de53 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/UserGroup.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class UserGroup : Api.Models.Internal.UserGroupBase, IApiTransformable + { + /// + /// The the has. + /// + [Required] + public PermissionSet PermissionSet { get; set; } + + /// + /// The s the has. + /// + public ICollection Users { get; set; } + + /// + /// Convert the to it's API form. + /// + /// If should be populated. + /// A new . + public Api.Models.UserGroup ToApi(bool showUsers) => new Api.Models.UserGroup + { + Id = Id, + Name = Name, + PermissionSet = PermissionSet.ToApi(), + Users = showUsers + ? Users + ?.Select(x => x.ToApi(false)) + .OfType() + .ToList() + ?? new List() + : null, + }; + + /// + public Api.Models.UserGroup ToApi() => ToApi(true); + } +} diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index ab0b58943d..fa83a096a9 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -14,7 +14,6 @@ namespace Tgstation.Server.Host /// static class Program { -#pragma warning disable SA1401 // Fields must be private /// /// The expected host watchdog . /// @@ -24,7 +23,6 @@ namespace Tgstation.Server.Host /// The to use. /// internal static IServerFactory ServerFactory = Application.CreateDefaultServerFactory(); -#pragma warning restore SA1401 // Fields must be private /// /// Entrypoint for the diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index b621a41c04..23b82e350a 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; @@ -14,7 +14,10 @@ namespace Tgstation.Server.Host.Security public User User { get; } /// - public InstanceUser InstanceUser { get; } + public PermissionSet PermissionSet { get; } + + /// + public InstancePermissionSet InstancePermissionSet { get; } /// public ISystemIdentity SystemIdentity { get; } @@ -29,13 +32,16 @@ namespace Tgstation.Server.Host.Security /// /// The value of /// The value of - /// The value of - public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstanceUser instanceUser) + /// The value of + public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstancePermissionSet instanceUser) { User = user ?? throw new ArgumentNullException(nameof(user)); if (systemIdentity == null && User.SystemIdentifier != null) throw new ArgumentNullException(nameof(systemIdentity)); - InstanceUser = instanceUser; + PermissionSet = user.PermissionSet + ?? user.Group.PermissionSet + ?? throw new ArgumentException("No PermissionSet provider", nameof(user)); + InstancePermissionSet = instanceUser; SystemIdentity = systemIdentity; } @@ -50,19 +56,19 @@ namespace Tgstation.Server.Host.Security if (User == null) throw new InvalidOperationException("Authentication context has no user!"); - if (isInstance && InstanceUser == null) + if (isInstance && InstancePermissionSet == null) return 0; var rightsEnum = RightsHelper.RightToType(rightsType); // use the api versions because they're the ones that contain the actual properties - var typeToCheck = isInstance ? typeof(InstanceUser) : typeof(User); + var typeToCheck = isInstance ? typeof(InstancePermissionSet) : typeof(PermissionSet); var nullableType = typeof(Nullable<>); var nullableRightsType = nullableType.MakeGenericType(rightsEnum); var prop = typeToCheck.GetProperties().Where(x => x.PropertyType == nullableRightsType).First(); - var right = prop.GetMethod.Invoke(isInstance ? (object)InstanceUser : User, Array.Empty()); + var right = prop.GetMethod.Invoke(isInstance ? (object)InstancePermissionSet : PermissionSet, Array.Empty()); if (right == null) throw new InvalidOperationException("A user right was null!"); diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index d74ef23d4a..be393e8db9 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -1,9 +1,11 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; @@ -30,19 +32,27 @@ namespace Tgstation.Server.Host.Security /// readonly ILogger logger; + /// + /// The for the . + /// + readonly SwarmConfiguration swarmConfiguration; + /// /// Construct an /// /// The value of /// The value of + /// The containing the value of . /// The value of . public AuthenticationContextFactory( IDatabaseContext databaseContext, IIdentityCache identityCache, + IOptions swarmConfigurationOptions, ILogger logger) { this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); + swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -60,6 +70,10 @@ namespace Tgstation.Server.Host.Security .AsQueryable() .Where(x => x.Id == userId) .Include(x => x.CreatedBy) + .Include(x => x.PermissionSet) + .Include(x => x.Group) + .ThenInclude(x => x.PermissionSet) + .Include(x => x.OAuthConnections) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); if (user == default) @@ -84,23 +98,27 @@ namespace Tgstation.Server.Host.Security systemIdentity = null; } + var userPermissionSet = user.PermissionSet ?? user.Group.PermissionSet; try { - InstanceUser instanceUser = null; + InstancePermissionSet instancePermissionSet = null; if (instanceId.HasValue) { - instanceUser = await databaseContext.InstanceUsers + instancePermissionSet = await databaseContext.InstancePermissionSets .AsQueryable() - .Where(x => x.UserId == userId && x.InstanceId == instanceId) + .Where(x => x.PermissionSetId == userPermissionSet.Id && x.InstanceId == instanceId && x.Instance.SwarmIdentifer == swarmConfiguration.Identifier) .Include(x => x.Instance) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); - if (instanceUser == null) + if (instancePermissionSet == null) logger.LogDebug("User {0} does not have permissions on instance {1}!", userId, instanceId.Value); } - CurrentAuthenticationContext = new AuthenticationContext(systemIdentity, user, instanceUser); + CurrentAuthenticationContext = new AuthenticationContext( + systemIdentity, + user, + instancePermissionSet); } catch { diff --git a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs index 00c3ce2bed..b370cb62a3 100644 --- a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs +++ b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs @@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Security // we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid // if user is null that means they got the token with an expired password var rightAsULong = authenticationContext.User == null - || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) + || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstancePermissionSet == null) ? ~0UL : authenticationContext.GetRight(I); var rightEnum = RightsHelper.RightToType(I); diff --git a/src/Tgstation.Server.Host/Security/CryptographySuite.cs b/src/Tgstation.Server.Host/Security/CryptographySuite.cs index 979ca8b5df..491d245ecd 100644 --- a/src/Tgstation.Server.Host/Security/CryptographySuite.cs +++ b/src/Tgstation.Server.Host/Security/CryptographySuite.cs @@ -46,7 +46,7 @@ namespace Tgstation.Server.Host.Security throw new ArgumentNullException(nameof(newPassword)); user.PasswordHash = passwordHasher.HashPassword(user, newPassword); if (!newUser) - user.LastPasswordUpdate = DateTimeOffset.Now; + user.LastPasswordUpdate = DateTimeOffset.UtcNow; } /// diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 6d8b53894d..4b81a2244a 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; @@ -15,15 +15,20 @@ namespace Tgstation.Server.Host.Security User User { get; } /// - /// The of if applicable + /// The 's effective . /// - InstanceUser InstanceUser { get; } + PermissionSet PermissionSet { get; } + + /// + /// The 's effective if applicable. + /// + InstancePermissionSet InstancePermissionSet { get; } /// /// Get the value of a given /// /// The of the right to get - /// The value of . Note that if is all based rights will return 0 + /// The value of . Note that if is all based rights will return 0 ulong GetRight(RightsType rightsType); /// @@ -31,4 +36,4 @@ namespace Tgstation.Server.Host.Security /// ISystemIdentity SystemIdentity { get; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs index da9ea886de..196ae9fecf 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -17,7 +17,7 @@ namespace Tgstation.Server.Host.Security /// /// Create an to populate /// - /// The of the + /// The of the /// The of the operation /// The the resulting 's password must be valid after /// The for the operation diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 9ee9ba309c..f955f6fcd8 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Tokens; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -18,9 +18,10 @@ namespace Tgstation.Server.Host.Security /// /// Create a for a given /// - /// The to create the token for. Must have the field available + /// The to create the token for. Must have the field available + /// Whether or not this is an OAuth login. /// The for the operation /// A resulting in a new - Task CreateToken(Models.User user, CancellationToken cancellationToken); + Task CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Security/IdentityCache.cs b/src/Tgstation.Server.Host/Security/IdentityCache.cs index ef7cd76d93..6d20f6fb6d 100644 --- a/src/Tgstation.Server.Host/Security/IdentityCache.cs +++ b/src/Tgstation.Server.Host/Security/IdentityCache.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; @@ -21,7 +21,7 @@ namespace Tgstation.Server.Host.Security readonly ILogger logger; /// - /// The map of s to s + /// The map of s to s /// readonly Dictionary cachedIdentities; diff --git a/src/Tgstation.Server.Host/Security/IdentityCacheObject.cs b/src/Tgstation.Server.Host/Security/IdentityCacheObject.cs index 9b54996f93..1684935a6c 100644 --- a/src/Tgstation.Server.Host/Security/IdentityCacheObject.cs +++ b/src/Tgstation.Server.Host/Security/IdentityCacheObject.cs @@ -41,9 +41,9 @@ namespace Tgstation.Server.Host.Security if (onExpiry == null) throw new ArgumentNullException(nameof(onExpiry)); - var now = DateTimeOffset.Now; + var now = DateTimeOffset.UtcNow; if (expiry < now) - throw new ArgumentOutOfRangeException(nameof(expiry), expiry, "expiry must be greater than DateTimeOffset.Now!"); + throw new ArgumentOutOfRangeException(nameof(expiry), expiry, "expiry must be greater than DateTimeOffset.UtcNow!"); cancellationTokenSource = new CancellationTokenSource(); diff --git a/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs new file mode 100644 index 0000000000..4cb1995f59 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/BaseOAuthValidator.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Base for s. + /// + abstract class BaseOAuthValidator : IOAuthValidator + { + /// + public abstract OAuthProvider Provider { get; } + + /// + /// The for the . + /// + protected ILogger Logger { get; } + + /// + /// The for the . + /// + protected OAuthConfiguration OAuthConfiguration { get; } + + /// + /// The for the . + /// + readonly IHttpClientFactory httpClientFactory; + + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// Gets that should be used. + /// + /// A new . + protected static JsonSerializerSettings SerializerSettings() => new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + } + }; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public BaseOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + { + this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + OAuthConfiguration = oAuthConfiguration ?? throw new ArgumentNullException(nameof(oAuthConfiguration)); + } + + /// + public abstract Task GetProviderInfo(CancellationToken cancellationToken); + + /// + public abstract Task ValidateResponseCode(string code, CancellationToken cancellationToken); + + /// + /// Create a new configured . + /// + /// A new configured . + protected HttpClient CreateHttpClient() + { + var httpClient = httpClientFactory.CreateClient(); + try + { + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + httpClient.DefaultRequestHeaders.UserAgent.Add(assemblyInformationProvider.ProductInfoHeaderValue); + return httpClient; + } + catch + { + httpClient.Dispose(); + throw; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs new file mode 100644 index 0000000000..37d055d08b --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/DiscordOAuthValidator.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Net.Http; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// OAuth validator for Discord. + /// + sealed class DiscordOAuthValidator : GenericOAuthValidator + { + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The for the . + /// The for the . + /// The for the . + public DiscordOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base(httpClientFactory, assemblyInformationProvider, logger, oAuthConfiguration) + { + } + + /// + public override OAuthProvider Provider => OAuthProvider.Discord; + + /// + protected override Uri TokenUrl => new Uri("https://discord.com/api/oauth2/token"); + + /// + protected override Uri UserInformationUrl => new Uri("https://discord.com/api/users/@me"); + + /// + protected override OAuthTokenRequest CreateTokenRequest(string code) => new OAuthTokenRequest(OAuthConfiguration, code, "identify"); + + /// + protected override string DecodeTokenPayload(dynamic responseJson) => responseJson.access_token; + + /// + protected override string DecodeUserInformationPayload(dynamic responseJson) => responseJson.id; + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs new file mode 100644 index 0000000000..4a6beb15e6 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// for generic OAuth2 endpoints. + /// + abstract class GenericOAuthValidator : BaseOAuthValidator + { + /// + /// to to to get the access token. + /// + protected abstract Uri TokenUrl { get; } + + /// + /// to the user information payload from. + /// + protected abstract Uri UserInformationUrl { get; } + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The for the + /// The for the . + public GenericOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base( + httpClientFactory, + assemblyInformationProvider, + logger, + oAuthConfiguration) + { + } + + /// + /// Decode the token payload . + /// + /// The token payload . + /// The OAuth2 bearer access token on success, otherwise. + protected abstract string DecodeTokenPayload(dynamic responseJson); + + /// + /// Decode the user information payload . + /// + /// The user information payload . + /// The user ID on success, otherwise. + protected abstract string DecodeUserInformationPayload(dynamic responseJson); + + /// + /// Create the for a given + /// + /// The OAuth code from the browser. + /// The to send to . + protected abstract OAuthTokenRequest CreateTokenRequest(string code); + + /// + public override async Task ValidateResponseCode(string code, CancellationToken cancellationToken) + { + using var httpClient = CreateHttpClient(); + try + { + Logger.LogTrace("Validating response code..."); + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, TokenUrl); + + var tokenRequestPayload = CreateTokenRequest(code); + + // roundabout but it works + var tokenRequestJson = JsonConvert.SerializeObject( + tokenRequestPayload, + SerializerSettings()); + + var tokenRequestDictionary = JsonConvert.DeserializeObject>(tokenRequestJson); + tokenRequest.Content = new FormUrlEncodedContent(tokenRequestDictionary); + + var tokenResponse = await httpClient.SendAsync(tokenRequest, cancellationToken).ConfigureAwait(false); + tokenResponse.EnsureSuccessStatusCode(); + var tokenResponsePayload = await tokenResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + var tokenResponseJson = JObject.Parse(tokenResponsePayload); + + var accessToken = DecodeTokenPayload(tokenResponseJson); + if (accessToken == null) + { + Logger.LogTrace("No token from DecodeTokenPayload!"); + return null; + } + + Logger.LogTrace("Getting user details..."); + using var userInformationRequest = new HttpRequestMessage(HttpMethod.Get, UserInformationUrl); + userInformationRequest.Headers.Authorization = new AuthenticationHeaderValue( + ApiHeaders.BearerAuthenticationScheme, + accessToken); + + var userInformationResponse = await httpClient.SendAsync(userInformationRequest, cancellationToken).ConfigureAwait(false); + userInformationResponse.EnsureSuccessStatusCode(); + + var userInformationPayload = await userInformationResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + var userInformationJson = JObject.Parse(userInformationPayload); + + return DecodeUserInformationPayload(userInformationJson); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error while completing OAuth handshake!"); + return null; + } + } + + /// + public override Task GetProviderInfo(CancellationToken cancellationToken) => Task.FromResult( + new OAuthProviderInfo + { + ClientId = OAuthConfiguration.ClientId, + RedirectUri = OAuthConfiguration.RedirectUrl, + ServerUrl = OAuthConfiguration.ServerUrl + }); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs new file mode 100644 index 0000000000..c678568ebe --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Logging; +using Octokit; +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// for GitHub. + /// + sealed class GitHubOAuthValidator : IOAuthValidator + { + /// + public OAuthProvider Provider => OAuthProvider.GitHub; + + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly OAuthConfiguration oAuthConfiguration; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + public GitHubOAuthValidator( + IGitHubClientFactory gitHubClientFactory, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + { + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.oAuthConfiguration = oAuthConfiguration ?? throw new ArgumentNullException(nameof(oAuthConfiguration)); + } + + /// + public async Task ValidateResponseCode(string code, CancellationToken cancellationToken) + { + if (code == null) + throw new ArgumentNullException(nameof(code)); + + var client = gitHubClientFactory.CreateClient(); + try + { + logger.LogTrace("Validating response code..."); + var response = await client + .Oauth + .CreateAccessToken( + new OauthTokenRequest( + oAuthConfiguration.ClientId, + oAuthConfiguration.ClientSecret, + code) + { + RedirectUri = oAuthConfiguration.RedirectUrl + }) + .ConfigureAwait(false); + + var token = response.AccessToken; + if (token == null) + return null; + + var authenticatedClient = gitHubClientFactory.CreateClient(token); + + logger.LogTrace("Getting user details..."); + var userDetails = await authenticatedClient + .User + .Current() + .ConfigureAwait(false); + + return userDetails.Id.ToString(CultureInfo.InvariantCulture); + } + catch (RateLimitExceededException) + { + throw; + } + catch(ApiException ex) + { + logger.LogWarning(ex, "API error while completing OAuth handshake!"); + return null; + } + } + + /// + public Task GetProviderInfo(CancellationToken cancellationToken) => Task.FromResult( + new OAuthProviderInfo + { + ClientId = oAuthConfiguration.ClientId, + RedirectUri = oAuthConfiguration.RedirectUrl + }); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs new file mode 100644 index 0000000000..06cae4e96e --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Contains s + /// + public interface IOAuthProviders + { + /// + /// Gets the for a given . + /// + /// The to get the validator for. + /// The for . + IOAuthValidator GetValidator(OAuthProvider oAuthProvider); + + /// + /// Gets a of the provider client IDs. + /// + /// The for the operation. + /// A resulting in a anew of the active s. + Task> ProviderInfos(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs new file mode 100644 index 0000000000..be8df49788 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Validates OAuth responses for a given . + /// + public interface IOAuthValidator + { + /// + /// The this validator is for. + /// + OAuthProvider Provider { get; } + + /// + /// Gets the of validator. + /// + /// The for the operation. + /// A resulting in the client ID of the validator on success, on failure. + Task GetProviderInfo(CancellationToken cancellationToken); + + /// + /// Validate a given OAuth response . + /// + /// The OAuth response string from web application. + /// The for the operation. + /// A resulting in if authentication failed, if a rate limit occurred, and the validated otherwise. + Task ValidateResponseCode(string code, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs new file mode 100644 index 0000000000..61e08a56ab --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/KeycloakOAuthValidator.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Net.Http; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// OAuth validator for Keycloak. + /// + sealed class KeycloakOAuthValidator : GenericOAuthValidator + { + /// + public override OAuthProvider Provider => OAuthProvider.Keycloak; + + /// + protected override Uri TokenUrl => new Uri($"{BaseProtocolPath}/token"); + + /// + protected override Uri UserInformationUrl => new Uri($"{BaseProtocolPath}/userinfo"); + + /// + /// Base path to the server's OAuth endpoint. + /// + string BaseProtocolPath => $"{OAuthConfiguration.ServerUrl}/protocol/openid-connect"; + + /// + /// Initializes a new instance of the . + /// + /// The for the . + /// The for the . + /// The for the . + /// The for the . + public KeycloakOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base(httpClientFactory, assemblyInformationProvider, logger, oAuthConfiguration) + { + } + + /// + protected override OAuthTokenRequest CreateTokenRequest(string code) => new OAuthTokenRequest(OAuthConfiguration, code, "openid"); + + /// + protected override string DecodeTokenPayload(dynamic responseJson) => responseJson.access_token; + + /// + protected override string DecodeUserInformationPayload(dynamic responseJson) => responseJson.sub; + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs new file mode 100644 index 0000000000..e8133706b2 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + sealed class OAuthProviders : IOAuthProviders + { + /// + /// The of s. + /// + readonly IReadOnlyCollection validators; + + /// + /// Initializes a new instance of the . + /// + /// The to use. + /// The to use. + /// The to use. + /// The to use. + /// The containing the to use. + public OAuthProviders( + IGitHubClientFactory gitHubClientFactory, + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILoggerFactory loggerFactory, + IOptions securityConfigurationOptions) + { + if (loggerFactory == null) + throw new ArgumentNullException(nameof(loggerFactory)); + + var securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); + + var validatorsBuilder = new List(); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.GitHub, out var gitHubConfig)) + validatorsBuilder.Add( + new GitHubOAuthValidator( + gitHubClientFactory, + loggerFactory.CreateLogger(), + gitHubConfig)); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.Discord, out var discordConfig)) + validatorsBuilder.Add( + new DiscordOAuthValidator( + httpClientFactory, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + discordConfig)); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.TGForums, out var tgConfig)) + validatorsBuilder.Add( + new TGForumsOAuthValidator( + httpClientFactory, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + tgConfig)); + + if (securityConfiguration.OAuth.TryGetValue(OAuthProvider.Keycloak, out var keyCloakConfig)) + validatorsBuilder.Add( + new KeycloakOAuthValidator( + httpClientFactory, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + keyCloakConfig)); + + validators = validatorsBuilder; + } + + /// + public IOAuthValidator GetValidator(OAuthProvider oAuthProvider) => validators.FirstOrDefault(x => x.Provider == oAuthProvider); + + /// + public async Task> ProviderInfos(CancellationToken cancellationToken) + { + var providersAndTasks = validators.ToDictionary( + x => x.Provider, + x => x.GetProviderInfo(cancellationToken)); + + await Task.WhenAll(providersAndTasks.Values).ConfigureAwait(false); + + return providersAndTasks + .Where(x => x.Value.Result != null) + .ToDictionary( + x => x.Key, + x => x.Value.Result); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs new file mode 100644 index 0000000000..eb715953c6 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthTokenRequest.cs @@ -0,0 +1,47 @@ +using System; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Generic OAuth token request. + /// + sealed class OAuthTokenRequest : OAuthConfigurationBase + { + /// + /// The OAuth code received from the browser. + /// + public string Code { get; } + + /// + /// The scopes being requested. + /// + public string Scope { get; } + + /// + /// The OAuth redirect URI. + /// + public Uri RedirectUri { get; } + + /// + /// The OAuth grant type. + /// + public string GrantType { get; } + + /// + /// Initializes a new instance of the . + /// + /// The to build from. + /// The value of . + /// The value of + public OAuthTokenRequest(OAuthConfiguration oAuthConfiguration, string code, string scope) + : base(oAuthConfiguration) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + Scope = scope ?? throw new ArgumentNullException(nameof(scope)); + + RedirectUri = oAuthConfiguration.RedirectUrl; + GrantType = "authorization_code"; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs new file mode 100644 index 0000000000..1f887179e3 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGBaseResponse.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Base for tgstation forum responses. + /// + abstract class TGBaseResponse + { + /// + /// Expected value of . + /// + public const string OkStatus = "OK"; + + /// + /// The response status. + /// + public string Status { get; set; } + + /// + /// The response error, if any. + /// + public string Error { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs new file mode 100644 index 0000000000..7d8d53face --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGCreateSessionResponse.cs @@ -0,0 +1,18 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Response when creating a tgstation forums session. + /// + sealed class TGCreateSessionResponse : TGBaseResponse + { + /// + /// The session's private token. Similar to OAuth authorization response code. + /// + public string SessionPrivateToken { get; set; } + + /// + /// The session's public token. Barely similar to OAuth client ID. + /// + public string SessionPublicToken { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs new file mode 100644 index 0000000000..3024e61001 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGForumsOAuthValidator.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// for /tg/ forums. + /// + sealed class TGForumsOAuthValidator : BaseOAuthValidator + { + /// + /// Amount of minutes until unused sessions that were created are forgotten. + /// + const uint SessionRetentionMinutes = 10; + + /// + public override OAuthProvider Provider => OAuthProvider.TGForums; + + /// + /// The active session. + /// + readonly List> sessions; + + /// + /// Initializes a new instance of the . + /// + /// The for the + /// The for the + /// The for the + /// The for the . + public TGForumsOAuthValidator( + IHttpClientFactory httpClientFactory, + IAssemblyInformationProvider assemblyInformationProvider, + ILogger logger, + OAuthConfiguration oAuthConfiguration) + : base( + httpClientFactory, + assemblyInformationProvider, + logger, + oAuthConfiguration) + { + sessions = new List>(); + } + + /// + public override async Task GetProviderInfo(CancellationToken cancellationToken) + { + var expiredSessions = sessions.RemoveAll(x => x.Item2.AddMinutes(SessionRetentionMinutes) < DateTimeOffset.UtcNow); + if (expiredSessions > 0) + Logger.LogTrace("Expired {0} sessions", expiredSessions); + + Logger.LogTrace("Creating new session..."); + try + { + UriBuilder builder = new UriBuilder("https://tgstation13.org/phpBB/oauth_create_session.php") + { + Query = $"site_private_token={HttpUtility.UrlEncode(Convert.ToBase64String(Encoding.UTF8.GetBytes(OAuthConfiguration.ClientSecret)))}&return_uri={HttpUtility.UrlEncode(OAuthConfiguration.RedirectUrl.ToString())}" + }; + + using var request = new HttpRequestMessage(HttpMethod.Get, builder.Uri); + using var httpClient = CreateHttpClient(); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var newSession = JsonConvert.DeserializeObject(json, SerializerSettings()); + + if (newSession.Status != TGBaseResponse.OkStatus) + { + Logger.LogWarning("Invalid status from /tg/ API! Status: {0}, Error: {1}", newSession.Status, newSession.Error); + return null; + } + + sessions.Add( + Tuple.Create( + newSession, + DateTimeOffset.UtcNow)); + return new OAuthProviderInfo + { + ClientId = newSession.SessionPublicToken, + RedirectUri = OAuthConfiguration.RedirectUrl + }; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to create TG Forums session!"); + return null; + } + } + + /// + public override async Task ValidateResponseCode(string code, CancellationToken cancellationToken) + { + try + { + var sessionTuple = sessions.FirstOrDefault(x => x.Item1.SessionPublicToken == code); + if(sessionTuple == null) + { + Logger.LogWarning("No known session with this code active!"); + return null; + } + + Logger.LogTrace("Validating session..."); + + UriBuilder builder = new UriBuilder("https://tgstation13.org/phpBB/oauth_get_session_info.php") + { + Query = $"site_private_token={HttpUtility.UrlEncode(Convert.ToBase64String(Encoding.UTF8.GetBytes(OAuthConfiguration.ClientSecret)))}&session_private_token={HttpUtility.UrlEncode(sessionTuple.Item1.SessionPrivateToken)}" + }; + + using var request = new HttpRequestMessage(HttpMethod.Get, builder.Uri); + using var httpClient = CreateHttpClient(); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var sessionInfo = JsonConvert.DeserializeObject(json, SerializerSettings()); + + if (sessionInfo.Status != TGBaseResponse.OkStatus) + { + Logger.LogWarning("Invalid status from /tg/ API! Status: {0}, Error: {1}", sessionInfo.Status, sessionInfo.Error); + return null; + } + + sessions.Remove(sessionTuple); + return sessionInfo.PhpbbUsername; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to create TG Forums session!"); + return null; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs b/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs new file mode 100644 index 0000000000..89b834e769 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/OAuth/TGGetSessionInfoResponse.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Host.Security.OAuth +{ + /// + /// Response when getting tgstation forum user's info. + /// + sealed class TGGetSessionInfoResponse : TGBaseResponse + { + /// + /// The user's forum account name. + /// + public string PhpbbUsername { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index aea0dbf6cf..80f5afaccf 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -7,4 +7,6 @@ - [IIdentityCache](./IIdentityCache.cs) and [implementation](./IdentityCache.cs) is used to store `ISystemIdentity`s for the duration of their associated tokens as [IdentityCacheObject](./IdentityCacheObject.cs)s. - [ITokenFactory](./ITokenFactory.cs) and [implementation](./TokenFactory.cs) is used to generate the Json Web Token for a session after a user successfully authenticates. - [ISystemIdentity](./ISystemIdentity.cs)s represent a logon session with the operating system for a given user. It contains a method to run code under the security context of said user. -- [ISystemIdentityFactory](./ISystemIdentityFactory.cs) is used to create `ISystemIdentity`s by attempting to log the user in with the OS with a given username and password. \ No newline at end of file +- [ISystemIdentityFactory](./ISystemIdentityFactory.cs) is used to create `ISystemIdentity`s by attempting to log the user in with the OS with a given username and password. + +- [OAuth](./OAuth) contains classes related to OAuth 2.0 authentication diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 40a898abd5..d4deedbb62 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -76,12 +76,12 @@ namespace Tgstation.Server.Host.Security } /// - public async Task CreateToken(Models.User user, CancellationToken cancellationToken) + public async Task CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken) { if (user == null) throw new ArgumentNullException(nameof(user)); - var now = DateTimeOffset.Now; + var now = DateTimeOffset.UtcNow; var nowUnix = now.ToUnixTimeSeconds(); // this prevents validation conflicts down the line @@ -93,7 +93,9 @@ namespace Tgstation.Server.Host.Security if (nowUnix == lpuUnix) await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); - var expiry = now.AddMinutes(securityConfiguration.TokenExpiryMinutes); + var expiry = now.AddMinutes(oAuth + ? securityConfiguration.OAuthTokenExpiryMinutes + : securityConfiguration.TokenExpiryMinutes); var claims = new Claim[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture)), diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 8ce353d0ae..3f98fbe38a 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.IO; +using Tgstation.Server.Host.Swarm; namespace Tgstation.Server.Host { @@ -20,6 +21,9 @@ namespace Tgstation.Server.Host /// public bool RestartRequested { get; private set; } + /// + public bool UpdateInProgress { get; private set; } + /// public bool WatchdogPresent => #if WATCHDOG_FREE_RESTART @@ -48,6 +52,11 @@ namespace Tgstation.Server.Host /// readonly object restartLock; + /// + /// The for the . + /// + ISwarmService swarmService; + /// /// The for the /// @@ -68,11 +77,6 @@ namespace Tgstation.Server.Host /// Exception propagatedException; - /// - /// If a server update has been or is being applied - /// - bool updating; - /// /// Construct a /// @@ -140,6 +144,7 @@ namespace Tgstation.Server.Host using var host = hostBuilder.Build(); try { + swarmService = host.Services.GetRequiredService(); logger = host.Services.GetRequiredService>(); using (cancellationToken.Register(() => logger.LogInformation("Server termination requested!"))) { @@ -174,13 +179,13 @@ namespace Tgstation.Server.Host lock (restartLock) { - if (updating || RestartRequested) + if (UpdateInProgress || RestartRequested) { logger.LogTrace("Aborted due to concurrency conflict!"); return false; } - updating = true; + UpdateInProgress = true; } async void RunUpdate() @@ -193,28 +198,63 @@ namespace Tgstation.Server.Host throw new InvalidOperationException("Tried to update a non-running Server!"); var cancellationToken = cancellationTokenSource.Token; - logger.LogTrace("Downloading zip package..."); - var updateZipData = await ioManager.DownloadFile(updateZipUrl, cancellationToken).ConfigureAwait(false); + var updatePrepareResult = await swarmService.PrepareUpdate(version, cancellationToken).ConfigureAwait(false); + if (!updatePrepareResult) + return; + MemoryStream updateZipData; try { - logger.LogTrace("Exctracting zip package to {0}...", updatePath); - await ioManager.ZipToDirectory(updatePath, updateZipData, cancellationToken).ConfigureAwait(false); + logger.LogTrace("Downloading zip package..."); + updateZipData = new MemoryStream( + await ioManager.DownloadFile( + updateZipUrl, + cancellationToken) + .ConfigureAwait(false)); } - catch (Exception e) + catch (Exception e1) { - updating = false; try { - // important to not leave this directory around if possible - await ioManager.DeleteDirectory(updatePath, default).ConfigureAwait(false); + await swarmService.AbortUpdate(cancellationToken).ConfigureAwait(false); } catch (Exception e2) { - throw new AggregateException(e, e2); + throw new AggregateException(e1, e2); + } + + throw; + } + + using (updateZipData) + { + var updateCommitResult = await swarmService.CommitUpdate(cancellationToken).ConfigureAwait(false); + if (!updateCommitResult) + { + logger.LogError("Swarm distributed commit failed, not applying update!"); + return; } - throw; + try + { + logger.LogTrace("Extracting zip package to {0}...", updatePath); + await ioManager.ZipToDirectory(updatePath, updateZipData, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + UpdateInProgress = false; + try + { + // important to not leave this directory around if possible + await ioManager.DeleteDirectory(updatePath, default).ConfigureAwait(false); + } + catch (Exception e2) + { + throw new AggregateException(e, e2); + } + + throw; + } } await Restart(version, null, true).ConfigureAwait(false); @@ -229,7 +269,7 @@ namespace Tgstation.Server.Host } finally { - updating = false; + UpdateInProgress = false; } } @@ -279,7 +319,7 @@ namespace Tgstation.Server.Host lock (restartLock) { - if ((updating && newVersion == null) || RestartRequested) + if ((UpdateInProgress && newVersion == null) || RestartRequested) { logger.LogTrace("Aborted due to concurrency conflict!"); return; diff --git a/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs b/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs index acc04b6518..edc85dea40 100644 --- a/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs +++ b/src/Tgstation.Server.Host/Setup/IPostSetupServices.cs @@ -1,4 +1,4 @@ -using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Setup @@ -18,6 +18,11 @@ namespace Tgstation.Server.Host.Setup /// DatabaseConfiguration DatabaseConfiguration { get; } + /// + /// The . + /// + SecurityConfiguration SecurityConfiguration { get; } + /// /// The . /// diff --git a/src/Tgstation.Server.Host/Setup/PostSetupServices.cs b/src/Tgstation.Server.Host/Setup/PostSetupServices.cs index f595e3ba65..1297381ef2 100644 --- a/src/Tgstation.Server.Host/Setup/PostSetupServices.cs +++ b/src/Tgstation.Server.Host/Setup/PostSetupServices.cs @@ -18,6 +18,9 @@ namespace Tgstation.Server.Host.Setup /// public DatabaseConfiguration DatabaseConfiguration => databaseConfigurationOptions.Value; + /// + public SecurityConfiguration SecurityConfiguration => securityConfigurationOptions.Value; + /// public FileLoggingConfiguration FileLoggingConfiguration => fileLoggingConfigurationOptions.Value; @@ -34,6 +37,11 @@ namespace Tgstation.Server.Host.Setup /// readonly IOptions databaseConfigurationOptions; + /// + /// Backing for . + /// + readonly IOptions securityConfigurationOptions; + /// /// Backing for . /// @@ -46,12 +54,14 @@ namespace Tgstation.Server.Host.Setup /// The used to create . /// The containing the value of . /// The containing the value of . + /// The containing the value of . /// The containing the value of . public PostSetupServices( IPlatformIdentifier platformIdentifier, ILoggerFactory loggerFactory, IOptions generalConfigurationOptions, IOptions databaseConfigurationOptions, + IOptions securityConfigurationOptions, IOptions fileLoggingConfigurationOptions) { PlatformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); @@ -61,6 +71,7 @@ namespace Tgstation.Server.Host.Setup Logger = loggerFactory.CreateLogger(); this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); this.databaseConfigurationOptions = databaseConfigurationOptions ?? throw new ArgumentNullException(nameof(databaseConfigurationOptions)); + this.securityConfigurationOptions = securityConfigurationOptions ?? throw new ArgumentNullException(nameof(securityConfigurationOptions)); this.fileLoggingConfigurationOptions = fileLoggingConfigurationOptions ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } } diff --git a/src/Tgstation.Server.Host/Setup/SetupApplication.cs b/src/Tgstation.Server.Host/Setup/SetupApplication.cs index 9f848efa8f..b6a04bd536 100644 --- a/src/Tgstation.Server.Host/Setup/SetupApplication.cs +++ b/src/Tgstation.Server.Host/Setup/SetupApplication.cs @@ -62,6 +62,7 @@ namespace Tgstation.Server.Host.Setup services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); + services.UseStandardConfig(Configuration); services.UseStandardConfig(Configuration); ConfigureHostedService(services); diff --git a/src/Tgstation.Server.Host/Setup/SetupWizard.cs b/src/Tgstation.Server.Host/Setup/SetupWizard.cs index efecf6cd04..6a7c99e03b 100644 --- a/src/Tgstation.Server.Host/Setup/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Setup/SetupWizard.cs @@ -747,6 +747,65 @@ namespace Tgstation.Server.Host.Setup return config; } + /// + /// Prompts the user to create a . + /// + /// The for the operation. + /// A resulting in the new . + async Task ConfigureSwarm(CancellationToken cancellationToken) + { + var enable = await PromptYesNo("Enable swarm mode? (y/n): ", cancellationToken).ConfigureAwait(false); + if (!enable) + return null; + + string identifer; + do + { + await console.WriteAsync("Enter this server's identifer: ", false, cancellationToken).ConfigureAwait(false); + identifer = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + } + while (String.IsNullOrWhiteSpace(identifer)); + + async Task ParseAddress(string question) + { + Uri address; + do + { + await console.WriteAsync(question, false, cancellationToken).ConfigureAwait(false); + var addressString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (Uri.TryCreate(addressString, UriKind.Absolute, out address) + && address.Scheme != Uri.UriSchemeHttp + && address.Scheme != Uri.UriSchemeHttps) + address = null; + } + while (address == null); + + return address; + } + + var address = await ParseAddress("Enter this server's HTTP(S) address: ").ConfigureAwait(false); + string privateKey; + do + { + await console.WriteAsync("Enter the swarm private key: ", false, cancellationToken).ConfigureAwait(false); + privateKey = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + } + while (String.IsNullOrWhiteSpace(privateKey)); + + var controller = await PromptYesNo("Is this server the swarm's controller? (y/n): ", cancellationToken).ConfigureAwait(false); + Uri controllerAddress = null; + if (!controller) + controllerAddress = await ParseAddress("Enter the swarm controller's HTTP(S) address: ").ConfigureAwait(false); + + return new SwarmConfiguration + { + Address = address, + ControllerAddress = controllerAddress, + Identifier = identifer, + PrivateKey = privateKey, + }; + } + /// /// Saves a given set to /// @@ -756,9 +815,18 @@ namespace Tgstation.Server.Host.Setup /// The to save /// The to save /// The to save + /// The to save. /// The for the operation /// A representing the running operation - async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration fileLoggingConfiguration, ControlPanelConfiguration controlPanelConfiguration, CancellationToken cancellationToken) + async Task SaveConfiguration( + string userConfigFileName, + ushort? hostingPort, + DatabaseConfiguration databaseConfiguration, + GeneralConfiguration newGeneralConfiguration, + FileLoggingConfiguration fileLoggingConfiguration, + ControlPanelConfiguration controlPanelConfiguration, + SwarmConfiguration swarmConfiguration, + CancellationToken cancellationToken) { await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken).ConfigureAwait(false); @@ -769,7 +837,8 @@ namespace Tgstation.Server.Host.Setup { DatabaseConfiguration.Section, databaseConfiguration }, { GeneralConfiguration.Section, newGeneralConfiguration }, { FileLoggingConfiguration.Section, fileLoggingConfiguration }, - { ControlPanelConfiguration.Section, controlPanelConfiguration } + { ControlPanelConfiguration.Section, controlPanelConfiguration }, + { SwarmConfiguration.Section, swarmConfiguration }, }; var json = JsonConvert.SerializeObject(map, Formatting.Indented); @@ -826,9 +895,20 @@ namespace Tgstation.Server.Host.Setup var controlPanelConfiguration = await ConfigureControlPanel(cancellationToken).ConfigureAwait(false); + var swarmConfiguration = await ConfigureSwarm(cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, newGeneralConfiguration, fileLoggingConfiguration, controlPanelConfiguration, cancellationToken).ConfigureAwait(false); + await SaveConfiguration( + userConfigFileName, + hostingPort, + databaseConfiguration, + newGeneralConfiguration, + fileLoggingConfiguration, + controlPanelConfiguration, + swarmConfiguration, + cancellationToken) + .ConfigureAwait(false); } /// diff --git a/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs new file mode 100644 index 0000000000..2b7ccf26d4 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Swarm service operations for the . + /// + public interface ISwarmOperations + { + /// + /// Pass in an updated list of to the node. + /// + /// An of the updated s. + void UpdateSwarmServersList(IEnumerable swarmServers); + + /// + /// Notify the node of an update request from the controller. + /// + /// The of TGS to update to. + /// The for the operation. + /// A resulting in if the node is able to update, otherwise. + Task PrepareUpdateFromController(Version version, CancellationToken cancellationToken); + + /// + /// Validate a given . + /// + /// The registration to validate. + /// if the registration is valid, otherwise. + bool ValidateRegistration(Guid registrationId); + + /// + /// Attempt to register a given with the controller. + /// + /// The that is registering. + /// The registration . + /// if the registration was successful, otherwise. + bool RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId); + + /// + /// Attempt to unregister a node with a given with the controller. + /// + /// The registration . + /// The for the operation. + /// A representing the running operation. + Task UnregisterNode(Guid registrationId, CancellationToken cancellationToken); + + /// + /// Notify the controller that the node with the given is ready to commit or notify the node of the controller telling it to commit. + /// + /// The registration . + /// The for the operation. + /// A representing the running operation. + Task RemoteCommitRecieved(Guid registrationId, CancellationToken cancellationToken); + + /// + /// Remotely abort an uncommitted update. + /// + /// The for the operation. + /// A representing the running operation. + Task RemoteAbortUpdate(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Swarm/ISwarmService.cs b/src/Tgstation.Server.Host/Swarm/ISwarmService.cs new file mode 100644 index 0000000000..1c80b46928 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/ISwarmService.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Used for swarm operations. Functions may be no-op based on configuration. + /// + public interface ISwarmService + { + /// + /// Attempt to register with the swarm controller if not one, sets up the database otherwise. + /// + /// The for the operation. + /// A resulting in the . + Task Initialize(CancellationToken cancellationToken); + + /// + /// Deregister with the swarm controller or put clients into querying state. + /// + /// The for the operation. + /// A representing the running operation. + Task Shutdown(CancellationToken cancellationToken); + + /// + /// Signal to the swarm that an update is requested. + /// + /// The to update to. + /// The for the operation. + /// A resulting in if the update should proceed, otherwise. + Task PrepareUpdate(Version version, CancellationToken cancellationToken); + + /// + /// Signal to the swarm that an update is ready to be applied. + /// + /// The for the operation. + /// A resulting in if the update should proceed, otherwise. + Task CommitUpdate(CancellationToken cancellationToken); + + /// + /// Gets the list of s in the swarm, including the current one. + /// + /// A of s in the swarm. If the server is not part of a swarm, will be returned. + ICollection GetSwarmServers(); + + /// + /// Abort an uncommitted update. + /// + /// The for the operation. + /// A representing the running operation. + Task AbortUpdate(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmConstants.cs b/src/Tgstation.Server.Host/Swarm/SwarmConstants.cs new file mode 100644 index 0000000000..c5a5d4fbbf --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmConstants.cs @@ -0,0 +1,35 @@ +using Tgstation.Server.Api; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Constants used by the swarm system. + /// + static class SwarmConstants + { + /// + /// The base route for . + /// + public const string ControllerRoute = Routes.Root + "Swarm"; + + /// + /// The header used to pass in the . + /// + public const string ApiKeyHeader = "X-API-KEY"; + + /// + /// The header used to pass in swarm registration IDs. + /// + public const string RegistrationIdHeader = "SwarmRegistration"; + + /// + /// The route used for swarm registration. + /// + public const string RegisterRoute = "Register"; + + /// + /// The route used for swarm updates. + /// + public const string UpdateRoute = "Update"; + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs new file mode 100644 index 0000000000..3de0403284 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs @@ -0,0 +1,18 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Models.Internal; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// A request to register with a swarm controller. + /// + public sealed class SwarmRegistrationRequest : SwarmServer + { + /// + /// The TGS of the sending server. + /// + [Required] + public Version ServerVersion { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs new file mode 100644 index 0000000000..585c9b7e3a --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs @@ -0,0 +1,28 @@ +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Result of attempting to register with a swarm controller. + /// + public enum SwarmRegistrationResult + { + /// + /// The registration succeeded. + /// + Success, + + /// + /// The swarm private keys didn't match. + /// + Unauthorized, + + /// + /// The swarm controller is running a different server version. + /// + VersionMismatch, + + /// + /// A communication error occurred. + /// + CommunicationFailure, + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs b/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs new file mode 100644 index 0000000000..ed480707ca --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmServersUpdateRequest.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// A request to update a nodes list of . + /// + public sealed class SwarmServersUpdateRequest + { + /// + /// The of updated s. + /// + [Required] + public ICollection SwarmServers { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs new file mode 100644 index 0000000000..d52f3fb2b6 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -0,0 +1,1292 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions.Converters; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Helps keep servers connected to the same database in sync by coordinating updates. + /// + sealed class SwarmService : ISwarmService, ISwarmOperations, IRestartHandler, IDisposable + { + /// + /// Interval at which the swarm controller makes health checks on nodes. + /// + const int ControllerHealthCheckIntervalMinutes = 3; + + /// + /// Interval at which the node makes health checks on the controller if it has not received one. + /// + const int NodeHealthCheckIntervalMinutes = 5; + + /// + /// Number of minutes the controller waits to receive a ready-commit from all nodes before aborting an update. + /// + const int UpdateCommitTimeoutMinutes = 10; + + /// + /// Number of seconds between triggering and a health check being performed. + /// + const int SecondsToDelayForcedHealthChecks = 15; + + /// + /// See for the swarm system. + /// + static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + Converters = new JsonConverter[] + { + new VersionConverter(), + new BoolConverter() + }, + DefaultValueHandling = DefaultValueHandling.Ignore, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }; + + /// + /// If the swarm system is enabled. + /// + bool SwarmMode => swarmConfiguration.PrivateKey != null; + + /// + /// Lazily constructed . + /// + readonly Lazy lazyRestartRegistration; + + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly IDatabaseSeeder databaseSeeder; + + /// + /// The for the . + /// + readonly IAssemblyInformationProvider assemblyInformationProvider; + + /// + /// The for the . + /// + readonly IHttpClientFactory httpClientFactory; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly IServerUpdateInitiator serverUpdater; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// The for the . + /// + readonly SwarmConfiguration swarmConfiguration; + + /// + /// The for . + /// + readonly CancellationTokenSource serverHealthCheckCancellationTokenSource; + + /// + /// of connected s. + /// + readonly List swarmServers; + + /// + /// of s to registration s. + /// + readonly Dictionary registrationIds; + + /// + /// used for accessing . + /// + readonly object updateSynchronizationLock; + + /// + /// If the current server is the swarm controller. + /// + readonly bool swarmController; + + /// + /// A that is used to force a health check. + /// + TaskCompletionSource forceHealthCheckTcs; + + /// + /// The that is used to proceed with committing an update. + /// + TaskCompletionSource updateCommitTcs; + + /// + /// of s that need to send a ready-commit before the update can proceed. + /// + List nodesThatNeedToBeReadyToCommit; + + /// + /// The for the . + /// + Task serverHealthCheckTask; + + /// + /// The set for a two phase commit update. + /// + Version targetUpdateVersion; + + /// + /// The registration provided by the swarm controller. + /// + Guid? controllerRegistration; + + /// + /// The last when the controller checked on this node. + /// + DateTimeOffset? lastControllerHealthCheck; + + /// + /// If was called. + /// + bool restarting; + + /// + /// If the list has been updated and needs to be resent to clients. + /// + bool serversDirty; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The to register ourselves as a with. + /// The value of . + /// The value of . + /// The containing the value of . + /// The value of . + public SwarmService( + IDatabaseContextFactory databaseContextFactory, + IDatabaseSeeder databaseSeeder, + IAssemblyInformationProvider assemblyInformationProvider, + IHttpClientFactory httpClientFactory, + IServerControl serverControl, + IServerUpdateInitiator serverUpdateInitiator, + IAsyncDelayer asyncDelayer, + IOptions swarmConfigurationOptions, + ILogger logger) + { + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.databaseSeeder = databaseSeeder ?? throw new ArgumentNullException(nameof(databaseSeeder)); + this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + if (serverControl == null) + throw new ArgumentNullException(nameof(serverControl)); + + serverUpdater = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); + this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); + swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (SwarmMode) + { + if (swarmConfiguration.Address == null) + throw new InvalidOperationException("Swarm configuration missing Address!"); + if (String.IsNullOrWhiteSpace(swarmConfiguration.Identifier)) + throw new InvalidOperationException("Swarm configuration missing Identifier!"); + } + + swarmController = !SwarmMode || swarmConfiguration.ControllerAddress == null; + if (SwarmMode) + { + serverHealthCheckCancellationTokenSource = new CancellationTokenSource(); + forceHealthCheckTcs = new TaskCompletionSource(); + if (swarmController) + registrationIds = new Dictionary(); + + swarmServers = new List + { + new SwarmServer + { + Address = swarmConfiguration.Address, + Controller = swarmController, + Identifier = swarmConfiguration.Identifier + } + }; + + updateSynchronizationLock = new object(); + } + + lazyRestartRegistration = new Lazy(() => serverControl.RegisterForRestart(this)); + } + + /// + public void Dispose() => serverHealthCheckCancellationTokenSource?.Dispose(); + + /// + public async Task RemoteAbortUpdate(CancellationToken cancellationToken) + { + if (targetUpdateVersion == null) + { + logger.LogTrace("Not remote aborting non-existent update"); + return; + } + + await AbortUpdate(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task AbortUpdate(CancellationToken cancellationToken) + { + if (!SwarmMode) + return; + + logger.LogInformation("Aborting swarm update!"); + updateCommitTcs?.TrySetResult(false); + updateCommitTcs = null; + nodesThatNeedToBeReadyToCommit = null; + targetUpdateVersion = null; + + using var httpClient = httpClientFactory.CreateClient(); + async Task SendRemoteAbort(SwarmServer swarmServer) + { + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Delete, + SwarmConstants.UpdateRoute, + null); + + try + { + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Unable to set remote abort to {0}!", + swarmController + ? $"node {swarmServer.Identifier}" + : "controller"); + } + } + + Task task; + if (!swarmController) + task = SendRemoteAbort(new SwarmServer + { + Address = swarmConfiguration.ControllerAddress, + }); + else + { + lock (swarmServers) + task = Task.WhenAll( + swarmServers + .Where(x => !x.Controller) + .Select(SendRemoteAbort)); + } + + await task.ConfigureAwait(false); + } + + /// + public async Task CommitUpdate(CancellationToken cancellationToken) + { + if (!SwarmMode) + return true; + + logger.LogInformation("Waiting to commit update..."); + using var httpClient = httpClientFactory.CreateClient(); + if (!swarmController) + { + // let the controller know we're ready + logger.LogTrace("Sending ready-commit to swarm controller..."); + using var commitReadyRequest = PrepareSwarmRequest( + null, + HttpMethod.Post, + SwarmConstants.UpdateRoute, + null); + + try + { + using var commitReadyResponse = await httpClient.SendAsync(commitReadyRequest, cancellationToken).ConfigureAwait(false); + commitReadyResponse.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to send ready-commit to swarm controller!"); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + return false; + } + } + + // wait for the update commit TCS + var commitTcsTask = updateCommitTcs?.Task; + if (commitTcsTask == null) + { + logger.LogDebug("Update commit failed, no pending task completion source!"); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + return false; + } + + var timeoutTask = swarmController + ? asyncDelayer.Delay( + TimeSpan.FromMinutes(UpdateCommitTimeoutMinutes), + cancellationToken) + : Extensions.TaskExtensions.InfiniteTask(); + + var commitTask = Task.WhenAny(commitTcsTask, timeoutTask); + + await commitTask.ConfigureAwait(false); + + var commitGoAhead = commitTcsTask.IsCompleted + && commitTcsTask.Result + && updateCommitTcs?.Task == commitTcsTask; + if (!commitGoAhead) + { + logger.LogDebug( + "Update commit failed!{0}", + timeoutTask.IsCompleted + ? " Timed out!" + : String.Empty); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + return false; + } + + logger.LogTrace("Update commit task complete"); + + // on nodes, it means we can go straight ahead + if (!swarmController) + return true; + + // 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 Task SendRemoteCommitUpdate(SwarmServer swarmServer) + { + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Post, + SwarmConstants.UpdateRoute, + null); + + try + { + // I know using the cancellationToken after this point doesn't seem very sane + // It's the token for Ctrl+C on server's console though, so we must respect it + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Failed to send update commit request to node {0}!", swarmServer.Identifier); + } + } + + Task task; + lock (swarmServers) + task = Task.WhenAll( + swarmServers + .Where(x => !x.Controller) + .Select(SendRemoteCommitUpdate)); + + await task.ConfigureAwait(false); + return true; + } + + /// + public ICollection GetSwarmServers() + { + if (!SwarmMode) + return null; + + lock (swarmServers) + return swarmServers.ToList(); + } + + /// + public Task PrepareUpdate(Version version, CancellationToken cancellationToken) + { + logger.LogTrace("Begin PrepareUpdate..."); + return PrepareUpdateImpl(version, true, cancellationToken); + } + + /// + public Task PrepareUpdateFromController(Version version, CancellationToken cancellationToken) + { + logger.LogTrace("Received remote update request from {0}", !swarmController ? "controller" : "node"); + return PrepareUpdateImpl(version, false, cancellationToken); + } + + /// + /// Implementation of , + /// + /// The being updated to. + /// Whether or not the update request originated on this server. + /// The for the operation. + /// A resulting in whether or not the update should proceed. + async Task PrepareUpdateImpl(Version version, bool initiator, CancellationToken cancellationToken) + { + if (version == null) + throw new ArgumentNullException(nameof(version)); + + logger.LogTrace("PrepareUpdateImpl {0}...", version); + + if (!SwarmMode) + return true; + + if (version == targetUpdateVersion) + { + logger.LogDebug("Prepare update short circuit!"); + return true; + } + + using var httpClient = httpClientFactory.CreateClient(); + async Task RemotePrepareUpdate(SwarmServer swarmServer) + { + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Put, + SwarmConstants.UpdateRoute, + new SwarmUpdateRequest + { + UpdateVersion = version + }); + + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + + var shouldAbort = false; + try + { + lock (updateSynchronizationLock) + { + if (targetUpdateVersion == version) + { + logger.LogTrace("PrepareUpdateFromController early out, already prepared!"); + return true; + } + + if (targetUpdateVersion != null) + { + logger.LogDebug("Aborting update preparation, version {0} already prepared!", targetUpdateVersion); + shouldAbort = true; + return false; + } + + targetUpdateVersion = version; + } + + if (!swarmController && initiator) + { + logger.LogDebug("Forwarding update request to swarm controller..."); + var result = await RemotePrepareUpdate(null).ConfigureAwait(false); + if (result) + updateCommitTcs = new TaskCompletionSource(); + + return result; + } + + if (!initiator) + { + logger.LogTrace("Beginning local update process..."); + updateCommitTcs = new TaskCompletionSource(); + var updateApplyResult = await serverUpdater.BeginUpdate( + version, + cancellationToken) + .ConfigureAwait(false); + if (updateApplyResult != ServerUpdateResult.Started) + { + logger.LogWarning("Failed to prepare update! Result: {0}", updateApplyResult); + shouldAbort = true; + return false; + } + } + + logger.LogDebug("Prepared for update to version {0}", version); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to prepare update!"); + shouldAbort = true; + return false; + } + finally + { + if (shouldAbort) + await AbortUpdate(cancellationToken).ConfigureAwait(false); + } + + if (!swarmController) + return true; + + try + { + logger.LogTrace("Sending remote prepare to nodes..."); + List> tasks; + lock (swarmServers) + { + nodesThatNeedToBeReadyToCommit = new List( + swarmServers + .Where(x => !x.Controller) + .Select(x => x.Identifier)); + tasks = swarmServers + .Where(x => !x.Controller) + .Select(RemotePrepareUpdate) + .ToList(); + } + + await Task.WhenAll(tasks); + + // if all succeeds... + if (tasks.All(x => x.Result)) + { + logger.LogDebug("Distributed prepare for update to version {0} complete.", version); + return true; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error remotely preparing updates!"); + } + + logger.LogDebug("Distrubuted prepare failed!"); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + return false; + } + + /// + public async Task Initialize(CancellationToken cancellationToken) + { + if (SwarmMode) + logger.LogInformation( + "Swarm mode enabled: {0} {1}", + swarmController + ? "Controller" + : "Node", + swarmConfiguration.Identifier); + else + logger.LogTrace("Swarm mode disabled"); + + var _ = lazyRestartRegistration.Value; + + SwarmRegistrationResult result; + if (swarmController) + { + await databaseContextFactory.UseContext( + databaseContext => databaseSeeder.Initialize(databaseContext, cancellationToken)) + .ConfigureAwait(false); + + result = SwarmRegistrationResult.Success; + } + else + result = await RegisterWithController(cancellationToken).ConfigureAwait(false); + + if (SwarmMode && result == SwarmRegistrationResult.Success) + serverHealthCheckTask = HealthCheckLoop(serverHealthCheckCancellationTokenSource.Token); + + return result; + } + + /// + public async Task Shutdown(CancellationToken cancellationToken) + { + async Task SendUnregistrationRequest(SwarmServer swarmServer) + { + using var httpClient = httpClientFactory.CreateClient(); + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Delete, + SwarmConstants.RegisterRoute, + null); + + try + { + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Error unregistering {0}!", + swarmController + ? $"node {swarmServer.Identifier}" + : "from controller"); + } + } + + if (serverHealthCheckTask != null) + { + serverHealthCheckCancellationTokenSource.Cancel(); + await serverHealthCheckTask.ConfigureAwait(false); + } + + if (!swarmController) + { + // if we restart a node, we don't want to unregister it so the controller doesn't try to update without it + // if we're shutting it down, though we should unregister it + if (!restarting) + { + logger.LogInformation("Unregistering from swarm controller..."); + await SendUnregistrationRequest(null); + } + else + logger.LogTrace("Not unregistering from swarm controller as we are restarting"); + + return; + } + + // downgrade the db if necessary + if (targetUpdateVersion != null + && targetUpdateVersion < assemblyInformationProvider.Version) + await databaseContextFactory.UseContext( + db => databaseSeeder.Downgrade(db, targetUpdateVersion, cancellationToken)) + .ConfigureAwait(false); + + if (SwarmMode) + { + // Put the nodes into a reconnecting state + if (targetUpdateVersion == null) + { + logger.LogInformation("Unregistering nodes..."); + Task task; + lock (swarmServers) + { + task = Task.WhenAll( + swarmServers + .Where(x => !x.Controller) + .Select(SendUnregistrationRequest)); + swarmServers.RemoveRange(1, swarmServers.Count - 1); + registrationIds.Clear(); + } + + await task.ConfigureAwait(false); + } + + logger.LogTrace("Swarm controller shutdown"); + } + } + + /// + /// Ping each node to see that they are still running. + /// + /// The for the operation. + /// A representing the running operation. + async Task HealthCheckNodes(CancellationToken cancellationToken) + { + using var httpClient = httpClientFactory.CreateClient(); + + List currentSwarmServers; + lock (swarmServers) + currentSwarmServers = swarmServers.ToList(); + + async Task HealthRequestForServer(SwarmServer swarmServer) + { + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Get, + String.Empty, + null); + + try + { + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return; + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + logger.LogWarning( + ex, + "Error during swarm server health check on node '{0}'! Unregistering...", + swarmServer.Identifier); + } + + lock (swarmServers) + { + swarmServers.Remove(swarmServer); + registrationIds.Remove(swarmServer.Identifier); + }; + } + + await Task.WhenAll( + currentSwarmServers + .Where(x => !x.Controller) + .Select(HealthRequestForServer)) + .ConfigureAwait(false); + + lock (swarmServers) + if (swarmServers.Count != currentSwarmServers.Count) + MarkServersDirty(); + + if (serversDirty) + await SendUpdatedServerListToNodes(cancellationToken).ConfigureAwait(false); + } + + /// + /// Set and complete the current . + /// + void MarkServersDirty() + { + serversDirty = true; + if(TriggerHealthCheck()) + logger.LogTrace("Server list is dirty!"); + } + + /// + /// Complete the current . + /// + /// the result of the call to . + bool TriggerHealthCheck() + { + var currentTcs = forceHealthCheckTcs; + forceHealthCheckTcs = new TaskCompletionSource(); + return currentTcs.TrySetResult(null); + } + + /// + /// Ping the swarm controller to see that it is still running. If need be, reregister. + /// + /// The for the operation. + /// A representing the running operation. + async Task HealthCheckController(CancellationToken cancellationToken) + { + using var httpClient = httpClientFactory.CreateClient(); + + if (controllerRegistration.HasValue) + try + { + using var request = PrepareSwarmRequest( + null, + HttpMethod.Get, + String.Empty, + null); + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + logger.LogTrace("Controller health check successful"); + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error during swarm controller health check! Attempting to re-register..."); + controllerRegistration = null; + } + + SwarmRegistrationResult registrationResult; + for (var I = 1UL; ; ++I) + { + logger.LogInformation("Swarm re-registration attempt {0}...", I); + registrationResult = await RegisterWithController(cancellationToken).ConfigureAwait(false); + + if (registrationResult == SwarmRegistrationResult.Success) + return; + + if (registrationResult == SwarmRegistrationResult.Unauthorized) + { + logger.LogError("Swarm re-registration failed, controller's private key has changed!"); + break; + } + + if (registrationResult == SwarmRegistrationResult.VersionMismatch) + { + logger.LogError("Swarm re-registration failed, controller's TGS version has changed!"); + break; + } + + await asyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + + // we could do something here... but what? + // best to just let the health check loop keep retrying... we won't be able to update at least + } + + /// + /// Attempt to register the node with the controller. + /// + /// The for the operation. + /// A resulting in the . + async Task RegisterWithController(CancellationToken cancellationToken) + { + logger.LogInformation("Attempting to register with swarm controller at {0}...", swarmConfiguration.ControllerAddress); + var requestedRegistrationId = Guid.NewGuid(); + + using var httpClient = httpClientFactory.CreateClient(); + using var registrationRequest = PrepareSwarmRequest( + null, + HttpMethod.Post, + SwarmConstants.RegisterRoute, + new SwarmRegistrationRequest + { + ServerVersion = assemblyInformationProvider.Version, + Identifier = swarmConfiguration.Identifier, + Address = swarmConfiguration.Address + }, + requestedRegistrationId); + + try + { + using var response = await httpClient.SendAsync(registrationRequest, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + logger.LogInformation("Sucessfully registered with ID {0}", requestedRegistrationId); + controllerRegistration = requestedRegistrationId; + lastControllerHealthCheck = DateTimeOffset.UtcNow; + return SwarmRegistrationResult.Success; + } + + logger.LogWarning("Unable to register with swarm: HTTP {0}!", response.StatusCode); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + return SwarmRegistrationResult.Unauthorized; + + if (response.StatusCode == HttpStatusCode.UpgradeRequired) + return SwarmRegistrationResult.VersionMismatch; + + logger.LogWarning("Error registering with swarm controller: HTTP {0}", response.StatusCode); + try + { + var responseData = await response.Content.ReadAsStringAsync(); + if (!String.IsNullOrWhiteSpace(responseData)) + logger.LogDebug("Response:{0}{1}", Environment.NewLine, responseData); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error reading registration response content stream!"); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error sending registration request!"); + } + + return SwarmRegistrationResult.CommunicationFailure; + } + + /// + /// Sends the controllers list of nodes to all nodes. + /// + /// The for the operation. + /// A representing the running operation. + async Task SendUpdatedServerListToNodes(CancellationToken cancellationToken) + { + logger.LogDebug("Sending updated server list to all nodes..."); + List currentSwarmServers; + lock (swarmServers) + currentSwarmServers = swarmServers.ToList(); + + using var httpClient = httpClientFactory.CreateClient(); + async Task UpdateRequestForServer(SwarmServer swarmServer) + { + using var request = PrepareSwarmRequest( + swarmServer, + HttpMethod.Post, + String.Empty, + new SwarmServersUpdateRequest + { + SwarmServers = currentSwarmServers + }); + + try + { + using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + logger.LogWarning(ex, "Error during swarm server list update for node '{0}'! Unregistering...", swarmServer.Identifier); + + lock (swarmServers) + { + swarmServers.Remove(swarmServer); + registrationIds.Remove(swarmServer.Identifier); + } + } + } + + await Task.WhenAll( + currentSwarmServers + .Where(x => !x.Controller) + .Select(UpdateRequestForServer)) + .ConfigureAwait(false); + serversDirty = false; + } + + /// + /// Prepares a for swarm communication. + /// + /// The the message is for, if null will be sent to swarm controller. + /// The . + /// The route on to use. + /// The body if any. + /// An optional override to the . + /// A new . + HttpRequestMessage PrepareSwarmRequest( + SwarmServer swarmServer, + HttpMethod httpMethod, + string subroute, + object body, + Guid? registrationIdOverride = null) + { + swarmServer ??= new SwarmServer + { + Address = swarmConfiguration.ControllerAddress, + }; + + subroute = $"{SwarmConstants.ControllerRoute}/{subroute}"; + logger.LogTrace( + "{0} {1} to swarm server {2}", + httpMethod, + subroute, + swarmServer.Identifier ?? swarmServer.Address.ToString()); + + var request = new HttpRequestMessage( + httpMethod, + swarmServer.Address + subroute[1..]); + + request.Headers.Add(SwarmConstants.ApiKeyHeader, swarmConfiguration.PrivateKey); + request.Headers.UserAgent.Clear(); + request.Headers.UserAgent.Add(assemblyInformationProvider.ProductInfoHeaderValue); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + if (registrationIdOverride.HasValue) + request.Headers.Add(SwarmConstants.RegistrationIdHeader, registrationIdOverride.Value.ToString()); + else if (swarmController) + { + lock (swarmServers) + if (registrationIds.TryGetValue(swarmServer.Identifier, out var registrationId)) + request.Headers.Add(SwarmConstants.RegistrationIdHeader, registrationId.ToString()); + } + else if (controllerRegistration.HasValue) + request.Headers.Add(SwarmConstants.RegistrationIdHeader, controllerRegistration.Value.ToString()); + + try + { + if (body != null) + request.Content = new StringContent( + JsonConvert.SerializeObject(body, SerializerSettings), + Encoding.UTF8, + MediaTypeNames.Application.Json); + + return request; + } + catch + { + request.Dispose(); + throw; + } + } + + /// + public void UpdateSwarmServersList(IEnumerable swarmServers) + { + if (swarmServers == null) + throw new ArgumentNullException(nameof(swarmServers)); + + if (swarmController) + throw new InvalidOperationException("Cannot UpdateSwarmServersList on swarm controller!"); + + lock (this.swarmServers) + { + this.swarmServers.Clear(); + this.swarmServers.AddRange(swarmServers); + logger.LogDebug("Updated swarm server list with {0} total nodes", this.swarmServers.Count); + } + } + + /// + /// Timed loop for calling . + /// + /// The for the operation + /// A representing the rinning operation + async Task HealthCheckLoop(CancellationToken cancellationToken) + { + logger.LogTrace("Starting HealthCheckLoop..."); + try + { + var nextForceHealthCheckTask = forceHealthCheckTcs.Task; + while (!cancellationToken.IsCancellationRequested) + { + TimeSpan delay; + if (swarmController) + delay = TimeSpan.FromMinutes(ControllerHealthCheckIntervalMinutes); + else + { + delay = TimeSpan.FromMinutes(NodeHealthCheckIntervalMinutes); + if (lastControllerHealthCheck.HasValue) + { + var recommendedTimeOfNextCheck = lastControllerHealthCheck.Value + delay; + + if (recommendedTimeOfNextCheck > DateTimeOffset.UtcNow) + delay = recommendedTimeOfNextCheck - DateTimeOffset.UtcNow; + } + } + + var delayTask = asyncDelayer.Delay( + delay, + cancellationToken); + + var awakeningTask = Task.WhenAny( + delayTask, + nextForceHealthCheckTask); + + await awakeningTask.ConfigureAwait(false); + + if (nextForceHealthCheckTask.IsCompleted && swarmController) + { + // Intentionally wait a few seconds for the other server to start up before interogating it + logger.LogTrace("Next health check triggering in {0}s...", SecondsToDelayForcedHealthChecks); + await asyncDelayer.Delay(TimeSpan.FromSeconds(SecondsToDelayForcedHealthChecks), cancellationToken); + } + else if (!swarmController && !nextForceHealthCheckTask.IsCompleted) + { + if (!lastControllerHealthCheck.HasValue) + { + logger.LogTrace("Not initially registered with controller, skipping health check."); + continue; // unregistered + } + + if ((DateTimeOffset.UtcNow - lastControllerHealthCheck.Value).TotalMinutes < NodeHealthCheckIntervalMinutes) + { + logger.LogTrace("Controller seems to be active, skipping health check."); + continue; + } + } + + nextForceHealthCheckTask = forceHealthCheckTcs.Task; + + logger.LogDebug("Performing swarm health check..."); + try + { + if (swarmController) + await HealthCheckNodes(cancellationToken).ConfigureAwait(false); + else + await HealthCheckController(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + logger.LogError(ex, "Health check error!"); + } + } + } + catch (OperationCanceledException ex) + { + logger.LogTrace(ex, "Health check loop cancelled!"); + } + + logger.LogTrace("Stopped HealthCheckLoop"); + } + + /// + public bool ValidateRegistration(Guid registrationId) + { + if (swarmController) + lock (swarmServers) + return registrationIds.Values.Any(x => x == registrationId); + + if (registrationId != controllerRegistration) + return false; + + lastControllerHealthCheck = DateTimeOffset.UtcNow; + return true; + } + + /// + public bool RegisterNode(Api.Models.Internal.SwarmServer node, Guid registrationId) + { + if (node == null) + throw new ArgumentNullException(nameof(node)); + + if (node.Identifier == null) + throw new ArgumentException("Node missing Identifier!", nameof(node)); + + if (node.Address == null) + throw new ArgumentException("Node missing Address!", nameof(node)); + + if (!swarmController) + throw new InvalidOperationException("Cannot RegisterNode on swarm node!"); + + lock (updateSynchronizationLock) + { + if (targetUpdateVersion != null) + { + logger.LogInformation("Not registering node {0} as a distributed update is in progress.", node.Identifier); + return false; + } + + lock (swarmServers) + { + if (registrationIds.Any(x => x.Value == registrationId)) + { + var preExistingRegistrationKvp = registrationIds.FirstOrDefault(x => x.Value == registrationId); + if (preExistingRegistrationKvp.Key == node.Identifier) + { + logger.LogWarning("Node {0} has already registered!", node.Identifier); + return true; + } + + logger.LogWarning( + "Registration ID collision! Node {0} tried to register with {1}'s registration ID: {2}", + node.Identifier, + preExistingRegistrationKvp.Key, + registrationId); + return false; + } + + if (registrationIds.TryGetValue(node.Identifier, out var oldRegistration)) + { + logger.LogInformation("Node {0} is re-registering without first unregistering. Indicative of restart.", node.Identifier); + swarmServers.RemoveAll(x => x.Identifier == node.Identifier); + registrationIds.Remove(node.Identifier); + } + + swarmServers.Add(new SwarmServer + { + Address = node.Address, + Identifier = node.Identifier, + Controller = false, + }); + registrationIds.Add(node.Identifier, registrationId); + } + } + + logger.LogInformation("Registered node {0} ({1}) with ID {2}", node.Identifier, node.Address, registrationId); + MarkServersDirty(); + return true; + } + + /// + public Task HandleRestart(Version updateVersion, CancellationToken cancellationToken) + { + restarting = true; + return Task.CompletedTask; + } + + /// + public async Task RemoteCommitRecieved(Guid registrationId, CancellationToken cancellationToken) + { + if (!swarmController) + { + logger.LogDebug("Received remote commit go ahead"); + var commitTcs = updateCommitTcs; + commitTcs?.TrySetResult(true); + return commitTcs != null; + } + + var nodeIdentifier = NodeIdentifierFromRegistration(registrationId); + if (nodeIdentifier == null) + { + // Something fucky is happening, take no chances. + logger.LogDebug("Aborting update due to unforseen circumstances!"); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + return false; + } + + var nodeList = nodesThatNeedToBeReadyToCommit; + if (nodeList == null) + { + logger.LogDebug("Ignoring ready-commit from node {0} as the update appears to have been aborted.", nodeIdentifier); + return false; + } + + logger.LogDebug("Node {0} is ready to commit.", nodeIdentifier); + lock (nodeList) + { + nodeList.Remove(nodeIdentifier); + if (nodeList.Count == 0) + { + logger.LogTrace("All nodes ready, update commit is a go once controller is ready"); + var commitTcs = updateCommitTcs; + commitTcs?.TrySetResult(true); + return commitTcs != null; + } + } + + return true; + } + + /// + /// Gets the from a given . + /// + /// The registration . + /// The registered or if it does not exist. + string NodeIdentifierFromRegistration(Guid registrationId) + { + if (!swarmController) + throw new InvalidOperationException("NodeIdentifierFromRegistration on node!"); + + lock (swarmServers) + { + var exists = registrationIds.Any(x => x.Value == registrationId); + if (!exists) + { + logger.LogDebug("A node that was to be looked up ({0}) disappeared from our records!", registrationId); + return null; + } + + return registrationIds.First(x => x.Value == registrationId).Key; + } + } + + /// + public async Task UnregisterNode(Guid registrationId, CancellationToken cancellationToken) + { + if (!swarmController) + { + // immediately trigger a health check + logger.LogInformation("Controller unregistering, will attempt re-registration..."); + controllerRegistration = null; + TriggerHealthCheck(); + return; + } + + logger.LogTrace("UnregisterNode {0}", registrationId); + var nodeIdentifier = NodeIdentifierFromRegistration(registrationId); + if (nodeIdentifier == null) + return; + + logger.LogInformation("Unregistering node {0}...", nodeIdentifier); + await AbortUpdate(cancellationToken).ConfigureAwait(false); + lock (swarmServers) + { + swarmServers.RemoveAll(x => x.Identifier == nodeIdentifier); + registrationIds.Remove(nodeIdentifier); + } + + MarkServersDirty(); + } + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmUpdateRequest.cs b/src/Tgstation.Server.Host/Swarm/SwarmUpdateRequest.cs new file mode 100644 index 0000000000..410794de68 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmUpdateRequest.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Swarm +{ + /// + /// A request to update the swarm's TGS version. + /// + public sealed class SwarmUpdateRequest + { + /// + /// The TGS to update to. + /// + [Required] + public Version UpdateVersion { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs b/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs index 94c1c00664..b4933d7b6c 100644 --- a/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs +++ b/src/Tgstation.Server.Host/System/AssemblyInformationProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http.Headers; using System.Reflection; using Tgstation.Server.Api; @@ -22,6 +23,11 @@ namespace Tgstation.Server.Host.System /// public string VersionString { get; } + /// + public ProductInfoHeaderValue ProductInfoHeaderValue => new ProductInfoHeaderValue( + VersionPrefix, + Version.ToString()); + /// /// Initializes a new instance of the . /// diff --git a/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs b/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs index 30c97a618d..162b627888 100644 --- a/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs +++ b/src/Tgstation.Server.Host/System/IAssemblyInformationProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http.Headers; using System.Reflection; namespace Tgstation.Server.Host.System @@ -32,5 +33,10 @@ namespace Tgstation.Server.Host.System /// The version of the assembly. /// Version Version { get; } + + /// + /// The for the assembly. + /// + ProductInfoHeaderValue ProductInfoHeaderValue { get; } } } diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index f4872a4579..3951c10411 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -1,4 +1,4 @@ - + @@ -10,6 +10,7 @@ false true bin\$(Configuration)\netcoreapp3.1\Tgstation.Server.Host.xml + true API1000 @@ -65,57 +66,45 @@ + - - - - all - runtime; build; native; contentfiles; analyzers - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + - + - - all - runtime; build; native; contentfiles; analyzers - - - + + - - - - - + + + + + - - - - - diff --git a/src/Tgstation.Server.Host/Transfer/FileDownloadProvider.cs b/src/Tgstation.Server.Host/Transfer/FileDownloadProvider.cs new file mode 100644 index 0000000000..16f78aad1a --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/FileDownloadProvider.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Transfer +{ + /// + /// Represents a file on disk to be downloaded. + /// + public sealed class FileDownloadProvider + { + /// + /// A to run before providing the download. If it returns a non-null , a 400 error with that code will be returned instead of a download stream. + /// + public Func ActivationCallback { get; } + + /// + /// A to specially provide a returning the . + /// + public Func> FileStreamProvider { get; } + + /// + /// The full path to the file on disk to download. + /// + public string FilePath { get; } + + /// + /// If the file read stream should be allowed to share writes. If this is set, the entire file will be buffered to avoid Content-Length mismatches. + /// + public bool ShareWrite { get; } + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The optional value of . + /// The value of . + /// The value of . + public FileDownloadProvider( + Func activationCallback, + Func> fileStreamProvider, + string filePath, + bool shareWrite) + { + ActivationCallback = activationCallback ?? throw new ArgumentNullException(nameof(activationCallback)); + FileStreamProvider = fileStreamProvider; + FilePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + ShareWrite = shareWrite; + } + } +} diff --git a/src/Tgstation.Server.Host/Transfer/FileTransferService.cs b/src/Tgstation.Server.Host/Transfer/FileTransferService.cs new file mode 100644 index 0000000000..08c2649b86 --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/FileTransferService.cs @@ -0,0 +1,277 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.IO; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Transfer +{ + /// + /// Implementation of the file transfer service. + /// + sealed class FileTransferService : IFileTransferTicketProvider, IFileTransferStreamHandler, IAsyncDisposable + { + /// + /// Number of minutes before transfer ticket expire. + /// + const int TicketValidityMinutes = 5; + + /// + /// The for the . + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the . + /// + readonly IIOManager ioManager; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// of s to upload s. + /// + readonly Dictionary uploadTickets; + + /// + /// of s to s. + /// + readonly Dictionary downloadTickets; + + /// + /// that is triggered when is called. + /// + readonly CancellationTokenSource disposeCts; + + /// + /// used to update . + /// + readonly object synchronizationLock; + + /// + /// Combined of all calls. + /// + Task expireTask; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public FileTransferService( + ICryptographySuite cryptographySuite, + IIOManager ioManager, + IAsyncDelayer asyncDelayer, + ILogger logger) + { + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + uploadTickets = new Dictionary(); + downloadTickets = new Dictionary(); + + disposeCts = new CancellationTokenSource(); + + expireTask = Task.CompletedTask; + synchronizationLock = new object(); + } + + /// + public async ValueTask DisposeAsync() + { + Task toAwait; + lock (synchronizationLock) + if (expireTask != null) + { + disposeCts.Cancel(); + disposeCts.Dispose(); + toAwait = expireTask; + expireTask = null; + } + else + toAwait = Task.CompletedTask; + + await toAwait.ConfigureAwait(false); + } + + /// + /// Creates a new . + /// + /// A new . + FileTicketResult CreateTicket() => new FileTicketResult + { + FileTicket = cryptographySuite.GetSecureString() + }; + + void QueueExpiry(Action expireAction) + { + Task oldExpireTask = null; + + async Task ExpireAsync() + { + var expireAt = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(TicketValidityMinutes); + try + { + await oldExpireTask.WithToken(disposeCts.Token).ConfigureAwait(false); + + var now = DateTimeOffset.UtcNow; + if (now < expireAt) + await asyncDelayer.Delay(expireAt - now, disposeCts.Token).ConfigureAwait(false); + } + finally + { + expireAction(); + } + } + + lock (synchronizationLock) + { + oldExpireTask = expireTask; + expireTask = ExpireAsync(); + } + } + + /// + public FileTicketResult CreateDownload(FileDownloadProvider downloadProvider) + { + if (downloadProvider == null) + throw new ArgumentNullException(nameof(downloadProvider)); + + logger.LogDebug("Creating download ticket for path {0}", downloadProvider.FilePath); + var ticketResult = CreateTicket(); + + lock (downloadTickets) + downloadTickets.Add(ticketResult.FileTicket, downloadProvider); + + QueueExpiry(() => + { + lock (downloadTickets) + if(downloadTickets.Remove(ticketResult.FileTicket)) + logger.LogTrace("Expired download ticket {0}...", ticketResult.FileTicket); + }); + + logger.LogTrace("Created download ticket {0}", ticketResult.FileTicket); + + return ticketResult; + } + + /// + public IFileUploadTicket CreateUpload(bool requireSynchronousIO) + { + logger.LogDebug("Creating upload ticket..."); + var uploadTicket = new FileUploadProvider(CreateTicket(), requireSynchronousIO); + + lock (uploadTickets) + uploadTickets.Add(uploadTicket.Ticket.FileTicket, uploadTicket); + + QueueExpiry(() => + { + lock (uploadTickets) + if (uploadTickets.Remove(uploadTicket.Ticket.FileTicket)) + logger.LogTrace("Expired upload ticket {0}...", uploadTicket.Ticket.FileTicket); + else + return; + + uploadTicket.Expire(); + }); + + logger.LogTrace("Created upload ticket {0}", uploadTicket.Ticket.FileTicket); + + return uploadTicket; + } + + /// + public async Task> RetrieveDownloadStream(FileTicketResult ticket, CancellationToken cancellationToken) + { + if (ticket == null) + throw new ArgumentNullException(nameof(ticket)); + + FileDownloadProvider downloadProvider; + lock (downloadTickets) + { + if (!downloadTickets.TryGetValue(ticket.FileTicket, out downloadProvider)) + { + logger.LogTrace("Download ticket {0} not found!", ticket.FileTicket); + return Tuple.Create(null, null); + } + + downloadTickets.Remove(ticket.FileTicket); + } + + var errorCode = downloadProvider.ActivationCallback(); + if (errorCode.HasValue) + { + logger.LogDebug("Download ticket {0} failed activation!", ticket.FileTicket); + return Tuple.Create(null, new ErrorMessage(errorCode.Value)); + } + + FileStream stream; + try + { + if (downloadProvider.FileStreamProvider != null) + stream = await downloadProvider.FileStreamProvider(cancellationToken).ConfigureAwait(false); + else + stream = ioManager.GetFileStream(downloadProvider.FilePath, downloadProvider.ShareWrite); + } + catch (IOException ex) + { + return Tuple.Create( + null, + new ErrorMessage(ErrorCode.IOError) + { + AdditionalData = ex.ToString() + }); + } + + try + { + logger.LogTrace("Ticket {0} downloading...", ticket.FileTicket); + return Tuple.Create(stream, null); + } + catch + { + stream.Dispose(); + throw; + } + } + + /// + public async Task SetUploadStream(FileTicketResult ticket, Stream stream, CancellationToken cancellationToken) + { + if (ticket == null) + throw new ArgumentNullException(nameof(ticket)); + + FileUploadProvider uploadProvider; + lock (uploadTickets) + { + if (!uploadTickets.TryGetValue(ticket.FileTicket, out uploadProvider)) + { + logger.LogTrace("Upload ticket {0} not found!", ticket.FileTicket); + return new ErrorMessage(ErrorCode.ResourceNotPresent); + } + + uploadTickets.Remove(ticket.FileTicket); + } + + return await uploadProvider.Completion(stream, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Host/Transfer/FileUploadProvider.cs b/src/Tgstation.Server.Host/Transfer/FileUploadProvider.cs new file mode 100644 index 0000000000..ea5e47a245 --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/FileUploadProvider.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.WebUtilities; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.IO; + +namespace Tgstation.Server.Host.Transfer +{ + /// + sealed class FileUploadProvider : IFileUploadTicket + { + /// + public FileTicketResult Ticket { get; } + + /// + /// The for the ticket duration. + /// + readonly CancellationTokenSource ticketExpiryCts; + + /// + /// The for the . + /// + readonly TaskCompletionSource taskCompletionSource; + + /// + /// The that completes in or when is called. + /// + readonly TaskCompletionSource completionTcs; + + /// + /// If synchronous IO is required. Uses a as a backend if set. + /// + readonly bool requireSynchronousIO; + + /// + /// The that occurred while processing the upload if any. + /// + ErrorMessage errorMessage; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of + public FileUploadProvider(FileTicketResult ticket, bool requireSynchronousIO) + { + Ticket = ticket ?? throw new ArgumentNullException(nameof(ticket)); + + ticketExpiryCts = new CancellationTokenSource(); + taskCompletionSource = new TaskCompletionSource(); + completionTcs = new TaskCompletionSource(); + this.requireSynchronousIO = requireSynchronousIO; + } + + /// + public void Dispose() + { + ticketExpiryCts.Dispose(); + completionTcs.TrySetResult(null); + } + + /// + public async Task GetResult(CancellationToken cancellationToken) + { + using (cancellationToken.Register(() => taskCompletionSource.TrySetCanceled())) + using (ticketExpiryCts.Token.Register(() => taskCompletionSource.TrySetResult(null))) + return await taskCompletionSource.Task.ConfigureAwait(false); + } + + /// + /// Expire the . + /// + public void Expire() + { + if (!completionTcs.Task.IsCompleted) + ticketExpiryCts.Cancel(); + } + + /// + /// Resolve the for the and awaits the upload. + /// + /// The containing uploaded data. + /// The for the operation. + /// A resulting in , otherwise. + public async Task Completion(Stream stream, CancellationToken cancellationToken) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + if (ticketExpiryCts.IsCancellationRequested) + return new ErrorMessage(ErrorCode.ResourceNotPresent); + + Stream bufferedStream = null; + if (requireSynchronousIO) + { + // big reads, we should buffer to disk + bufferedStream = new FileBufferingReadStream(stream, DefaultIOManager.DefaultBufferSize); + await bufferedStream.DrainAsync(cancellationToken).ConfigureAwait(false); + } + + using (bufferedStream) + { + taskCompletionSource.TrySetResult(bufferedStream ?? stream); + + await completionTcs.Task.WithToken(cancellationToken).ConfigureAwait(false); + return errorMessage; + } + } + + /// + public void SetErrorMessage(ErrorMessage errorMessage) + { + if (errorMessage == null) +#pragma warning disable IDE0016 // Use 'throw' expression + throw new ArgumentNullException(nameof(errorMessage)); +#pragma warning restore IDE0016 // Use 'throw' expression + + if (this.errorMessage != null) + throw new InvalidOperationException("ErrorMessage already set!"); + + this.errorMessage = errorMessage; + completionTcs.TrySetResult(null); + } + } +} diff --git a/src/Tgstation.Server.Host/Transfer/IFileTransferStreamHandler.cs b/src/Tgstation.Server.Host/Transfer/IFileTransferStreamHandler.cs new file mode 100644 index 0000000000..fd8a051332 --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/IFileTransferStreamHandler.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Transfer +{ + /// + /// Reads and writes to s associated with s. + /// + public interface IFileTransferStreamHandler + { + /// + /// Sets the for a given associated with a pending upload. + /// + /// The . + /// The with uploaded data. + /// The for the operation. + /// if the upload completed successfully, otherwise. + Task SetUploadStream(FileTicketResult ticket, Stream stream, CancellationToken cancellationToken); + + /// + /// Gets the the for a given associated with a pending download. + /// + /// The . + /// The for the operation. + /// A containing either a containing the data to download or an to return. + Task> RetrieveDownloadStream(FileTicketResult ticket, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Transfer/IFileTransferTicketProvider.cs b/src/Tgstation.Server.Host/Transfer/IFileTransferTicketProvider.cs new file mode 100644 index 0000000000..5edfb86cd7 --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/IFileTransferTicketProvider.cs @@ -0,0 +1,24 @@ +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Transfer +{ + /// + /// Service for temporarily storing files to be downloaded or uploaded. + /// + public interface IFileTransferTicketProvider + { + /// + /// Create a for a download. + /// + /// The . + /// A new for a download. + FileTicketResult CreateDownload(FileDownloadProvider fileDownloadProvider); + + /// + /// Create a . + /// + /// If synchronous IO is required on the provided stream. + /// A new . + IFileUploadTicket CreateUpload(bool requiresSynchronousIO); + } +} diff --git a/src/Tgstation.Server.Host/Transfer/IFileUploadTicket.cs b/src/Tgstation.Server.Host/Transfer/IFileUploadTicket.cs new file mode 100644 index 0000000000..4eb1ea622c --- /dev/null +++ b/src/Tgstation.Server.Host/Transfer/IFileUploadTicket.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Transfer +{ + /// + /// A that waits for a pending upload. + /// + public interface IFileUploadTicket : IDisposable + { + /// + /// The . + /// + FileTicketResult Ticket { get; } + + /// + /// Gets the for the uploaded file. + /// + /// The for the operation. + /// A resulting in the uploaded of the file on success, if the ticket timed out. + /// The resulting is short lived and should be buffered if it needs use outside the lifetime of the . + Task GetResult(CancellationToken cancellationToken); + + /// + /// Sets an for the upload. Will be returned in upload request as a 409 error. + /// + /// The to set. + void SetErrorMessage(ErrorMessage errorMessage); + } +} diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json index 63f9edf1ff..efc04c7461 100644 --- a/src/Tgstation.Server.Host/appsettings.json +++ b/src/Tgstation.Server.Host/appsettings.json @@ -8,6 +8,7 @@ "ApiPort": 5000, "UseBasicWatchdog": false, "UserLimit": 100, + "UserGroupLimit": 25, "InstanceLimit": 10, "ValidInstancePaths": null, "HostApiDocumentation": false @@ -54,6 +55,12 @@ "TokenExpiryMinutes": 15, "TokenClockSkewMinutes": 1, "TokenSigningKeyByteAmount": 256, - "CustomTokenSigningKeyBase64": null + "CustomTokenSigningKeyBase64": null, + "OAuth": { + "GitHub": null, + "Discord": null, + "TGForums": null, + "Keycloak": null + } } } diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 0f78162c50..8a310fb869 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -1,9 +1,10 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Headers; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Net.Http.Headers; using System.Net.Mime; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Api.Tests { @@ -21,6 +22,7 @@ namespace Tgstation.Server.Api.Tests Assert.ThrowsException(() => new ApiHeaders(null, null)); Assert.ThrowsException(() => new ApiHeaders(productHeaderValue, null)); var headers = new ApiHeaders(productHeaderValue, String.Empty); + headers = new ApiHeaders(productHeaderValue, String.Empty, OAuthProvider.GitHub); } [TestMethod] diff --git a/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj b/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj index 4ac3e5c0c5..01fb79d54d 100644 --- a/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj +++ b/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj @@ -13,7 +13,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs b/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs index a70423e97c..60d40191ad 100644 --- a/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs +++ b/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; using System.Collections.Generic; @@ -19,7 +19,7 @@ namespace Tgstation.Server.Client.Components.Tests var example = new Job { Id = 347, - StartedAt = DateTimeOffset.Now + StartedAt = DateTimeOffset.UtcNow }; var inst = new Instance diff --git a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj index 1ce9b9982d..a9ec4e77b5 100644 --- a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj +++ b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj @@ -12,8 +12,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj b/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj index c37b05c82f..5a8865fcb8 100644 --- a/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj +++ b/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -12,8 +12,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj index c47523526f..9fbb5c8fc7 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj +++ b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj @@ -18,7 +18,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs b/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs index 0b49bbc5d6..a57e899721 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Repository/TestRepositoryFactory.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -16,11 +16,11 @@ namespace Tgstation.Server.Host.Components.Repository.Tests { static ILibGit2RepositoryFactory CreateFactory() => new LibGit2RepositoryFactory(Mock.Of>()); - static Task TestRepoLoading( + static async Task TestRepoLoading( string path, ILibGit2RepositoryFactory repositoryFactory = null) => - (repositoryFactory ?? CreateFactory()) - .CreateFromPath(path, default); + (await (repositoryFactory ?? CreateFactory()) + .CreateFromPath(path, default)); [TestMethod] public void TestConstructionThrows() => Assert.ThrowsException(() => new LibGit2RepositoryFactory(null)); @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Components.Repository.Tests [TestMethod] public void TestInMemoryRepoCreation() { - new LibGit2RepositoryFactory(Mock.Of>()).CreateInMemory().Dispose(); + new LibGit2RepositoryFactory(Mock.Of>()).CreateInMemory(); } [TestMethod] diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs index b94d88c5ad..672fadd9c9 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Configuration; @@ -31,28 +31,32 @@ namespace Tgstation.Server.Host.Core.Tests var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object); Assert.ThrowsException(() => app.ConfigureServices(null)); - Assert.ThrowsException(() => app.Configure(null, null, null, null, null, null, null)); + Assert.ThrowsException(() => app.Configure(null, null, null, null, null, null, null, null)); var mockAppBuilder = new Mock(); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, null, null, null, null, null, null, null)); var mockServerControl = new Mock(); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, null, null, null, null, null, null)); var mockTokenFactory = new Mock(); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, null, null, null, null, null)); var mockInstanceManager = new Mock(); mockInstanceManager.SetupGet(x => x.Ready).Returns(Extensions.TaskExtensions.InfiniteTask()); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, null, null, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, null, null, null, null)); + + var mockServerPortProvider = new Mock(); + mockServerPortProvider.SetupGet(x => x.HttpApiPort).Returns(5345); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, mockServerPortProvider.Object, null, null, null)); var mockControlPanelOptions = new Mock>(); mockControlPanelOptions.SetupGet(x => x.Value).Returns(new ControlPanelConfiguration()).Verifiable(); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, mockControlPanelOptions.Object, null, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, mockServerPortProvider.Object, mockControlPanelOptions.Object, null, null)); var mockGeneralOptions = new Mock>(); mockGeneralOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration()).Verifiable(); - Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, mockControlPanelOptions.Object, mockGeneralOptions.Object, null)); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockServerControl.Object, mockTokenFactory.Object, mockInstanceManager.Object, mockServerPortProvider.Object, mockControlPanelOptions.Object, mockGeneralOptions.Object, null)); mockControlPanelOptions.VerifyAll(); mockGeneralOptions.VerifyAll(); } diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs b/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs index 192bc07957..7a8c565f6b 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestGitHubClientFactory.cs @@ -1,7 +1,8 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Octokit; using System; +using System.Net.Http.Headers; using System.Threading.Tasks; using Tgstation.Server.Host.System; @@ -17,8 +18,7 @@ namespace Tgstation.Server.Host.Core.Tests public async Task TestCreateBasicClient() { var mockApp = new Mock(); - mockApp.SetupGet(x => x.Version).Returns(new Version()).Verifiable(); - mockApp.SetupGet(x => x.VersionPrefix).Returns("TGSTests").Verifiable(); + mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var factory = new GitHubClientFactory(mockApp.Object); @@ -36,8 +36,7 @@ namespace Tgstation.Server.Host.Core.Tests public async Task TestCreateTokenClient() { var mockApp = new Mock(); - mockApp.SetupGet(x => x.Version).Returns(new Version()).Verifiable(); - mockApp.SetupGet(x => x.VersionPrefix).Returns("TGSTests").Verifiable(); + mockApp.SetupGet(x => x.ProductInfoHeaderValue).Returns(new ProductInfoHeaderValue("TGSTests", "1.2.3")).Verifiable(); var factory = new GitHubClientFactory(mockApp.Object); diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index c5c45cebce..f97b7c0795 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; using Tgstation.Server.Api.Rights; @@ -18,12 +18,15 @@ namespace Tgstation.Server.Host.Security.Tests Assert.ThrowsException(() => new AuthenticationContext(null, null, null)); var mockSystemIdentity = new Mock(); - var user = new User(); + var user = new User() + { + PermissionSet = new PermissionSet() + }; var authContext = new AuthenticationContext(null, user, null); Assert.ThrowsException(() => new AuthenticationContext(mockSystemIdentity.Object, null, null)); - var instanceUser = new InstanceUser(); + var instanceUser = new InstancePermissionSet(); Assert.ThrowsException(() => new AuthenticationContext(null, null, instanceUser)); Assert.ThrowsException(() => new AuthenticationContext(mockSystemIdentity.Object, null, instanceUser)); @@ -38,13 +41,16 @@ namespace Tgstation.Server.Host.Security.Tests [TestMethod] public void TestGetRightsGeneric() { - var user = new User(); - var instanceUser = new InstanceUser(); + var user = new User() + { + PermissionSet = new PermissionSet() + }; + var instanceUser = new InstancePermissionSet(); var authContext = new AuthenticationContext(null, user, instanceUser); - user.AdministrationRights = AdministrationRights.WriteUsers; + user.PermissionSet.AdministrationRights = AdministrationRights.WriteUsers; instanceUser.ByondRights = ByondRights.InstallOfficialOrChangeActiveVersion | ByondRights.ReadActive; - Assert.AreEqual((ulong)user.AdministrationRights, authContext.GetRight(RightsType.Administration)); + Assert.AreEqual((ulong)user.PermissionSet.AdministrationRights, authContext.GetRight(RightsType.Administration)); Assert.AreEqual((ulong)instanceUser.ByondRights, authContext.GetRight(RightsType.Byond)); } } diff --git a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs index f45af4db4b..2667e33a3f 100644 --- a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs @@ -177,6 +177,8 @@ namespace Tgstation.Server.Host.Setup.Tests //cp config "y", "y", + // swarm config + "n", //saved, now for second run //this time use defaults amap String.Empty, @@ -204,6 +206,15 @@ namespace Tgstation.Server.Host.Setup.Tests "y", "n", String.Empty, + //swarm config + "y", + "node1", + "not a url", + "net.tcp://notandhttpAddress.com", + "http://node1:3400", + "privatekey", + "n", + "http://controller.com", //third run, we already hit all the code coverage so just get through it String.Empty, nameof(DatabaseType.MariaDB), @@ -229,6 +240,12 @@ namespace Tgstation.Server.Host.Setup.Tests "y", "n", "http://fake.com, https://example.org", + //swarm config + "y", + "controller", + "https://controller.com", + "privatekey", + "y" }; var inputPos = 0; diff --git a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj index 60aba1cdb0..8ebbc1c373 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -12,8 +12,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj b/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj index c4cfd002c5..8043952ec1 100644 --- a/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj +++ b/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj @@ -18,8 +18,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/tests/Tgstation.Server.Tests/AdministrationTest.cs b/tests/Tgstation.Server.Tests/AdministrationTest.cs index 578e7f3e91..cae7bc8763 100644 --- a/tests/Tgstation.Server.Tests/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/AdministrationTest.cs @@ -1,8 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; -using SQLitePCL; using System; using System.Linq; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -28,17 +26,17 @@ namespace Tgstation.Server.Tests async Task TestLogs(CancellationToken cancellationToken) { - var logs = await client.ListLogs(cancellationToken); + var logs = await client.ListLogs(null, cancellationToken); Assert.AreNotEqual(0, logs.Count); var logFile = logs.First(); Assert.IsNotNull(logFile); Assert.IsFalse(String.IsNullOrWhiteSpace(logFile.Name)); - Assert.IsNull(logFile.Content); + Assert.IsNull(logFile.FileTicket); - var downloaded = await client.GetLog(logFile, cancellationToken); - Assert.AreEqual(logFile.Name, downloaded.Name); - Assert.IsTrue(logFile.LastModified <= downloaded.LastModified); - Assert.IsNull(logFile.Content); + var downloadedTuple = await client.GetLog(logFile, cancellationToken); + Assert.AreEqual(logFile.Name, downloadedTuple.Item1.Name); + Assert.IsTrue(logFile.LastModified <= downloadedTuple.Item1.LastModified); + Assert.IsNull(logFile.FileTicket); await ApiAssert.ThrowsException(() => client.GetLog(new LogFile { @@ -68,7 +66,6 @@ namespace Tgstation.Server.Tests // CI fails all the time b/c of this, ignore it return; } - Assert.AreEqual(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), model.WindowsHost); //we've released a few 4.x versions now, check the release checker is at least somewhat functional Assert.AreEqual(4, model.LatestVersion.Major); diff --git a/tests/Tgstation.Server.Tests/ApiAssert.cs b/tests/Tgstation.Server.Tests/ApiAssert.cs index fcb7f8ba9b..0689fe51d2 100644 --- a/tests/Tgstation.Server.Tests/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/ApiAssert.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -27,7 +27,7 @@ namespace Tgstation.Server.Tests } catch (TApiException ex) { - Assert.AreEqual(expectedErrorCode, ex.ErrorCode, "Wrong error code for expected API exception!"); + Assert.AreEqual(expectedErrorCode, ex.ErrorCode, $"Wrong error code for expected API exception! Additional Data: {ex.AdditionalServerData}"); return; } diff --git a/tests/Tgstation.Server.Tests/Instance/ByondTest.cs b/tests/Tgstation.Server.Tests/Instance/ByondTest.cs index 9266e01da6..ca48a4edf5 100644 --- a/tests/Tgstation.Server.Tests/Instance/ByondTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/ByondTest.cs @@ -1,4 +1,4 @@ -using Castle.Core.Logging; +using Castle.Core.Logging; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -18,7 +18,7 @@ namespace Tgstation.Server.Tests.Instance { sealed class ByondTest : JobsRequiredTest { - public static readonly Version TestVersion = new Version(513, 1527); + public static readonly Version TestVersion = new Version(513, 1536); readonly IByondClient byondClient; @@ -45,7 +45,7 @@ namespace Tgstation.Server.Tests.Instance { Version = new Version(5011, 1385) }; - var test = await byondClient.SetActiveVersion(newModel, cancellationToken).ConfigureAwait(false); + var test = await byondClient.SetActiveVersion(newModel, null, cancellationToken).ConfigureAwait(false); Assert.IsNotNull(test.InstallJob); await WaitForJob(test.InstallJob, 60, true, ErrorCode.ByondDownloadFail, cancellationToken).ConfigureAwait(false); } @@ -56,10 +56,10 @@ namespace Tgstation.Server.Tests.Instance { Version = TestVersion }; - var test = await byondClient.SetActiveVersion(newModel, cancellationToken).ConfigureAwait(false); + var test = await byondClient.SetActiveVersion(newModel, null, cancellationToken).ConfigureAwait(false); Assert.IsNotNull(test.InstallJob); Assert.IsNull(test.Version); - await WaitForJob(test.InstallJob, 60, false, null, cancellationToken).ConfigureAwait(false); + await WaitForJob(test.InstallJob, 120, false, null, cancellationToken).ConfigureAwait(false); var currentShit = await byondClient.ActiveVersion(cancellationToken).ConfigureAwait(false); Assert.AreEqual(newModel.Version.Semver(), currentShit.Version); @@ -75,7 +75,7 @@ namespace Tgstation.Server.Tests.Instance async Task TestNoVersion(CancellationToken cancellationToken) { - var allVersionsTask = byondClient.InstalledVersions(cancellationToken); + var allVersionsTask = byondClient.InstalledVersions(null, cancellationToken); var currentShit = await byondClient.ActiveVersion(cancellationToken).ConfigureAwait(false); Assert.IsNotNull(currentShit); Assert.IsNull(currentShit.InstallJob); @@ -98,11 +98,18 @@ namespace Tgstation.Server.Tests.Instance Mock.Of>()); // get the bytes for stable - var test = await byondClient.SetActiveVersion(new Api.Models.Byond - { - Version = TestVersion, - Content = await byondInstaller.DownloadVersion(TestVersion, cancellationToken) - }, cancellationToken).ConfigureAwait(false); + using var stableBytesMs = new MemoryStream( + await byondInstaller.DownloadVersion(TestVersion, cancellationToken)); + + var test = await byondClient.SetActiveVersion( + new Api.Models.Byond + { + Version = TestVersion, + UploadCustomZip = true + }, + stableBytesMs, + cancellationToken) + .ConfigureAwait(false); Assert.IsNotNull(test.InstallJob); await WaitForJob(test.InstallJob, 60, false, null, cancellationToken).ConfigureAwait(false); @@ -114,17 +121,17 @@ namespace Tgstation.Server.Tests.Instance newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond { Version = TestVersion - }, cancellationToken); + }, null, cancellationToken); Assert.IsNull(newSettings.InstallJob); await ApiAssert.ThrowsException(() => byondClient.SetActiveVersion(new Api.Models.Byond { Version = new Version(TestVersion.Major, TestVersion.Minor, 2) - }, cancellationToken), ErrorCode.ByondNonExistentCustomVersion); + }, null, cancellationToken), ErrorCode.ByondNonExistentCustomVersion); newSettings = await byondClient.SetActiveVersion(new Api.Models.Byond { Version = new Version(TestVersion.Major, TestVersion.Minor, 1) - }, cancellationToken); + }, null, cancellationToken); Assert.IsNull(newSettings.InstallJob); } } diff --git a/tests/Tgstation.Server.Tests/Instance/ChatTest.cs b/tests/Tgstation.Server.Tests/Instance/ChatTest.cs index f41c1016d5..ef6b8837e7 100644 --- a/tests/Tgstation.Server.Tests/Instance/ChatTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/ChatTest.cs @@ -52,7 +52,7 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(csb.ToString(), firstBot.ConnectionString); Assert.AreNotEqual(0, firstBot.Id); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); Assert.AreEqual(firstBot.Id, bots.First(x => x.Provider.Value == ChatProvider.Irc).Id); var retrievedBot = await chatClient.GetId(firstBot, cancellationToken); @@ -115,7 +115,7 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(csb.ToString(), firstBot.ConnectionString); Assert.AreNotEqual(0, firstBot.Id); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); Assert.AreEqual(firstBot.Id, bots.First(x => x.Provider.Value == ChatProvider.Discord).Id); var retrievedBot = await chatClient.GetId(firstBot, cancellationToken); @@ -154,13 +154,13 @@ namespace Tgstation.Server.Tests.Instance public async Task RunPostTest(CancellationToken cancellationToken) { - var activeBots = await chatClient.List(cancellationToken); + var activeBots = await chatClient.List(null, cancellationToken); Assert.AreEqual(2, activeBots.Count); await Task.WhenAll(activeBots.Select(bot => chatClient.Delete(bot, cancellationToken))); - var nowBots = await chatClient.List(cancellationToken); + var nowBots = await chatClient.List(null, cancellationToken); Assert.AreEqual(0, nowBots.Count); } @@ -173,7 +173,7 @@ namespace Tgstation.Server.Tests.Instance Provider = ChatProvider.Irc }, cancellationToken), ErrorCode.ChatBotMax); - var bots = await chatClient.List(cancellationToken); + var bots = await chatClient.List(null, cancellationToken); var discordBot = bots.First(bot => bot.Provider.Value == ChatProvider.Discord); // We limited chat bots and channels to 1 and 2 respectively, try violating them diff --git a/tests/Tgstation.Server.Tests/Instance/ConfigurationTest.cs b/tests/Tgstation.Server.Tests/Instance/ConfigurationTest.cs index b30178169f..b9e353a632 100644 --- a/tests/Tgstation.Server.Tests/Instance/ConfigurationTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/ConfigurationTest.cs @@ -1,6 +1,9 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; +using System.Net.Http; +using System.Net.Mime; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -25,7 +28,8 @@ namespace Tgstation.Server.Tests.Instance { var tmp = (file.Path?.StartsWith('/') ?? false) ? '.' + file.Path : file.Path; var path = Path.Combine(instance.Path, "Configuration", tmp); - return File.Exists(path); + var result = File.Exists(path); + return result; } async Task TestDeleteDirectory(CancellationToken cancellationToken) @@ -39,20 +43,43 @@ namespace Tgstation.Server.Tests.Instance await configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken).ConfigureAwait(false); //try to delete non-empty + const string TestString = "Hello world!"; + using var uploadMs = new MemoryStream(Encoding.UTF8.GetBytes(TestString)); var file = await configurationClient.Write(new ConfigurationFile { - Content = Encoding.UTF8.GetBytes("Hello world!"), Path = TestDir.Path + "/test.txt" - }, cancellationToken).ConfigureAwait(false); + }, uploadMs, cancellationToken).ConfigureAwait(false); Assert.IsTrue(FileExists(file)); + Assert.IsNull(file.LastReadHash); + + var updatedFileTuple = await configurationClient.Read(file, cancellationToken).ConfigureAwait(false); + var updatedFile = updatedFileTuple.Item1; + Assert.IsNotNull(updatedFile.LastReadHash); + using (var downloadMemoryStream = new MemoryStream()) + { + using (var downloadStream = updatedFileTuple.Item2) + { + var requestStream = downloadStream as CachedResponseStream; + Assert.IsNotNull(requestStream); + var response = (HttpResponseMessage)requestStream.GetType().GetField("response", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(requestStream); + Assert.AreEqual(response.Content.Headers.ContentType.MediaType, MediaTypeNames.Application.Octet); + await downloadStream.CopyToAsync(downloadMemoryStream); + } + Assert.AreEqual(TestString, Encoding.UTF8.GetString(downloadMemoryStream.ToArray()).Trim()); + } await ApiAssert.ThrowsException(() => configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken), ErrorCode.ConfigurationDirectoryNotEmpty).ConfigureAwait(false); - file.Content = null; - await configurationClient.Write(file, cancellationToken).ConfigureAwait(false); + file.FileTicket = null; + await configurationClient.Write(updatedFile, null, cancellationToken).ConfigureAwait(false); + Assert.IsFalse(FileExists(file)); await configurationClient.DeleteEmptyDirectory(TestDir, cancellationToken).ConfigureAwait(false); + + var tmp = (TestDir.Path?.StartsWith('/') ?? false) ? '.' + TestDir.Path : TestDir.Path; + var path = Path.Combine(instance.Path, "Configuration", tmp); + Assert.IsFalse(Directory.Exists(path)); } public async Task Run(CancellationToken cancellationToken) diff --git a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs index 3bf72f06e1..32c94dc8ae 100644 --- a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs @@ -22,7 +22,6 @@ namespace Tgstation.Server.Tests.Instance public async Task Run(Task repositoryTask, CancellationToken cancellationToken) { - Assert.IsFalse(repositoryTask.IsCompleted); var deployJob = await dreamMakerClient.Compile(cancellationToken); deployJob = await WaitForJob(deployJob, 30, true, null, cancellationToken); Assert.IsTrue(deployJob.ErrorCode == ErrorCode.RepoCloning || deployJob.ErrorCode == ErrorCode.RepoMissing); diff --git a/tests/Tgstation.Server.Tests/Instance/RepositoryTest.cs b/tests/Tgstation.Server.Tests/Instance/RepositoryTest.cs index def6f1385d..36f2da7900 100644 --- a/tests/Tgstation.Server.Tests/Instance/RepositoryTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/RepositoryTest.cs @@ -22,28 +22,13 @@ namespace Tgstation.Server.Tests.Instance public async Task RunPreWatchdog(CancellationToken cancellationToken) { - const string GitHubRef = "TGS4_GITHUB_REF"; - var branchSourceEnvVars = new List - { - "TGS4_TEST_BRANCH", - "APPVEYOR_REPO_BRANCH", - "TRAVIS_BRANCH", - GitHubRef - }; - + const string TestRefEnvVar = "TGS4_GITHUB_REF"; + var envVar = Environment.GetEnvironmentVariable(TestRefEnvVar); string workingBranch = null; - foreach (var envVarName in branchSourceEnvVars) + if (!String.IsNullOrWhiteSpace(envVar)) { - var envVar = Environment.GetEnvironmentVariable(envVarName); - if (!String.IsNullOrWhiteSpace(envVar)) - { - if(envVarName == GitHubRef) - envVar = envVar.Substring("refs/heads/".Length); - - workingBranch = envVar; - Console.WriteLine($"TEST: Set working branch to '{workingBranch}' from env var '{envVarName}'"); - break; - } + workingBranch = envVar; + Console.WriteLine($"TEST: Set working branch to '{workingBranch}' from env var '{TestRefEnvVar}'"); } if (workingBranch == null) @@ -60,13 +45,13 @@ namespace Tgstation.Server.Tests.Instance Assert.IsNull(initalRepo.ActiveJob); const string Origin = "https://github.com/tgstation/tgstation-server"; - initalRepo.Origin = Origin; + initalRepo.Origin = new Uri(Origin); initalRepo.Reference = workingBranch; var clone = await repositoryClient.Clone(initalRepo, cancellationToken).ConfigureAwait(false); await ApiAssert.ThrowsException(() => repositoryClient.Read(cancellationToken), ErrorCode.RepoCloning); Assert.IsNotNull(clone); - Assert.AreEqual(Origin, clone.Origin); + Assert.AreEqual(initalRepo.Origin, clone.Origin); Assert.AreEqual(workingBranch, clone.Reference); Assert.IsNull(clone.RevisionInformation); Assert.IsNotNull(clone.ActiveJob); @@ -79,29 +64,29 @@ namespace Tgstation.Server.Tests.Instance clone = await repositoryClient.Clone(initalRepo, cancellationToken).ConfigureAwait(false); - await WaitForJob(clone.ActiveJob, 180, false, null, cancellationToken).ConfigureAwait(false); - var cloned = await repositoryClient.Read(cancellationToken); + await WaitForJob(clone.ActiveJob, 900, false, null, cancellationToken).ConfigureAwait(false); + var readAfterClone = await repositoryClient.Read(cancellationToken); - Assert.AreEqual(Origin, cloned.Origin); - Assert.AreEqual(workingBranch, cloned.Reference); - Assert.IsNotNull(cloned.RevisionInformation); - Assert.IsNotNull(cloned.RevisionInformation.ActiveTestMerges); - Assert.AreEqual(0, cloned.RevisionInformation.ActiveTestMerges.Count); - Assert.IsNotNull(cloned.RevisionInformation.CommitSha); - Assert.IsNotNull(cloned.RevisionInformation.OriginCommitSha); - Assert.IsNotNull(cloned.RevisionInformation.CompileJobs); - Assert.AreEqual(0, cloned.RevisionInformation.CompileJobs.Count); - Assert.IsNotNull(cloned.RevisionInformation.OriginCommitSha); - Assert.IsNull(cloned.RevisionInformation.PrimaryTestMerge); - Assert.AreEqual(cloned.RevisionInformation.CommitSha, cloned.RevisionInformation.OriginCommitSha); + Assert.AreEqual(initalRepo.Origin, readAfterClone.Origin); + Assert.AreEqual(workingBranch, readAfterClone.Reference); + Assert.IsNotNull(readAfterClone.RevisionInformation); + Assert.IsNotNull(readAfterClone.RevisionInformation.ActiveTestMerges); + Assert.AreEqual(0, readAfterClone.RevisionInformation.ActiveTestMerges.Count); + Assert.IsNotNull(readAfterClone.RevisionInformation.CommitSha); + Assert.IsNotNull(readAfterClone.RevisionInformation.OriginCommitSha); + Assert.IsNotNull(readAfterClone.RevisionInformation.CompileJobs); + Assert.AreEqual(0, readAfterClone.RevisionInformation.CompileJobs.Count); + Assert.IsNotNull(readAfterClone.RevisionInformation.OriginCommitSha); + Assert.IsNull(readAfterClone.RevisionInformation.PrimaryTestMerge); + Assert.AreEqual(readAfterClone.RevisionInformation.CommitSha, readAfterClone.RevisionInformation.OriginCommitSha); - cloned.Origin = "https://github.com/tgstation/tgstation"; - await ApiAssert.ThrowsException(() => repositoryClient.Update(cloned, cancellationToken), ErrorCode.RepoCantChangeOrigin); - cloned.Origin = Origin; + readAfterClone.Origin = new Uri("https://github.com/tgstation/tgstation"); + await ApiAssert.ThrowsException(() => repositoryClient.Update(readAfterClone, cancellationToken), ErrorCode.RepoCantChangeOrigin); + readAfterClone.Origin = new Uri(Origin); // checkout V3 and back - cloned.Reference = "V3"; - var updated = await Checkout(cloned, false, true, cancellationToken); + readAfterClone.Reference = "V3"; + var updated = await Checkout(readAfterClone, false, true, cancellationToken); // Specific SHA updated.CheckoutSha = "f43f5bd"; @@ -181,7 +166,7 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(1, withMerge.RevisionInformation.ActiveTestMerges.Count); Assert.AreEqual(prNumber, withMerge.RevisionInformation.ActiveTestMerges.First().Number); Assert.AreEqual(prNumber, withMerge.RevisionInformation.PrimaryTestMerge.Number); - var prRevision = withMerge.RevisionInformation.PrimaryTestMerge.PullRequestRevision; + var prRevision = withMerge.RevisionInformation.PrimaryTestMerge.TargetCommitSha; Assert.IsNotNull(prRevision); Assert.IsNotNull(withMerge.RevisionInformation.PrimaryTestMerge.MergedBy); Assert.IsNotNull(withMerge.RevisionInformation.PrimaryTestMerge.MergedAt); @@ -189,7 +174,7 @@ namespace Tgstation.Server.Tests.Instance Assert.IsNull(withMerge.RevisionInformation.PrimaryTestMerge.Comment); Assert.IsNotNull(withMerge.RevisionInformation.PrimaryTestMerge.TitleAtMerge); Assert.IsNotNull(withMerge.RevisionInformation.PrimaryTestMerge.BodyAtMerge); - if (withMerge.RevisionInformation.PrimaryTestMerge.Url != "REMOTE API ERROR: RATE LIMITED") + if (withMerge.RevisionInformation.PrimaryTestMerge.Url != "GITHUB API ERROR: RATE LIMITED") Assert.AreEqual($"https://github.com/tgstation/tgstation-server/pull/{prNumber}", withMerge.RevisionInformation.PrimaryTestMerge.Url); Assert.AreEqual(orignCommit, withMerge.RevisionInformation.OriginCommitSha); Assert.AreNotEqual(orignCommit, withMerge.RevisionInformation.CommitSha); @@ -203,7 +188,7 @@ namespace Tgstation.Server.Tests.Instance { Number = prNumber, Comment = "asdffdsa", - PullRequestRevision = prRevision + TargetCommitSha = prRevision } }; @@ -214,7 +199,7 @@ namespace Tgstation.Server.Tests.Instance var final = await repositoryClient.Read(cancellationToken); Assert.AreEqual("asdffdsa", final.RevisionInformation.PrimaryTestMerge.Comment); Assert.AreEqual(prNumber, final.RevisionInformation.PrimaryTestMerge.Number); - Assert.AreEqual(prRevision, final.RevisionInformation.PrimaryTestMerge.PullRequestRevision); + Assert.AreEqual(prRevision, final.RevisionInformation.PrimaryTestMerge.TargetCommitSha); } public async Task RunPostTest(CancellationToken cancellationToken) diff --git a/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs index 0c6e8915df..7203d1c646 100644 --- a/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs @@ -36,7 +36,7 @@ namespace Tgstation.Server.Tests.Instance // Increase startup timeout, disable heartbeats var initialSettings = await instanceClient.DreamDaemon.Update(new DreamDaemon { - StartupTimeout = 30, + StartupTimeout = 60, HeartbeatSeconds = 0, Port = IntegrationTest.DDPort }, cancellationToken); @@ -389,13 +389,14 @@ namespace Tgstation.Server.Tests.Instance var startJob = await StartDD(cancellationToken).ConfigureAwait(false); - await WaitForJob(startJob, 40, false, null, cancellationToken); + await WaitForJob(startJob, 70, false, null, cancellationToken); var byondInstallJobTask = instanceClient.Byond.SetActiveVersion( new Api.Models.Byond { Version = versionToInstall }, + null, cancellationToken); var byondInstallJob = await byondInstallJobTask; diff --git a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs index fbab7dec20..4d58695cdf 100644 --- a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs +++ b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; using System.Linq; @@ -137,21 +137,21 @@ namespace Tgstation.Server.Tests public async Task RunPostTest(CancellationToken cancellationToken) { - var instances = await instanceManagerClient.List(cancellationToken); + var instances = await instanceManagerClient.List(null, cancellationToken); var firstTest = instances.Single(x => x.Name == TestInstanceName); var instanceClient = instanceManagerClient.CreateClient(firstTest); //can regain permissions on instance without instance user - var ourInstanceUser = await instanceClient.Users.Read(cancellationToken).ConfigureAwait(false); - await instanceClient.Users.Delete(ourInstanceUser, cancellationToken).ConfigureAwait(false); + var ourInstanceUser = await instanceClient.PermissionSets.Read(cancellationToken).ConfigureAwait(false); + await instanceClient.PermissionSets.Delete(ourInstanceUser, cancellationToken).ConfigureAwait(false); - await Assert.ThrowsExceptionAsync(() => instanceClient.Users.Read(cancellationToken)).ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => instanceClient.PermissionSets.Read(cancellationToken)).ConfigureAwait(false); await instanceManagerClient.GrantPermissions(new Api.Models.Instance { Id = firstTest.Id }, cancellationToken).ConfigureAwait(false); - ourInstanceUser = await instanceClient.Users.Read(cancellationToken).ConfigureAwait(false); + ourInstanceUser = await instanceClient.PermissionSets.Read(cancellationToken).ConfigureAwait(false); Assert.AreEqual(RightsHelper.AllRights(), ourInstanceUser.DreamDaemonRights.Value); @@ -174,7 +174,11 @@ namespace Tgstation.Server.Tests var update = new UserUpdate { Id = current.Id, - InstanceManagerRights = InstanceManagerRights.SetChatBotLimit + PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.SetChatBotLimit, + AdministrationRights = RightsHelper.AllRights() + } }; await usersClient.Update(update, cancellationToken); var update2 = new Api.Models.Instance @@ -184,7 +188,7 @@ namespace Tgstation.Server.Tests }; var newThing = await instanceManagerClient.Update(update2, cancellationToken); - update.InstanceManagerRights |= InstanceManagerRights.Delete | InstanceManagerRights.Create | InstanceManagerRights.List; + update.PermissionSet.InstanceManagerRights |= InstanceManagerRights.Delete | InstanceManagerRights.Create | InstanceManagerRights.List; await usersClient.Update(update, cancellationToken); //but only if the attach file exists diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index 5fcb6e5a42..83b09adc0f 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -5,19 +5,23 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using Newtonsoft.Json; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Mime; using System.Net.Sockets; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Repository; @@ -37,9 +41,9 @@ namespace Tgstation.Server.Tests readonly IServerClientFactory clientFactory = new ServerClientFactory(new ProductHeaderValue(Assembly.GetExecutingAssembly().GetName().Name, Assembly.GetExecutingAssembly().GetName().Version.ToString())); [TestMethod] - public async Task TestUpdateProtocol() + public async Task TestUpdateProtocolAndDisabledOAuth() { - using var server = new TestingServer(); + using var server = new TestingServer(null, false); using var serverCts = new CancellationTokenSource(); var cancellationToken = serverCts.Token; var serverTask = server.Run(cancellationToken); @@ -47,11 +51,32 @@ namespace Tgstation.Server.Tests { var testUpdateVersion = new Version(4, 3, 0); using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + { + // Disabled OAuth test + using (var httpClient = new HttpClient()) + using (var request = new HttpRequestMessage(HttpMethod.Post, server.Url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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()); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.OAuthProviderDisabled, message.ErrorCode); + } + //attempt to update to stable await adminClient.Administration.Update(new Administration { NewVersion = testUpdateVersion }, cancellationToken).ConfigureAwait(false); + var serverInfo = await adminClient.ServerInformation(cancellationToken); + Assert.IsTrue(serverInfo.UpdateInProgress); + } //wait up to 3 minutes for the dl and install await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(3), cancellationToken)).ConfigureAwait(false); @@ -90,6 +115,373 @@ namespace Tgstation.Server.Tests Assert.IsTrue(server.RestartRequested, "Server not requesting restart!"); } + [TestMethod] + public async Task TestSwarmSynchronizationAndUpdates() + { + // cleanup existing directories + new TestingServer(null, false).Dispose(); + + const string PrivateKey = "adlfj73ywifhks7iwrgfegjs"; + + var controllerAddress = new Uri("http://localhost:5011"); + using (var controller = new TestingServer(new SwarmConfiguration + { + Address = controllerAddress, + Identifier = "controller", + PrivateKey = PrivateKey + }, false, 5011)) + { + using var node1 = new TestingServer(new SwarmConfiguration + { + Address = new Uri("http://localhost:5012"), + ControllerAddress = controllerAddress, + Identifier = "node1", + PrivateKey = PrivateKey + }, false, 5012); + using var node2 = new TestingServer(new SwarmConfiguration + { + Address = new Uri("http://localhost:5013"), + ControllerAddress = controllerAddress, + Identifier = "node2", + PrivateKey = PrivateKey + }, false, 5013); + using var serverCts = new CancellationTokenSource(); + var cancellationToken = serverCts.Token; + var serverTask = Task.WhenAll( + node1.Run(cancellationToken), + node2.Run(cancellationToken), + controller.Run(cancellationToken)); + + try + { + using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); + using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); + using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); + + var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + + async Task WaitForSwarmServerUpdate() + { + ServerInformation serverInformation; + do + { + await Task.Delay(TimeSpan.FromSeconds(10)); + serverInformation = await node1Client.ServerInformation(cancellationToken); + } + while (serverInformation.SwarmServers.Count == 1); + } + + static void CheckInfo(ServerInformation serverInformation) + { + Assert.IsNotNull(serverInformation.SwarmServers); + Assert.AreEqual(3, serverInformation.SwarmServers.Count); + + var node1 = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "node1"); + Assert.IsNotNull(node1); + Assert.AreEqual(node1.Address, "http://localhost:5012"); + Assert.IsFalse(node1.Controller); + + var node2 = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"); + Assert.IsNotNull(node2); + Assert.AreEqual(node2.Address, "http://localhost:5013"); + Assert.IsFalse(node2.Controller); + + var controller = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "controller"); + Assert.IsNotNull(controller); + Assert.AreEqual(controller.Address, "http://localhost:5011"); + Assert.IsTrue(controller.Controller); + } + + CheckInfo(controllerInfo); + + // wait a few minutes for the updated server list to dispatch + await Task.WhenAny( + WaitForSwarmServerUpdate(), + Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); + + var node2Info = await node2Client.ServerInformation(cancellationToken); + var node1Info = await node1Client.ServerInformation(cancellationToken); + CheckInfo(node1Info); + CheckInfo(node2Info); + + // check user info is shared + var newUser = await node2Client.Users.Create(new UserUpdate + { + Name = "asdf", + Password = "asdfasdfasdfasdf", + Enabled = true, + PermissionSet = new PermissionSet + { + AdministrationRights = AdministrationRights.ChangeVersion + } + }, cancellationToken); + + var node1User = await node1Client.Users.GetId(newUser, cancellationToken); + Assert.AreEqual(newUser.Name, node1User.Name); + Assert.AreEqual(newUser.Enabled, node1User.Enabled); + + using var controllerUserClient = await clientFactory.CreateFromLogin( + controllerAddress, + newUser.Name, + "asdfasdfasdfasdf"); + + using var node1BadClient = clientFactory.CreateFromToken(node1.Url, controllerUserClient.Token); + await Assert.ThrowsExceptionAsync(() => node1BadClient.Administration.Read(cancellationToken)); + + // check instance info is not shared + var controllerInstance = await controllerClient.Instances.CreateOrAttach( + new Api.Models.Instance + { + Name = "ControllerInstance", + Path = Path.Combine(controller.Directory, "ControllerInstance") + }, + cancellationToken); + + var node2Instance = await node2Client.Instances.CreateOrAttach( + new Api.Models.Instance + { + Name = "Node2Instance", + Path = Path.Combine(node2.Directory, "Node2Instance") + }, + cancellationToken); + var node2InstanceList = await node2Client.Instances.List(null, cancellationToken); + Assert.AreEqual(1, node2InstanceList.Count); + Assert.AreEqual(node2Instance.Id, node2InstanceList.First().Id); + Assert.IsNotNull(await node2Client.Instances.GetId(node2Instance, cancellationToken)); + var controllerInstanceList = await controllerClient.Instances.List(null, cancellationToken); + Assert.AreEqual(1, controllerInstanceList.Count); + Assert.AreEqual(controllerInstance.Id, controllerInstanceList.First().Id); + Assert.IsNotNull(await controllerClient.Instances.GetId(controllerInstance, cancellationToken)); + + await Assert.ThrowsExceptionAsync(() => controllerClient.Instances.GetId(node2Instance, cancellationToken)); + await Assert.ThrowsExceptionAsync(() => node1Client.Instances.GetId(controllerInstance, cancellationToken)); + + // test update + var testUpdateVersion = new Version(4, 6, 2); + await node1Client.Administration.Update( + new Administration + { + NewVersion = testUpdateVersion + }, + cancellationToken); + await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); + Assert.IsTrue(serverTask.IsCompleted); + + void CheckServerUpdated(TestingServer server) + { + Assert.IsTrue(Directory.Exists(server.UpdatePath), "Update directory not present!"); + + var updatedAssemblyPath = Path.Combine(server.UpdatePath, "Tgstation.Server.Host.dll"); + Assert.IsTrue(File.Exists(updatedAssemblyPath), "Updated assembly missing!"); + + var updatedAssemblyVersion = FileVersionInfo.GetVersionInfo(updatedAssemblyPath); + Assert.AreEqual(testUpdateVersion, Version.Parse(updatedAssemblyVersion.FileVersion).Semver()); + } + + CheckServerUpdated(controller); + CheckServerUpdated(node1); + CheckServerUpdated(node2); + } + finally + { + serverCts.Cancel(); + await serverTask; + } + } + + new TestingServer(null, false).Dispose(); + } + + [TestMethod] + public async Task TestSwarmReconnection() + { + // cleanup existing directories + new TestingServer(null, false).Dispose(); + + const string PrivateKey = "adlfj73ywifhks7iwrgfegjs"; + + var controllerAddress = new Uri("http://localhost:5011"); + using (var controller = new TestingServer(new SwarmConfiguration + { + Address = controllerAddress, + Identifier = "controller", + PrivateKey = PrivateKey + }, false, 5011)) + { + using var node1 = new TestingServer(new SwarmConfiguration + { + Address = new Uri("http://localhost:5012"), + ControllerAddress = controllerAddress, + Identifier = "node1", + PrivateKey = PrivateKey + }, false, 5012); + using var node2 = new TestingServer(new SwarmConfiguration + { + Address = new Uri("http://localhost:5013"), + ControllerAddress = controllerAddress, + Identifier = "node2", + PrivateKey = PrivateKey + }, false, 5013); + using var serverCts = new CancellationTokenSource(); + + var cancellationToken = serverCts.Token; + using var node1Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task node1Task, node2Task, controllerTask; + var serverTask = Task.WhenAll( + node1Task = node1.Run(node1Cts.Token), + node2Task = node2.Run(cancellationToken), + controllerTask = controller.Run(cancellationToken)); + + try + { + using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); + using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); + using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); + + var controllerInfo = await controllerClient.ServerInformation(cancellationToken); + + async Task WaitForSwarmServerUpdate(IServerClient client, int currentServerCount) + { + ServerInformation serverInformation; + do + { + await Task.Delay(TimeSpan.FromSeconds(10)); + serverInformation = await client.ServerInformation(cancellationToken); + } + while (serverInformation.SwarmServers.Count == currentServerCount); + } + + static void CheckInfo(ServerInformation serverInformation) + { + Assert.IsNotNull(serverInformation.SwarmServers); + Assert.AreEqual(3, serverInformation.SwarmServers.Count); + + var node1 = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "node1"); + Assert.IsNotNull(node1); + Assert.AreEqual(node1.Address, "http://localhost:5012"); + Assert.IsFalse(node1.Controller); + + var node2 = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "node2"); + Assert.IsNotNull(node2); + Assert.AreEqual(node2.Address, "http://localhost:5013"); + Assert.IsFalse(node2.Controller); + + var controller = serverInformation.SwarmServers.SingleOrDefault(x => x.Identifier == "controller"); + Assert.IsNotNull(controller); + Assert.AreEqual(controller.Address, "http://localhost:5011"); + Assert.IsTrue(controller.Controller); + } + + CheckInfo(controllerInfo); + + // wait a few minutes for the updated server list to dispatch + await Task.WhenAny( + WaitForSwarmServerUpdate(node1Client, 1), + Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); + + var node2Info = await node2Client.ServerInformation(cancellationToken); + var node1Info = await node1Client.ServerInformation(cancellationToken); + CheckInfo(node1Info); + CheckInfo(node2Info); + + // kill node1 + node1Cts.Cancel(); + await Task.WhenAny( + node1Task, + Task.Delay(TimeSpan.FromMinutes(1))); + Assert.IsTrue(node1Task.IsCompleted); + + // it should unregister + controllerInfo = await controllerClient.ServerInformation(cancellationToken); + Assert.AreEqual(2, controllerInfo.SwarmServers.Count); + Assert.IsFalse(controllerInfo.SwarmServers.Any(x => x.Identifier == "node1")); + + // wait a few minutes for the updated server list to dispatch + await Task.WhenAny( + WaitForSwarmServerUpdate(node2Client, 3), + Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); + + node2Info = await node2Client.ServerInformation(cancellationToken); + Assert.AreEqual(2, node2Info.SwarmServers.Count); + Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); + + // restart the controller + await controllerClient.Administration.Restart(cancellationToken); + await Task.WhenAny( + controllerTask, + Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); + Assert.IsTrue(controllerTask.IsCompleted); + + controllerTask = controller.Run(cancellationToken); + using var controllerClient2 = await CreateAdminClient(controller.Url, cancellationToken); + + // node 2 should reconnect once it's health check triggers + await Task.WhenAny( + WaitForSwarmServerUpdate(controllerClient2, 1), + Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)); + + controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + Assert.AreEqual(2, controllerInfo.SwarmServers.Count); + Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); + + // wait a few seconds to dispatch the updated list to node2 + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + + // restart node2 + await node2Client.Administration.Restart(cancellationToken); + await Task.WhenAny( + node2Task, + Task.Delay(TimeSpan.FromMinutes(1))); + Assert.IsTrue(node1Task.IsCompleted); + + // should remain registered + controllerInfo = await controllerClient2.ServerInformation(cancellationToken); + Assert.AreEqual(2, controllerInfo.SwarmServers.Count); + Assert.IsNotNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); + + // update should fail + await controllerClient2.Administration.Update(new Administration + { + NewVersion = new Version(4, 6, 2) + }, cancellationToken); + + async Task WaitForUpdateFailure() + { + ServerInformation serverInformation; + serverInformation = await controllerClient2.ServerInformation(cancellationToken); + while (serverInformation.UpdateInProgress) + { + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + serverInformation = await controllerClient2.ServerInformation(cancellationToken); + } + } + + var updateFailureTask = WaitForUpdateFailure(); + await Task.WhenAny(updateFailureTask, Task.Delay(TimeSpan.FromMinutes(5), cancellationToken)); + + node2Task = node2.Run(cancellationToken); + using var node2Client2 = await CreateAdminClient(node2.Url, cancellationToken); + + // should re-register + await Task.WhenAny( + WaitForSwarmServerUpdate(node2Client2, 1), + Task.Delay(TimeSpan.FromMinutes(4), cancellationToken)); + + node2Info = await node2Client2.ServerInformation(cancellationToken); + Assert.AreEqual(2, node2Info.SwarmServers.Count); + Assert.IsNotNull(node2Info.SwarmServers.SingleOrDefault(x => x.Identifier == "controller")); + } + finally + { + serverCts.Cancel(); + await serverTask; + } + } + + new TestingServer(null, false).Dispose(); + } + static void TerminateAllDDs() { foreach (var proc in System.Diagnostics.Process.GetProcessesByName("DreamDaemon")) @@ -99,11 +491,12 @@ namespace Tgstation.Server.Tests async Task CreateAdminClient(Uri url, CancellationToken cancellationToken) { - var giveUpAt = DateTimeOffset.Now.AddSeconds(60); - do + var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(2); + for(var I = 1; ; ++I) { try { + Console.WriteLine($"TEST: CreateAdminClient attempt {I}..."); return await clientFactory.CreateFromLogin( url, User.AdminName, @@ -115,21 +508,20 @@ namespace Tgstation.Server.Tests catch (HttpRequestException) { //migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } catch (ServiceUnavailableException) { // migrating, to be expected - if (DateTimeOffset.Now > giveUpAt) + if (DateTimeOffset.UtcNow > giveUpAt) throw; await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); } - } while (true); + } } -#if DEBUG [TestMethod] public async Task TestDownMigrations() { @@ -143,7 +535,7 @@ namespace Tgstation.Server.Tests Assert.Inconclusive("No/invalid database type configured in env var TGS4_TEST_DATABASE_TYPE!"); string migrationName = null; - DbContext CreateContext() + DatabaseContext CreateContext() { string serverVersion = Environment.GetEnvironmentVariable($"{DatabaseConfiguration.Section}__{nameof(DatabaseConfiguration.ServerVersion)}"); if (String.IsNullOrWhiteSpace(serverVersion)) @@ -184,17 +576,96 @@ namespace Tgstation.Server.Tests return null; } - Task Delete(DbContext context) => databaseType == DatabaseType.Sqlite ? Task.CompletedTask : context.Database.EnsureCreatedAsync(); - using var context = CreateContext(); - await Delete(context); + await context.Database.EnsureDeletedAsync(); await context.Database.MigrateAsync(default); + + // add usergroups and dummy instances for testing purposes + var group = new Host.Models.UserGroup + { + PermissionSet = new Host.Models.PermissionSet + { + AdministrationRights = AdministrationRights.ChangeVersion, + InstanceManagerRights = InstanceManagerRights.GrantPermissions + }, + Name = "TestGroup", + }; + + const string TestUserName = "TestUser42"; + var user = new Host.Models.User + { + Name = TestUserName, + CreatedAt = DateTimeOffset.UtcNow, + OAuthConnections = new List(), + CanonicalName = Host.Models.User.CanonicalizeName(TestUserName), + Enabled = false, + Group = group, + PasswordHash = "_", + }; + + var instance = new Host.Models.Instance + { + AutoUpdateInterval = 0, + ChatBotLimit = 1, + ChatSettings = new List(), + ConfigurationType = ConfigurationType.HostWrite, + DreamDaemonSettings = new Host.Models.DreamDaemonSettings + { + AllowWebClient = false, + AutoStart = false, + HeartbeatSeconds = 0, + Port = 1447, + SecurityLevel = DreamDaemonSecurity.Safe, + StartupTimeout = 1000, + TopicRequestTimeout = 1000, + AdditionalParameters = String.Empty, + }, + DreamMakerSettings = new Host.Models.DreamMakerSettings + { + ApiValidationPort = 1557, + ApiValidationSecurityLevel = DreamDaemonSecurity.Trusted, + RequireDMApiValidation = false, + }, + InstancePermissionSets = new List + { + new Host.Models.InstancePermissionSet + { + ByondRights = ByondRights.InstallCustomVersion, + ChatBotRights = ChatBotRights.None, + ConfigurationRights = ConfigurationRights.Read, + DreamDaemonRights = DreamDaemonRights.ReadRevision, + DreamMakerRights = DreamMakerRights.SetApiValidationPort, + InstancePermissionSetRights = InstancePermissionSetRights.Write, + PermissionSet = group.PermissionSet, + RepositoryRights = RepositoryRights.SetReference + } + }, + Name = "sfdsadfsa", + Online = false, + Path = "/a/b/c/d", + RepositorySettings = new Host.Models.RepositorySettings + { + AutoUpdatesKeepTestMerges = false, + AutoUpdatesSynchronize = false, + CommitterEmail = "email@eample.com", + CommitterName = "blubluh", + CreateGitHubDeployments = false, + PostTestMergeComment = false, + PushTestMergeCommits = false, + ShowTestMergeCommitters = false, + }, + }; + + context.Users.Add(user); + context.Groups.Add(group); + context.Instances.Add(instance); + await context.Save(default); + var dbServiceProvider = ((IInfrastructure)context.Database).Instance; var migrator = dbServiceProvider.GetRequiredService(); await migrator.MigrateAsync(migrationName, default); - await Delete(context); + await context.Database.EnsureDeletedAsync(); } -#endif [TestMethod] public async Task TestServer() @@ -207,7 +678,7 @@ namespace Tgstation.Server.Tests Assert.Inconclusive("Cannot run server test because DreamDaemon will not start headless while the BYOND pager is running!"); } - using var server = new TestingServer(); + using var server = new TestingServer(null, true); const int MaximumTestMinutes = 20; using var hardTimeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(MaximumTestMinutes)); @@ -246,19 +717,19 @@ namespace Tgstation.Server.Tests } catch (Exception ex) { - Console.WriteLine($"[{DateTimeOffset.Now}] TEST ERROR: {ex}"); + Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex}"); serverCts.Cancel(); throw; } } - var rootTest = FailFast(new RootTest().Run(clientFactory, adminClient, cancellationToken)); + var rootTest = FailFast(new RawRequestTests().Run(clientFactory, adminClient, cancellationToken)); var adminTest = FailFast(new AdministrationTest(adminClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(adminClient.Users).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(adminClient).Run(cancellationToken)); instance = await new InstanceManagerTest(adminClient.Instances, adminClient.Users, server.Directory).RunPreInstanceTest(cancellationToken); - Assert.IsTrue(Directory.Exists(instance.Path)); var instanceClient = adminClient.Instances.CreateClient(instance); + Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); var instanceTests = FailFast(new InstanceTest(instanceClient, adminClient.Instances).RunTests(cancellationToken)); @@ -292,17 +763,17 @@ namespace Tgstation.Server.Tests await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); Assert.IsTrue(serverTask.IsCompleted); - var preStartupTime = DateTimeOffset.Now; + var preStartupTime = DateTimeOffset.UtcNow; serverTask = server.Run(cancellationToken); using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); - var jobs = await instanceClient.Jobs.ListActive(cancellationToken); + var jobs = await instanceClient.Jobs.ListActive(null, cancellationToken); if (!jobs.Any()) { - var entities = await instanceClient.Jobs.List(cancellationToken); + var entities = await instanceClient.Jobs.List(null, cancellationToken); var getTasks = entities .Select(e => instanceClient.Jobs.GetId(e, cancellationToken)) .ToList(); @@ -336,16 +807,16 @@ namespace Tgstation.Server.Tests await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); Assert.IsTrue(serverTask.IsCompleted); - preStartupTime = DateTimeOffset.Now; + preStartupTime = DateTimeOffset.UtcNow; serverTask = server.Run(cancellationToken); using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); - var jobs = await instanceClient.Jobs.ListActive(cancellationToken); + var jobs = await instanceClient.Jobs.ListActive(null, cancellationToken); if (!jobs.Any()) { - var entities = await instanceClient.Jobs.List(cancellationToken); + var entities = await instanceClient.Jobs.List(null, cancellationToken); var getTasks = entities .Select(e => instanceClient.Jobs.GetId(e, cancellationToken)) .ToList(); @@ -377,12 +848,12 @@ namespace Tgstation.Server.Tests } catch(ApiException ex) { - Console.WriteLine($"[{DateTimeOffset.Now}] TEST ERROR: {ex.ErrorCode}: {ex.Message}\n{ex.AdditionalServerData}"); + Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex.ErrorCode}: {ex.Message}\n{ex.AdditionalServerData}"); throw; } catch (Exception ex) { - Console.WriteLine($"[{DateTimeOffset.Now}] TEST ERROR: {ex}"); + Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex}"); throw; } finally @@ -450,7 +921,7 @@ namespace Tgstation.Server.Tests [TestMethod] public async Task TestRepoParentLookup() { - using var testingServer = new TestingServer(); + using var testingServer = new TestingServer(null, false); LibGit2Sharp.Repository.Clone("https://github.com/Cyberboss/test", testingServer.Directory); var libGit2Repo = new LibGit2Sharp.Repository(testingServer.Directory); using var repo = new Host.Components.Repository.Repository( @@ -459,6 +930,7 @@ namespace Tgstation.Server.Tests Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of>(), () => { }); diff --git a/tests/Tgstation.Server.Tests/RawRequestTests.cs b/tests/Tgstation.Server.Tests/RawRequestTests.cs new file mode 100644 index 0000000000..df9bad0018 --- /dev/null +++ b/tests/Tgstation.Server.Tests/RawRequestTests.cs @@ -0,0 +1,319 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Client; +using Tgstation.Server.Host; + +namespace Tgstation.Server.Tests +{ + sealed class RawRequestTests + { + async Task TestRequestValidation(IServerClient serverClient, CancellationToken cancellationToken) + { + var url = serverClient.Url; + var token = serverClient.Token.Bearer; + // check that 400s are returned appropriately + using var httpClient = new HttpClient(); + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml)); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ApiHeaders.Version, message.ApiVersion); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Administration.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.Administration.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + request.Content = new StringContent( + "{ newVersion: 1234 }", + Encoding.UTF8, + MediaTypeNames.Application.Json); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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, token); + request.Headers.Add(ApiHeaders.OAuthProviderHeader, "FakeProvider"); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); + } + } + + async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + { + var serverInfo = await serverClient.ServerInformation(default).ConfigureAwait(false); + + Assert.AreEqual(ApiHeaders.Version, serverInfo.ApiVersion); + var assemblyVersion = typeof(IServer).Assembly.GetName().Version.Semver(); + Assert.AreEqual(assemblyVersion, serverInfo.Version); + Assert.AreEqual(10U, serverInfo.MinimumPasswordLength); + Assert.AreEqual(11U, serverInfo.InstanceLimit); + Assert.AreEqual(150U, serverInfo.UserLimit); + Assert.AreEqual(47U, serverInfo.UserGroupLimit); + Assert.AreEqual(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), serverInfo.WindowsHost); + + //check that modifying the token even slightly fucks up the auth + var newToken = new Token + { + ExpiresAt = serverClient.Token.ExpiresAt, + Bearer = serverClient.Token.Bearer + '0' + }; + + var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); + await Assert.ThrowsExceptionAsync(() => badClient.Administration.Read(cancellationToken)).ConfigureAwait(false); + } + + async Task TestOAuthFails(IServerClient serverClient, CancellationToken cancellationToken) + { + var url = serverClient.Url; + var token = serverClient.Token.Bearer; + // check that 400s are returned appropriately + using var httpClient = new HttpClient(); + + // just hitting each type of oauth provider for coverage + foreach (var I in Enum.GetValues(typeof(OAuthProvider))) + using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString())) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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, token); + request.Headers.Add(ApiHeaders.OAuthProviderHeader, I.ToString()); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + } + async Task TestInvalidTransfers(IServerClient serverClient, CancellationToken cancellationToken) + { + var url = serverClient.Url; + var token = serverClient.Token.Bearer; + // check that 400s are returned appropriately + using var httpClient = new HttpClient(); + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); + Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Put, url.ToString() + Routes.Transfer.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + var message = JsonConvert.DeserializeObject(content); + Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); + Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket")) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); + Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket")) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet)); + request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/" + ApiHeaders.Version); + request.Headers.Authorization = new AuthenticationHeaderValue(ApiHeaders.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); + Assert.AreEqual(HttpStatusCode.Gone, response.StatusCode); + } + + using (var request = new HttpRequestMessage(HttpMethod.Put, url.ToString() + Routes.Transfer.Substring(1) + "?ticket=veryfaketicket")) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(MediaTypeNames.Application.Json, response.Content.Headers.ContentType.MediaType); + Assert.AreEqual(HttpStatusCode.Gone, response.StatusCode); + } + } + + async Task RegressionTestForLeakedPasswordHashesBug(IServerClient serverClient, CancellationToken cancellationToken) + { + // See what https://github.com/tgstation/tgstation-server/commit/6c8dc87c4af36620885b262175d7974aca2b3c2b fixed + + var url = serverClient.Url; + var token = serverClient.Token.Bearer; + // check that 400s are returned appropriately + using var httpClient = new HttpClient(); + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.User.Substring(1))) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.IsFalse(content.Contains("passwordHash")); + } + + using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString() + Routes.User.Substring(1) + '/' + Routes.List + "?pageSize=100")) + { + request.Headers.Accept.Clear(); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); + 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.BearerAuthenticationScheme, token); + using var response = await httpClient.SendAsync(request, cancellationToken); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.IsFalse(content.Contains("passwordHash")); + } + } + + public Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) + => Task.WhenAll( + TestRequestValidation(serverClient, cancellationToken), + TestOAuthFails(serverClient, cancellationToken), + TestServerInformation(clientFactory, serverClient, cancellationToken), + TestInvalidTransfers(serverClient, cancellationToken), + RegressionTestForLeakedPasswordHashesBug(serverClient, cancellationToken)); + } +} diff --git a/tests/Tgstation.Server.Tests/RootTest.cs b/tests/Tgstation.Server.Tests/RootTest.cs deleted file mode 100644 index b94e2c3dc7..0000000000 --- a/tests/Tgstation.Server.Tests/RootTest.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Mime; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Client; -using Tgstation.Server.Host; - -namespace Tgstation.Server.Tests -{ - class RootTest - { - async Task TestRequestValidation(IServerClient serverClient, CancellationToken cancellationToken) - { - var url = serverClient.Url; - var token = serverClient.Token.Bearer; - // check that 400s are returned appropriately - using var httpClient = new HttpClient(); - using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml)); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.NotAcceptable, response.StatusCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.BadHeaders, message.ErrorCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Get, url.ToString())) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); - request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/6.0.0"); - request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.UpgradeRequired, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ApiMismatch, message.ErrorCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.Administration.Substring(1))) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); - request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); - request.Content = new StringContent( - "{ newVersion: 1234 }", - Encoding.UTF8, - MediaTypeNames.Application.Json); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.ModelValidationFailure, message.ErrorCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); - } - - using (var request = new HttpRequestMessage(HttpMethod.Post, url.ToString() + Routes.DreamDaemon.Substring(1))) - { - request.Headers.Accept.Clear(); - request.Headers.UserAgent.Add(new ProductInfoHeaderValue("RootTest", "1.0.0")); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); - request.Headers.Add(ApiHeaders.ApiVersionHeader, "Tgstation.Server.Api/7.0.0"); - request.Headers.Authorization = new AuthenticationHeaderValue("bearer", token); - using var response = await httpClient.SendAsync(request, cancellationToken); - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var message = JsonConvert.DeserializeObject(content); - Assert.AreEqual(ErrorCode.InstanceHeaderRequired, message.ErrorCode); - } - } - - async Task TestServerInformation(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) - { - var serverInfo = await serverClient.Version(default).ConfigureAwait(false); - - Assert.AreEqual(ApiHeaders.Version, serverInfo.ApiVersion); - var assemblyVersion = typeof(IServer).Assembly.GetName().Version.Semver(); - Assert.AreEqual(assemblyVersion, serverInfo.Version); - Assert.AreEqual(10U, serverInfo.MinimumPasswordLength); - Assert.AreEqual(11U, serverInfo.InstanceLimit); - Assert.AreEqual(150U, serverInfo.UserLimit); - - //check that modifying the token even slightly fucks up the auth - var newToken = new Token - { - ExpiresAt = serverClient.Token.ExpiresAt, - Bearer = serverClient.Token.Bearer + '0' - }; - - var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); - await Assert.ThrowsExceptionAsync(() => badClient.Version(cancellationToken)).ConfigureAwait(false); - } - - public Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) - => Task.WhenAll( - TestRequestValidation(serverClient, cancellationToken), - TestServerInformation(clientFactory, serverClient, cancellationToken)); - } -} diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index 8962ce2d58..12f04a0b1b 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Models; using Tgstation.Server.Host; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; @@ -31,13 +32,13 @@ namespace Tgstation.Server.Tests IServer realServer; - public TestingServer() + public TestingServer(SwarmConfiguration swarmConfiguration, bool enableOAuth, ushort port = 5010) { Directory = Environment.GetEnvironmentVariable("TGS4_TEST_TEMP_DIRECTORY"); if (String.IsNullOrWhiteSpace(Directory)) { Directory = Path.Combine(Path.GetTempPath(), "TGS4_INTEGRATION_TEST"); - if (System.IO.Directory.Exists(Directory)) + if (System.IO.Directory.Exists(Directory) && swarmConfiguration == null) try { System.IO.Directory.Delete(Directory, true); @@ -48,8 +49,8 @@ namespace Tgstation.Server.Tests Directory = Path.Combine(Directory, Guid.NewGuid().ToString()); System.IO.Directory.CreateDirectory(Directory); - const string UrlString = "http://localhost:5010"; - Url = new Uri(UrlString); + string urlString = $"http://localhost:{port}"; + Url = new Uri(urlString); //so we need a db //we have to rely on env vars @@ -72,13 +73,14 @@ namespace Tgstation.Server.Tests var args = new List() { String.Format(CultureInfo.InvariantCulture, "Database:DropDatabase={0}", true), - String.Format(CultureInfo.InvariantCulture, "General:ApiPort={0}", 5010), + String.Format(CultureInfo.InvariantCulture, "General:ApiPort={0}", port), String.Format(CultureInfo.InvariantCulture, "Database:DatabaseType={0}", DatabaseType), String.Format(CultureInfo.InvariantCulture, "Database:ConnectionString={0}", connectionString), String.Format(CultureInfo.InvariantCulture, "General:SetupWizardMode={0}", SetupWizardMode.Never), String.Format(CultureInfo.InvariantCulture, "General:MinimumPasswordLength={0}", 10), String.Format(CultureInfo.InvariantCulture, "General:InstanceLimit={0}", 11), String.Format(CultureInfo.InvariantCulture, "General:UserLimit={0}", 150), + String.Format(CultureInfo.InvariantCulture, "General:UserGroupLimit={0}", 47), String.Format(CultureInfo.InvariantCulture, "General:HostApiDocumentation={0}", DumpOpenApiSpecpath), String.Format(CultureInfo.InvariantCulture, "FileLogging:Directory={0}", Path.Combine(Directory, "Logs")), String.Format(CultureInfo.InvariantCulture, "FileLogging:LogLevel={0}", "Trace"), @@ -86,6 +88,28 @@ namespace Tgstation.Server.Tests "General:ByondTopicTimeout=3000" }; + if (swarmConfiguration != null) + { + args.Add($"Swarm:PrivateKey={swarmConfiguration.PrivateKey}"); + args.Add($"Swarm:Identifier={swarmConfiguration.Identifier}"); + args.Add($"Swarm:Address={swarmConfiguration.Address}"); + if (swarmConfiguration.ControllerAddress != null) + args.Add($"Swarm:ControllerAddress={swarmConfiguration.ControllerAddress}"); + } + + // enable all oauth providers + if (enableOAuth) + foreach (var I in Enum.GetValues(typeof(OAuthProvider))) + { + args.Add($"Security:OAuth:{I}:ClientId=Fake"); + args.Add($"Security:OAuth:{I}:ClientSecret=Faker"); + args.Add($"Security:OAuth:{I}:RedirectUrl=https://fakest.com"); + args.Add($"Security:OAuth:{I}:ServerUrl=https://fakestest.com"); + } + + // SPECIFICALLY DELETE THE DEV APPSETTINGS, WE DON'T WANT IT IN THE WAY + File.Delete("appsettings.Development.json"); + if (!String.IsNullOrEmpty(gitHubAccessToken)) args.Add(String.Format(CultureInfo.InvariantCulture, "General:GitHubAccessToken={0}", gitHubAccessToken)); diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index ebea335bde..c6cde39a21 100644 --- a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj +++ b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj @@ -12,8 +12,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/tests/Tgstation.Server.Tests/UsersTest.cs b/tests/Tgstation.Server.Tests/UsersTest.cs index 7241361cf9..5861ea0f0a 100644 --- a/tests/Tgstation.Server.Tests/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/UsersTest.cs @@ -1,10 +1,11 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; using Tgstation.Server.Host.System; @@ -12,11 +13,11 @@ namespace Tgstation.Server.Tests { sealed class UsersTest { - readonly IUsersClient client; + readonly IServerClient serverClient; - public UsersTest(IUsersClient client) + public UsersTest(IServerClient serverClient) { - this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.serverClient = serverClient ?? throw new ArgumentNullException(nameof(serverClient)); } public async Task Run(CancellationToken cancellationToken) @@ -25,30 +26,168 @@ namespace Tgstation.Server.Tests BasicTests(cancellationToken), TestCreateSysUser(cancellationToken), TestSpamCreation(cancellationToken)).ConfigureAwait(false); + + await TestPagination(cancellationToken); } async Task BasicTests(CancellationToken cancellationToken) { - var user = await this.client.Read(cancellationToken).ConfigureAwait(false); + var user = await serverClient.Users.Read(cancellationToken).ConfigureAwait(false); Assert.IsNotNull(user); Assert.AreEqual("Admin", user.Name); Assert.IsNull(user.SystemIdentifier); Assert.AreEqual(true, user.Enabled); + Assert.IsNotNull(user.OAuthConnections); + Assert.IsNotNull(user.PermissionSet); + Assert.IsNotNull(user.PermissionSet.Id); + Assert.IsNotNull(user.PermissionSet.InstanceManagerRights); + Assert.IsNotNull(user.PermissionSet.AdministrationRights); var systemUser = user.CreatedBy; Assert.IsNotNull(systemUser); Assert.AreEqual("TGS", systemUser.Name); - Assert.AreEqual(false, systemUser.Enabled); - var users = await client.List(cancellationToken); + var users = await serverClient.Users.List(null, cancellationToken); Assert.IsTrue(users.Count > 0); Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); - await ApiAssert.ThrowsException(() => client.GetId(systemUser, cancellationToken), null); - await ApiAssert.ThrowsException(() => client.Update(new UserUpdate + await ApiAssert.ThrowsException(() => serverClient.Users.GetId(systemUser, cancellationToken), null); + await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdate { Id = systemUser.Id }, cancellationToken), null); + + var sampleOAuthConnections = new List + { + new OAuthConnection + { + ExternalUserId = "asdfasdf", + Provider = OAuthProvider.Discord + } + }; + await ApiAssert.ThrowsException(() => serverClient.Users.Update(new UserUpdate + { + Id = user.Id, + OAuthConnections = sampleOAuthConnections + }, cancellationToken), ErrorCode.AdminUserCannotOAuth); + + var testUser = await serverClient.Users.Create( + new UserUpdate + { + Name = $"BasicTestUser", + Password = "asdfasdjfhauwiehruiy273894234jhndjkwh" + }, + cancellationToken).ConfigureAwait(false); + + Assert.IsNotNull(testUser.OAuthConnections); + testUser = await serverClient.Users.Update( + new UserUpdate + { + Id = testUser.Id, + OAuthConnections = sampleOAuthConnections + }, + cancellationToken).ConfigureAwait(false); + + Assert.AreEqual(1, testUser.OAuthConnections.Count); + Assert.AreEqual(sampleOAuthConnections.First().ExternalUserId, testUser.OAuthConnections.First().ExternalUserId); + Assert.AreEqual(sampleOAuthConnections.First().Provider, testUser.OAuthConnections.First().Provider); + + + var group = await serverClient.Groups.Create( + new UserGroup + { + Name = "TestGroup" + }, + cancellationToken); + Assert.AreEqual(group.Name, "TestGroup"); + Assert.IsNotNull(group.PermissionSet); + Assert.IsNotNull(group.PermissionSet.Id); + Assert.AreEqual(AdministrationRights.None, group.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.None, group.PermissionSet.InstanceManagerRights); + + var group2 = await serverClient.Groups.Create(new UserGroup + { + Name = "TestGroup2", + PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.List + } + }, cancellationToken); + Assert.AreEqual(AdministrationRights.None, group2.PermissionSet.AdministrationRights); + Assert.AreEqual(InstanceManagerRights.List, group2.PermissionSet.InstanceManagerRights); + + var groups = await serverClient.Groups.List(null, cancellationToken); + Assert.AreEqual(2, groups.Count); + + foreach (var igroup in groups) + { + Assert.IsNotNull(igroup.Users); + Assert.IsNotNull(igroup.PermissionSet); + } + + await serverClient.Groups.Delete(group2, cancellationToken); + + groups = await serverClient.Groups.List(null, cancellationToken); + Assert.AreEqual(1, groups.Count); + + group.PermissionSet.InstanceManagerRights = RightsHelper.AllRights(); + group.PermissionSet.AdministrationRights = RightsHelper.AllRights(); + group.Users = null; + + group = await serverClient.Groups.Update(group, cancellationToken); + + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.AdministrationRights); + Assert.AreEqual(RightsHelper.AllRights(), group.PermissionSet.InstanceManagerRights); + + await ApiAssert.ThrowsException(() => serverClient.Groups.Update(group, cancellationToken), ErrorCode.UserGroupControllerCantEditMembers); + + var testUserUpdate = new UserUpdate + { + Name = "TestUserWithNoPassword", + Password = String.Empty + }; + + await ApiAssert.ThrowsException(() => serverClient.Users.Create(testUserUpdate, cancellationToken), ErrorCode.UserPasswordLength); + + testUserUpdate.OAuthConnections = new List + { + new OAuthConnection + { + ExternalUserId = "asdf", + Provider = OAuthProvider.GitHub + } + }; + + var testUser2 = await serverClient.Users.Create(testUserUpdate, cancellationToken); + + testUserUpdate = new UserUpdate + { + Id = testUser2.Id, + PermissionSet = testUser2.PermissionSet, + Group = new Api.Models.Internal.UserGroup + { + Id = group.Id + }, + }; + await ApiAssert.ThrowsException( + () => serverClient.Users.Update( + testUserUpdate, + cancellationToken), + ErrorCode.UserGroupAndPermissionSet); + + testUserUpdate.PermissionSet = null; + + testUser2 = await serverClient.Users.Update(testUserUpdate, cancellationToken); + + Assert.IsNull(testUser2.PermissionSet); + Assert.IsNotNull(testUser2.Group); + Assert.AreEqual(group.Id, testUser2.Group.Id); + + group = await serverClient.Groups.GetId(group, cancellationToken); + Assert.IsNotNull(group.Users); + Assert.AreEqual(1, group.Users.Count); + Assert.AreEqual(testUser2.Id, group.Users.First().Id); + Assert.IsNotNull(group.PermissionSet); } async Task TestCreateSysUser(CancellationToken cancellationToken) @@ -59,9 +198,9 @@ namespace Tgstation.Server.Tests SystemIdentifier = sysId }; if (new PlatformIdentifier().IsWindows) - await client.Create(update, cancellationToken); + await serverClient.Users.Create(update, cancellationToken); else - await ApiAssert.ThrowsException(() => client.Create(update, cancellationToken), ErrorCode.RequiresPosixSystemIdentity); + await ApiAssert.ThrowsException(() => serverClient.Users.Create(update, cancellationToken), ErrorCode.RequiresPosixSystemIdentity); } async Task TestSpamCreation(CancellationToken cancellationToken) @@ -79,7 +218,7 @@ namespace Tgstation.Server.Tests for (int i = 0; i < RepeatCount; ++i) { tasks.Add( - client.Create( + serverClient.Users.Create( new UserUpdate { Name = $"SpamTestUser_{i}", @@ -97,5 +236,55 @@ namespace Tgstation.Server.Tests Assert.AreEqual(RepeatCount, tasks.Select(task => task.Result.Id).Distinct().Count(), "Did not receive expected number of unique user IDs!"); } + + async Task TestPagination(CancellationToken cancellationToken) + { + // we test pagination here b/c it's the only spot we have a decent amount of entities + var nullSettings = await serverClient.Users.List(null, cancellationToken); + var emptySettings = await serverClient.Users.List( + new PaginationSettings + { + }, cancellationToken); + + Assert.AreEqual(nullSettings.Count, emptySettings.Count); + Assert.IsTrue(nullSettings.All(x => emptySettings.SingleOrDefault(y => x.Id == y.Id) != null)); + + await ApiAssert.ThrowsException(() => serverClient.Users.List( + new PaginationSettings + { + PageSize = -2143 + }, cancellationToken), ErrorCode.ApiInvalidPageOrPageSize); + await ApiAssert.ThrowsException(() => serverClient.Users.List( + new PaginationSettings + { + PageSize = Int32.MaxValue + }, cancellationToken), ErrorCode.ApiPageTooLarge); + + await serverClient.Users.List( + new PaginationSettings + { + PageSize = 50 + }, + cancellationToken); + + var skipped = await serverClient.Users.List(new PaginationSettings + { + Offset = 50, + RetrieveCount = 5 + }, cancellationToken); + Assert.AreEqual(5, skipped.Count); + + var allAfterSkipped = await serverClient.Users.List(new PaginationSettings + { + Offset = 50, + }, cancellationToken); + Assert.IsTrue(5 < allAfterSkipped.Count); + + var limited = await serverClient.Users.List(new PaginationSettings + { + RetrieveCount = 12, + }, cancellationToken); + Assert.AreEqual(12, limited.Count); + } } } diff --git a/tests/Tgstation.Server.Tests/VersionsTest.cs b/tests/Tgstation.Server.Tests/VersionsTest.cs index f6502984be..1a1cf908da 100644 --- a/tests/Tgstation.Server.Tests/VersionsTest.cs +++ b/tests/Tgstation.Server.Tests/VersionsTest.cs @@ -95,7 +95,7 @@ namespace Tgstation.Server.Tests var versionLine = lines.FirstOrDefault(l => l.StartsWith(Prefix)); Assert.IsNotNull(versionLine); - versionLine = versionLine.Substring(Prefix.Length + 1, 5); + versionLine = versionLine.Substring(Prefix.Length + 1, expected.ToString().Length); Assert.IsTrue(Version.TryParse(versionLine, out var actual)); Assert.AreEqual(expected, actual); @@ -146,7 +146,6 @@ namespace Tgstation.Server.Tests Assert.IsNotNull(line); } -#if DEBUG [TestMethod] public void TestDowngradeMigrations() { @@ -201,6 +200,5 @@ namespace Tgstation.Server.Tests Assert.AreEqual(latestMigrationPG, DatabaseContext.PGLatestMigration); Assert.AreEqual(latestMigrationSL, DatabaseContext.SLLatestMigration); } -#endif } } diff --git a/tools/ReleaseNotes/Program.cs b/tools/ReleaseNotes/Program.cs index 28cafa17a7..65760f4b8d 100644 --- a/tools/ReleaseNotes/Program.cs +++ b/tools/ReleaseNotes/Program.cs @@ -60,7 +60,6 @@ namespace ReleaseNotes { Milestone = $"v{versionString}", Type = IssueTypeQualifier.PullRequest, - State = ItemState.Closed, Repos = { { RepoOwner, RepoName } } }).ConfigureAwait(false);