diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a294fb05fde..2f3e3723def 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,22 @@ updates: interval: daily labels: - Dependencies + groups: + HotChocolate: + applies-to: version-updates + patterns: + - "HotChocolate.*" + - "StrawberryShake.Server" + Swashbuckle: + applies-to: version-updates + patterns: + - "Swashbuckle.*" + MSTest: + applies-to: version-updates + patterns: + - "MSTest.*" open-pull-requests-limit: 100 + - package-ecosystem: github-actions directory: / target-branch: master diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 5717627962f..b0c69de4d60 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -47,8 +47,17 @@ concurrency: cancel-in-progress: true jobs: + start-gate: + name: CI Start Gate + runs-on: ubuntu-latest + if: (!contains(github.event.head_commit.message, '[TGSRelease]')) + steps: + - name: GitHub Requires at Least One Step for a Job + run: exit 0 + build-releasenotes: name: Build ReleaseNotes for Other Jobs + needs: start-gate runs-on: ubuntu-latest steps: - name: Install Native Dependencies @@ -83,6 +92,7 @@ jobs: code-scanning: name: Run CodeQL + needs: start-gate runs-on: ubuntu-latest permissions: security-events: write @@ -134,6 +144,7 @@ jobs: dmapi-build: name: Build DMAPI + needs: start-gate strategy: fail-fast: false matrix: @@ -222,6 +233,7 @@ jobs: opendream-build: name: Build DMAPI (OpenDream) + needs: start-gate strategy: fail-fast: false matrix: @@ -278,6 +290,7 @@ jobs: efcore-version-match: name: Check Nuget Versions Match Tools runs-on: ubuntu-latest + needs: start-gate steps: - name: Checkout (Branch) uses: actions/checkout@v4 @@ -412,6 +425,7 @@ jobs: docker-build: name: Build Docker Image runs-on: ubuntu-latest + needs: start-gate env: TGS_TELEMETRY_KEY_FILE: tgs_telemetry_key.txt steps: @@ -438,6 +452,7 @@ jobs: linux-unit-tests: name: Linux Tests + needs: start-gate strategy: fail-fast: false matrix: @@ -510,6 +525,7 @@ jobs: windows-unit-tests: name: Windows Tests + needs: start-gate strategy: fail-fast: false matrix: @@ -1260,7 +1276,7 @@ jobs: path: ./code_coverage/integration_tests/windows_integration_tests_release_system_sqlite - name: Upload Coverage to CodeCov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: directory: ./code_coverage fail_ci_if_error: true @@ -1428,6 +1444,7 @@ jobs: build-msi: name: Build Windows Installer .exe runs-on: windows-latest + needs: start-gate env: TGS_TELEMETRY_KEY_FILE: C:/tgs_telemetry_key.txt steps: @@ -1521,6 +1538,10 @@ jobs: if (Test-Path -Path $log -PathType Leaf) { Get-Content $log } + $potentialMsiLog = [System.IO.Path]::GetFullPath("install_000_tgstation_server.msi.log") + if (Test-Path -Path $potentialMsiLog -PathType Leaf) { + Get-Content $potentialMsiLog + } $installCode = $procMain.ExitCode if($installCode -ne 0) { Write-Host "ERROR INSTALLER EXIT CODE $installCode" @@ -1715,6 +1736,12 @@ jobs: body_path: release_notes.md commitish: ${{ github.event.head_commit.id }} + - name: Generate Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./swagger/tgs_api.json + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload OpenApi Spec uses: actions/upload-release-asset@v1 env: @@ -1795,6 +1822,12 @@ jobs: commitish: ${{ github.event.head_commit.id }} prerelease: ${{ env.TGS_GRAPHQL_PRERELEASE }} + - name: Generate Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./schema/tgs_api.graphql + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload GraphQL Schema uses: actions/upload-release-asset@v1 env: @@ -1867,6 +1900,12 @@ jobs: body_path: release_notes.md commitish: ${{ github.event.head_commit.id }} + - name: Generate Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./DMAPI.zip + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload DMAPI Artifact uses: actions/upload-release-asset@v1 env: @@ -2132,7 +2171,19 @@ jobs: body_path: release_notes.md commitish: ${{ github.event.head_commit.id }} - - name: Upload Server Console Artifact + - name: Generate Server Console Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./ServerConsole.zip + github-token: ${{ steps.app-token-generation.outputs.token }} + + - name: Upload Server Console Zip Artifact to Action + uses: actions/upload-artifact@v4 + with: + name: server-console-release + path: ./ServerConsole.zip + + - name: Upload Server Console Artifact to Release uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} @@ -2142,6 +2193,12 @@ jobs: asset_name: ServerConsole.zip asset_content_type: application/zip + - name: Generate Server Service Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./ServerService.zip + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload Server Service Artifact uses: actions/upload-release-asset@v1 env: @@ -2152,6 +2209,12 @@ jobs: asset_name: ServerService.zip asset_content_type: application/zip + - name: Generate DMAPI Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./DMAPI.zip + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload DMAPI Artifact uses: actions/upload-release-asset@v1 env: @@ -2162,6 +2225,12 @@ jobs: asset_name: DMAPI.zip asset_content_type: application/zip + - name: Generate REST API Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./swagger/tgs_api.json + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload REST API Artifact uses: actions/upload-release-asset@v1 env: @@ -2172,6 +2241,12 @@ jobs: asset_name: swagger.json asset_content_type: application/json + - name: Generate GraphQL API Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./schema/tgs-api.graphql + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload GraphQL API Artifact uses: actions/upload-release-asset@v1 env: @@ -2182,6 +2257,12 @@ jobs: asset_name: tgs-api.graphql asset_content_type: text/plain + - name: Generate Server Update Package Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./ServerUpdatePackage.zip + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload Server Update Package Artifact uses: actions/upload-release-asset@v1 env: @@ -2192,7 +2273,13 @@ jobs: asset_name: ServerUpdatePackage.zip asset_content_type: application/zip - - name: Upload Debian Pacakaging Artifact + - name: Generate Debian Packaging Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./packaging-debian/tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz + github-token: ${{ steps.app-token-generation.outputs.token }} + + - name: Upload Debian Packaging Artifact uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ steps.app-token-generation.outputs.token }} @@ -2202,6 +2289,12 @@ jobs: asset_name: tgstation-server-v${{ env.TGS_VERSION }}.debian.packaging.tar.xz asset_content_type: application/x-tar + - name: Generate MariaDB .msi Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/bin/Release/mariadb.msi + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload MariaDB .msi uses: actions/upload-release-asset@v1 env: @@ -2212,6 +2305,12 @@ jobs: asset_name: mariadb-${{ env.MARIADB_VERSION }}-winx64.msi asset_content_type: application/octet-stream + - name: Generate Installer .exe Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: ./build/package/winget/tgstation-server-installer.exe + github-token: ${{ steps.app-token-generation.outputs.token }} + - name: Upload Installer .exe uses: actions/upload-release-asset@v1 env: @@ -2222,6 +2321,58 @@ jobs: asset_name: tgstation-server-installer.exe asset_content_type: application/octet-stream + update-nix: + name: Update Nix SHA + needs: deploy-tgs + runs-on: ubuntu-latest + if: (!(cancelled() || failure())) && needs.deploy-tgs.result == 'success' + steps: + - name: Install Native Packages # Name checked in rerunFlakyTests.js + run: | + sudo apt-get update + sudo apt-get install -y xmlstarlet + + - name: Setup Nix + uses: cachix/install-nix-action@v27 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Checkout + uses: actions/checkout@v4 + + - name: Parse TGS version + run: echo "TGS_VERSION=$(xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsCoreVersion build/Version.props)" >> $GITHUB_ENV + + - name: Retrieve ServerConsole.zip Artifact + uses: actions/download-artifact@v4 + with: + name: server-console-release + path: server-console-release + + - name: Regenerate Nix Hash + run: | + nix hash path ./server-console-release > build/package/nix/ServerConsole.sha256 + cat build/package/nix/ServerConsole.sha256 + + - name: Commit + run: | + git config --global push.default simple + git config user.name "tgstation-server-ci[bot]" + git config user.email "161980869+tgstation-server-ci[bot]@users.noreply.github.com" + git add build/package/nix/ServerConsole.sha256 + git commit -m "Update nix SHA256 for [TGSRelease] v${{ env.TGS_VERSION }}" + + - name: Re-tag + run: | + git tag -d tgstation-server-v${{ env.TGS_VERSION }} + git tag tgstation-server-v${{ env.TGS_VERSION }} + + - name: Push Commit + run: git push + + - name: Force Push Tags + run: git push -f --tags + changelog-regen: name: Regenerate Changelog runs-on: ubuntu-latest @@ -2285,7 +2436,7 @@ jobs: run: echo "TGS_VERSION=$(xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsCoreVersion build/Version.props)" >> $GITHUB_ENV - name: Docker Build and Push - uses: elgohr/Publish-Docker-Github-Action@977fe38375c65e8e3b01d226d72c1f7d488e45dc # v5 + uses: elgohr/Publish-Docker-Github-Action@eb53b3ec07136a6ebaed78d8135806da64f7c7e2 # v5 with: name: tgstation/server username: ${{ secrets.DOCKER_USERNAME }} diff --git a/README.md b/README.md index a03445fd6b5..680f2f2a6bd 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,31 @@ sudo dpkg --add-architecture i386 \ The service will execute as the newly created user: `tgstation-server`. You should, ideally, store your instances somewhere under `/home/tgstation-server`. +##### Nix Flake + +TGS supports being setup on Nix starting with version 6.12.0. Add the [flake](./build/package/nix/flake.nix) to your own system by adding the following code to your flake inputs. +```nix + tgstation-server = { + url = "github:tgstation/tgstation-server/tgstation-server-v${version}?dir=build/package/nix"; + }; +``` + +Where `version` is the latest major TGS version you wish to use. + +Note that changing this version does not change the core version of TGS used after the first launch. Instead, have TGS self-update via its API. + +For maximum game server uptime, do NOT modify this version unless you are doing a major TGS version update in which case it is a requirement. + +Configure TGS by setting up its service definition: +```nix + services.tgstation-server = { + enable = true; + production-appsettings = (builtins.readFile ./path/to/your/appsettings.Production.yml); + }; +``` + +Refer to [tgstation-server.nix](./build/package/nix/tgstation-server.nix) for the full list of available configuration options. + ##### Manual Setup The following dependencies are required. @@ -243,6 +268,8 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will - `General:SkipAddingByondFirewallException`: Set to `true` if you have Windows firewall disabled +- `General:AdditionalEventScriptsDirectories`: An array of directories that are considered to contain EventScripts alongside instance directories. Working directory for executed scripts will remain the instance EventScripts directory. + - `Session:HighPriorityLiveDreamDaemon`: Boolean controlling if live DreamDaemon instances get set to above normal priority processes. - `Session:LowPriorityDeploymentProcesses `: Boolean controlling if DreamMaker and API validation DreamDaemon instances get set to below normal priority processes. @@ -292,7 +319,7 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will - `Swarm:UpdateRequiredNodeCount`: Should be set to the total number of servers in your swarm minus 1. Prevents updates from occurring unless the non-controller server count in the swarm is greater than or equal to this value. -- `Security:OAuth:`: Sets the OAuth client ID and secret for a given ``. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` 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: +- `Security:OAuth:`: Sets the OAuth client ID and secret for a given ``. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry: ```yml Security: OAuth: @@ -301,6 +328,7 @@ Security: ClientSecret: "..." RedirectUrl: "..." ServerUrl: "..." + Gateway: Disabled # Can be one of `Disabled` disallowing gateway auth (default), `Enabled` allowing gateway auth, or `Only` allowing gateway auth and disabling OAuth logins with this provider UserInformationUrlOverride: "..." # For power users, leave out of configuration for most cases. Not supported by GitHub provider. ``` The following providers use the `RedirectUrl` setting: @@ -315,6 +343,8 @@ The following providers use the `ServerUrl` setting: - Keycloak - InvisionCommunity +Gateway auth simply allows the users to authenticate with the service using the configuration you provide and have their impersonation token passed back to the client. An example of this is using GitHub gateway auth to allow clients to enumerate pull requests without getting rate limited. + - `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list - `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository. @@ -637,7 +667,7 @@ This functionality has the following prerequisites: Here are tools for interacting with the TGS web API - [tgstation-server-webpanel](https://github.com/tgstation/tgstation-server-webpanel): Official client and included with the server. 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.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Deprecated 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. 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. diff --git a/build/TestCommon.props b/build/TestCommon.props index 719e316c8ea..c48c4b6acb1 100644 --- a/build/TestCommon.props +++ b/build/TestCommon.props @@ -13,14 +13,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - + diff --git a/build/Version.props b/build/Version.props index a6146fedf06..8209b0f18a0 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,19 +3,18 @@ - 6.11.4 - 5.3.0 - 10.10.0 - 0.2.0 + 6.12.0 + 5.4.0 + 10.12.0 + 0.5.0 7.0.0 - 16.1.0 - 19.1.0 + 17.0.0 + 20.0.0 7.3.0 5.10.0 - 1.5.0 - 7.0.0 + 1.6.0 + 8.0.0 1.2.1 - 2.0.0 netstandard2.0 8 diff --git a/build/WebpanelVersion.props b/build/WebpanelVersion.props index d3c0595003f..d03002d8802 100644 --- a/build/WebpanelVersion.props +++ b/build/WebpanelVersion.props @@ -1,6 +1,6 @@ - 6.4.0 + 6.7.3 diff --git a/build/package/nix/ServerConsole.sha256 b/build/package/nix/ServerConsole.sha256 new file mode 100644 index 00000000000..ae3d91ae7d9 --- /dev/null +++ b/build/package/nix/ServerConsole.sha256 @@ -0,0 +1 @@ +sha256-mHlRHPSeZxyJPqN3KUmc0ftYNZgh81LauIu+fCSKPUI= diff --git a/build/package/nix/flake.nix b/build/package/nix/flake.nix new file mode 100644 index 00000000000..f2bf8c14a93 --- /dev/null +++ b/build/package/nix/flake.nix @@ -0,0 +1,13 @@ +{ + description = "tgstation-server"; + + inputs = {}; + + outputs = { ... }: { + nixosModules = { + default = { ... }: { + imports = [ ./tgstation-server.nix ]; + }; + }; + }; +} diff --git a/build/package/nix/package.nix b/build/package/nix/package.nix new file mode 100644 index 00000000000..7d04f757a4b --- /dev/null +++ b/build/package/nix/package.nix @@ -0,0 +1,121 @@ +{ + pkgs, + ... +}: + +let + inherit (pkgs) stdenv lib; + + versionParse = stdenv.mkDerivation { + pname = "tgstation-server-version-parse"; + version = "1.0.0"; + + meta = with pkgs.lib; { + description = "Version parser for tgstation-server"; + homepage = "https://github.com/tgstation/tgstation-server"; + changelog = "https://github.com/tgstation/tgstation-server/blob/gh-pages/changelog.yml"; + license = licenses.agpl3Plus; + platforms = platforms.x86_64; + }; + + nativeBuildInputs = with pkgs; [ + xmlstarlet + ]; + + src = ./../..; + + installPhase = '' + mkdir -p $out + xmlstarlet sel -N X="http://schemas.microsoft.com/developer/msbuild/2003" --template --value-of /X:Project/X:PropertyGroup/X:TgsCoreVersion ./Version.props > $out/tgs_version.txt + ''; + }; + + fixedOutput = stdenv.mkDerivation { + pname = "tgstation-server-release-server-console-zip"; + version = (builtins.readFile "${versionParse}/tgs_version.txt"); + + meta = with pkgs.lib; { + description = "Host watchdog binaries for tgstation-server"; + homepage = "https://github.com/tgstation/tgstation-server"; + changelog = "https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v${version}"; + license = licenses.agpl3Plus; + platforms = platforms.x86_64; + }; + + nativeBuildInputs = with pkgs; [ + curl + cacert + versionParse + ]; + + src = ./.; + + buildPhase = '' + curl -L https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v${version}/ServerConsole.zip -o ServerConsole.zip + ''; + + installPhase = '' + mkdir -p $out + mv ServerConsole.zip $out/ServerConsole.zip + ''; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = (builtins.readFile ./ServerConsole.sha256); + }; + rpath = lib.makeLibraryPath [ pkgs.stdenv_32bit.cc.cc.lib ]; +in +stdenv.mkDerivation { + pname = "tgstation-server"; + version = (builtins.readFile "${versionParse}/tgs_version.txt"); + + meta = with pkgs.lib; { + description = "A production scale tool for DreamMaker server management"; + homepage = "https://github.com/tgstation/tgstation-server"; + changelog = "https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v${version}"; + license = licenses.agpl3Plus; + platforms = platforms.x86_64; + }; + + buildInputs = with pkgs; [ + dotnetCorePackages.sdk_8_0 + gdb + systemd + zlib + gcc_multi + glibc + bash + ]; + nativeBuildInputs = with pkgs; [ + makeWrapper + unzip + fixedOutput + versionParse + ]; + + src = ./.; + + installPhase = '' + mkdir -p $out/bin + unzip "${fixedOutput}/ServerConsole.zip" -d $out/bin + rm -rf $out/bin/lib + makeWrapper ${pkgs.dotnetCorePackages.sdk_8_0}/dotnet $out/bin/tgstation-server --suffix PATH : ${ + lib.makeBinPath ( + with pkgs; + [ + dotnetCorePackages.sdk_8_0 + gdb + bash + ] + ) + } --suffix LD_LIBRARY_PATH : ${ + lib.makeLibraryPath ( + with pkgs; + [ + systemd + zlib + ] + ) + } --add-flags "$out/bin/Tgstation.Server.Host.Console.dll --bootstrap" + ''; +} diff --git a/build/package/nix/tgstation-server.nix b/build/package/nix/tgstation-server.nix new file mode 100644 index 00000000000..745391341a6 --- /dev/null +++ b/build/package/nix/tgstation-server.nix @@ -0,0 +1,144 @@ +inputs@{ + config, + lib, + nixpkgs, + pkgs, + writeShellScriptBin, + ... +}: + +let + pkgs-i686 = nixpkgs.legacyPackages.i686-linux; + + cfg = config.services.tgstation-server; + + package = import ./package.nix inputs; + + stdenv = pkgs-i686.stdenv_32bit; + + rpath = pkgs-i686.lib.makeLibraryPath [ + stdenv.cc.cc.lib + ]; + + byond-patcher = pkgs-i686.writeShellScriptBin "EngineInstallComplete-050-TgsPatchELFByond.sh" '' + # If the file doesn't exist, assume OD + if [[ ! -f ../../Byond/$1/byond/bin/DreamDaemon ]] ; then + echo "DreamDaemon doesn't appear to exist. Assuming OD install" + exit + fi + + BYOND_PATH=$(realpath ../../Byond/$1/byond/bin/) + + ${pkgs.patchelf}/bin/patchelf --set-interpreter "$(cat ${stdenv.cc}/nix-support/dynamic-linker)" \ + --set-rpath "$BYOND_PATH:${rpath}" \ + $BYOND_PATH/{DreamDaemon,DreamDownload,DreamMaker} + ''; + + tgs-wrapper = pkgs.writeShellScriptBin "tgs-path-wrapper" '' + export PATH=$PATH:${cfg.extra-path} + exec ${package}/bin/tgstation-server --appsettings-base-path=/etc/tgstation-server.d --General:SetupWizardMode=Never --General:AdditionalEventScriptsDirectories:0=/etc/tgstation-server.d/EventScripts --General:AdditionalEventScriptsDirectories:1=${byond-patcher}/bin + ''; +in +{ + ##### interface. here we define the options that users of our service can specify + options = { + # the options for our service will be located under services.foo + services.tgstation-server = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to enable tgstation-server. + ''; + }; + + username = lib.mkOption { + type = lib.types.str; + default = "tgstation-server"; + description = '' + The name of the user used to execute tgstation-server. + ''; + }; + + groupname = lib.mkOption { + type = lib.types.str; + default = "tgstation-server"; + description = '' + The name of group the user used to execute tgstation-server will belong to. + ''; + }; + + home-directory = lib.mkOption { + type = lib.types.str; + default = "/home/tgstation-server"; + description = '' + The home directory of TGS. Should be persistent. + ''; + }; + + production-appsettings = lib.mkOption { + type = lib.types.lines; + default = ''''; + description = '' + The contents of appsettings.Production.yml in the /etc/tgstation-server.d directory. + ''; + }; + + extra-path = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + Extra PATH entries to add to the TGS process + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + users.groups."${cfg.groupname}" = { }; + + users.users."${cfg.username}" = { + isSystemUser = true; + createHome = true; + group = cfg.groupname; + home = cfg.home-directory; + }; + + environment.etc = { + "tgstation-server.d/appsettings.yml" = { + text = (builtins.readFile "${package}/bin/appsettings.yml"); + group = cfg.groupname; + mode = "0644"; + }; + "tgstation-server.d/appsettings.Production.yml" = { + text = cfg.production-appsettings; + group = cfg.groupname; + mode = "0640"; + }; + "tgstation-server.d/EventScripts/README.txt" = { + text = "TGS event scripts placed here will be executed by all online instances"; + group = cfg.groupname; + mode = "0640"; + }; + }; + + systemd.services.tgstation-server = { + description = "tgstation-server"; + serviceConfig = { + User = cfg.username; + Type = "notify-reload"; + NotifyAccess = "all"; + WorkingDirectory = "${package}/bin"; + ExecStart = "${tgs-wrapper}/bin/tgs-path-wrapper"; + Restart = "always"; + KillMode = "process"; + ReloadSignal = "SIGUSR2"; + AmbientCapabilities = "CAP_SYS_NICE CAP_SYS_PTRACE"; + WatchdogSec = "60"; + WatchdogSignal = "SIGTERM"; + LogsDirectory = "tgstation-server"; + }; + wantedBy = [ "multi-user.target" ]; + }; + }; +} diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Bundle.wxs b/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Bundle.wxs index 9caccc81aee..ac3386ff60e 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Bundle.wxs +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix.Bundle/Bundle.wxs @@ -1,7 +1,7 @@ - + diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/InstallationExtensions.cs b/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/InstallationExtensions.cs index d6a46b77006..ddecf51c9a0 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/InstallationExtensions.cs +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix.Extensions/InstallationExtensions.cs @@ -16,7 +16,7 @@ public static class InstallationExtensions /// /// Package name /// - /// As much as I'd like to use Tgstation.Server.Common.Constants.CanonicalPackageName here, attempting to reference it makes Tgstation.Server.Migrator.Comms fail due to referencing the net2.0 version of that library. EVEN THOUGH IT'S A TRANSITIVE DEPENDENCY OF Tgstation.Server.Client!!!!! If that dead-ass tool has been removed, feel free to do this. + /// As much as I'd like to use Tgstation.Server.Common.Constants.CanonicalPackageName here, attempting to reference it makes VS go crazy with fake errors. const string CanonicalPackageName = "tgstation-server"; /// diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix/Package.wxs b/build/package/winget/Tgstation.Server.Host.Service.Wix/Package.wxs index 240250b62f4..bde9181f975 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix/Package.wxs +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix/Package.wxs @@ -48,6 +48,10 @@ + + + + diff --git a/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj b/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj index 9ca13531e21..272694a58e5 100644 --- a/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj +++ b/build/package/winget/Tgstation.Server.Host.Service.Wix/Tgstation.Server.Host.Service.Wix.wixproj @@ -18,11 +18,6 @@ true true - - ServiceHostWatchdogComponentGroup - ApplicationDirectory - true - diff --git a/src/Tgstation.Server.Api/Models/Instance.cs b/src/Tgstation.Server.Api/Models/Instance.cs index 3881eec855d..dbcd7e5fe80 100644 --- a/src/Tgstation.Server.Api/Models/Instance.cs +++ b/src/Tgstation.Server.Api/Models/Instance.cs @@ -40,7 +40,7 @@ public abstract class Instance : NamedEntity /// /// Updates will not be triggered if the previous update is still running. Incompatible with . [Required] - [StringLength(Limits.MaximumStringLength)] + [StringLength(Limits.CronStringLength)] public string? AutoUpdateCron { get; set; } /// @@ -48,5 +48,21 @@ public abstract class Instance : NamedEntity /// [Required] public ushort? ChatBotLimit { get; set; } + + /// + /// A cron expression indicating when the game server should start. Must be a valid 6 part cron schedule (SECONDS MINUTES HOURS DAY/MONTH MONTH DAY/WEEK). Empty disables. + /// + /// This will have no effect if the game server is already running when it fires. + [Required] + [StringLength(Limits.CronStringLength)] + public string? AutoStartCron { get; set; } + + /// + /// A cron expression indicating when the game server should stop. Must be a valid 6 part cron schedule (SECONDS MINUTES HOURS DAY/MONTH MONTH DAY/WEEK). Empty disables. + /// + /// This will have no effect if the game server is not running when it fires. + [Required] + [StringLength(Limits.CronStringLength)] + public string? AutoStopCron { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Limits.cs b/src/Tgstation.Server.Api/Models/Limits.cs index abe7dd0da89..0ef931c6362 100644 --- a/src/Tgstation.Server.Api/Models/Limits.cs +++ b/src/Tgstation.Server.Api/Models/Limits.cs @@ -12,6 +12,11 @@ public static class Limits /// public const int MaximumStringLength = 10000; + /// + /// Length limit for cron strings in fields. + /// + public const int CronStringLength = 1000; + /// /// Length limit for s. /// diff --git a/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs index dd34dbe536e..6a45bbc6fcb 100644 --- a/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs +++ b/src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs @@ -22,5 +22,10 @@ public sealed class OAuthProviderInfo /// [ResponseOptions] public Uri? ServerUrl { get; set; } + + /// + /// If the OAuth provider may only be used for gateway authentication. If the OAuth provider may be used for server logins or gateway authentication. If the OAuth provider may only be used for server logins. + /// + public bool? GatewayOnly { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs b/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs new file mode 100644 index 00000000000..0d8b5775d7a --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Api.Models.Response +{ + /// + /// Success result for an OAuth gateway login attempt. + /// + public sealed class OAuthGatewayResponse + { + /// + /// The user's access token for the requested OAuth service. + /// + public string? AccessCode { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs index 37d80915bd2..7cee5afbdee 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs @@ -67,5 +67,15 @@ public enum InstanceManagerRights : ulong /// User can give themselves or their group full on ALL instances. /// GrantPermissions = 1 << 10, + + /// + /// User can change . + /// + SetAutoStart = 1 << 11, + + /// + /// User can change . + /// + SetAutoStop = 1 << 12, } } diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 73092b2ba6e..57ca211fbda 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -28,9 +28,10 @@ - + + diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql new file mode 100644 index 00000000000..2b23e65443c --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RepositoryBasedServerUpdate.graphql @@ -0,0 +1,11 @@ +mutation RepositoryBasedServerUpdate($targetVersion: Semver!) { + changeServerNodeVersionViaTrackedRepository(input: { targetVersion: $targetVersion }) { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql new file mode 100644 index 00000000000..62b9fea1fc6 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Mutations/RestartServer.graphql @@ -0,0 +1,11 @@ +mutation RestartServer() { + restartServerNode() { + errors { + ... on ErrorMessageError { + additionalData + errorCode + message + } + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql new file mode 100644 index 00000000000..dfa34982160 --- /dev/null +++ b/src/Tgstation.Server.Client.GraphQL/GQL/Queries/GetUpdateInformation.graphql @@ -0,0 +1,10 @@ +query GetUpdateInformation($forceFresh: Boolean!) { + swarm { + updateInformation { + generatedAt + latestVersion(forceFresh: $forceFresh) + updateInProgress + trackedRepositoryUrl(forceFresh: $forceFresh) + } + } +} diff --git a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj index 780cbbcd7cf..2de8741b519 100644 --- a/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj +++ b/src/Tgstation.Server.Client.GraphQL/Tgstation.Server.Client.GraphQL.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql index 8991e1fa70c..81915bf7e45 100644 --- a/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql +++ b/src/Tgstation.Server.Client.GraphQL/schema.extensions.graphql @@ -16,3 +16,4 @@ extend schema @key(fields: "id") extend scalar UnsignedInt @serializationType(name: "global::System.UInt32") @runtimeType(name: "global::System.UInt32") extend scalar Semver @serializationType(name: "global::System.String") @runtimeType(name: "global::System.Version") extend scalar Jwt @serializationType(name: "global::System.String") @runtimeType(name: "global::Microsoft.IdentityModel.JsonWebTokens.JsonWebToken") +extend scalar FileUploadTicket @serializationType(name: "global::System.String") @runtimeType(name: "global::System.String") diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 5046c091a88..91278a6dcde 100644 --- a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj +++ b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/src/Tgstation.Server.Common/Tgstation.Server.Common.csproj b/src/Tgstation.Server.Common/Tgstation.Server.Common.csproj index b23195dfca9..4b119caf8b1 100644 --- a/src/Tgstation.Server.Common/Tgstation.Server.Common.csproj +++ b/src/Tgstation.Server.Common/Tgstation.Server.Common.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Tgstation.Server.Host.Common/DotnetHelper.cs b/src/Tgstation.Server.Host.Common/DotnetHelper.cs index 5945eac25dc..c4edb2d3bf5 100644 --- a/src/Tgstation.Server.Host.Common/DotnetHelper.cs +++ b/src/Tgstation.Server.Host.Common/DotnetHelper.cs @@ -14,10 +14,10 @@ namespace Tgstation.Server.Host.Common public static class DotnetHelper { /// - /// Gets the path to the dotnet executable. + /// Gets potential paths to the dotnet executable. /// /// If the current system is a Windows OS. - /// The path to the dotnet executable. + /// An of potential paths to the dotnet executable. public static IEnumerable GetPotentialDotnetPaths(bool isWindows) { var enviromentPath = Environment.GetEnvironmentVariable("PATH"); 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 ff23533a163..57b784056e3 100644 --- a/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj +++ b/src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj @@ -4,6 +4,7 @@ Exe $(TgsFrameworkVersion) + $(TgsCoreVersion) enable false @@ -12,9 +13,9 @@ - + - + diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index 4a9d39fcd21..53623ff2b80 100644 --- a/src/Tgstation.Server.Host.Service/Program.cs +++ b/src/Tgstation.Server.Host.Service/Program.cs @@ -190,10 +190,9 @@ void InvokeSC(string? serviceToUninstall) // Mimicing Tgstation.Server.Host.Service.Wix here, which is the source of truth for this data installer.Context = new InstallContext( Path.Combine(programDataDirectory, $"tgs-install-{Guid.NewGuid()}.log"), - new[] - { + [ $"assemblypath=\"{exePath}\"{(String.IsNullOrWhiteSpace(PassthroughArgs) ? String.Empty : $" -p=\"{PassthroughArgs}\"")}", - }); + ]); installer.Description = $"{Server.Common.Constants.CanonicalPackageName} running as a Windows service."; installer.DisplayName = Server.Common.Constants.CanonicalPackageName; installer.StartType = ServiceStartMode.Automatic; 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 417e03092ca..80a62d106df 100644 --- a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj +++ b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj @@ -7,6 +7,7 @@ enable $(TgsFrameworkVersion) + $(TgsCoreVersion) false @@ -20,15 +21,21 @@ - + - + - + + + + + + + - + diff --git a/src/Tgstation.Server.Host.Watchdog/BootstrapSettings.cs b/src/Tgstation.Server.Host.Watchdog/BootstrapSettings.cs new file mode 100644 index 00000000000..10c9ab11b4f --- /dev/null +++ b/src/Tgstation.Server.Host.Watchdog/BootstrapSettings.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; + +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Host.Watchdog +{ + /// + /// Settings for the bootstrapper feature. + /// + sealed class BootstrapSettings + { + /// + /// The current supported major version of . + /// + public const int FileMajorVersion = 1; + + /// + /// The token used to substitute . + /// + public const string VersionSubstitutionToken = "${version}"; + + /// + /// The version of the boostrapper file. + /// + public Version FileVersion { get; set; } = new Version(FileMajorVersion, 0, 0); + + /// + /// The of TGS last launched in the lib/Default directory. + /// + public Version TgsVersion { get; set; } = Assembly.GetEntryAssembly()!.GetName().Version!.Semver(); + + /// + /// The URL to format with to get the download URL. + /// + public string ServerUpdatePackageUrlFormatter { get; set; } = $"https://github.com/tgstation/tgstation-server/releases/download/tgstation-server-v{VersionSubstitutionToken}/ServerUpdatePackage.zip"; + } +} 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 dad6a144544..7b46bb024a6 100644 --- a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj +++ b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs index 4813383440d..09a985c9ddf 100644 --- a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs @@ -3,9 +3,12 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Net.Http; using System.Reflection; using System.Runtime.InteropServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -89,7 +92,10 @@ public async ValueTask RunAsync(bool runConfigure, string[] args, Cancella return false; } - var assemblyStoragePath = Path.Combine(rootLocation, "lib"); // always always next to watchdog + var bootstrappable = args.Contains("--bootstrap", StringComparer.OrdinalIgnoreCase); + var homeDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".tgstation-server"); + + var assemblyStoragePath = Path.Combine(bootstrappable ? homeDirectory : rootLocation, "lib"); // always always next to watchdog var defaultAssemblyPath = Path.GetFullPath(Path.Combine(assemblyStoragePath, "Default")); @@ -119,6 +125,47 @@ public async ValueTask RunAsync(bool runConfigure, string[] args, Cancella else Directory.CreateDirectory(assemblyStoragePath); + var bootstrapperSettingsFile = Path.Combine(homeDirectory, "bootstrap.json"); + BootstrapSettings? bootstrapSettings = null; + if (!Directory.Exists(defaultAssemblyPath)) + { + if (!bootstrappable) + { + logger.LogCritical("Unable to locate host assembly directory!"); + return false; + } + + if (File.Exists(bootstrapperSettingsFile)) + { + logger.LogInformation("Loading bootstrap settings..."); + var bootstrapperSettingsJson = await File.ReadAllTextAsync(bootstrapperSettingsFile, cancellationToken); + bootstrapSettings = JsonSerializer.Deserialize(bootstrapperSettingsJson); + if (bootstrapSettings == null) + { + logger.LogCritical("Failed to deserialize {settingsFile}!", bootstrapperSettingsFile); + return false; + } + } + else + { + logger.LogInformation("Using default bootstrap settings..."); + bootstrapSettings = new BootstrapSettings(); // defaults + } + + if (bootstrapSettings.FileVersion.Major != BootstrapSettings.FileMajorVersion) + { + logger.LogCritical("Unable to parse bootstrapper file! Expected version: {expected}.X.X", BootstrapSettings.FileMajorVersion); + return false; + } + + string downloadUrl = bootstrapSettings.ServerUpdatePackageUrlFormatter.Replace(BootstrapSettings.VersionSubstitutionToken, bootstrapSettings.TgsVersion.ToString(), StringComparison.Ordinal); + logger.LogInformation("Bootstrapping from: {url}", downloadUrl); + using var httpClient = new HttpClient(); + await using var zipData = await httpClient.GetStreamAsync(new Uri(downloadUrl), cancellationToken); + using var archive = new ZipArchive(zipData, ZipArchiveMode.Read, true); + archive.ExtractToDirectory(defaultAssemblyPath); + } + var assemblyName = String.Join(".", nameof(Tgstation), nameof(Server), nameof(Host), "dll"); var assemblyPath = Path.Combine(defaultAssemblyPath, assemblyName); @@ -128,28 +175,59 @@ public async ValueTask RunAsync(bool runConfigure, string[] args, Cancella return false; } - if (!File.Exists(assemblyPath)) - { - logger.LogCritical("Unable to locate host assembly!"); - return false; - } + var watchdogVersion = executingAssembly.GetName().Version?.Semver().ToString(); - var fileVersion = FileVersionInfo.GetVersionInfo(assemblyPath).FileVersion; - if (fileVersion == null) + while (!cancellationToken.IsCancellationRequested) { - logger.LogCritical("Failed to parse version info from {assemblyPath}!", assemblyPath); - return false; - } + if (!File.Exists(assemblyPath)) + { + logger.LogCritical("Unable to locate host assembly!"); + return false; + } - initialHostVersionTcs.SetResult( - Version.Parse( - fileVersion)); + var fileVersion = FileVersionInfo.GetVersionInfo(assemblyPath).FileVersion; + if (fileVersion == null) + { + logger.LogCritical("Failed to parse version info from {assemblyPath}!", assemblyPath); + return false; + } - var watchdogVersion = executingAssembly.GetName().Version?.Semver().ToString(); + if (bootstrappable) + { + if (!Version.TryParse(fileVersion, out var bootstrappedVersion)) + { + logger.LogCritical("Failed to parse bootstrapped version prior to launch: {fileVersion}", fileVersion); + } + else + { + // save bootstrapper settings + var oldUrl = bootstrapSettings?.ServerUpdatePackageUrlFormatter; + bootstrapSettings = new BootstrapSettings + { + TgsVersion = bootstrappedVersion.Semver(), + }; + + bootstrapSettings.ServerUpdatePackageUrlFormatter = oldUrl ?? bootstrapSettings.ServerUpdatePackageUrlFormatter; + + Directory.CreateDirectory(homeDirectory); + await File.WriteAllTextAsync( + bootstrapperSettingsFile, + JsonSerializer.Serialize( + bootstrapSettings, + new JsonSerializerOptions + { + WriteIndented = true, + }), + cancellationToken); + } + } - while (!cancellationToken.IsCancellationRequested) using (logger.BeginScope("Host invocation")) { + initialHostVersionTcs.SetResult( + Version.Parse( + fileVersion)); + updateDirectory = Path.GetFullPath(Path.Combine(assemblyStoragePath, Guid.NewGuid().ToString())); logger.LogInformation("Update path set to {updateDirectory}", updateDirectory); using (var process = new Process()) @@ -358,6 +436,7 @@ public async ValueTask RunAsync(bool runConfigure, string[] args, Cancella } } } + } } catch (OperationCanceledException ex) { diff --git a/src/Tgstation.Server.Host/.config/dotnet-tools.json b/src/Tgstation.Server.Host/.config/dotnet-tools.json index 6b7095305f3..02afa3f0723 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": "8.0.10", + "version": "8.0.11", "commands": [ "dotnet-ef" ] diff --git a/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs new file mode 100644 index 00000000000..76c7fc937bb --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/AdministrationAuthority.cs @@ -0,0 +1,243 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +using Octokit; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Transfer; +using Tgstation.Server.Host.Utils.GitHub; + +namespace Tgstation.Server.Host.Authority +{ + /// + sealed class AdministrationAuthority : AuthorityBase, IAdministrationAuthority + { + /// + /// Default for s. + /// + const string OctokitException = "Bad GitHub API response, check configuration!"; + + /// + /// The key for . + /// + static readonly object ReadCacheKey = new(); + + /// + /// The for the . + /// + readonly IGitHubServiceFactory gitHubServiceFactory; + + /// + /// The for the . + /// + readonly IServerControl serverControl; + + /// + /// The for the . + /// + readonly IServerUpdateInitiator serverUpdateInitiator; + + /// + /// The for the . + /// + readonly IFileTransferTicketProvider fileTransferService; + + /// + /// The for the . + /// + readonly IMemoryCache cacheService; + + /// + /// Initializes a new instance of the class. + /// + /// The to use. + /// The to use. + /// The to use. + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public AdministrationAuthority( + IAuthenticationContext authenticationContext, + IDatabaseContext databaseContext, + ILogger logger, + IGitHubServiceFactory gitHubServiceFactory, + IServerControl serverControl, + IServerUpdateInitiator serverUpdateInitiator, + IFileTransferTicketProvider fileTransferService, + IMemoryCache cacheService) + : base( + authenticationContext, + databaseContext, + logger) + { + this.gitHubServiceFactory = gitHubServiceFactory ?? throw new ArgumentNullException(nameof(gitHubServiceFactory)); + this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); + this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); + this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); + this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + } + + /// + public async ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken) + { + try + { + async Task CacheFactory() + { + Version? greatestVersion = null; + Uri? repoUrl = null; + var scopeCancellationToken = CancellationToken.None; // DCT: None available + try + { + var gitHubService = await gitHubServiceFactory.CreateService(scopeCancellationToken); + var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(scopeCancellationToken); + var releases = await gitHubService.GetTgsReleases(scopeCancellationToken); + + foreach (var kvp in releases) + { + var version = kvp.Key; + var release = kvp.Value; + if (version.Major > 3 // Forward/backward compatible but not before TGS4 + && (greatestVersion == null || version > greatestVersion)) + greatestVersion = version; + } + + repoUrl = await repositoryUrlTask; + } + catch (NotFoundException e) + { + Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); + } + + return new AdministrationResponse + { + LatestVersion = greatestVersion, + TrackedRepositoryUrl = repoUrl, + GeneratedAt = DateTimeOffset.UtcNow, + }; + } + + var ttl = TimeSpan.FromMinutes(30); + Task task; + if (forceFresh || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) + { + using var entry = cacheService.CreateEntry(ReadCacheKey); + entry.AbsoluteExpirationRelativeToNow = ttl; + entry.Value = task = CacheFactory(); + } + else + task = (Task)rawCacheObject!; + + var result = await task.WaitAsync(cancellationToken); + return new AuthorityResponse(result); + } + catch (RateLimitExceededException e) + { + return RateLimit(e); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + } + + /// + public async ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken) + { + var attemptingUpload = uploadZip == true; + if (attemptingUpload) + { + if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) + return Forbid(); + } + else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) + return Forbid(); + + if (targetVersion.Major < 4) + return BadRequest(ErrorCode.CannotChangeServerSuite); + + if (!serverControl.WatchdogPresent) + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + + IFileUploadTicket? uploadTicket = attemptingUpload + ? fileTransferService.CreateUpload(FileUploadStreamKind.None) + : null; + + ServerUpdateResult updateResult; + try + { + try + { + updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, targetVersion, cancellationToken); + } + catch + { + if (attemptingUpload) + await uploadTicket!.DisposeAsync(); + + throw; + } + } + catch (RateLimitExceededException ex) + { + return RateLimit(ex); + } + catch (ApiException e) + { + Logger.LogWarning(e, OctokitException); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.RemoteApiError) + { + AdditionalData = e.Message, + }, + HttpFailureResponse.FailedDependency); + } + + return updateResult switch + { + ServerUpdateResult.Started => new AuthorityResponse(new ServerUpdateResponse(targetVersion, uploadTicket?.Ticket.FileTicket), HttpSuccessResponse.Accepted), + ServerUpdateResult.ReleaseMissing => Gone(), + ServerUpdateResult.UpdateInProgress => BadRequest(ErrorCode.ServerUpdateInProgress), + ServerUpdateResult.SwarmIntegrityCheckFailed => new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed), + HttpFailureResponse.FailedDependency), + _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), + }; + } + + /// + public async ValueTask TriggerServerRestart() + { + if (!serverControl.WatchdogPresent) + { + Logger.LogDebug("Restart request failed due to lack of host watchdog!"); + return new AuthorityResponse( + new ErrorMessageResponse(ErrorCode.MissingHostWatchdog), + HttpFailureResponse.UnprocessableEntity); + } + + await serverControl.Restart(); + return new AuthorityResponse(); + } + } +} diff --git a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs index f0e39151a39..0ea01a89da0 100644 --- a/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs +++ b/src/Tgstation.Server.Host/Authority/Core/AuthorityBase.cs @@ -60,7 +60,7 @@ protected static AuthorityResponse Unauthorized() /// A new, errored . protected static AuthorityResponse Gone() => new( - new ErrorMessageResponse(), + new ErrorMessageResponse(ErrorCode.ResourceNotPresent), HttpFailureResponse.Gone); /// @@ -80,7 +80,7 @@ protected static AuthorityResponse Forbid() /// A new, errored . protected static AuthorityResponse NotFound() => new( - new ErrorMessageResponse(), + new ErrorMessageResponse(ErrorCode.ResourceNeverPresent), HttpFailureResponse.NotFound); /// diff --git a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs index 5aa3c18089d..af2956826e1 100644 --- a/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs +++ b/src/Tgstation.Server.Host/Authority/Core/HttpFailureResponse.cs @@ -59,5 +59,10 @@ public enum HttpFailureResponse /// HTTP 501. /// NotImplemented, + + /// + /// HTTP 503. + /// + ServiceUnavailable, } } diff --git a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs index 680dc98a4ef..52532c2f0eb 100644 --- a/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs +++ b/src/Tgstation.Server.Host/Authority/Core/RestAuthorityInvoker{TAuthority}.cs @@ -55,7 +55,7 @@ static IActionResult CreateSuccessfulActionResult(ApiControl return failureResponse switch { HttpFailureResponse.BadRequest => controller.BadRequest(errorMessage), - HttpFailureResponse.Unauthorized => controller.Unauthorized(errorMessage), + HttpFailureResponse.Unauthorized => controller.Unauthorized(), HttpFailureResponse.Forbidden => controller.Forbid(), HttpFailureResponse.NotFound => controller.NotFound(errorMessage), HttpFailureResponse.NotAcceptable => controller.StatusCode(HttpStatusCode.NotAcceptable, errorMessage), @@ -65,6 +65,7 @@ static IActionResult CreateSuccessfulActionResult(ApiControl HttpFailureResponse.FailedDependency => controller.StatusCode(HttpStatusCode.FailedDependency, errorMessage), HttpFailureResponse.RateLimited => controller.StatusCode(HttpStatusCode.TooManyRequests, errorMessage), HttpFailureResponse.NotImplemented => controller.StatusCode(HttpStatusCode.NotImplemented, errorMessage), + HttpFailureResponse.ServiceUnavailable => controller.StatusCode(HttpStatusCode.ServiceUnavailable, errorMessage), _ => throw new InvalidOperationException($"Invalid {nameof(HttpFailureResponse)}: {failureResponse}"), }; } diff --git a/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs new file mode 100644 index 00000000000..7fb28925b54 --- /dev/null +++ b/src/Tgstation.Server.Host/Authority/IAdministrationAuthority.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority.Core; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Authority +{ + /// + /// for administrative server operations. + /// + public interface IAdministrationAuthority : IAuthority + { + /// + /// Gets the containing server update information. + /// + /// Bypass the caching that the authority performs for this request, forcing it to contact GitHub. + /// The for the operation. + /// A resulting in the . + [TgsAuthorize(AdministrationRights.ChangeVersion)] + ValueTask> GetUpdateInformation(bool forceFresh, CancellationToken cancellationToken); + + /// + /// Triggers a restart of tgstation-server without terminating running game instances, setting its version to a given . + /// + /// The TGS will switch to upon reboot. + /// If a will be returned and the call must provide an uploaded zip file containing the update data to the file transfer service. + /// The for the operation. + /// A resulting in the . + [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] + ValueTask> TriggerServerVersionChange(Version targetVersion, bool uploadZip, CancellationToken cancellationToken); + + /// + /// Triggers a restart of tgstation-server without terminating running game instances. + /// + /// A resulting in the . + [TgsAuthorize(AdministrationRights.RestartHost)] + ValueTask TriggerServerRestart(); + } +} diff --git a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs index b55e94be643..0558bbfaa02 100644 --- a/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/ILoginAuthority.cs @@ -12,10 +12,17 @@ namespace Tgstation.Server.Host.Authority public interface ILoginAuthority : IAuthority { /// - /// Attempt to login to the server with the current crentials. + /// Attempt to login to the server with the current Basic or OAuth credentials. /// /// The for the operation. - /// A resulting in a and . + /// A resulting in a . ValueTask> AttemptLogin(CancellationToken cancellationToken); + + /// + /// Attempt to login to an OAuth service with the current OAuth credentials. + /// + /// The for the operation. + /// A resulting in an . + ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 9e0602d07e1..c98863d01c7 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -61,9 +61,10 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority /// /// Generate an for a given . /// + /// The of to generate. /// The to generate a response for. /// A new, errored . - static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) + static AuthorityResponse GenerateHeadersExceptionResponse(HeadersException headersException) => new( new ErrorMessageResponse(ErrorCode.BadHeaders) { @@ -135,7 +136,7 @@ public async ValueTask> AttemptLogin(Cancellation { var headers = apiHeadersProvider.ApiHeaders; if (headers == null) - return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); if (headers.IsTokenAuthentication) return BadRequest(ErrorCode.TokenWithToken); @@ -161,32 +162,14 @@ public async ValueTask> AttemptLogin(Cancellation if (oAuthLogin) { var oAuthProvider = headers.OAuthProvider!.Value; - string? externalUserId; - try - { - var validator = oAuthProviders - .GetValidator(oAuthProvider); - - if (validator == null) - return BadRequest(ErrorCode.OAuthProviderDisabled); - - externalUserId = await validator - .ValidateResponseCode(headers.OAuthCode!, cancellationToken); - - Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); - } - catch (Octokit.RateLimitExceededException ex) - { - return RateLimit(ex); - } - - if (externalUserId == null) - return Unauthorized(); + var (errorResponse, oauthResult) = await TryOAuthenticate(headers, oAuthProvider, true, cancellationToken); + if (errorResponse != null) + return errorResponse; query = query.Where( x => x.OAuthConnections!.Any( y => y.Provider == oAuthProvider - && y.ExternalUserId == externalUserId)); + && y.ExternalUserId == oauthResult!.Value.UserID)); } else { @@ -281,6 +264,30 @@ public async ValueTask> AttemptLogin(Cancellation } } + /// + public async ValueTask> AttemptOAuthGatewayLogin(CancellationToken cancellationToken) + { + var headers = apiHeadersProvider.ApiHeaders; + if (headers == null) + return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!); + + var oAuthProvider = headers.OAuthProvider; + if (!oAuthProvider.HasValue) + return BadRequest(ErrorCode.BadHeaders); + + var (errorResponse, oAuthResult) = await TryOAuthenticate(headers, oAuthProvider.Value, false, cancellationToken); + if (errorResponse != null) + return errorResponse; + + Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value); + + return new AuthorityResponse( + new OAuthGatewayLoginResult + { + AccessCode = oAuthResult!.Value.AccessCode, + }); + } + /// /// Add a given to the . /// @@ -296,5 +303,40 @@ private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User identExpiry += TimeSpan.FromSeconds(15); await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry); } + + /// + /// Attempt OAuth authentication. + /// + /// The to use for errored s. + /// The current . + /// The to use. + /// If this is for a server login. + /// The for the operation. + /// A resulting in an errored on failure or the result of the call to on success. + async ValueTask<(AuthorityResponse? ErrorResponse, (string? UserID, string AccessCode)? OAuthResult)> TryOAuthenticate(ApiHeaders headers, OAuthProvider oAuthProvider, bool forLogin, CancellationToken cancellationToken) + { + (string? UserID, string AccessCode)? oauthResult; + try + { + var validator = oAuthProviders + .GetValidator(oAuthProvider, forLogin); + + if (validator == null) + return (BadRequest(ErrorCode.OAuthProviderDisabled), null); + oauthResult = await validator + .ValidateResponseCode(headers.OAuthCode!, forLogin, cancellationToken); + + Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, oauthResult); + } + catch (Octokit.RateLimitExceededException ex) + { + return (RateLimit(ex), null); + } + + if (!oauthResult.HasValue) + return (Unauthorized(), null); + + return (null, OAuthResult: oauthResult); + } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index 0eedc7c9491..2aa9916bf7c 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -710,7 +710,7 @@ await Task.WhenAny( Task.WhenAll( disconnectTask, listenTask ?? Task.CompletedTask), - AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken)); + AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken).AsTask()); } /// diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index e71385439d7..8c523d97d5b 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -532,7 +532,7 @@ async Task LogLockStates() foreach (var lockManager in jobLockManagers.Values) lockManager.LogLockStats(builder); - logger.LogDebug("Periodic deployment log states report (R.e. Issue #1779):{newLine}{report}", Environment.NewLine, builder); // TODO: Reduce to trace once #1779 is fixed + logger.LogTrace("Periodic deployment log states report:{newLine}{report}", Environment.NewLine, builder); await asyncDelayer.Delay(TimeSpan.FromMinutes(10), cancellationToken); } diff --git a/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs b/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs index 40fbde1a40c..e02c109e6bf 100644 --- a/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs +++ b/src/Tgstation.Server.Host/Components/Engine/ByondInstallerBase.cs @@ -74,29 +74,30 @@ protected ByondInstallerBase(IIOManager ioManager, ILogger l } /// - public override IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask) + public override ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) { CheckVersionValidity(version); var installationIOManager = new ResolvingIOManager(IOManager, path); var supportsMapThreads = version.Version >= MapThreadsVersion; - return new ByondInstallation( - installationIOManager, - installationTask, - version, - installationIOManager.ResolvePath( - installationIOManager.ConcatPath( - ByondBinPath, - GetDreamDaemonName( - version.Version!, - out var supportsCli))), - installationIOManager.ResolvePath( - installationIOManager.ConcatPath( - ByondBinPath, - DreamMakerName)), - supportsCli, - supportsMapThreads); + return ValueTask.FromResult( + new ByondInstallation( + installationIOManager, + installationTask, + version, + installationIOManager.ResolvePath( + installationIOManager.ConcatPath( + ByondBinPath, + GetDreamDaemonName( + version.Version!, + out var supportsCli))), + installationIOManager.ResolvePath( + installationIOManager.ConcatPath( + ByondBinPath, + DreamMakerName)), + supportsCli, + supportsMapThreads)); } /// diff --git a/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs index c24eee3e8b0..5b3cc20c36a 100644 --- a/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/DelegatingEngineInstaller.cs @@ -33,8 +33,8 @@ public Task CleanCache(CancellationToken cancellationToken) => Task.WhenAll(delegatedInstallers.Values.Select(installer => installer.CleanCache(cancellationToken))); /// - public IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask) - => DelegateCall(version, installer => installer.CreateInstallation(version, path, installationTask)); + public ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) + => DelegateCall(version, installer => installer.CreateInstallation(version, path, installationTask, cancellationToken)); /// public ValueTask DownloadVersion(EngineVersion version, JobProgressReporter jobProgressReporter, CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs b/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs index 6ca2b940306..30329edd34b 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineInstallerBase.cs @@ -40,7 +40,7 @@ protected EngineInstallerBase(IIOManager ioManager, ILogger } /// - public abstract IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask); + public abstract ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); /// public abstract Task CleanCache(CancellationToken cancellationToken); diff --git a/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs b/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs index 970e3ab89d5..eeadebc71f7 100644 --- a/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs +++ b/src/Tgstation.Server.Host/Components/Engine/EngineManager.cs @@ -337,7 +337,8 @@ async ValueTask ReadVersion(string path) try { - AddInstallationContainer(version, path, Task.CompletedTask); + var installation = await engineInstaller.CreateInstallation(version, path, Task.CompletedTask, cancellationToken); + AddInstallationContainer(installation); logger.LogDebug("Added detected BYOND version {versionKey}...", version); } catch (Exception ex) @@ -410,95 +411,121 @@ async ValueTask AssertAndLockVersion( IEngineInstallation installation; EngineExecutableLock installLock; bool installedOrInstalling; - lock (installedVersions) + + // loop is because of the race condition with potentialInstallation, installedVersions, and CustomIteration selection + while (true) { - if (customVersionStream != null) + lock (installedVersions) { - var customInstallationNumber = 1; - do + if (customVersionStream != null) { - version.CustomIteration = customInstallationNumber++; + var customInstallationNumber = 1; + do + { + version.CustomIteration = customInstallationNumber++; + } + while (installedVersions.ContainsKey(version)); } - while (installedVersions.ContainsKey(version)); } - installedOrInstalling = installedVersions.TryGetValue(version, out var installationContainerNullable); - ReferenceCountingContainer installationContainer; - if (!installedOrInstalling) - { - if (!allowInstallation) - throw new InvalidOperationException($"Engine version {version} not installed!"); - - installationContainer = AddInstallationContainer( - version, - ioManager.ResolvePath(version.ToString()), - ourTcs.Task); - } - else - installationContainer = installationContainerNullable!; - - installation = installationContainer.Instance; - installLock = installationContainer.AddReference(); - } + var potentialInstallation = await engineInstaller.CreateInstallation( + version, + ioManager.ResolvePath(version.ToString()), + ourTcs.Task, + cancellationToken); - var deploymentPipelineProcesses = !neededForLock; - try - { - if (installedOrInstalling) + lock (installedVersions) { - progressReporter.StageName = "Waiting for existing installation job..."; + if (customVersionStream != null && installedVersions.ContainsKey(version)) + continue; - if (neededForLock && !installation.InstallationTask.IsCompleted) - logger.LogWarning("The required engine version ({version}) is not readily available! We will have to wait for it to install.", version); + installedOrInstalling = installedVersions.TryGetValue(version, out var installationContainerNullable); + ReferenceCountingContainer installationContainer; + if (!installedOrInstalling) + { + if (!allowInstallation) + throw new InvalidOperationException($"Engine version {version} not installed!"); - await installation.InstallationTask.WaitAsync(cancellationToken); - return installLock; + installationContainer = AddInstallationContainer(potentialInstallation); + } + else + installationContainer = installationContainerNullable!; + + installation = installationContainer.Instance; + installLock = installationContainer.AddReference(); } - // okay up to us to install it then + var deploymentPipelineProcesses = !neededForLock; try { - if (customVersionStream != null) - logger.LogInformation("Installing custom engine version as {version}...", version); - else if (neededForLock) + if (installedOrInstalling) { - if (version.CustomIteration.HasValue) - throw new JobException(ErrorCode.EngineNonExistentCustomVersion); + progressReporter.StageName = "Waiting for existing installation job..."; - logger.LogWarning("The required engine version ({version}) is not readily available! We will have to install it.", version); + if (neededForLock && !installation.InstallationTask.IsCompleted) + logger.LogWarning("The required engine version ({version}) is not readily available! We will have to wait for it to install.", version); + + await installation.InstallationTask.WaitAsync(cancellationToken); + return installLock; } - else - logger.LogInformation("Requested engine version {version} not currently installed. Doing so now...", version); - progressReporter.StageName = "Running event"; + // okay up to us to install it then + string? installPath = null; + try + { + if (customVersionStream != null) + logger.LogInformation("Installing custom engine version as {version}...", version); + else if (neededForLock) + { + if (version.CustomIteration.HasValue) + throw new JobException(ErrorCode.EngineNonExistentCustomVersion); + + logger.LogWarning("The required engine version ({version}) is not readily available! We will have to install it.", version); + } + else + logger.LogInformation("Requested engine version {version} not currently installed. Doing so now...", version); - var versionString = version.ToString(); - await eventConsumer.HandleEvent(EventType.EngineInstallStart, new List { versionString }, deploymentPipelineProcesses, cancellationToken); + progressReporter.StageName = "Running event"; - await InstallVersionFiles(progressReporter, version, customVersionStream, deploymentPipelineProcesses, cancellationToken); + var versionString = version.ToString(); + await eventConsumer.HandleEvent(EventType.EngineInstallStart, new List { versionString }, deploymentPipelineProcesses, cancellationToken); - ourTcs.SetResult(); + installPath = await InstallVersionFiles(progressReporter, version, customVersionStream, deploymentPipelineProcesses, cancellationToken); + await eventConsumer.HandleEvent(EventType.EngineInstallComplete, new List { versionString }, deploymentPipelineProcesses, cancellationToken); - await eventConsumer.HandleEvent(EventType.EngineInstallComplete, new List { versionString }, deploymentPipelineProcesses, cancellationToken); - } - catch (Exception ex) - { - if (ex is not OperationCanceledException) - await eventConsumer.HandleEvent(EventType.EngineInstallFail, new List { ex.Message }, deploymentPipelineProcesses, cancellationToken); + ourTcs.SetResult(); + } + catch (Exception ex) + { + if (installPath != null) + { + try + { + logger.LogDebug("Cleaning up failed installation at {path}...", installPath); + await ioManager.DeleteDirectory(installPath, cancellationToken); + } + catch (Exception ex2) + { + logger.LogError(ex2, "Error cleaning up failed installation!"); + } + } + else if (ex is not OperationCanceledException) + await eventConsumer.HandleEvent(EventType.EngineInstallFail, new List { ex.Message }, deploymentPipelineProcesses, cancellationToken); - lock (installedVersions) - installedVersions.Remove(version); + lock (installedVersions) + installedVersions.Remove(version); + + ourTcs.SetException(ex); + throw; + } - ourTcs.SetException(ex); + return installLock; + } + catch + { + installLock.Dispose(); throw; } - - return installLock; - } - catch - { - installLock.Dispose(); - throw; } } @@ -510,8 +537,8 @@ async ValueTask AssertAndLockVersion( /// Custom zip file to use. Will cause a number to be added. /// If processes should be launched as part of the deployment pipeline. /// The for the operation. - /// A representing the running operation. - async ValueTask InstallVersionFiles( + /// A resulting in the directory the engine was installed to. + async ValueTask InstallVersionFiles( JobProgressReporter progressReporter, EngineVersion version, Stream? customVersionStream, @@ -597,23 +624,21 @@ await ioManager.WriteAllBytes( await ioManager.DeleteDirectory(installFullPath, cancellationToken); throw; } + + return installFullPath; } /// /// Create and add a new to . /// - /// The being added. - /// The path to the installation. - /// The representing the installation process. - /// The new . - ReferenceCountingContainer AddInstallationContainer(EngineVersion version, string installPath, Task installationTask) + /// The being added. + /// A new for the /. + ReferenceCountingContainer AddInstallationContainer(IEngineInstallation installation) { - var installation = engineInstaller.CreateInstallation(version, installPath, installationTask); - var installationContainer = new ReferenceCountingContainer(installation); lock (installedVersions) - installedVersions.Add(version, installationContainer); + installedVersions.Add(installation.Version, installationContainer); return installationContainer; } diff --git a/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs index 0df793e219c..1e943f97599 100644 --- a/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/IEngineInstaller.cs @@ -17,8 +17,9 @@ interface IEngineInstaller /// The of the installation. /// The path to the installation. /// The representing the installation process for the installation. - /// The . - IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask); + /// The for the operation. + /// A resulting in the . + ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken); /// /// Download a given engine . diff --git a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs index ed5ee75a565..b8f5e7f8779 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstallation.cs @@ -60,30 +60,46 @@ sealed class OpenDreamInstallation : EngineInstallationBase /// readonly IAbstractHttpClientFactory httpClientFactory; + /// + /// Path to the Robust.Server.dll. + /// + readonly string serverDllPath; + + /// + /// Path to the DMCompiler.dll. + /// + readonly string compilerDllPath; + /// /// Initializes a new instance of the class. /// /// The for the . /// The value of . /// The value of . - /// The value of . - /// The value of . + /// The path to the dotnet executable. + /// The value of . + /// The value of . /// The value of . /// The value of . public OpenDreamInstallation( IIOManager installationIOManager, IAsyncDelayer asyncDelayer, IAbstractHttpClientFactory httpClientFactory, - string serverExePath, - string compilerExePath, + string dotnetPath, + string serverDllPath, + string compilerDllPath, Task installationTask, EngineVersion version) : base(installationIOManager) { this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); - ServerExePath = serverExePath ?? throw new ArgumentNullException(nameof(serverExePath)); - CompilerExePath = compilerExePath ?? throw new ArgumentNullException(nameof(compilerExePath)); + + ServerExePath = dotnetPath ?? throw new ArgumentNullException(nameof(dotnetPath)); + CompilerExePath = dotnetPath; + + this.serverDllPath = serverDllPath ?? throw new ArgumentNullException(nameof(serverDllPath)); + this.compilerDllPath = compilerDllPath ?? throw new ArgumentNullException(nameof(compilerDllPath)); InstallationTask = installationTask ?? throw new ArgumentNullException(nameof(installationTask)); Version = version ?? throw new ArgumentNullException(nameof(version)); @@ -107,7 +123,7 @@ public override string FormatServerArguments( var parametersString = EncodeParameters(parameters, launchParameters); - var arguments = $"--cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value} --cvar opendream.world_params=\"{parametersString}\" --cvar opendream.json_path=\"./{dmbProvider.DmbName}\""; + var arguments = $"{serverDllPath} --cvar {(logFilePath != null ? $"log.path=\"{InstallationIOManager.GetDirectoryName(logFilePath)}\" --cvar log.format=\"{InstallationIOManager.GetFileName(logFilePath)}\"" : "log.enabled=false")} --cvar watchdog.token={accessIdentifier} --cvar log.runtimelog=false --cvar net.port={launchParameters.Port!.Value} --cvar opendream.topic_port={launchParameters.OpenDreamTopicPort!.Value} --cvar opendream.world_params=\"{parametersString}\" --cvar opendream.json_path=\"./{dmbProvider.DmbName}\""; return arguments; } @@ -119,7 +135,7 @@ public override string FormatCompilerArguments(string dmePath, string? additiona else additionalArguments = $"{additionalArguments.Trim()} "; - return $"--suppress-unimplemented --notices-enabled {additionalArguments}\"{dmePath ?? throw new ArgumentNullException(nameof(dmePath))}\""; + return $"{compilerDllPath} --suppress-unimplemented --notices-enabled {additionalArguments}\"{dmePath ?? throw new ArgumentNullException(nameof(dmePath))}\""; } /// @@ -135,7 +151,7 @@ public override async ValueTask StopServerProcess( const int MaximumTerminationSeconds = 5; logger.LogTrace("Attempting Robust.Server graceful exit (Timeout: {seconds}s)...", MaximumTerminationSeconds); - var timeout = asyncDelayer.Delay(TimeSpan.FromSeconds(MaximumTerminationSeconds), cancellationToken); + var timeout = asyncDelayer.Delay(TimeSpan.FromSeconds(MaximumTerminationSeconds), cancellationToken).AsTask(); var lifetime = process.Lifetime; if (lifetime.IsCompleted) logger.LogTrace("Robust.Server already exited"); diff --git a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs index f37c4489b14..ad2fb20e95d 100644 --- a/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs +++ b/src/Tgstation.Server.Host/Components/Engine/OpenDreamInstaller.cs @@ -118,14 +118,18 @@ public OpenDreamInstaller( public override Task CleanCache(CancellationToken cancellationToken) => Task.CompletedTask; /// - public override IEngineInstallation CreateInstallation(EngineVersion version, string path, Task installationTask) + public override async ValueTask CreateInstallation(EngineVersion version, string path, Task installationTask, CancellationToken cancellationToken) { CheckVersionValidity(version); GetExecutablePaths(path, out var serverExePath, out var compilerExePath); + + var dotnetPath = (await DotnetHelper.GetDotnetPath(platformIdentifier, IOManager, cancellationToken)) + ?? throw new JobException("Failed to find dotnet path!"); return new OpenDreamInstallation( new ResolvingIOManager(IOManager, path), asyncDelayer, httpClientFactory, + dotnetPath, serverExePath, compilerExePath, installationTask, @@ -370,21 +374,19 @@ protected virtual ValueTask HandleExtremelyLongPathOperation( /// The path to the DMCompiler executable. protected void GetExecutablePaths(string installationPath, out string serverExePath, out string compilerExePath) { - var exeExtension = platformIdentifier.IsWindows - ? ".exe" - : String.Empty; + const string DllExtension = ".dll"; serverExePath = IOManager.ConcatPath( installationPath, BinDir, ServerDir, - $"Robust.Server{exeExtension}"); + $"Robust.Server{DllExtension}"); compilerExePath = IOManager.ConcatPath( installationPath, BinDir, InstallationCompilerDirectory, - $"DMCompiler{exeExtension}"); + $"DMCompiler{DllExtension}"); } } } diff --git a/src/Tgstation.Server.Host/Components/IInstanceCore.cs b/src/Tgstation.Server.Host/Components/IInstanceCore.cs index c9fb88becce..7d10b5e7c69 100644 --- a/src/Tgstation.Server.Host/Components/IInstanceCore.cs +++ b/src/Tgstation.Server.Host/Components/IInstanceCore.cs @@ -51,5 +51,19 @@ public interface IInstanceCore : ILatestCompileJobProvider, IRenameNotifyee /// The new auto-update cron schedule. /// A representing the running operation. ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron); + + /// + /// Change the server auto-start timing for the . + /// + /// The new auto-start cron schedule. + /// A representing the running operation. + ValueTask ScheduleServerStart(string? newCron); + + /// + /// Change the server auto-stop timing for the . + /// + /// The new auto-stop cron schedule. + /// A representing the running operation. + ValueTask ScheduleServerStop(string? newCron); } } diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index d178eb94e0f..f0fd27f6618 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -90,19 +89,39 @@ sealed class Instance : IInstance readonly Api.Models.Instance metadata; /// - /// for and . + /// for and . /// readonly object timerLock; /// - /// The auto update . + /// The auto-update . /// - Task? timerTask; + Task? autoUpdateTask; /// - /// for . + /// for . /// - CancellationTokenSource? timerCts; + CancellationTokenSource? autoUpdateCts; + + /// + /// The auto-start . + /// + Task? autoStartTask; + + /// + /// for . + /// + CancellationTokenSource? autoStartCts; + + /// + /// The auto-stop . + /// + Task? autoStopTask; + + /// + /// for . + /// + CancellationTokenSource? autoStopCts; /// /// Initializes a new instance of the class. @@ -160,7 +179,9 @@ public async ValueTask DisposeAsync() { var chatDispose = Chat.DisposeAsync(); var watchdogDispose = Watchdog.DisposeAsync(); - timerCts?.Dispose(); + autoUpdateCts?.Dispose(); + autoStartCts?.Dispose(); + autoStopCts?.Dispose(); Configuration.Dispose(); dmbFactory.Dispose(); RepositoryManager.Dispose(); @@ -188,6 +209,8 @@ public async Task StartAsync(CancellationToken cancellationToken) { await Task.WhenAll( ScheduleAutoUpdate(metadata.Require(x => x.AutoUpdateInterval), metadata.AutoUpdateCron).AsTask(), + ScheduleServerStart(null).AsTask(), + ScheduleServerStop(null).AsTask(), Configuration.StartAsync(cancellationToken), EngineManager.StartAsync(cancellationToken), Chat.StartAsync(cancellationToken), @@ -224,14 +247,14 @@ public async ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron) Task toWait; lock (timerLock) - if (timerTask != null) + if (autoUpdateTask != null) { logger.LogTrace("Cancelling auto-update task"); - timerCts!.Cancel(); - timerCts.Dispose(); - toWait = timerTask; - timerTask = null; - timerCts = null; + autoUpdateCts!.Cancel(); + autoUpdateCts.Dispose(); + toWait = autoUpdateTask; + autoUpdateTask = null; + autoUpdateCts = null; } else toWait = Task.CompletedTask; @@ -246,14 +269,95 @@ public async ValueTask ScheduleAutoUpdate(uint newInterval, string? newCron) lock (timerLock) { // race condition, just quit - if (timerTask != null) + if (autoUpdateTask != null) { logger.LogWarning("Aborting auto-update scheduling change due to race condition!"); return; } - timerCts = new CancellationTokenSource(); - timerTask = TimerLoop(newInterval, newCron, timerCts.Token); + autoUpdateCts = new CancellationTokenSource(); + autoUpdateTask = TimerLoop(AutoUpdateAction, "auto-update", newInterval, newCron, autoUpdateCts.Token); + } + } + + /// + public async ValueTask ScheduleServerStart(string? newCron) + { + Task toWait; + lock (timerLock) + if (autoStartTask != null) + { + logger.LogTrace("Cancelling auto-start task"); + autoStartCts!.Cancel(); + autoStartCts.Dispose(); + toWait = autoStartTask; + autoStartTask = null; + autoStartCts = null; + } + else + toWait = Task.CompletedTask; + + await toWait; + if (String.IsNullOrWhiteSpace(newCron)) + { + logger.LogTrace("Auto-start disabled. Not starting task."); + return; + } + + lock (timerLock) + { + // race condition, just quit + if (autoStartTask != null) + { + logger.LogWarning("Aborting auto-start scheduling change due to race condition!"); + return; + } + + autoStartCts = new CancellationTokenSource(); + autoStartTask = TimerLoop(Watchdog.Launch, "auto-start", 0, newCron, autoStartCts.Token); + } + } + + /// + public async ValueTask ScheduleServerStop(string? newCron) + { + Task toWait; + lock (timerLock) + if (autoStopTask != null) + { + logger.LogTrace("Cancelling auto-stop task"); + autoStopCts!.Cancel(); + autoStopCts.Dispose(); + toWait = autoStopTask; + autoStopTask = null; + autoStopCts = null; + } + else + toWait = Task.CompletedTask; + + await toWait; + if (String.IsNullOrWhiteSpace(newCron)) + { + logger.LogTrace("Auto-stop disabled. Not stoping task."); + return; + } + + lock (timerLock) + { + // race condition, just quit + if (autoStopTask != null) + { + logger.LogWarning("Aborting auto-stop scheduling change due to race condition!"); + return; + } + + autoStopCts = new CancellationTokenSource(); + autoStopTask = TimerLoop( + async cancellationToken => await Watchdog.Terminate(true, cancellationToken), + "auto-stop", + 0, + newCron, + autoStopCts.Token); } } @@ -486,14 +590,15 @@ await repo.ResetToOrigin( #pragma warning restore CA1502 // Cyclomatic complexity /// - /// Pull the repository and compile for every set of given . + /// Runs a every set of given or on a given schedule. /// + /// The action to take when the timer elapses. + /// A description of the . /// How many minutes the operation should repeat. Does not include running time. /// Alternative cron schedule. /// The for the operation. /// A representing the running operation. -#pragma warning disable CA1502 // TODO: Decomplexify - async Task TimerLoop(uint minutes, string? cron, CancellationToken cancellationToken) + async Task TimerLoop(Func timerAction, string description, uint minutes, string? cron, CancellationToken cancellationToken) { logger.LogDebug("Entering auto-update loop"); while (true) @@ -520,100 +625,86 @@ async Task TimerLoop(uint minutes, string? cron, CancellationToken cancellationT delay = TimeSpan.FromMinutes(minutes); } - logger.LogInformation("Next auto-update will occur at {time}", DateTimeOffset.UtcNow + delay); - - // https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay?view=net-8.0#system-threading-tasks-task-delay(system-timespan) - const uint DelayMinutesLimit = UInt32.MaxValue - 1; - Debug.Assert(DelayMinutesLimit == 4294967294, "Delay limit assertion failure!"); - - var maxDelayIterations = 0UL; - if (delay.TotalMilliseconds >= UInt32.MaxValue) - { - maxDelayIterations = (ulong)Math.Floor(delay.TotalMilliseconds / DelayMinutesLimit); - logger.LogDebug("Breaking interval into {iterationCount} iterations", maxDelayIterations + 1); - delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds - (maxDelayIterations * DelayMinutesLimit)); - } - - if (maxDelayIterations > 0) - { - var longDelayTimeSpan = TimeSpan.FromMilliseconds(DelayMinutesLimit); - for (var i = 0UL; i < maxDelayIterations; ++i) - { - logger.LogTrace("Long delay #{iteration}...", i + 1); - await asyncDelayer.Delay(longDelayTimeSpan, cancellationToken); - } - - logger.LogTrace("Final delay iteration #{iteration}...", maxDelayIterations + 1); - } + logger.LogInformation("Next {desc} will occur at {time}", description, DateTimeOffset.UtcNow + delay); await asyncDelayer.Delay(delay, cancellationToken); - logger.LogInformation("Beginning auto update..."); - await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, Enumerable.Empty(), true, cancellationToken); - var repositoryUpdateJob = Job.Create(Api.Models.JobCode.RepositoryAutoUpdate, null, metadata, RepositoryRights.CancelPendingChanges); - await jobManager.RegisterOperation( - repositoryUpdateJob, - RepositoryAutoUpdateJob, - cancellationToken); + await timerAction(cancellationToken); + } + catch (OperationCanceledException) + { + logger.LogDebug("Cancelled {desc} loop!", description); + break; + } + catch (Exception e) + { + logger.LogError(e, "Error in {desc} loop!", description); + continue; + } - var repoUpdateJobResult = await jobManager.WaitForJobCompletion(repositoryUpdateJob, null, cancellationToken, cancellationToken); - if (repoUpdateJobResult == false) - { - logger.LogWarning("Aborting auto-update due to repository update error!"); - continue; - } + logger.LogTrace("Leaving {desc} loop...", description); + } - Job compileProcessJob; - using (var repo = await RepositoryManager.LoadRepository(cancellationToken)) - { - if (repo == null) - throw new JobException(Api.Models.ErrorCode.RepoMissing); + /// + /// Pulls the repository and compiles. + /// + /// The for the operation. + /// A representing the running operation. + async ValueTask AutoUpdateAction(CancellationToken cancellationToken) + { + logger.LogInformation("Beginning auto update..."); + await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, Enumerable.Empty(), true, cancellationToken); - var deploySha = repo.Head; - if (deploySha == null) - { - logger.LogTrace("Aborting auto update, repository error!"); - continue; - } + var repositoryUpdateJob = Job.Create(Api.Models.JobCode.RepositoryAutoUpdate, null, metadata, RepositoryRights.CancelPendingChanges); + await jobManager.RegisterOperation( + repositoryUpdateJob, + RepositoryAutoUpdateJob, + cancellationToken); - if (deploySha == (await LatestCompileJob())?.RevisionInformation.CommitSha) - { - logger.LogTrace("Aborting auto update, same revision as latest CompileJob"); - continue; - } + var repoUpdateJobResult = await jobManager.WaitForJobCompletion(repositoryUpdateJob, null, cancellationToken, cancellationToken); + if (repoUpdateJobResult == false) + { + logger.LogWarning("Aborting auto-update due to repository update error!"); + return; + } - // finally set up the job - compileProcessJob = Job.Create(Api.Models.JobCode.AutomaticDeployment, null, metadata, DreamMakerRights.CancelCompile); - await jobManager.RegisterOperation( - compileProcessJob, - (core, databaseContextFactory, job, progressReporter, jobCancellationToken) => - { - if (core != this) - throw new InvalidOperationException(DifferentCoreExceptionMessage); - return DreamMaker.DeploymentProcess( - job, - databaseContextFactory, - progressReporter, - jobCancellationToken); - }, - cancellationToken); - } + Job compileProcessJob; + using (var repo = await RepositoryManager.LoadRepository(cancellationToken)) + { + if (repo == null) + throw new JobException(Api.Models.ErrorCode.RepoMissing); - await jobManager.WaitForJobCompletion(compileProcessJob, null, default, cancellationToken); - } - catch (OperationCanceledException) + var deploySha = repo.Head; + if (deploySha == null) { - logger.LogDebug("Cancelled auto update loop!"); - break; + logger.LogTrace("Aborting auto update, repository error!"); + return; } - catch (Exception e) + + if (deploySha == (await LatestCompileJob())?.RevisionInformation.CommitSha) { - logger.LogError(e, "Error in auto update loop!"); - continue; + logger.LogTrace("Aborting auto update, same revision as latest CompileJob"); + return; } - logger.LogTrace("Leaving auto update loop..."); + // finally set up the job + compileProcessJob = Job.Create(Api.Models.JobCode.AutomaticDeployment, null, metadata, DreamMakerRights.CancelCompile); + await jobManager.RegisterOperation( + compileProcessJob, + (core, databaseContextFactory, job, progressReporter, jobCancellationToken) => + { + if (core != this) + throw new InvalidOperationException(DifferentCoreExceptionMessage); + return DreamMaker.DeploymentProcess( + job, + databaseContextFactory, + progressReporter, + jobCancellationToken); + }, + cancellationToken); + } + + await jobManager.WaitForJobCompletion(compileProcessJob, null, default, cancellationToken); } -#pragma warning restore CA1502 } } diff --git a/src/Tgstation.Server.Host/Components/InstanceManager.cs b/src/Tgstation.Server.Host/Components/InstanceManager.cs index 5f6a101260c..d0f91c4c461 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -541,7 +541,7 @@ async ValueTask OfflineInstanceImmediate(IInstance instance, CancellationToken c loggedDelay = true; } - delayTask = asyncDelayer.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + delayTask = asyncDelayer.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).AsTask(); } await delayTask; @@ -603,7 +603,7 @@ async Task Initialize(CancellationToken cancellationToken) logger.LogInformation("{versionString}", assemblyInformationProvider.VersionString); console.SetTitle(assemblyInformationProvider.VersionString); - CheckSystemCompatibility(); + PreflightChecks(); // To let the web server startup immediately before we do any intense work await Task.Yield(); @@ -673,7 +673,7 @@ async ValueTask EnumerateInstances(IDatabaseContext databaseContext) /// /// Check we have a valid system and configuration. /// - void CheckSystemCompatibility() + void PreflightChecks() { logger.LogDebug("Running as user: {username}", Environment.UserName); diff --git a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs index b5a835420d9..be74378ff4f 100644 --- a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs +++ b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs @@ -59,5 +59,11 @@ public InstanceWrapper() /// public ValueTask LatestCompileJob() => Instance.LatestCompileJob(); + + /// + public ValueTask ScheduleServerStart(string? newCron) => Instance.ScheduleServerStart(newCron); + + /// + public ValueTask ScheduleServerStop(string? newCron) => Instance.ScheduleServerStop(newCron); } } diff --git a/src/Tgstation.Server.Host/Components/Repository/RepositoryUpdateService.cs b/src/Tgstation.Server.Host/Components/Repository/RepositoryUpdateService.cs index 341db7f6172..cb15e878f1c 100644 --- a/src/Tgstation.Server.Host/Components/Repository/RepositoryUpdateService.cs +++ b/src/Tgstation.Server.Host/Components/Repository/RepositoryUpdateService.cs @@ -642,11 +642,10 @@ public async ValueTask RepositoryRecloneJob( await deleteTask; } - IRepository newRepo; try { - using var cloneReporter = progressReporter.CreateSection("Cloning New Repository", 0.8); - newRepo = await instance.RepositoryManager.CloneRepository( + using var cloneReporter = progressReporter.CreateSection("Cloning New Repository", 0.9); + using var newRepo = await instance.RepositoryManager.CloneRepository( origin, oldReference, currentModel.AccessUser, @@ -671,19 +670,6 @@ await databaseContextFactory.UseContextTaskReturn(context => throw; } - - using (newRepo) - using (var checkoutReporter = progressReporter.CreateSection("Checking out previous Detached Commit", 0.1)) - { - await newRepo.CheckoutObject( - oldSha, - currentModel.AccessUser, - currentModel.AccessToken, - false, - oldReference != null, - checkoutReporter, - cancellationToken); - } } } } diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index 77a25fd979a..53f8086f072 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -547,7 +547,8 @@ async Task GetLaunchResult( toAwait, asyncDelayer.Delay( TimeSpan.FromSeconds(startupTimeout.Value), - CancellationToken.None)); // DCT: None available, task will clean up after delay + CancellationToken.None) + .AsTask()); // DCT: None available, task will clean up after delay Logger.LogTrace( "Waiting for LaunchResult based on {launchResultCompletionCause}{possibleTimeout}...", @@ -611,7 +612,7 @@ async Task PostValidationShutdown(Task proceedTask) const int GracePeriodSeconds = 30; Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds); - var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None); // DCT: None available + var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None).AsTask(); // DCT: None available await Task.WhenAny(process.Lifetime, delayTask); if (!process.Lifetime.IsCompleted) diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index f0edb6fc09d..e1b37b50b33 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs @@ -787,27 +787,48 @@ async ValueTask ExecuteEventScripts(IEnumerable parameters, bool deploy // always execute in serial using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken, logger)) { - var files = await ioManager.GetFilesWithExtension(EventScriptsSubdirectory, platformIdentifier.ScriptFileExtension, false, cancellationToken); - var resolvedScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory); + var directories = generalConfiguration.AdditionalEventScriptsDirectories?.ToList() ?? new List(); + directories.Add(EventScriptsSubdirectory); - var scriptFiles = files - .Select(x => ioManager.GetFileName(x)) - .Where(x => scriptNames.Any( - scriptName => x.StartsWith(scriptName, StringComparison.Ordinal))) + var allScripts = new List(); + var tasks = directories.Select( + async scriptDirectory => + { + var resolvedScriptsDir = ioManager.ResolvePath(scriptDirectory); + logger.LogTrace("Checking for scripts in {directory}...", scriptDirectory); + var files = await ioManager.GetFilesWithExtension(scriptDirectory, platformIdentifier.ScriptFileExtension, false, cancellationToken); + + var scriptFiles = files + .Select(ioManager.GetFileName) + .Where(x => scriptNames.Any( + scriptName => x.StartsWith(scriptName, StringComparison.Ordinal))) + .Select(x => + { + var fullScriptPath = ioManager.ConcatPath(resolvedScriptsDir, x); + logger.LogTrace("Found matching script: {scriptPath}", fullScriptPath); + return fullScriptPath; + }); + + lock (allScripts) + allScripts.AddRange(scriptFiles); + }) .ToList(); - if (scriptFiles.Count == 0) + await ValueTaskExtensions.WhenAll(tasks); + if (allScripts.Count == 0) { logger.LogTrace("No event scripts starting with \"{scriptName}\" detected", String.Join("\" or \"", scriptNames)); return; } - foreach (var scriptFile in scriptFiles) + var resolvedInstanceScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory); + + foreach (var scriptFile in allScripts.OrderBy(ioManager.GetFileName)) { logger.LogTrace("Running event script {scriptFile}...", scriptFile); await using (var script = await processExecutor.LaunchProcess( - ioManager.ConcatPath(resolvedScriptsDir, scriptFile), - resolvedScriptsDir, + scriptFile, + resolvedInstanceScriptsDir, String.Join( ' ', parameters.Select(arg => diff --git a/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs index b58db536015..68e44eee5d5 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs @@ -185,7 +185,7 @@ async Task CleanupLingeringDeployment() lingeringDeploymentExpirySeconds); // DCT: A cancel firing here can result in us leaving a dmbprovider undisposed, localDeploymentCleanupGate will always fire in that case - var timeout = AsyncDelayer.Delay(TimeSpan.FromSeconds(lingeringDeploymentExpirySeconds), CancellationToken.None); + var timeout = AsyncDelayer.Delay(TimeSpan.FromSeconds(lingeringDeploymentExpirySeconds), CancellationToken.None).AsTask(); var completedTask = await Task.WhenAny( localDeploymentCleanupGate.Task, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index e0aff03f405..7fa4fc4926d 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; -using BetterWin32Errors; - using Microsoft.Extensions.Logging; using Serilog.Context; diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index 1790316e370..5ea16ae64e7 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -150,6 +153,11 @@ public sealed class GeneralConfiguration : ServerInformationBase /// public bool OpenDreamSuppressInstallOutput { get; set; } + /// + /// List of directories that have their contents merged with instance EventScripts directories when executing scripts. + /// + public List? AdditionalEventScriptsDirectories { get; set; } + /// /// Initializes a new instance of the class. /// @@ -190,6 +198,9 @@ public void CheckCompatibility(ILogger logger) if (ByondTopicTimeout <= 1000) logger.LogWarning("The timeout for sending BYOND topics is very low ({ms}ms). Topic calls may fail to complete at all!", ByondTopicTimeout); + + if (AdditionalEventScriptsDirectories?.Any(path => !Path.IsPathRooted(path)) == true) + logger.LogWarning($"Config option \"{nameof(AdditionalEventScriptsDirectories)}\" contains non-rooted paths. These will be evaluated relative to each instances \"Configuration\" directory!"); } } } diff --git a/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs index a170cd235f3..1236b698344 100644 --- a/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs +++ b/src/Tgstation.Server.Host/Configuration/OAuthConfigurationBase.cs @@ -17,6 +17,11 @@ public abstract class OAuthConfigurationBase /// public string? ClientSecret { get; set; } + /// + /// If the OAuth setup is only to be used for passing the user's OAuth token to clients. + /// + public OAuthGatewayStatus? Gateway { get; set; } + /// /// Initializes a new instance of the class. /// @@ -33,6 +38,7 @@ public OAuthConfigurationBase(OAuthConfigurationBase oAuthConfiguration) ArgumentNullException.ThrowIfNull(oAuthConfiguration); ClientId = oAuthConfiguration.ClientId; ClientSecret = oAuthConfiguration.ClientSecret; + Gateway = oAuthConfiguration.Gateway; } } } diff --git a/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs b/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs new file mode 100644 index 00000000000..8abf7d4afcf --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host +{ + /// + /// Status of the OAuth gateway for a provider. + /// + public enum OAuthGatewayStatus + { + /// + /// The OAuth Gateway is disabled. + /// + Disabled, + + /// + /// The OAuth Gateway is enabled. + /// + Enabled, + + /// + /// The provider may ONLY be used as an OAuth Gateway. + /// + Only, + } +} diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index c41113069d2..52004ad9685 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -1,34 +1,28 @@ using System; using System.IO; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using System.Web; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Octokit; - using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Transfer; using Tgstation.Server.Host.Utils; -using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Controllers { @@ -39,29 +33,9 @@ namespace Tgstation.Server.Host.Controllers public sealed class AdministrationController : ApiController { /// - /// Default for s. - /// - const string OctokitException = "Bad GitHub API response, check configuration!"; - - /// - /// The key for . + /// The for the . /// - static readonly object ReadCacheKey = new(); - - /// - /// The for the . - /// - readonly IGitHubServiceFactory gitHubServiceFactory; - - /// - /// The for the . - /// - readonly IServerControl serverControl; - - /// - /// The for the . - /// - readonly IServerUpdateInitiator serverUpdateInitiator; + readonly IRestAuthorityInvoker administrationAuthority; /// /// The for the . @@ -83,11 +57,6 @@ public sealed class AdministrationController : ApiController /// readonly IFileTransferTicketProvider fileTransferService; - /// - /// The for the . - /// - readonly IMemoryCache cacheService; - /// /// The for the . /// @@ -98,46 +67,37 @@ public sealed class AdministrationController : ApiController /// /// The for the . /// The for the . - /// The value of . - /// The value of . - /// The value of . + /// The for the . + /// The for the . + /// The value of . /// The value of . /// The value of . /// The value of . /// The value of . - /// The value of . - /// The for the . /// The containing value of . - /// The for the . public AdministrationController( IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, - IGitHubServiceFactory gitHubServiceFactory, - IServerControl serverControl, - IServerUpdateInitiator serverUpdateInitiator, + IApiHeadersProvider apiHeadersProvider, + ILogger logger, + IRestAuthorityInvoker administrationAuthority, IAssemblyInformationProvider assemblyInformationProvider, IIOManager ioManager, IPlatformIdentifier platformIdentifier, IFileTransferTicketProvider fileTransferService, - IMemoryCache cacheService, - ILogger logger, - IOptions fileLoggingConfigurationOptions, - IApiHeadersProvider apiHeadersProvider) + IOptions fileLoggingConfigurationOptions) : base( - databaseContext, - authenticationContext, - apiHeadersProvider, - logger, - true) + databaseContext, + authenticationContext, + apiHeadersProvider, + logger, + true) { - this.gitHubServiceFactory = gitHubServiceFactory ?? throw new ArgumentNullException(nameof(gitHubServiceFactory)); - this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); - this.serverUpdateInitiator = serverUpdateInitiator ?? throw new ArgumentNullException(nameof(serverUpdateInitiator)); + this.administrationAuthority = administrationAuthority ?? throw new ArgumentNullException(nameof(administrationAuthority)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); - this.cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); fileLoggingConfiguration = fileLoggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(fileLoggingConfigurationOptions)); } @@ -151,74 +111,14 @@ public AdministrationController( /// The GitHub API rate limit was hit. See response header Retry-After. /// A GitHub API error occurred. See error message for details. [HttpGet] - [TgsAuthorize(AdministrationRights.ChangeVersion)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.GetUpdateInformation))] [ProducesResponseType(typeof(AdministrationResponse), 200)] [ProducesResponseType(typeof(ErrorMessageResponse), 424)] [ProducesResponseType(typeof(ErrorMessageResponse), 429)] - public async ValueTask Read([FromQuery] bool? fresh, CancellationToken cancellationToken) - { - try - { - async Task CacheFactory() - { - Version? greatestVersion = null; - Uri? repoUrl = null; - try - { - var gitHubService = await gitHubServiceFactory.CreateService(cancellationToken); - var repositoryUrlTask = gitHubService.GetUpdatesRepositoryUrl(cancellationToken); - var releases = await gitHubService.GetTgsReleases(cancellationToken); - - foreach (var kvp in releases) - { - var version = kvp.Key; - var release = kvp.Value; - if (version.Major > 3 // Forward/backward compatible but not before TGS4 - && (greatestVersion == null || version > greatestVersion)) - greatestVersion = version; - } - - repoUrl = await repositoryUrlTask; - } - catch (NotFoundException e) - { - Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); - } - - return Json(new AdministrationResponse - { - LatestVersion = greatestVersion, - TrackedRepositoryUrl = repoUrl, - GeneratedAt = DateTimeOffset.UtcNow, - }); - } - - var ttl = TimeSpan.FromMinutes(30); - Task task; - if (fresh == true || !cacheService.TryGetValue(ReadCacheKey, out var rawCacheObject)) - { - using var entry = cacheService.CreateEntry(ReadCacheKey); - entry.AbsoluteExpirationRelativeToNow = ttl; - entry.Value = task = CacheFactory(); - } - else - task = (Task)rawCacheObject!; - - return await task; - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }); - } - } + public ValueTask Read([FromQuery] bool? fresh, CancellationToken cancellationToken) + => administrationAuthority.Invoke( + this, + authority => authority.GetUpdateInformation(fresh ?? false, cancellationToken)); /// /// Attempt to perform a server upgrade. @@ -232,7 +132,7 @@ async Task CacheFactory() /// A GitHub rate limit was encountered or the swarm integrity check failed. /// A GitHub API error occurred. [HttpPost] - [TgsAuthorize(AdministrationRights.ChangeVersion | AdministrationRights.UploadVersion)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerVersionChange))] [ProducesResponseType(typeof(ServerUpdateResponse), 202)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] @@ -242,28 +142,15 @@ public async ValueTask Update([FromBody] ServerUpdateRequest mode { ArgumentNullException.ThrowIfNull(model); - var attemptingUpload = model.UploadZip == true; - if (attemptingUpload) - { - if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.UploadVersion)) - return Forbid(); - } - else if (!AuthenticationContext.PermissionSet.AdministrationRights!.Value.HasFlag(AdministrationRights.ChangeVersion)) - return Forbid(); - if (model.NewVersion == null) return BadRequest(new ErrorMessageResponse(ErrorCode.ModelValidationFailure) { AdditionalData = "newVersion is required!", }); - if (model.NewVersion.Major < 3) - return BadRequest(new ErrorMessageResponse(ErrorCode.CannotChangeServerSuite)); - - if (!serverControl.WatchdogPresent) - return UnprocessableEntity(new ErrorMessageResponse(ErrorCode.MissingHostWatchdog)); - - return await AttemptInitiateUpdate(model.NewVersion, attemptingUpload, cancellationToken); + return await administrationAuthority.Invoke( + this, + authority => authority.TriggerServerVersionChange(model.NewVersion, model.UploadZip ?? false, cancellationToken)); } /// @@ -273,27 +160,15 @@ public async ValueTask Update([FromBody] ServerUpdateRequest mode /// Restart begun successfully. /// Restart operations are unavailable due to the launch configuration of TGS. [HttpDelete] - [TgsAuthorize(AdministrationRights.RestartHost)] + [TgsRestAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] [ProducesResponseType(204)] [ProducesResponseType(typeof(ErrorMessageResponse), 422)] - public async ValueTask Delete() - { - try - { - if (!serverControl.WatchdogPresent) - { - Logger.LogDebug("Restart request failed due to lack of host watchdog!"); - return UnprocessableEntity(new ErrorMessageResponse(ErrorCode.MissingHostWatchdog)); - } - - await serverControl.Restart(); - return NoContent(); - } - catch (InvalidOperationException) - { - return StatusCode(HttpStatusCode.ServiceUnavailable); - } - } + public ValueTask Delete() +#pragma warning disable API1001 // Action returns undeclared success result + => administrationAuthority.Invoke( + this, + authority => authority.TriggerServerRestart()); +#pragma warning restore API1001 // Action returns undeclared success result /// /// List s present. @@ -399,56 +274,5 @@ public async ValueTask GetLog(string path, CancellationToken canc }); } } - - /// - /// Attempt to initiate an update. - /// - /// The being updated to. - /// If an upload is being attempted. - /// The for the operation. - /// A resulting in the of the request. - async ValueTask AttemptInitiateUpdate(Version newVersion, bool attemptingUpload, CancellationToken cancellationToken) - { - IFileUploadTicket? uploadTicket = attemptingUpload - ? fileTransferService.CreateUpload(FileUploadStreamKind.None) - : null; - - ServerUpdateResult updateResult; - try - { - try - { - updateResult = await serverUpdateInitiator.InitiateUpdate(uploadTicket, newVersion, cancellationToken); - } - catch - { - if (attemptingUpload) - await uploadTicket!.DisposeAsync(); - - throw; - } - } - catch (RateLimitExceededException e) - { - return RateLimit(e); - } - catch (ApiException e) - { - Logger.LogWarning(e, OctokitException); - return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError) - { - AdditionalData = e.Message, - }); - } - - return updateResult switch - { - ServerUpdateResult.Started => Accepted(new ServerUpdateResponse(newVersion, uploadTicket?.Ticket.FileTicket)), - ServerUpdateResult.ReleaseMissing => this.Gone(), - ServerUpdateResult.UpdateInProgress => BadRequest(new ErrorMessageResponse(ErrorCode.ServerUpdateInProgress)), - ServerUpdateResult.SwarmIntegrityCheckFailed => this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.SwarmIntegrityCheckFailed)), - _ => throw new InvalidOperationException($"Unexpected ServerUpdateResult: {updateResult}"), - }; - } } } diff --git a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs index 433ec5716f5..49f3e89c2bc 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiRootController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiRootController.cs @@ -188,5 +188,19 @@ public ValueTask CreateToken(CancellationToken cancellationToken) return loginAuthority.InvokeTransformable(this, authority => authority.AttemptLogin(cancellationToken)); } + + /// + /// Attempt to authenticate a using . + /// + /// The for the operation. + /// A resulting in the of the operation. + /// generated successfully. + /// OAuth authentication failed. + /// OAuth authentication failed due to rate limiting. + [HttpPost("oauth_gateway")] + [ProducesResponseType(typeof(OAuthGatewayResponse), 200)] + [ProducesResponseType(typeof(ErrorMessageResponse), 429)] + public ValueTask CreateOAuthGatewayToken(CancellationToken cancellationToken) + => loginAuthority.InvokeTransformable(this, authority => authority.AttemptOAuthGatewayLogin(cancellationToken)); } } diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index bada9ace1ba..8f3f53dca20 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -20,6 +20,7 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers.Results; @@ -341,7 +342,15 @@ await DatabaseContext /// Instance updated successfully and relocation job created. /// The database entity for the requested instance could not be retrieved. The instance was likely detached. [HttpPost] - [TgsAuthorize(InstanceManagerRights.Relocate | InstanceManagerRights.Rename | InstanceManagerRights.SetAutoUpdate | InstanceManagerRights.SetConfiguration | InstanceManagerRights.SetOnline | InstanceManagerRights.SetChatBotLimit)] + [TgsAuthorize( + InstanceManagerRights.Relocate + | InstanceManagerRights.Rename + | InstanceManagerRights.SetAutoUpdate + | InstanceManagerRights.SetConfiguration + | InstanceManagerRights.SetOnline + | InstanceManagerRights.SetChatBotLimit + | InstanceManagerRights.SetAutoStart + | InstanceManagerRights.SetAutoStop)] [ProducesResponseType(typeof(InstanceResponse), 200)] [ProducesResponseType(typeof(InstanceResponse), 202)] [ProducesResponseType(typeof(ErrorMessageResponse), 410)] @@ -422,13 +431,18 @@ bool CheckModified(Expression> expression, Insta var oldAutoUpdateInterval = originalModel.AutoUpdateInterval!.Value; var oldAutoUpdateCron = originalModel.AutoUpdateCron; + var oldAutoStartCron = originalModel.AutoStartCron; + var oldAutoStopCron = originalModel.AutoStopCron; var earlyOut = ValidateCronSetting(model); if (earlyOut != null) return earlyOut; - var changedAutoInterval = model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval; - var changedAutoCron = model.AutoUpdateCron != null && oldAutoUpdateCron != model.AutoUpdateCron; + var changedAutoUpdateInterval = model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval; + var changedAutoUpdateCron = model.AutoUpdateCron != null && oldAutoUpdateCron != model.AutoUpdateCron; + + var changedAutoStart = model.AutoStartCron != null && oldAutoStartCron != model.AutoStartCron; + var changedAutoStop = model.AutoStopCron != null && oldAutoStopCron != model.AutoStopCron; var renamed = model.Name != null && originalModel.Name != model.Name; @@ -437,7 +451,9 @@ bool CheckModified(Expression> expression, Insta || CheckModified(x => x.ConfigurationType, InstanceManagerRights.SetConfiguration) || CheckModified(x => x.Name, InstanceManagerRights.Rename) || CheckModified(x => x.Online, InstanceManagerRights.SetOnline) - || CheckModified(x => x.ChatBotLimit, InstanceManagerRights.SetChatBotLimit)) + || CheckModified(x => x.ChatBotLimit, InstanceManagerRights.SetChatBotLimit) + || CheckModified(x => x.AutoStartCron, InstanceManagerRights.SetAutoStart) + || CheckModified(x => x.AutoStopCron, InstanceManagerRights.SetAutoStop)) return Forbid(); if (model.ChatBotLimit.HasValue) @@ -452,9 +468,9 @@ bool CheckModified(Expression> expression, Insta return Conflict(new ErrorMessageResponse(ErrorCode.ChatBotMax)); } - if (changedAutoCron) + if (changedAutoUpdateCron) model.AutoUpdateInterval = 0; - else if (changedAutoInterval) + else if (changedAutoUpdateInterval) model.AutoUpdateCron = String.Empty; await DatabaseContext.Save(cancellationToken); @@ -518,13 +534,27 @@ await jobManager.RegisterOperation( api.MoveJob = job.ToApi(); } - if (changedAutoInterval || changedAutoCron) + var changedAutoUpdate = changedAutoUpdateInterval || changedAutoUpdateCron; + if (changedAutoUpdate || changedAutoStart || changedAutoStop) { // ignoring retval because we don't care if it's offline await WithComponentInstanceNullable( async componentInstance => { - await componentInstance.ScheduleAutoUpdate(model.AutoUpdateInterval!.Value, model.AutoUpdateCron); + var autoUpdateTask = changedAutoUpdate + ? componentInstance.ScheduleAutoUpdate(model.AutoUpdateInterval!.Value, model.AutoUpdateCron) + : ValueTask.CompletedTask; + + var autoStartTask = changedAutoStart + ? componentInstance.ScheduleServerStart(model.AutoStartCron) + : ValueTask.CompletedTask; + + var autoStopTask = changedAutoStop + ? componentInstance.ScheduleServerStop(model.AutoStopCron) + : ValueTask.CompletedTask; + + await ValueTaskExtensions.WhenAll(autoUpdateTask, autoStartTask, autoStopTask); + return null; }, originalModel); @@ -769,6 +799,8 @@ public async ValueTask GrantPermissions(long id, CancellationToke Path = initialSettings.Path, AutoUpdateInterval = initialSettings.AutoUpdateInterval ?? 0, AutoUpdateCron = initialSettings.AutoUpdateCron ?? String.Empty, + AutoStartCron = initialSettings.AutoStartCron ?? String.Empty, + AutoStopCron = initialSettings.AutoStopCron ?? String.Empty, ChatBotLimit = initialSettings.ChatBotLimit ?? Models.Instance.DefaultChatBotLimit, RepositorySettings = new Models.RepositorySettings { diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 429cbc89898..2517f8502fc 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -487,6 +487,7 @@ void AddTypedContext() services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // configure misc services services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index 3d4bd73f0f8..6b8013f8c67 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -451,22 +451,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) /// /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. /// - internal static readonly Type MSLatestMigration = typeof(MSAddDMApiValidationMode); + internal static readonly Type MSLatestMigration = typeof(MSAddAutoStartAndStop); /// /// Used by unit tests to remind us to setup the correct MYSQL migration downgrades. /// - internal static readonly Type MYLatestMigration = typeof(MYAddDMApiValidationMode); + internal static readonly Type MYLatestMigration = typeof(MYAddAutoStartAndStop); /// /// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades. /// - internal static readonly Type PGLatestMigration = typeof(PGAddDMApiValidationMode); + internal static readonly Type PGLatestMigration = typeof(PGAddAutoStartAndStop); /// /// Used by unit tests to remind us to setup the correct SQLite migration downgrades. /// - internal static readonly Type SLLatestMigration = typeof(SLAddDMApiValidationMode); + internal static readonly Type SLLatestMigration = typeof(SLAddAutoStartAndStop); /// /// Gets the name of the migration to run for migrating down to a given for the . @@ -482,10 +482,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); // !!! DON'T FORGET TO UPDATE THE SWARM PROTOCOL MAJOR VERSION !!! + if (targetVersion < new Version(6, 12, 0)) + targetMigration = currentDatabaseType switch + { + DatabaseType.MySql => nameof(MYAddDMApiValidationMode), + DatabaseType.PostgresSql => nameof(PGAddDMApiValidationMode), + DatabaseType.SqlServer => nameof(MSAddDMApiValidationMode), + DatabaseType.Sqlite => nameof(SLAddDMApiValidationMode), + _ => BadDatabaseType(), + }; + if (targetVersion < new Version(6, 7, 0)) targetMigration = currentDatabaseType switch { - DatabaseType.MySql => nameof(MSAddCronAutoUpdates), + DatabaseType.MySql => nameof(MYAddCronAutoUpdates), DatabaseType.PostgresSql => nameof(PGAddCronAutoUpdates), DatabaseType.SqlServer => nameof(MSAddCronAutoUpdates), DatabaseType.Sqlite => nameof(SLAddCronAutoUpdates), diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.Designer.cs new file mode 100644 index 00000000000..4521759b2d8 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.Designer.cs @@ -0,0 +1,1102 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20241103161845_MSAddAutoStartAndStop")] + partial class MSAddAutoStartAndStop + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + 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("EngineVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("bigint"); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HealthCheckSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("bit"); + + b.Property("MapThreads") + .HasColumnType("bigint"); + + b.Property("Minidumps") + .IsRequired() + .HasColumnType("bit"); + + b.Property("OpenDreamTopicPort") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("bit"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("CompilerAdditionalArguments") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiValidationMode") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AutoUpdateCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + 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("EngineRights") + .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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + 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("JobCode") + .HasColumnType("tinyint"); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("LaunchVisibility") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.Property("TopicPort") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + 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.Property("UpdateSubmodules") + .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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + 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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + 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() + .HasMaxLength(40) + .HasColumnType("nvarchar(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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(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() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("nvarchar(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"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(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.Navigation("Instance"); + }); + + 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.Navigation("ChatSettings"); + }); + + 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(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + 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.Navigation("Instance"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + 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(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + 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", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + 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"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.cs new file mode 100644 index 00000000000..3a3f397d88a --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161845_MSAddAutoStartAndStop.cs @@ -0,0 +1,84 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + /// + public partial class MSAddAutoStartAndStop : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 10000); + + migrationBuilder.AddColumn( + name: "AutoStartCron", + table: "Instances", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + + migrationBuilder.AddColumn( + name: "AutoStopCron", + table: "Instances", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropColumn( + name: "AutoStartCron", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "AutoStopCron", + table: "Instances"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "nvarchar(max)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(1000)", + oldMaxLength: 1000); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.Designer.cs new file mode 100644 index 00000000000..7ffa91e4834 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.Designer.cs @@ -0,0 +1,1173 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20241103161856_MYAddAutoStartAndStop")] + partial class MYAddAutoStartAndStop + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ConnectionString"), "utf8mb4"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("IrcChannel"), "utf8mb4"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Tag"), "utf8mb4"); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("DmeName"), "utf8mb4"); + + b.Property("EngineVersion") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("EngineVersion"), "utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("bigint"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Output"), "utf8mb4"); + + b.Property("RepositoryOrigin") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("RepositoryOrigin"), "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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AdditionalParameters"), "utf8mb4"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HealthCheckSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("MapThreads") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("Minidumps") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("OpenDreamTopicPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("CompilerAdditionalArguments") + .HasMaxLength(10000) + .HasColumnType("varchar(10000)"); + + b.Property("DMApiValidationMode") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ProjectName"), "utf8mb4"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("time(6)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AutoUpdateCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Path"), "utf8mb4"); + + b.Property("SwarmIdentifer") + .HasColumnType("varchar(255)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("SwarmIdentifer"), "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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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("EngineRights") + .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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Description"), "utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ExceptionDetails"), "utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("JobCode") + .HasColumnType("tinyint unsigned"); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ExternalUserId"), "utf8mb4"); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessIdentifier"), "utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("LaunchVisibility") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.Property("TopicPort") + .HasColumnType("smallint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessToken"), "utf8mb4"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessUser"), "utf8mb4"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitterEmail"), "utf8mb4"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitterName"), "utf8mb4"); + + 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.Property("UpdateSubmodules") + .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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitSha"), "utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("OriginCommitSha"), "utf8mb4"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Author"), "utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("BodyAtMerge"), "utf8mb4"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Comment"), "utf8mb4"); + + 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() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("TargetCommitSha"), "utf8mb4"); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("TitleAtMerge"), "utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Url"), "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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CanonicalName"), "utf8mb4"); + + 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() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("PasswordHash"), "utf8mb4"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("SystemIdentifier"), "utf8mb4"); + + 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"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + 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.Navigation("Instance"); + }); + + 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.Navigation("ChatSettings"); + }); + + 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(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + 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.Navigation("Instance"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + 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(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + 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", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + 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"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.cs new file mode 100644 index 00000000000..f9c0598b7c2 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161856_MYAddAutoStartAndStop.cs @@ -0,0 +1,90 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + /// + public partial class MYAddAutoStartAndStop : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "varchar(1000)", + maxLength: 1000, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(10000)", + oldMaxLength: 10000) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "AutoStartCron", + table: "Instances", + type: "varchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "AutoStopCron", + table: "Instances", + type: "varchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropColumn( + name: "AutoStartCron", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "AutoStopCron", + table: "Instances"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "varchar(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(1000)", + oldMaxLength: 1000) + .Annotation("MySql:CharSet", "utf8mb4") + .OldAnnotation("MySql:CharSet", "utf8mb4"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.Designer.cs new file mode 100644 index 00000000000..6a73238fb0e --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.Designer.cs @@ -0,0 +1,1096 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20241103161906_PGAddAutoStartAndStop")] + partial class PGAddAutoStartAndStop + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("character varying(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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("EngineVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("bigint"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HealthCheckSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("MapThreads") + .HasColumnType("bigint"); + + b.Property("Minidumps") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("OpenDreamTopicPort") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.Property("Visibility") + .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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("CompilerAdditionalArguments") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("DMApiValidationMode") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AutoUpdateCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("EngineRights") + .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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("JobCode") + .HasColumnType("smallint"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("LaunchVisibility") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.Property("TopicPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(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.Property("UpdateSubmodules") + .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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("character varying(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() + .HasMaxLength(40) + .HasColumnType("character varying(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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(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() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("character varying(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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(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.Navigation("Instance"); + }); + + 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.Navigation("ChatSettings"); + }); + + 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(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + 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.Navigation("Instance"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + 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(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + 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", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + 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"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.cs new file mode 100644 index 00000000000..b5bdf601dfc --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161906_PGAddAutoStartAndStop.cs @@ -0,0 +1,84 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + /// + public partial class PGAddAutoStartAndStop : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "character varying(1000)", + maxLength: 1000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(10000)", + oldMaxLength: 10000); + + migrationBuilder.AddColumn( + name: "AutoStartCron", + table: "Instances", + type: "character varying(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + + migrationBuilder.AddColumn( + name: "AutoStopCron", + table: "Instances", + type: "character varying(1000)", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropColumn( + name: "AutoStartCron", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "AutoStopCron", + table: "Instances"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "bigint", + nullable: true, + oldClrType: typeof(long), + oldType: "bigint"); + + migrationBuilder.AlterColumn( + name: "AutoUpdateCron", + table: "Instances", + type: "character varying(10000)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(1000)", + oldMaxLength: 1000); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.Designer.cs new file mode 100644 index 00000000000..2d72393781c --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.Designer.cs @@ -0,0 +1,1069 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20241103161915_SLAddAutoStartAndStop")] + partial class SLAddAutoStartAndStop + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.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() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + 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("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("EngineVersion") + .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() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HealthCheckSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("MapThreads") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Minidumps") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("OpenDreamTopicPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .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("CompilerAdditionalArguments") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("DMApiValidationMode") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("TEXT"); + + 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("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AutoUpdateCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("EngineRights") + .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("JobCode") + .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() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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("InitialCompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("LaunchVisibility") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.Property("TopicPort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + 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.Property("UpdateSubmodules") + .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() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + 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") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + 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.Navigation("Instance"); + }); + + 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.Navigation("ChatSettings"); + }); + + 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(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + 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.Navigation("Instance"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + 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(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + 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", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + 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.Navigation("Instance"); + }); + + 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(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + 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"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.cs b/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.cs new file mode 100644 index 00000000000..43cd35a14a2 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20241103161915_SLAddAutoStartAndStop.cs @@ -0,0 +1,64 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Tgstation.Server.Host.Database +{ + /// + public partial class SLAddAutoStartAndStop : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "INTEGER", + nullable: false, + defaultValue: 0L, + oldClrType: typeof(long), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "AutoStartCron", + table: "Instances", + type: "TEXT", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + + migrationBuilder.AddColumn( + name: "AutoStopCron", + table: "Instances", + type: "TEXT", + maxLength: 1000, + nullable: false, + defaultValue: String.Empty); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + + migrationBuilder.DropColumn( + name: "AutoStartCron", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "AutoStopCron", + table: "Instances"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "OAuthConnections", + type: "INTEGER", + nullable: true, + oldClrType: typeof(long), + oldType: "INTEGER"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs index 5bd818d66b2..82823ebae9a 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -namespace Tgstation.Server.Host.Database.Migrations +namespace Tgstation.Server.Host.Database { [DbContext(typeof(MySqlDatabaseContext))] partial class MySqlDatabaseContextModelSnapshot : ModelSnapshot @@ -13,7 +13,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -318,10 +318,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + b.Property("AutoUpdateCron") .IsRequired() - .HasMaxLength(10000) - .HasColumnType("varchar(10000)"); + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); b.Property("AutoUpdateInterval") .IsRequired() @@ -489,7 +499,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Provider") .HasColumnType("int"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("bigint"); b.HasKey("Id"); @@ -980,7 +990,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Tgstation.Server.Host.Models.User", "User") .WithMany("OAuthConnections") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("User"); }); diff --git a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs index 784cf2c86d1..281085cf059 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -namespace Tgstation.Server.Host.Database.Migrations +namespace Tgstation.Server.Host.Database { [DbContext(typeof(PostgresSqlDatabaseContext))] partial class PostgresSqlDatabaseContextModelSnapshot : ModelSnapshot @@ -13,7 +13,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -291,10 +291,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + b.Property("AutoUpdateCron") .IsRequired() - .HasMaxLength(10000) - .HasColumnType("character varying(10000)"); + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); b.Property("AutoUpdateInterval") .HasColumnType("bigint"); @@ -448,7 +458,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Provider") .HasColumnType("integer"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("bigint"); b.HasKey("Id"); @@ -903,7 +913,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Tgstation.Server.Host.Models.User", "User") .WithMany("OAuthConnections") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("User"); }); diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs index 562bbe5a06f..28821afc190 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -namespace Tgstation.Server.Host.Database.Migrations +namespace Tgstation.Server.Host.Database { [DbContext(typeof(SqlServerDatabaseContext))] partial class SqlServerDatabaseContextModelSnapshot : ModelSnapshot @@ -13,7 +13,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -293,10 +293,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + b.Property("AutoUpdateCron") .IsRequired() - .HasMaxLength(10000) - .HasColumnType("nvarchar(max)"); + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); b.Property("AutoUpdateInterval") .HasColumnType("bigint"); @@ -451,7 +461,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Provider") .HasColumnType("int"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("bigint"); b.HasKey("Id"); @@ -909,7 +919,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Tgstation.Server.Host.Models.User", "User") .WithMany("OAuthConnections") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("User"); }); diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs index 4c735efae2e..3de5ce1013e 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -namespace Tgstation.Server.Host.Database.Migrations +namespace Tgstation.Server.Host.Database { [DbContext(typeof(SqliteDatabaseContext))] partial class SqliteDatabaseContextModelSnapshot : ModelSnapshot @@ -12,7 +12,7 @@ partial class SqliteDatabaseContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.10"); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => { @@ -284,9 +284,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AutoStartCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AutoStopCron") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + b.Property("AutoUpdateCron") .IsRequired() - .HasMaxLength(10000) + .HasMaxLength(1000) .HasColumnType("TEXT"); b.Property("AutoUpdateInterval") @@ -437,7 +447,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Provider") .HasColumnType("INTEGER"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -876,7 +886,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Tgstation.Server.Host.Models.User", "User") .WithMany("OAuthConnections") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); b.Navigation("User"); }); diff --git a/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs b/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs new file mode 100644 index 00000000000..0faad835b29 --- /dev/null +++ b/src/Tgstation.Server.Host/Extensions/OAuthGatewayStatusExtensions.cs @@ -0,0 +1,24 @@ +using System; + +namespace Tgstation.Server.Host.Extensions +{ + /// + /// Extension methods for . + /// + static class OAuthGatewayStatusExtensions + { + /// + /// Convert a given to a for API usage. + /// + /// The to convert. + /// The form of the . + public static bool? ToBoolean(this OAuthGatewayStatus oAuthGatewayStatus) + => oAuthGatewayStatus switch + { + OAuthGatewayStatus.Disabled => null, + OAuthGatewayStatus.Enabled => false, + OAuthGatewayStatus.Only => true, + _ => throw new InvalidOperationException($"Invalid {nameof(OAuthGatewayStatus)}: {oAuthGatewayStatus}"), + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutation.cs b/src/Tgstation.Server.Host/GraphQL/Mutation.cs index c30790791ca..286fe00619d 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutation.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutation.cs @@ -23,11 +23,11 @@ public sealed class Mutation public const string GraphQLDescription = "Root Mutation type"; /// - /// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication. + /// Generate a JWT for authenticating with server. This requires either the Basic authentication or OAuth authentication schemes. /// /// The for the . /// The for the operation. - /// A Bearer token to be used with further communication with the server. + /// A . [Error(typeof(ErrorMessageException))] public ValueTask Login( [Service] IGraphQLAuthorityInvoker loginAuthority, @@ -38,5 +38,22 @@ public ValueTask Login( return loginAuthority.Invoke( authority => authority.AttemptLogin(cancellationToken)); } + + /// + /// Generate an OAuth user token for the requested service. This requires the OAuth authentication scheme. + /// + /// The for the . + /// The for the operation. + /// An . + [Error(typeof(ErrorMessageException))] + public ValueTask OAuthGateway( + [Service] IGraphQLAuthorityInvoker loginAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(loginAuthority); + + return loginAuthority.Invoke( + authority => authority.AttemptOAuthGatewayLogin(cancellationToken)); + } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs new file mode 100644 index 00000000000..7390113a24a --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/AdministrationMutations.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; +using HotChocolate.Types; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.GraphQL.Scalars; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.GraphQL.Mutations +{ + /// + /// related s. + /// + [ExtendObjectType(typeof(Mutation))] + [GraphQLDescription(Mutation.GraphQLDescription)] + public sealed class AdministrationMutations + { + /// + /// Restarts the mutated without terminating running game instances. + /// + /// The for the . + /// A representing the running operation. + [TgsGraphQLAuthorize(nameof(IAdministrationAuthority.TriggerServerRestart))] + [Error(typeof(ErrorMessageException))] + public async ValueTask RestartServerNode( + [Service] IGraphQLAuthorityInvoker administrationAuthority) + { + ArgumentNullException.ThrowIfNull(administrationAuthority); + await administrationAuthority.Invoke( + authority => authority.TriggerServerRestart()); + + return new Query(); + } + + /// + /// Restarts the mutated without terminating running game instances and changes its . + /// + /// The semver of the server available in the tracked repository to switch to. + /// The for the . + /// The for the operation. + /// A representing the running operation. + [TgsGraphQLAuthorize(AdministrationRights.ChangeVersion)] + [Error(typeof(ErrorMessageException))] + public async ValueTask ChangeServerNodeVersionViaTrackedRepository( + Version targetVersion, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetVersion); + ArgumentNullException.ThrowIfNull(administrationAuthority); + await administrationAuthority.Invoke( + authority => authority.TriggerServerVersionChange(targetVersion, false, cancellationToken)); + return new Query(); + } + + /// + /// Restarts the mutated without terminating running game instances and changes its . + /// + /// The semver of the server available in the tracked repository to switch to. + /// The for the . + /// The for the operation. + /// A FileTicket that should be used to upload a zip containing the update data to the file transfer service. + [TgsGraphQLAuthorize(AdministrationRights.UploadVersion)] + [Error(typeof(ErrorMessageException))] + [GraphQLType] + public async ValueTask ChangeServerNodeVersionViaUpload( + Version targetVersion, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetVersion); + ArgumentNullException.ThrowIfNull(administrationAuthority); + var response = await administrationAuthority.Invoke( + authority => authority.TriggerServerVersionChange(targetVersion, true, cancellationToken)); + + return response.FileTicket ?? throw new InvalidOperationException("Administration authority did not generate a FileUploadTicket!"); + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs index cf30206115b..a28589c21d3 100644 --- a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/LoginResult.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads public sealed class LoginResult : ILegacyApiTransformable { /// - /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time. + /// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login endpoints. Contains an expiry time. /// [GraphQLType] [GraphQLNonNullType] diff --git a/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs new file mode 100644 index 00000000000..12b369cc95d --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Mutations/Payloads/OAuthGatewayLoginResult.cs @@ -0,0 +1,26 @@ +using HotChocolate; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads +{ + /// + /// Success result for an OAuth gateway login attempt. + /// + public sealed class OAuthGatewayLoginResult : ILegacyApiTransformable + { + /// + /// The user's access token for the requested OAuth service. + /// + public required string AccessCode { get; init; } + + /// + [GraphQLIgnore] + public OAuthGatewayResponse ToApi() + => new() + { + AccessCode = AccessCode, + }; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs new file mode 100644 index 00000000000..24d7ca08087 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/FileUploadTicketType.cs @@ -0,0 +1,17 @@ +namespace Tgstation.Server.Host.GraphQL.Scalars +{ + /// + /// A for upload s. + /// + public sealed class FileUploadTicketType : StringScalarType + { + /// + /// Initializes a new instance of the class. + /// + public FileUploadTicketType() + : base("FileUploadTicket") + { + Description = "Represents a ticket that can be used with the file transfer service to upload a file"; + } + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs index 170a744a42e..fd6057392af 100644 --- a/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/JwtType.cs @@ -1,14 +1,11 @@ using System; -using HotChocolate.Language; -using HotChocolate.Types; - namespace Tgstation.Server.Host.GraphQL.Scalars { /// - /// A for encoded JSON Web Tokens. + /// A for encoded JSON Web Tokens. /// - public sealed class JwtType : ScalarType + public sealed class JwtType : StringScalarType { /// /// Initializes a new instance of the class. @@ -19,20 +16,5 @@ public JwtType() Description = "Represents an encoded JSON Web Token"; SpecifiedBy = new Uri("https://datatracker.ietf.org/doc/html/rfc7519"); } - - /// - public override IValueNode ParseResult(object? resultValue) - => ParseValue(resultValue); - - /// - protected override string ParseLiteral(StringValueNode valueSyntax) - { - ArgumentNullException.ThrowIfNull(valueSyntax); - return valueSyntax.Value; - } - - /// - protected override StringValueNode ParseValue(string runtimeValue) - => new(runtimeValue); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs index de8c1e530a3..3f6f6608af6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/SemverType.cs @@ -67,7 +67,8 @@ protected override StringValueNode ParseValue(Version runtimeValue) protected override bool IsInstanceOfType(StringValueNode valueSyntax) { ArgumentNullException.ThrowIfNull(valueSyntax); - return IsInstanceOfType(valueSyntax.Value); + return Version.TryParse(valueSyntax.Value, out var parsedVersion) + && IsInstanceOfType(parsedVersion); } /// diff --git a/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs b/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs new file mode 100644 index 00000000000..05f43289468 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Scalars/StringScalarType.cs @@ -0,0 +1,37 @@ +using System; + +using HotChocolate.Language; +using HotChocolate.Types; + +namespace Tgstation.Server.Host.GraphQL.Scalars +{ + /// + /// A for specialized types. + /// + public abstract class StringScalarType : ScalarType + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the GraphQL scalar type. + public StringScalarType(string name) + : base(name) + { + } + + /// + public override IValueNode ParseResult(object? resultValue) + => ParseValue(resultValue); + + /// + protected override string ParseLiteral(StringValueNode valueSyntax) + { + ArgumentNullException.ThrowIfNull(valueSyntax); + return valueSyntax.Value; + } + + /// + protected override StringValueNode ParseValue(string runtimeValue) + => new(runtimeValue); + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs index 192a5b377b1..26223a455eb 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/Instance.cs @@ -14,5 +14,12 @@ public sealed class Instance : Entity /// Queryable s. public IQueryable QueryableInstancePermissionSets() => throw new NotImplementedException(); + + /// + /// Gets the callers effective on the . + /// + /// The callers effective if it exists or . + public InstancePermissionSet? EffectivePermissionSet() + => throw new NotImplementedException(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs index 76bcabcac68..ebe30ac0728 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/InstancePermissionSet.cs @@ -1,4 +1,6 @@ -using Tgstation.Server.Api.Rights; +using System; + +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.GraphQL.Types { @@ -7,6 +9,13 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class InstancePermissionSet { + /// + /// Gets the the belongs to. + /// + /// The owning . + public PermissionSet PermissionSet() + => throw new NotImplementedException(); + /// /// The of the . /// diff --git a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs index f503d7adc8d..3eac5890128 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/OAuth/BasicOAuthProviderInfo.cs @@ -14,6 +14,11 @@ public class BasicOAuthProviderInfo /// public string ClientID { get; } + /// + /// If the OAuth provider can only be used for gateway authentication. If the OAuth provider may be used for server logins or gateway authentication. If the OAuth provider may only be used for server logins. + /// + public bool? GatewayOnly { get; } + /// /// Initializes a new instance of the class. /// @@ -23,6 +28,7 @@ public BasicOAuthProviderInfo(OAuthProviderInfo providerInfo) ArgumentNullException.ThrowIfNull(providerInfo); ClientID = providerInfo.ClientId ?? throw new InvalidOperationException("ClientID not set!"); + GatewayOnly = providerInfo.GatewayOnly; } } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs index 286aad2adfc..35d82985dd6 100644 --- a/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs +++ b/src/Tgstation.Server.Host/GraphQL/Types/ServerSwarm.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Options; using Tgstation.Server.Host.Configuration; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.GraphQL.Interfaces; using Tgstation.Server.Host.Properties; using Tgstation.Server.Host.Security; @@ -20,19 +19,6 @@ namespace Tgstation.Server.Host.GraphQL.Types /// public sealed class ServerSwarm { - /// - /// If there is a swarm update in progress. - /// - /// The to use. - /// if there is an update in progress, otherwise. - [TgsGraphQLAuthorize] - public bool UpdateInProgress( - [Service] IServerControl serverControl) - { - ArgumentNullException.ThrowIfNull(serverControl); - return serverControl.UpdateInProgress; - } - /// /// Gets the swarm protocol major version in use. /// @@ -78,5 +64,12 @@ public IServerNode CurrentNode( ArgumentNullException.ThrowIfNull(swarmService); return swarmService.GetSwarmServers()?.Select(x => new SwarmNode(x)).ToList(); } + + /// + /// Gets the for the swarm. + /// + /// A new . + [TgsGraphQLAuthorize] + public UpdateInformation UpdateInformation() => new(); } } diff --git a/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs b/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs new file mode 100644 index 00000000000..937e07b69a7 --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/UpdateInformation.cs @@ -0,0 +1,118 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using HotChocolate; + +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Authority; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Gets information about updates for the . + /// + public sealed class UpdateInformation : IDisposable + { + /// + /// to prevent duplicate cache generations in one query. + /// + readonly SemaphoreSlim cacheReadSemaphore; + + /// + /// If the cache was already force generated this query. + /// + bool cacheForceGenerated; + + /// + /// Initializes a new instance of the class. + /// + public UpdateInformation() + { + cacheReadSemaphore = new SemaphoreSlim(1, 1); + } + + /// + public void Dispose() + => cacheReadSemaphore.Dispose(); + + /// + /// If there is a swarm update in progress. This is not affected by . + /// + /// The to use. + /// if there is an update in progress, otherwise. + public bool UpdateInProgress( + [Service] IServerControl serverControl) + { + ArgumentNullException.ThrowIfNull(serverControl); + return serverControl.UpdateInProgress; + } + + /// + /// Gets the of the GitHub repository updates are sourced from. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// The of the GitHub repository updates are sourced from on success. if a GitHub API error occurred. + public async ValueTask TrackedRepositoryUrl( + bool? forceFresh, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(forceFresh, administrationAuthority, cancellationToken)).TrackedRepositoryUrl; + + /// + /// Gets the time the was generated. + /// + /// The for the to use. + /// The for the operation. + /// The time the was generated on success. if a GitHub API error occurred. + public async ValueTask GeneratedAt( + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(false, administrationAuthority, cancellationToken)).GeneratedAt; + + /// + /// Gets the latest of tgstation-server available on the GitHub repository updates are sourced from. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// The of the latest TGS version on success. if a GitHub API error occurred. + public async ValueTask LatestVersion( + bool? forceFresh, + [Service] IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + => (await GetAdministrationResponseSafe(forceFresh, administrationAuthority, cancellationToken)).LatestVersion; + + /// + /// Safely retrieve the from a given without generating the cache multiple times in one query. + /// + /// If the local cache TGS keeps of this data will be bypassed. + /// The for the to use. + /// The for the operation. + /// A resulting in the from the . + async ValueTask GetAdministrationResponseSafe( + bool? forceFresh, + IGraphQLAuthorityInvoker administrationAuthority, + CancellationToken cancellationToken) + { + using (await SemaphoreSlimContext.Lock(cacheReadSemaphore, cancellationToken)) + { + forceFresh ??= false; + if (cacheForceGenerated) + forceFresh = false; + else + cacheForceGenerated |= forceFresh.Value; + + ArgumentNullException.ThrowIfNull(administrationAuthority); + var response = await administrationAuthority.Invoke( + authority => authority.GetUpdateInformation(forceFresh.Value, cancellationToken)); + + return response; + } + } + } +} diff --git a/src/Tgstation.Server.Host/IO/WindowsFilesystemLinkFactory.cs b/src/Tgstation.Server.Host/IO/WindowsFilesystemLinkFactory.cs index 6636838ab42..54ff36b3205 100644 --- a/src/Tgstation.Server.Host/IO/WindowsFilesystemLinkFactory.cs +++ b/src/Tgstation.Server.Host/IO/WindowsFilesystemLinkFactory.cs @@ -1,10 +1,9 @@ using System; +using System.ComponentModel; using System.IO; using System.Threading; using System.Threading.Tasks; -using BetterWin32Errors; - using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.IO diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index ded8b8fd558..e793c2959c6 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -76,6 +76,8 @@ public Instance() Path = Path, Online = Online, ChatBotLimit = ChatBotLimit, + AutoStartCron = AutoStartCron, + AutoStopCron = AutoStopCron, }; } } diff --git a/src/Tgstation.Server.Host/Models/OAuthConnection.cs b/src/Tgstation.Server.Host/Models/OAuthConnection.cs index 7c871b672a7..d79954cf9f4 100644 --- a/src/Tgstation.Server.Host/Models/OAuthConnection.cs +++ b/src/Tgstation.Server.Host/Models/OAuthConnection.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.Host.Models +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models { /// public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTransformable @@ -16,6 +18,7 @@ public sealed class OAuthConnection : Api.Models.OAuthConnection, ILegacyApiTran /// /// The owning . /// + [Required] public User? User { get; set; } /// diff --git a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs index 5963023b0fc..b68813be3cb 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GenericOAuthValidator.cs @@ -15,6 +15,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; namespace Tgstation.Server.Host.Security.OAuth { @@ -26,6 +27,9 @@ abstract class GenericOAuthValidator : IOAuthValidator /// public abstract OAuthProvider Provider { get; } + /// + public OAuthGatewayStatus GatewayStatus => OAuthConfiguration.Gateway ?? default; + /// /// The for the . /// @@ -80,7 +84,7 @@ public GenericOAuthValidator( } /// - public async ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken) + public async ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken) { using var httpClient = CreateHttpClient(); string? tokenResponsePayload = null; @@ -112,6 +116,9 @@ public GenericOAuthValidator( return null; } + if (!requireUserID) + return (null, AccessCode: accessToken); + Logger.LogTrace("Getting user details..."); var userInfoUrl = OAuthConfiguration?.UserInformationUrlOverride ?? UserInformationUrl; @@ -126,7 +133,7 @@ public GenericOAuthValidator( var userInformationJson = JObject.Parse(userInformationPayload); - return DecodeUserInformationPayload(userInformationJson); + return (DecodeUserInformationPayload(userInformationJson), AccessCode: accessToken); } catch (Exception ex) { @@ -146,6 +153,7 @@ public OAuthProviderInfo GetProviderInfo() ClientId = OAuthConfiguration.ClientId, RedirectUri = OAuthConfiguration.RedirectUrl, ServerUrl = OAuthConfiguration.ServerUrl, + GatewayOnly = GatewayStatus.ToBoolean(), }; /// diff --git a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs index d535d978843..3bb7164f63c 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/GitHubOAuthValidator.cs @@ -8,6 +8,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Security.OAuth @@ -20,6 +21,9 @@ sealed class GitHubOAuthValidator : IOAuthValidator /// public OAuthProvider Provider => OAuthProvider.GitHub; + /// + public OAuthGatewayStatus GatewayStatus => oAuthConfiguration.Gateway ?? default; + /// /// The for the . /// @@ -52,7 +56,7 @@ public GitHubOAuthValidator( } /// - public async ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken) + public async ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(code); @@ -65,12 +69,15 @@ public GitHubOAuthValidator( if (token == null) return null; + if (!requireUserID) + return (null, AccessCode: token); + var authenticatedClient = await gitHubServiceFactory.CreateService(token, cancellationToken); logger.LogTrace("Getting user details..."); var userId = await authenticatedClient.GetCurrentUserId(cancellationToken); - return userId.ToString(CultureInfo.InvariantCulture); + return (userId.ToString(CultureInfo.InvariantCulture), AccessCode: token); } catch (RateLimitExceededException) { @@ -89,6 +96,7 @@ public OAuthProviderInfo GetProviderInfo() { ClientId = oAuthConfiguration.ClientId, RedirectUri = oAuthConfiguration.RedirectUrl, + GatewayOnly = GatewayStatus.ToBoolean(), }; } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs index 2da6651c041..3b72cd45bef 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthProviders.cs @@ -13,8 +13,9 @@ public interface IOAuthProviders /// Gets the for a given . /// /// The to get the validator for. + /// If the resulting will be used to authenticate a server login. /// The for . - IOAuthValidator? GetValidator(OAuthProvider oAuthProvider); + IOAuthValidator? GetValidator(OAuthProvider oAuthProvider, bool forLogin); /// /// Gets a of the provider client IDs. diff --git a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs index 472a7ff091d..95f51e354d3 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/IOAuthValidator.cs @@ -15,6 +15,11 @@ public interface IOAuthValidator /// OAuthProvider Provider { get; } + /// + /// The for the . + /// + OAuthGatewayStatus GatewayStatus { get; } + /// /// Gets the of validator. /// @@ -25,8 +30,9 @@ public interface IOAuthValidator /// Validate a given OAuth response . /// /// The OAuth response string from web application. + /// If the resulting user ID should be retrieved. /// The for the operation. - /// A resulting in if authentication failed, if a rate limit occurred, and the validated otherwise. - ValueTask ValidateResponseCode(string code, CancellationToken cancellationToken); + /// A resulting in if authentication failed or the validated and OAuth access code otherwise. + ValueTask<(string? UserID, string AccessCode)?> ValidateResponseCode(string code, bool requireUserID, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs index 9d28ab1c824..5b5af32ba95 100644 --- a/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs +++ b/src/Tgstation.Server.Host/Security/OAuth/OAuthProviders.cs @@ -80,7 +80,10 @@ public OAuthProviders( } /// - public IOAuthValidator? GetValidator(OAuthProvider oAuthProvider) => validators.FirstOrDefault(x => x.Provider == oAuthProvider); + public IOAuthValidator? GetValidator(OAuthProvider oAuthProvider, bool forLogin) + => validators.FirstOrDefault( + x => x.Provider == oAuthProvider + && ((forLogin && x.GatewayStatus != OAuthGatewayStatus.Only) || (!forLogin && x.GatewayStatus != OAuthGatewayStatus.Disabled))); /// public Dictionary ProviderInfos() diff --git a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs index 5f5157e3619..00880fc1b39 100644 --- a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs +++ b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs @@ -40,7 +40,7 @@ sealed class SessionInvalidationTracker : ISessionInvalidationTracker readonly ILogger logger; /// - /// of tracked s and s to the for their s. + /// of tracked s and s to the for their s. /// readonly ConcurrentDictionary<(string SessionId, long UserId), TaskCompletionSource> trackedSessions; @@ -86,7 +86,7 @@ async void SendInvalidationTopic() var timeTillSessionExpiry = authenticationContext.SessionExpiry - DateTimeOffset.UtcNow; if (timeTillSessionExpiry > TimeSpan.Zero) { - var delayTask = asyncDelayer.Delay(timeTillSessionExpiry, applicationLifetime.ApplicationStopping); + var delayTask = asyncDelayer.Delay(timeTillSessionExpiry, applicationLifetime.ApplicationStopping).AsTask(); await Task.WhenAny(delayTask, otherCancellationReason); @@ -122,7 +122,7 @@ async void SendInvalidationTopic() } /// - public void UserModifiedInvalidateSessions(Models.User user) + public void UserModifiedInvalidateSessions(User user) { ArgumentNullException.ThrowIfNull(user); diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 57a26f669b7..640b0c85179 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -208,7 +208,23 @@ public bool TryStartUpdate(IServerUpdateExecutor updateExecutor, Version newVers async Task RunUpdate() { - if (await updateExecutor.ExecuteUpdate(updatePath, criticalCancellationToken, criticalCancellationToken)) + var updateExecutedSuccessfully = false; + try + { + updateExecutedSuccessfully = await updateExecutor.ExecuteUpdate(updatePath, criticalCancellationToken, criticalCancellationToken); + } + catch (OperationCanceledException ex) + { + logger.LogDebug(ex, "Update cancelled!"); + UpdateInProgress = false; + } + catch (Exception ex) + { + logger.LogError(ex, "Update errored!"); + UpdateInProgress = false; + } + + if (updateExecutedSuccessfully) { logger.LogTrace("Update complete!"); await RestartImpl(newVersion, null, true, true); diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index b6a0e51df65..dcb3737ff99 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -298,7 +298,7 @@ public async ValueTask CommitUpdate(CancellationToken cancell var timeoutTask = swarmController ? asyncDelayer.Delay( TimeSpan.FromMinutes(SwarmConstants.UpdateCommitTimeoutMinutes), - cancellationToken) + cancellationToken).AsTask() : Extensions.TaskExtensions.InfiniteTask.WaitAsync(cancellationToken); var commitTask = Task.WhenAny(localUpdateOperation.CommitGate, timeoutTask); @@ -1512,7 +1512,8 @@ async Task HealthCheckLoop(CancellationToken cancellationToken) var delayTask = asyncDelayer.Delay( delay, - cancellationToken); + cancellationToken) + .AsTask(); var awakeningTask = Task.WhenAny( delayTask, diff --git a/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs b/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs index f9894e9ac9e..12a0164a1f5 100644 --- a/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs +++ b/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -62,6 +64,39 @@ public PosixProcessFeatures(Lazy lazyLoadedProcessExecutor, II this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// Gets potential paths to the gcore executable. + /// + /// The potential paths to the gcore executable. + static IEnumerable GetPotentialGCorePaths() + { + var enviromentPath = Environment.GetEnvironmentVariable("PATH"); + IEnumerable enumerator; + if (enviromentPath == null) + enumerator = Enumerable.Empty(); + else + { + var paths = enviromentPath.Split(';'); + enumerator = paths + .Select(x => x.Split(':')) + .SelectMany(x => x); + } + + var exeName = "gcore"; + + enumerator = enumerator + .Concat(new List(2) + { + "/usr/bin", + "/usr/share/bin", + "/bin", + }); + + enumerator = enumerator.Select(x => Path.Combine(x, exeName)); + + return enumerator; + } + /// public void ResumeProcess(global::System.Diagnostics.Process process) { @@ -88,8 +123,15 @@ public async ValueTask CreateDump(global::System.Diagnostics.Process process, st ArgumentNullException.ThrowIfNull(process); ArgumentNullException.ThrowIfNull(outputFile); - const string GCorePath = "/usr/bin/gcore"; - if (!await ioManager.FileExists(GCorePath, cancellationToken)) + string? gcorePath = null; + foreach (var path in GetPotentialGCorePaths()) + if (await ioManager.FileExists(path, cancellationToken)) + { + gcorePath = path; + break; + } + + if (gcorePath == null) throw new JobException(ErrorCode.MissingGCore); int pid; @@ -108,7 +150,7 @@ public async ValueTask CreateDump(global::System.Diagnostics.Process process, st string? output; int exitCode; await using (var gcoreProc = await lazyLoadedProcessExecutor.Value.LaunchProcess( - GCorePath, + gcorePath, Environment.CurrentDirectory, $"{(!minidump ? "-a " : String.Empty)}-o {outputFile} {process.Id}", cancellationToken, diff --git a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs index 9bbcd497ce0..bd7b1f645ec 100644 --- a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs +++ b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; -using BetterWin32Errors; - using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs b/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs index 6b71b2b2539..21855c0062e 100644 --- a/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs +++ b/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; @@ -8,7 +9,6 @@ using System.Threading; using System.Threading.Tasks; -using BetterWin32Errors; using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Models; diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 23c527b5277..08a82223570 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -87,48 +87,46 @@ - - - + - + - + - + - + - + - + - + - + - + - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -136,15 +134,15 @@ - + - + - + @@ -152,15 +150,15 @@ - + - + - + - + - + diff --git a/src/Tgstation.Server.Host/Utils/AsyncDelayer.cs b/src/Tgstation.Server.Host/Utils/AsyncDelayer.cs index 79ffce18386..c8adc33436d 100644 --- a/src/Tgstation.Server.Host/Utils/AsyncDelayer.cs +++ b/src/Tgstation.Server.Host/Utils/AsyncDelayer.cs @@ -1,13 +1,57 @@ using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + namespace Tgstation.Server.Host.Utils { /// sealed class AsyncDelayer : IAsyncDelayer { + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public AsyncDelayer(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// - public Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken) => Task.Delay(timeSpan, cancellationToken); + public async ValueTask Delay(TimeSpan timeSpan, CancellationToken cancellationToken) + { + // https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay?view=net-8.0#system-threading-tasks-task-delay(system-timespan) + const uint DelayMinutesLimit = UInt32.MaxValue - 1; + Debug.Assert(DelayMinutesLimit == 4294967294, "Delay limit assertion failure!"); + + var maxDelayIterations = 0UL; + if (timeSpan.TotalMilliseconds >= UInt32.MaxValue) + { + maxDelayIterations = (ulong)Math.Floor(timeSpan.TotalMilliseconds / DelayMinutesLimit); + logger.LogDebug("Breaking interval into {iterationCount} iterations", maxDelayIterations + 1); + timeSpan = TimeSpan.FromMilliseconds(timeSpan.TotalMilliseconds - (maxDelayIterations * DelayMinutesLimit)); + } + + if (maxDelayIterations > 0) + { + var longDelayTimeSpan = TimeSpan.FromMilliseconds(DelayMinutesLimit); + for (var i = 0UL; i < maxDelayIterations; ++i) + { + logger.LogTrace("Long delay #{iteration}...", i + 1); + await Task.Delay(longDelayTimeSpan, cancellationToken); + } + + logger.LogTrace("Final delay iteration #{iteration}...", maxDelayIterations + 1); + } + + await Task.Delay(timeSpan, cancellationToken); + } } } diff --git a/src/Tgstation.Server.Host/Utils/IAsyncDelayer.cs b/src/Tgstation.Server.Host/Utils/IAsyncDelayer.cs index d47c5cbc626..e60799fc295 100644 --- a/src/Tgstation.Server.Host/Utils/IAsyncDelayer.cs +++ b/src/Tgstation.Server.Host/Utils/IAsyncDelayer.cs @@ -14,7 +14,7 @@ interface IAsyncDelayer /// /// The that must elapse. /// The for the operation. - /// A representing the running operation. - Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken); + /// A representing the running operation. + ValueTask Delay(TimeSpan timeSpan, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/appsettings.yml b/src/Tgstation.Server.Host/appsettings.yml index b965a326ed3..2978befbf9f 100644 --- a/src/Tgstation.Server.Host/appsettings.yml +++ b/src/Tgstation.Server.Host/appsettings.yml @@ -19,6 +19,7 @@ General: OpenDreamGitUrl: https://github.com/OpenDreamProject/OpenDream # The repository to retrieve OpenDream from OpenDreamGitTagPrefix: v # The prefix to the OpenDream semver as tags appear in the git repository OpenDreamSuppressInstallOutput: false # Suppress the dotnet output of creating an OpenDream installation. Known to cause hangs in CI. + AdditionalEventScriptsDirectories: # An array of directories that are considered to contain EventScripts alongside instance directories. Working directory for exectued scripts will remain the instance EventScripts directory. Session: HighPriorityLiveDreamDaemon: false # If DreamDaemon instances should run as higher priority processes LowPriorityDeploymentProcesses: true # If TGS Deployments should run as lower priority processes @@ -65,6 +66,7 @@ Security: # ClientId: # OAuth client ID # ClientSecret: # OAuth client secret # ServerUrl: # Only used by Keycloak and InvisionCommunity. Server URL (Includes Keycloak realm) +# Gateway: # Can be one of `Disabled` disallowing gateway auth (default), `Enabled` allowing gateway auth, or `Only` allowing gateway auth and disabling OAuth logins with this provider # UserInformationUrlOverride: # Not supported by GitHub. Overrides the URL TGS uses to retrieve a user's information GitHub: # https://github.com OAuth configuration Discord: # https://discord.com OAuth configuration diff --git a/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj b/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj index 14c6e899813..f93188baa9a 100644 --- a/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj +++ b/src/Tgstation.Server.Shared/Tgstation.Server.Shared.csproj @@ -10,11 +10,11 @@ - + - + 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 c2c09fe7b87..0e74bf0b36b 100644 --- a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj +++ b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/tests/Tgstation.Server.Host.Tests.Signals/Program.cs b/tests/Tgstation.Server.Host.Tests.Signals/Program.cs index 92a733e59d2..bcb5aa6699a 100644 --- a/tests/Tgstation.Server.Host.Tests.Signals/Program.cs +++ b/tests/Tgstation.Server.Host.Tests.Signals/Program.cs @@ -25,7 +25,7 @@ static async Task Main() .Returns(ValueTask.CompletedTask); var mockAsyncDelayer = new Mock(); - mockAsyncDelayer.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + mockAsyncDelayer.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns(ValueTask.CompletedTask); using var signalHandler = new PosixSignalHandler(mockServerControl.Object, mockAsyncDelayer.Object, Mock.Of>()); Assert.IsFalse(tcs.Task.IsCompleted); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs index bc1ecbf0a49..834be3bcad6 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs @@ -89,7 +89,7 @@ public async Task TestConnectAndDisconnect() Instance = new Models.Instance(), }; - await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), Mock.Of(), chatBot, new FileLoggingConfiguration()); + await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(loggerFactory.CreateLogger()), loggerFactory.CreateLogger(), Mock.Of(), chatBot, new FileLoggingConfiguration()); Assert.IsFalse(provider.Connected); await InvokeConnect(provider); Assert.IsTrue(provider.Connected); diff --git a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs index 5598673dc29..fba28a4d6ed 100644 --- a/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Setup/TestSetupWizard.cs @@ -91,7 +91,7 @@ public async Task TestWithUserStupidity() mockInternalConfigurationOptions.Object); mockPlatformIdentifier.SetupGet(x => x.IsWindows).Returns(true).Verifiable(); - mockAsyncDelayer.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + mockAsyncDelayer.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns(ValueTask.CompletedTask).Verifiable(); await RunWizard(); diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs index c5f22614b84..9c130af80e5 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs @@ -129,7 +129,7 @@ public TestableSwarmNode( mockAsyncDelayer.Setup( x => x.Delay(It.IsAny(), It.IsAny())) .Returns( - (delay, ct) => Task.Delay(TimeSpan.FromMilliseconds(100), ct)); + async (delay, ct) => await Task.Delay(TimeSpan.FromMilliseconds(100), ct)); var mockServerUpdater = new Mock(); @@ -152,7 +152,7 @@ static ILoggerFactory CreateLoggerFactoryForLogger(ILogger logger, out Mock>()), Mock.Of(), - new AsyncDelayer(), // use a real one here because otherwise tickets expire too fast + new AsyncDelayer(Mock.Of>()), // use a real one here because otherwise tickets expire too fast CreateLoggerFactoryForLogger(loggerFactory.CreateLogger($"FileTransferService-{swarmConfiguration.Identifier}"), out var mockLoggerFactory).CreateLogger()); RpcMapper = new SwarmRpcMapper( 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 a0e5d2ba452..69c414d1e2e 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs b/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs index fcad8785c5c..991a03ceb37 100644 --- a/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs +++ b/tests/Tgstation.Server.Host.Tests/Utils/TestAsyncDelayer.cs @@ -2,8 +2,11 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + namespace Tgstation.Server.Host.Utils.Tests { [TestClass] @@ -12,7 +15,7 @@ public sealed class TestAsyncDelayer [TestMethod] public async Task TestDelay() { - var delayer = new AsyncDelayer(); + var delayer = new AsyncDelayer(Mock.Of>()); var startDelay = delayer.Delay(TimeSpan.FromSeconds(1), CancellationToken.None); var checkDelay = Task.Delay(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(100), CancellationToken.None); await startDelay; @@ -22,10 +25,10 @@ public async Task TestDelay() [TestMethod] public async Task TestCancel() { - var delayer = new AsyncDelayer(); + var delayer = new AsyncDelayer(Mock.Of>()); using var cts = new CancellationTokenSource(); cts.Cancel(); - await Assert.ThrowsExceptionAsync(() => delayer.Delay(TimeSpan.FromSeconds(1), cts.Token)); + await Assert.ThrowsExceptionAsync(() => delayer.Delay(TimeSpan.FromSeconds(1), cts.Token).AsTask()); } } } diff --git a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs index afeabef6f5d..d50205da90d 100644 --- a/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/Live/AdministrationTest.cs @@ -13,11 +13,13 @@ namespace Tgstation.Server.Tests.Live { sealed class AdministrationTest { - readonly IAdministrationClient client; + readonly IMultiServerClient client; + readonly IAdministrationClient restClient; - public AdministrationTest(IAdministrationClient client) + public AdministrationTest(MultiServerClient client) { this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.restClient = client.RestClient.Administration; } public async Task Run(CancellationToken cancellationToken) @@ -29,24 +31,24 @@ public async Task Run(CancellationToken cancellationToken) async Task TestLogs(CancellationToken cancellationToken) { - var logs = await client.ListLogs(null, cancellationToken); + var logs = await restClient.ListLogs(null, cancellationToken); Assert.AreNotEqual(0, logs.Count); var logFile = logs[0]; Assert.IsNotNull(logFile); Assert.IsFalse(string.IsNullOrWhiteSpace(logFile.Name)); Assert.IsNull(logFile.FileTicket); - var downloadedTuple = await client.GetLog(logFile, cancellationToken); + var downloadedTuple = await restClient.GetLog(logFile, cancellationToken); Assert.AreEqual(logFile.Name, downloadedTuple.Item1.Name); Assert.IsTrue(logFile.LastModified <= downloadedTuple.Item1.LastModified); Assert.IsNull(logFile.FileTicket); - await ApiAssert.ThrowsException>(() => client.GetLog(new LogFileResponse + await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse { Name = "very_fake_path.log" }, cancellationToken), ErrorCode.IOError); - await ApiAssert.ThrowsException>(() => client.GetLog(new LogFileResponse + await ApiAssert.ThrowsException>(() => restClient.GetLog(new LogFileResponse { Name = "../out_of_bounds.file" }, cancellationToken)); @@ -54,24 +56,59 @@ await ApiAssert.ThrowsException + { + var restClient = restServerClient.Administration; - //we've released a few 5.x versions now, check the release checker is at least somewhat functional - Assert.IsTrue(4 < model.LatestVersion.Major); - Assert.IsNotNull(model.TrackedRepositoryUrl); - Assert.IsTrue(model.GeneratedAt.HasValue); - Assert.IsTrue(model.GeneratedAt.Value <= DateTimeOffset.UtcNow); + var model = await restClient.Read(false, cancellationToken); - // test the cache - var newerModel = await client.Read(false, cancellationToken); - Assert.AreEqual(model.GeneratedAt, newerModel.GeneratedAt); + //we've released a few 5.x versions now, check the release checker is at least somewhat functional + Assert.IsNotNull(model.LatestVersion); + Assert.IsTrue(4 < model.LatestVersion.Major); + Assert.IsNotNull(model.TrackedRepositoryUrl); + Assert.IsTrue(model.GeneratedAt.HasValue); + Assert.IsTrue(model.GeneratedAt.Value <= DateTimeOffset.UtcNow); - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + // test the cache + var newerModel = await restClient.Read(false, cancellationToken); + Assert.AreEqual(model.GeneratedAt, newerModel.GeneratedAt); - var newestModel = await client.Read(true, cancellationToken); - Assert.AreNotEqual(model.GeneratedAt, newestModel.GeneratedAt); - Assert.IsNotNull(newestModel.GeneratedAt); - Assert.IsTrue(model.GeneratedAt < newestModel.GeneratedAt); + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + var newestModel = await restClient.Read(true, cancellationToken); + Assert.AreNotEqual(model.GeneratedAt, newestModel.GeneratedAt); + Assert.IsNotNull(newestModel.GeneratedAt); + Assert.IsTrue(model.GeneratedAt < newestModel.GeneratedAt); + }, + async gqlClient => + { + var queryResult = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(false, cancellationToken), + cancellationToken); + + // we've released a few 5.x versions now, check the release checker is at least somewhat functional + Assert.IsNotNull(queryResult.Swarm.UpdateInformation.LatestVersion); + Assert.IsTrue(4 < queryResult.Swarm.UpdateInformation.LatestVersion.Major); + Assert.IsNotNull(queryResult.Swarm.UpdateInformation.TrackedRepositoryUrl); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.HasValue); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.Value <= DateTimeOffset.UtcNow); + + // test the cache + var queryResult2 = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(false, cancellationToken), + cancellationToken); + Assert.AreEqual(queryResult.Swarm.UpdateInformation.GeneratedAt, queryResult2.Swarm.UpdateInformation.GeneratedAt); + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + var queryResult3 = await gqlClient.RunQueryEnsureNoErrors( + gql => gql.GetUpdateInformation.ExecuteAsync(true, cancellationToken), + cancellationToken); + + Assert.AreNotEqual(queryResult.Swarm.UpdateInformation.GeneratedAt, queryResult3.Swarm.UpdateInformation.GeneratedAt); + Assert.IsNotNull(queryResult3.Swarm.UpdateInformation.GeneratedAt); + Assert.IsTrue(queryResult.Swarm.UpdateInformation.GeneratedAt.Value < queryResult3.Swarm.UpdateInformation.GeneratedAt.Value); + }); } } } diff --git a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs index 5bd736c6cce..2792289dd6a 100644 --- a/tests/Tgstation.Server.Tests/Live/ApiAssert.cs +++ b/tests/Tgstation.Server.Tests/Live/ApiAssert.cs @@ -11,6 +11,8 @@ using Tgstation.Server.Client; using Tgstation.Server.Client.GraphQL; +using static HotChocolate.ErrorCodes; + namespace Tgstation.Server.Tests.Live { /// @@ -78,6 +80,7 @@ public static async ValueTask OperationFails( var payload = payloadSelector(operationResult.Data); + Assert.AreNotSame(operationResult.Data, payload, "Select the mutation payload from the operation result!"); var payloadErrors = (IEnumerable)payload.GetType().GetProperty("Errors").GetValue(payload); var error = payloadErrors.Single(); diff --git a/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs b/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs index 872e59e6e65..b82cf582267 100644 --- a/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs +++ b/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs @@ -51,7 +51,7 @@ static IAsyncDelayer CreateMockDelayer() // at time of writing, this is used exclusively for the reconnection interval which works in minutes // shorten it to 3s var mock = new Mock(); - mock.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns((delay, cancellationToken) => Task.Delay(TimeSpan.FromSeconds(3), cancellationToken)); + mock.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns(async (delay, cancellationToken) => await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken)); return mock.Object; } diff --git a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs index 993b8999ad9..db373b61f6c 100644 --- a/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs +++ b/tests/Tgstation.Server.Tests/Live/GraphQLServerClientExtensions.cs @@ -33,6 +33,7 @@ public static async ValueTask RunMutationEnsureNoErrors(result.Data, data, "Select the mutation payload from the operation result!"); var errorsObject = data.GetType().GetProperty("Errors").GetValue(data); if (errorsObject != null) { diff --git a/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs index 4179a82cc11..f5599d88c96 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/RepositoryTest.cs @@ -116,8 +116,6 @@ public async Task AbortLongCloneAndCloneSomethingQuick(Task longClo await ApiAssert.ThrowsException(() => Checkout(new RepositoryUpdateRequest { Reference = "master", CheckoutSha = "286bb75" }, false, false, cancellationToken), ErrorCode.RepoMismatchShaAndReference); var updated = await Checkout(new RepositoryUpdateRequest { CheckoutSha = "286bb75" }, false, false, cancellationToken); - await RecloneTest(cancellationToken); - // Fake SHA updated = await Checkout(new RepositoryUpdateRequest { CheckoutSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, true, false, cancellationToken); @@ -127,6 +125,8 @@ public async Task AbortLongCloneAndCloneSomethingQuick(Task longClo // Back updated = await Checkout(new RepositoryUpdateRequest { Reference = "master" }, false, true, cancellationToken); + await RecloneTest(cancellationToken); + // enable the good shit if possible if (!String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TGS_TEST_GITHUB_TOKEN")) && !(Boolean.TryParse(Environment.GetEnvironmentVariable("TGS_TEST_OD_EXCLUSIVE"), out var odExclusive) && odExclusive)) diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index 16d3ac63fa8..df2476444b3 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -309,7 +309,7 @@ public static async ValueTask SendTestTopic(string queryString, I ? await session.TopicSendSemaphore.Lock(cancellationToken) : null) return await topicClient.SendWithOptionalPriority( - new AsyncDelayer(), + new AsyncDelayer(loggerFactory.CreateLogger()), loggerFactory.CreateLogger(), queryString, topicPort, @@ -586,13 +586,13 @@ async Task DumpTests(bool mini, CancellationToken cancellationToken) // these can also happen if (!(new PlatformIdentifier().IsWindows - && (job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: E_ACCESSDENIED: Access is denied.") - || job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: E_HANDLE: The handle is invalid.") - || job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: 3489660936: Unknown error (0xd0000008)") - || job.ExceptionDetails.Contains("System.InvalidOperationException: No process is associated with this object.") - || job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: 2147942424: The program issued a command but the command length is incorrect.") - || job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: 2147942699: Only part of a ReadProcessMemory or WriteProcessMemory request was completed.") - || job.ExceptionDetails.Contains("BetterWin32Errors.Win32Exception: 3489660964: Unknown error (0xd0000024)")))) + && (job.ExceptionDetails.Contains("Access is denied.") + || job.ExceptionDetails.Contains("The handle is invalid.") + || job.ExceptionDetails.Contains("Unknown error") + || job.ExceptionDetails.Contains("No process is associated with this object.") + || job.ExceptionDetails.Contains("The program issued a command but the command length is incorrect.") + || job.ExceptionDetails.Contains("Only part of a ReadProcessMemory or WriteProcessMemory request was completed.") + || job.ExceptionDetails.Contains("Unknown error")))) break; var restartJob = await instanceClient.DreamDaemon.Restart(cancellationToken); diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 9140a29df3b..208a33cc763 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -45,7 +45,6 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.System; -using Tgstation.Server.Host.Utils; using Tgstation.Server.Tests.Live.Instance; namespace Tgstation.Server.Tests.Live @@ -94,7 +93,17 @@ public TestLiveServer() result.AddRange(System.Diagnostics.Process.GetProcessesByName("dd")); break; case EngineType.OpenDream: - result.AddRange(System.Diagnostics.Process.GetProcessesByName("Robust.Server")); + var potentialProcesses = System.Diagnostics.Process.GetProcessesByName("dotnet") + .Where(process => + { + if (GetCommandLine(process).Contains("Robust.Server")) + return true; + + process.Dispose(); + return false; + }); + + result.AddRange(potentialProcesses); break; default: Assert.Fail($"Unknown engine type: {engineType}"); @@ -340,21 +349,29 @@ async ValueTask TestWithoutAndWithPermission(Func adminClient.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion, - UploadZip = false, - }, - null, - cancellationToken), - adminClient.RestClient, - AdministrationRights.ChangeVersion); - - Assert.IsNotNull(responseModel); - Assert.IsNull(responseModel.FileTicket); - Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + await adminClient.Execute( + async restClient => + { + var responseModel = await TestWithoutAndWithPermission( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion, + UploadZip = false, + }, + null, + cancellationToken), + adminClient.RestClient, + AdministrationRights.ChangeVersion); + + Assert.IsNotNull(responseModel); + Assert.IsNull(responseModel.FileTicket); + Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + }, + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result.ChangeServerNodeVersionViaTrackedRepository, + cancellationToken)); try { @@ -524,17 +541,25 @@ static void CheckInfo(ServerInformationResponse serverInformation) CheckInfo(controllerInfo); // test update - var responseModel = await controllerClient.RestClient.Administration.Update( - new ServerUpdateRequest + await controllerClient.Execute( + async restClient => { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken); + var responseModel = await restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken); - Assert.IsNotNull(responseModel); - Assert.IsNull(responseModel.FileTicket); - Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + Assert.IsNotNull(responseModel); + Assert.IsNull(responseModel.FileTicket); + Assert.AreEqual(TestUpdateVersion, responseModel.NewVersion); + }, + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result.ChangeServerNodeVersionViaTrackedRepository, + cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); Assert.IsTrue(serverTask.IsCompleted); @@ -711,13 +736,18 @@ await Task.WhenAny( await ApiAssert.ThrowsException(() => node1Client.RestClient.Instances.GetId(controllerInstance, cancellationToken), Api.Models.ErrorCode.ResourceNotPresent); // test update - await node1Client.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken); + await node1Client.Execute( + async restClient => await restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result.ChangeServerNodeVersionViaTrackedRepository, + cancellationToken)); await Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(2)), serverTask); Assert.IsTrue(serverTask.IsCompleted); @@ -752,13 +782,22 @@ void CheckServerUpdated(LiveTestingServer server) await using var controllerClient2 = await CreateAdminClient(controller.ApiUrl, cancellationToken); await using var node1Client2 = await CreateAdminClient(node1.ApiUrl, cancellationToken); - await ApiAssert.ThrowsException(() => controllerClient2.RestClient.Administration.Update( - new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken), Api.Models.ErrorCode.SwarmIntegrityCheckFailed); + await controllerClient2.Execute( + async restClient => await ApiAssert.ThrowsException( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + Api.Models.ErrorCode.SwarmIntegrityCheckFailed), + async gqlClient => await ApiAssert.OperationFails( + gqlClient, + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result.ChangeServerNodeVersionViaTrackedRepository, + Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, + cancellationToken)); // regression: test updating also works from the controller serverTask = Task.WhenAll( @@ -954,7 +993,13 @@ await Task.WhenAny( Assert.IsFalse(node2Info.SwarmServers.Any(x => x.Identifier == "node1")); // restart the controller - await controllerClient.RestClient.Administration.Restart(cancellationToken); + await controllerClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result.RestartServerNode, + cancellationToken)); + await Task.WhenAny( controllerTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -976,7 +1021,12 @@ await Task.WhenAny( await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); // restart node2 - await node2Client.RestClient.Administration.Restart(cancellationToken); + await node2Client.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result.RestartServerNode, + cancellationToken)); await Task.WhenAny( node2Task, Task.Delay(TimeSpan.FromMinutes(1))); @@ -988,14 +1038,22 @@ await Task.WhenAny( Assert.IsNull(controllerInfo.SwarmServers.SingleOrDefault(x => x.Identifier == "node2")); // update should fail - await ApiAssert.ThrowsException( - () => controllerClient2.RestClient.Administration.Update(new ServerUpdateRequest - { - NewVersion = TestUpdateVersion - }, - null, - cancellationToken), - Api.Models.ErrorCode.SwarmIntegrityCheckFailed); + await controllerClient2.Execute( + async restClient => await ApiAssert.ThrowsException( + () => restClient.Administration.Update( + new ServerUpdateRequest + { + NewVersion = TestUpdateVersion + }, + null, + cancellationToken), + Api.Models.ErrorCode.SwarmIntegrityCheckFailed), + async gqlClient => await ApiAssert.OperationFails( + gqlClient, + gql => gql.RepositoryBasedServerUpdate.ExecuteAsync(TestUpdateVersion, cancellationToken), + result => result.ChangeServerNodeVersionViaTrackedRepository, + Client.GraphQL.ErrorCode.SwarmIntegrityCheckFailed, + cancellationToken)); node2Task = node2.Run(cancellationToken).AsTask(); await using var node2Client2 = await CreateAdminClient(node2.ApiUrl, cancellationToken); @@ -1486,7 +1544,7 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( jobsHubTestTask = FailFast(await jobsHubTest.Run(cancellationToken)); // returns Task var rootTest = FailFast(RawRequestTests.Run(restClientFactory, firstAdminRestClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(firstAdminRestClient.Administration).Run(cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminMultiClient).Run(cancellationToken)); var usersTest = FailFast(new UsersTest(firstAdminMultiClient).Run(cancellationToken).AsTask()); var instanceManagerTest = new InstanceManagerTest(firstAdminRestClient, server.Directory); @@ -1632,7 +1690,12 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( await Task.Delay(1000, cancellationToken); jobsHubTest.ExpectShutdown(); - await firstAdminRestClient.Administration.Restart(cancellationToken); + await firstAdminMultiClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result.RestartServerNode, + cancellationToken)); } catch { @@ -1777,7 +1840,12 @@ await firstAdminMultiClient.GraphQLClient.RunQueryEnsureNoErrors( Assert.AreEqual(WatchdogStatus.Offline, dd.Status); jobsHubTest.ExpectShutdown(); - await adminClient.Administration.Restart(cancellationToken); + await multiClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result.RestartServerNode, + cancellationToken)); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1844,7 +1912,12 @@ await instanceClient.DreamDaemon.Update(new DreamDaemonRequest expectedStaged = compileJob.Id.Value; jobsHubTest.ExpectShutdown(); - await restAdminClient.Administration.Restart(cancellationToken); + await adminClient.Execute( + restClient => restClient.Administration.Restart(cancellationToken), + async gqlClient => await gqlClient.RunMutationEnsureNoErrors( + gql => gql.RestartServer.ExecuteAsync(cancellationToken), + result => result.RestartServerNode, + cancellationToken)); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); diff --git a/tests/Tgstation.Server.Tests/TestDatabase.cs b/tests/Tgstation.Server.Tests/TestDatabase.cs index 0c0bb7ce48b..700c1296f65 100644 --- a/tests/Tgstation.Server.Tests/TestDatabase.cs +++ b/tests/Tgstation.Server.Tests/TestDatabase.cs @@ -109,6 +109,8 @@ DatabaseContext CreateContext() ChatBotLimit = 1, ChatSettings = new List(), ConfigurationType = ConfigurationType.HostWrite, + AutoStartCron = String.Empty, + AutoStopCron = String.Empty, DreamDaemonSettings = new Host.Models.DreamDaemonSettings { AllowWebClient = false, diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index e2651d56a29..84fb6104916 100644 --- a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj +++ b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/tgstation-server.sln b/tgstation-server.sln index 31d15d01590..8815c70e786 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -197,13 +197,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEM EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Host.Tests.Signals", "tests\Tgstation.Server.Host.Tests.Signals\Tgstation.Server.Host.Tests.Signals.csproj", "{5813CC33-B16C-485D-A74D-20204DDF6542}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Migrator", "tools\Tgstation.Server.Migrator\Tgstation.Server.Migrator.csproj", "{CE499888-B22B-457C-891E-0EA9DC317228}" - ProjectSection(ProjectDependencies) = postProject - {07ED0FD5-E46B-4841-931D-BA2B673E16B2} = {07ED0FD5-E46B-4841-931D-BA2B673E16B2} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Migrator.Comms", "tools\Tgstation.Server.Migrator.Comms\Tgstation.Server.Migrator.Comms.csproj", "{07ED0FD5-E46B-4841-931D-BA2B673E16B2}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Host.Common", "src\Tgstation.Server.Host.Common\Tgstation.Server.Host.Common.csproj", "{CF3968A0-EA81-4464-B2D4-C7D40F6B5BCB}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Common", "src\Tgstation.Server.Common\Tgstation.Server.Common.csproj", "{70CD9A98-D31A-44A4-81D1-D02764CEEEFD}" @@ -274,6 +267,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Shared.Tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tgstation.Server.Client.GraphQL", "src\Tgstation.Server.Client.GraphQL\Tgstation.Server.Client.GraphQL.csproj", "{8BF95E2D-FD27-470C-82B7-C21AC01BFBD7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nix", "nix", "{5130526C-A553-493B-A9B0-3DB452949886}" + ProjectSection(SolutionItems) = preProject + build\package\nix\flake.nix = build\package\nix\flake.nix + build\package\nix\package.nix = build\package\nix\package.nix + build\package\nix\ServerConsole.sha256 = build\package\nix\ServerConsole.sha256 + build\package\nix\tgstation-server.nix = build\package\nix\tgstation-server.nix + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -448,30 +449,6 @@ Global {5813CC33-B16C-485D-A74D-20204DDF6542}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU {5813CC33-B16C-485D-A74D-20204DDF6542}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU {5813CC33-B16C-485D-A74D-20204DDF6542}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.DebugNoWindows|Any CPU.Build.0 = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.DebugNoWix|Any CPU.ActiveCfg = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.DebugNoWix|Any CPU.Build.0 = Debug|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.Release|Any CPU.Build.0 = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.ReleaseNoWindows|Any CPU.ActiveCfg = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU - {CE499888-B22B-457C-891E-0EA9DC317228}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.DebugNoWindows|Any CPU.Build.0 = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.DebugNoWix|Any CPU.ActiveCfg = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.DebugNoWix|Any CPU.Build.0 = Debug|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.Release|Any CPU.Build.0 = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.ReleaseNoWindows|Any CPU.ActiveCfg = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.ReleaseNoWindows|Any CPU.Build.0 = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.ReleaseNoWix|Any CPU.ActiveCfg = Release|Any CPU - {07ED0FD5-E46B-4841-931D-BA2B673E16B2}.ReleaseNoWix|Any CPU.Build.0 = Release|Any CPU {CF3968A0-EA81-4464-B2D4-C7D40F6B5BCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CF3968A0-EA81-4464-B2D4-C7D40F6B5BCB}.Debug|Any CPU.Build.0 = Debug|Any CPU {CF3968A0-EA81-4464-B2D4-C7D40F6B5BCB}.DebugNoWindows|Any CPU.ActiveCfg = Debug|Any CPU @@ -605,8 +582,6 @@ Global {103C61AB-67D6-46FE-AA47-CC633B88EE0F} = {82066812-6C73-4360-943B-B23F2F491261} {CFFD7992-E73A-4D1F-9D7A-C817C07B7BEB} = {E82104F4-F5C4-4786-ACD4-B635166CDB21} {5813CC33-B16C-485D-A74D-20204DDF6542} = {316141B0-CD21-4769-A013-D53DA9B9EC09} - {CE499888-B22B-457C-891E-0EA9DC317228} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2} - {07ED0FD5-E46B-4841-931D-BA2B673E16B2} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2} {2648A85F-61AE-428E-95E1-66D06C7A3768} = {6FF654E6-3E2C-46D4-872D-D528F77D6973} {457A1F89-6201-4430-BCC6-2F4438A54B9E} = {2648A85F-61AE-428E-95E1-66D06C7A3768} {08E7C650-A447-4DE2-974E-ED123B50F8D6} = {457A1F89-6201-4430-BCC6-2F4438A54B9E} @@ -619,6 +594,7 @@ Global {69944039-65C2-40E1-9D86-0608FA0C2D70} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2} {7F7FCFDF-271D-45C2-830C-BCCB19C57077} = {A55C1117-5808-4AB2-BEA6-4D4A3E66A2F2} {EAB84FD0-5514-4254-B188-7D90ACB7284D} = {316141B0-CD21-4769-A013-D53DA9B9EC09} + {5130526C-A553-493B-A9B0-3DB452949886} = {2648A85F-61AE-428E-95E1-66D06C7A3768} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DFD36C95-3E49-41C7-ACDB-86BAF5B18A79} diff --git a/tools/Tgstation.Server.LogoGenerator/Program.cs b/tools/Tgstation.Server.LogoGenerator/Program.cs index 2ec845f18d5..820084663a1 100644 --- a/tools/Tgstation.Server.LogoGenerator/Program.cs +++ b/tools/Tgstation.Server.LogoGenerator/Program.cs @@ -3,6 +3,7 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.Runtime.InteropServices; using System.Text; using Svg; @@ -25,7 +26,45 @@ using var bitmap = svg.Draw(128, 128); using var whiteBgBitmap = whiteBgSvg.Draw(160, 160); -using var icon = Icon.FromHandle(whiteBgBitmap.GetHicon()); +// https://stackoverflow.com/questions/21387391/how-to-convert-an-image-to-an-icon-without-losing-transparency/21389253#21389253 +// slight modifications +static Icon IconFromImage(Image img) +{ + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + // Header + bw.Write((short)0); // 0 : reserved + bw.Write((short)1); // 2 : 1=ico, 2=cur + bw.Write((short)1); // 4 : number of images + // Image directory + var w = img.Width; + if (w >= 256) throw new InvalidOperationException("Width too big!"); + bw.Write((byte)w); // 0 : width of image + var h = img.Height; + if (h >= 256) throw new InvalidOperationException("Height too big!"); + bw.Write((byte)h); // 1 : height of image + bw.Write((byte)0); // 2 : number of colors in palette + bw.Write((byte)0); // 3 : reserved + bw.Write((short)0); // 4 : number of color planes + bw.Write((short)0); // 6 : bits per pixel + var sizeHere = ms.Position; + bw.Write(0); // 8 : image size + var start = (int)ms.Position + 4; + bw.Write(start); // 12: offset of image data + // Image data + img.Save(ms, ImageFormat.Png); + var imageSize = (int)ms.Position - start; + ms.Seek(sizeHere, SeekOrigin.Begin); + bw.Write(imageSize); + ms.Seek(0, SeekOrigin.Begin); + + // And load it + return new Icon(ms); +} + +using var icon = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? IconFromImage(whiteBgBitmap) + : Icon.FromHandle(whiteBgBitmap.GetHicon()); Directory.CreateDirectory("artifacts"); await using var iconStream = new FileStream("artifacts/tgs.ico", FileMode.Create); diff --git a/tools/Tgstation.Server.Migrator.Comms/Program.cs b/tools/Tgstation.Server.Migrator.Comms/Program.cs deleted file mode 100644 index 3c46d8a1cca..00000000000 --- a/tools/Tgstation.Server.Migrator.Comms/Program.cs +++ /dev/null @@ -1,389 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http.Headers; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -using TGS.Interface; - -using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Api.Models.Request; -using Tgstation.Server.Client; -using Tgstation.Server.Common.Extensions; - -static class Program -{ - static async Task Main(string[] args) - { - try - { - var tgs3Client = new Client(); - - switch (args[0]) - { - case "--verify-connection": - var status = tgs3Client.ConnectionStatus(out var error); - if (status != ConnectivityLevel.Administrator) - { - Console.WriteLine($"Connection Error: {error}"); - return 3; - } - return 0; - case "--migrate": - ushort apiPort = ushort.Parse(args[1]); - return await Migrate(tgs3Client, apiPort); - default: - return 2; - } - } - catch (Exception ex) - { - Console.WriteLine(ex); - return 1; - } - } - - static async Task Migrate(IClient tgs3Client, ushort apiPort) - { -#if DEBUG - Console.WriteLine("Test log line..."); - System.Diagnostics.Debugger.Launch(); -#endif - Console.WriteLine("Connecting to TGS3..."); - var status = tgs3Client.ConnectionStatus(out var tgs3Error); - if (status != ConnectivityLevel.Administrator) - { - Console.WriteLine($"Connection Error: {tgs3Client}"); - return 13; - } - - Console.WriteLine("Connected!"); - - Console.WriteLine("Connecting to TGS6..."); - var assemblyName = Assembly.GetExecutingAssembly().GetName(); - var productInfoHeaderValue = - new ProductInfoHeaderValue( - assemblyName.Name!, - assemblyName.Version!.Semver().ToString()); - - var serverUrl = new Uri($"http://localhost:{apiPort}"); - var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product); - var TGS6Client = await clientFactory.CreateFromLogin( - serverUrl, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword); - - Console.WriteLine("Connected!"); - - // we do this synchronously and patiently because we aren't chumbii and this is delicate - // We need clear logs - var tgs3Instances = tgs3Client.Server.Instances.ToList(); - foreach (var tgs3Instance in tgs3Instances) - { - var instanceName = tgs3Instance.Metadata.Name; - var instancePath = tgs3Instance.Metadata.Path; - - if (!tgs3Instance.Metadata.Enabled) - { - Console.WriteLine($"Skipping instance {instanceName} at {instancePath}. Disabled."); - continue; - } - - Console.WriteLine($"Migrating instance {instanceName} at {instancePath}"); - - RepositoryUpdateRequest? repositoryUpdateRequest = null; - if (tgs3Instance.Repository.Exists()) - { - Console.WriteLine("Gathering instance repository data..."); - repositoryUpdateRequest = new RepositoryUpdateRequest - { - CommitterEmail = tgs3Instance.Repository.GetCommitterEmail(), - CommitterName = tgs3Instance.Repository.GetCommitterName(), - UpdateSubmodules = true, // default in 3 - Reference = tgs3Instance.Repository.GetBranch(out tgs3Error), - }; - - if (tgs3Error != null) - { - Console.WriteLine($"Error retrieving current branch: {tgs3Error}"); - } - } - else - Console.WriteLine("Instance has no repository, that's fine."); - - Console.WriteLine("Gather DreamDaemon and DreamMaker data..."); - var dreamDaemonRequest = new DreamDaemonRequest - { - AllowWebClient = tgs3Instance.DreamDaemon.Webclient(), - AutoStart = tgs3Instance.DreamDaemon.Autostart(), - Port = tgs3Instance.DreamDaemon.Port(), - SecurityLevel = tgs3Instance.DreamDaemon.SecurityLevel() switch - { - TGS.Interface.DreamDaemonSecurity.Safe => Tgstation.Server.Api.Models.DreamDaemonSecurity.Safe, - TGS.Interface.DreamDaemonSecurity.Ultrasafe => Tgstation.Server.Api.Models.DreamDaemonSecurity.Ultrasafe, - _ => Tgstation.Server.Api.Models.DreamDaemonSecurity.Trusted, - } - }; - - var dreamMakerRequest = new DreamMakerRequest - { - ApiValidationSecurityLevel = dreamDaemonRequest.SecurityLevel, - ApiValidationPort = (ushort)(dreamDaemonRequest.Port + 111) // Best rotation we can do... - }; - - Console.WriteLine("Gathering chat data..."); - var providerInfos = tgs3Instance.Chat.ProviderInfos(); - var chatBotCreateRequests = new List(); - foreach(var providerInfo in providerInfos) - { - if (!providerInfo.Enabled) - continue; - - var createRequest = new ChatBotCreateRequest() - { - Provider = providerInfo.Provider switch - { - TGS.Interface.ChatProvider.Discord => Tgstation.Server.Api.Models.ChatProvider.Discord, - _ => Tgstation.Server.Api.Models.ChatProvider.Irc, - }, - Enabled = true, - ReconnectionInterval = 5, - }; - - var isDiscordProvider = createRequest.Provider == Tgstation.Server.Api.Models.ChatProvider.Discord; - createRequest.Name = isDiscordProvider - ? "Discord Bot" - : "IRC Bot"; - - Console.WriteLine($"Gathering data for {createRequest.Name}..."); - - ChatConnectionStringBuilder csb; - if (createRequest.Provider == Tgstation.Server.Api.Models.ChatProvider.Discord) - { - var discordSetupInfo = new DiscordSetupInfo(providerInfo); - csb = new DiscordConnectionStringBuilder - { - DMOutputDisplay = DiscordDMOutputDisplayType.Always, - BotToken = discordSetupInfo.BotToken - }; - - } - else - { - var ircSetupInfo = new IRCSetupInfo(providerInfo); - csb = new IrcConnectionStringBuilder - { - Address = ircSetupInfo.URL, - Nickname = ircSetupInfo.Nickname, - Port = ircSetupInfo.Port - }; - } - createRequest.ConnectionString = csb.ToString(); - - createRequest.Channels = new List(); - - static string NormalizeChannelId(string channelId) => channelId.ToLowerInvariant().Trim(); - - var distinctChannels = providerInfo.WatchdogChannels - .Union(providerInfo.DevChannels) - .Union(providerInfo.AdminChannels) - .Union(providerInfo.GameChannels) - .Select(NormalizeChannelId) - .Distinct(); - - foreach(var channelIdentifier in distinctChannels) - { - var newChatChannel = new ChatChannel - { - IsWatchdogChannel = providerInfo.WatchdogChannels.Any(x => NormalizeChannelId(x) == channelIdentifier), - IsAdminChannel = providerInfo.AdminChannels.Any(x => NormalizeChannelId(x) == channelIdentifier), - IsUpdatesChannel = providerInfo.DevChannels.Any(x => NormalizeChannelId(x) == channelIdentifier), - // system channels are too new a feature to target - ChannelData = channelIdentifier, - }; - - createRequest.Channels.Add(newChatChannel); - } - - chatBotCreateRequests.Add(createRequest); - } - - Console.WriteLine("Detaching TGS3 instance..."); - tgs3Client.Server.InstanceManager.DetachInstance(instanceName); - - Console.WriteLine("Creating TGS6 attach file..."); - File.WriteAllText(Path.Combine(instancePath, "TGS4_ALLOW_INSTANCE_ATTACH"), String.Empty); - - Console.WriteLine("Checking BYOND install..."); - var byondDirectory = Path.Combine(instancePath, "BYOND"); - var byondVersionFile = Path.Combine(byondDirectory, "byond_version.dat"); - - EngineVersionRequest? byondVersionRequest = null; - if (Directory.Exists(byondDirectory) && File.Exists(byondVersionFile)) - { - var byondVersion = Version.Parse(File.ReadAllText(byondVersionFile).Trim()); - Console.WriteLine($"Found installed BYOND version: {byondVersion.Major}.{byondVersion.Minor}"); - byondVersionRequest = new EngineVersionRequest - { - EngineVersion = new EngineVersion - { - Version = byondVersion - } - }; - } - - var oldStaticDirectory = Path.Combine(instancePath, "Static"); - var newConfigurationDirectory = Path.Combine(instancePath, "Configuration"); - if (Directory.Exists(oldStaticDirectory)) - { - Console.WriteLine("Migrating Static to Configuration/GameStaticFiles"); - var gameStaticFilesDirectory = Path.Combine(newConfigurationDirectory, "GameStaticFiles"); - Directory.CreateDirectory(newConfigurationDirectory); - Directory.Move(oldStaticDirectory, gameStaticFilesDirectory); - - Console.WriteLine("Moving code modifications..."); - var codeModsDirectory = Path.Combine(newConfigurationDirectory, "CodeModifications"); - Directory.CreateDirectory(codeModsDirectory); - - var allDmFiles = Directory.EnumerateFiles(gameStaticFilesDirectory, "*.dm", SearchOption.TopDirectoryOnly).ToList(); - foreach (var dmFile in allDmFiles) - { - File.Move(Path.Combine(gameStaticFilesDirectory, dmFile), Path.Combine(codeModsDirectory, Path.GetFileName(dmFile))); - } - - var allDmeFiles = Directory.EnumerateFiles(gameStaticFilesDirectory, "*.dme", SearchOption.TopDirectoryOnly).ToList(); - if (allDmeFiles.Any()) - { - foreach (var dmeFile in allDmeFiles) - { - File.Move(Path.Combine(gameStaticFilesDirectory, dmeFile), Path.Combine(codeModsDirectory, Path.GetFileName(dmeFile))); - } - } - else if (allDmFiles.Any()) - { - Console.WriteLine("Generating HeadInclude.dm..."); - var headIncludeBuilder = new StringBuilder(); - foreach (var dmFile in allDmFiles.OrderBy(fileName => fileName.ToUpperInvariant())) - { - headIncludeBuilder.Append("#include \""); - headIncludeBuilder.Append(Path.GetFileName(dmFile)); - headIncludeBuilder.Append("\""); - headIncludeBuilder.Append(Environment.NewLine); - } - - File.WriteAllText(Path.Combine(codeModsDirectory, "HeadInclude.dm"), headIncludeBuilder.ToString()); - } - } - - var eventHandlersDirectory = Path.Combine(instancePath, "EventHandlers"); - if (Directory.Exists(eventHandlersDirectory)) - { - Console.WriteLine("Moving event scripts..."); - Directory.CreateDirectory(newConfigurationDirectory); - Directory.Move(eventHandlersDirectory, Path.Combine(newConfigurationDirectory, "EventScripts")); - } - - var diagnosticsDirectory = Path.Combine(instancePath, "Diagnostics"); - var minidumpsDirectory = Path.Combine(diagnosticsDirectory, "Minidumps"); - if (Directory.Exists(minidumpsDirectory)) - { - Console.WriteLine("Renaming Minidumps folder to ProcessDumps..."); - Directory.Move(minidumpsDirectory, Path.Combine(diagnosticsDirectory, "ProcessDumps")); - } - - Console.WriteLine("Deleting BYOND folder..."); - await RecursivelyDeleteDirectory(new DirectoryInfo(byondDirectory)); - Console.WriteLine("Deleting RepoKey folder..."); - await RecursivelyDeleteDirectory(new DirectoryInfo(Path.Combine(instancePath, "RepoKey"))); - Console.WriteLine("Deleting Game folder..."); - await RecursivelyDeleteDirectory(new DirectoryInfo(Path.Combine(instancePath, "Game"))); - Console.WriteLine("Deleting Instance.json..."); - File.Delete(Path.Combine(instancePath, "Instance.json")); - Console.WriteLine("Deleting prtestjob.json..."); - File.Delete(Path.Combine(instancePath, "prtestjob.json")); - Console.WriteLine("Deleting TGS3.json..."); - File.Delete(Path.Combine(instancePath, "TGS3.json")); - Console.WriteLine("Deleting TGDreamDaemonBridge.dll..."); - File.Delete(Path.Combine(instancePath, "TGDreamDaemonBridge.dll")); - - Console.WriteLine("Attaching TGS6 instance..."); - var TGS6Instance = await TGS6Client.Instances.CreateOrAttach(new InstanceCreateRequest - { - ConfigurationType = ConfigurationType.Disallowed, - Name = instanceName, - Path = instancePath, - }, default); - - Console.WriteLine($"Onlining TGS6 instance ID {TGS6Instance.Id}..."); - TGS6Instance = await TGS6Client.Instances.Update(new InstanceUpdateRequest - { - Online = true, - Id = TGS6Instance.Id - }, default); - - var v5InstanceClient = TGS6Client.Instances.CreateClient(TGS6Instance); - - if (byondVersionRequest != null) - { - Console.WriteLine("Triggering BYOND install job..."); - await v5InstanceClient.Engine.SetActiveVersion(byondVersionRequest, null, default); - } - - if (repositoryUpdateRequest != null) - { - Console.WriteLine("Updating repository settings..."); - await v5InstanceClient.Repository.Update(repositoryUpdateRequest, default); - } - - Console.WriteLine("Updating deployment settings..."); - await v5InstanceClient.DreamMaker.Update(dreamMakerRequest, default); - - Console.WriteLine("Updating DreamDaemon settings..."); - await v5InstanceClient.DreamDaemon.Update(dreamDaemonRequest, default); - - foreach(var chatBotCreateRequest in chatBotCreateRequests) - { - Console.WriteLine($"Creating chat bot {chatBotCreateRequest.Name}..."); - await v5InstanceClient.ChatBots.Create(chatBotCreateRequest, default); - } - - Console.WriteLine($"Instance {instanceName} (TGS6 ID: {TGS6Instance.Id}) successfully migrated!"); - } - - Console.WriteLine("All enabled V3 instances migrated into V5 and detached from V3!"); - - return 0; - } - - static async Task RecursivelyDeleteDirectory(DirectoryInfo dir) - { - var tasks = new List(); - - if (!dir.Exists) - return; - - // check if we are a symbolic link - if (!dir.Attributes.HasFlag(FileAttributes.Directory) || dir.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - dir.Delete(); - return; - } - - foreach (var subDir in dir.EnumerateDirectories()) - tasks.Add(RecursivelyDeleteDirectory(subDir)); - - foreach (var file in dir.EnumerateFiles()) - { - file.Attributes = FileAttributes.Normal; - file.Delete(); - } - - await Task.WhenAll(tasks); - dir.Delete(true); - } -} diff --git a/tools/Tgstation.Server.Migrator.Comms/Tgstation.Server.Migrator.Comms.csproj b/tools/Tgstation.Server.Migrator.Comms/Tgstation.Server.Migrator.Comms.csproj deleted file mode 100644 index e43af6ce0f6..00000000000 --- a/tools/Tgstation.Server.Migrator.Comms/Tgstation.Server.Migrator.Comms.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - - Exe - net472 - win-x86 - $(TgsMigratorVersion) - enable - - - - - - - - - - - - - - - diff --git a/tools/Tgstation.Server.Migrator/Program.cs b/tools/Tgstation.Server.Migrator/Program.cs deleted file mode 100644 index 86cabd419e5..00000000000 --- a/tools/Tgstation.Server.Migrator/Program.cs +++ /dev/null @@ -1,544 +0,0 @@ -using System; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Configuration.Install; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Management; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Reflection; -using System.Security.Principal; -using System.ServiceProcess; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -using Microsoft.Extensions.Logging; - -using Octokit; - -using Tgstation.Server.Api; -using Tgstation.Server.Client; -using Tgstation.Server.Common; -using Tgstation.Server.Common.Extensions; -using Tgstation.Server.Common.Http; -using Tgstation.Server.Host.IO; -using Tgstation.Server.Host.Setup; -using Tgstation.Server.Migrator.Properties; - -using FileMode = System.IO.FileMode; - -[DoesNotReturn] -static void ExitPause(int exitCode) -{ - Console.WriteLine("Consider saving the console text to report issues. Press any key to exit..."); - Console.ReadKey(); - Environment.Exit(exitCode); -} - -try -{ - var commandLine = Environment.GetCommandLineArgs(); - var commandLineArguments = commandLine.Skip(1); - var skipPreamble = commandLineArguments.Any(x => x.Equals("--skip-preamble", StringComparison.OrdinalIgnoreCase)); - - Console.WriteLine("This is a very straightfoward script to migrate the instances of a TGS3 install into a new TGS6 install"); - - static bool PromptYesOrNo(string question) - { - Console.Write($"{question} (y/n):"); - var character = Console.ReadKey(); - Console.WriteLine(); - return character.KeyChar.ToString().ToUpperInvariant() == "Y"; - } - - // WORKING DIRECTORY CHECK - var currentAssembly = Assembly.GetExecutingAssembly(); - if(Path.GetDirectoryName(Path.GetFullPath(currentAssembly.Location))!.Replace("\\", "/").ToUpperInvariant() - != Path.GetFullPath(Environment.CurrentDirectory).Replace("\\", "/").ToUpperInvariant()) - { - Console.WriteLine("Please keep the working directory equivalent to the program directory for this migration!"); - ExitPause(8); - } - - // PREREQUISITE CHECK - if (!skipPreamble) - { - Console.WriteLine("We need to ensure you're running this program as Administrator because there are several operations we'll do that require it."); - Console.WriteLine("If not, you will be prompted to elevate this process."); - } - - // ADMINISTRATOR CHECK - static bool IsAdministrator() - { - using var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - return principal.IsInRole(WindowsBuiltInRole.Administrator); - } - - if (!IsAdministrator()) - { - Console.WriteLine("Not running as admin. Elevating process..."); - var selfExecutable = commandLine.First().Replace(".dll", ".exe"); - var selfArguments = String.Join(" ", commandLineArguments) + " --skip-preamble"; - using var elevatedProcess = new Process(); - elevatedProcess.StartInfo.UseShellExecute = true; - elevatedProcess.StartInfo.FileName = selfExecutable; - elevatedProcess.StartInfo.Arguments = selfArguments; - elevatedProcess.StartInfo.Verb = "runas"; - - elevatedProcess.Start(); - return; - } - - Console.WriteLine("Administrative privileges confirmed."); - - // TGS3 SERVICE CHECK - - const string PathToCommsBinary = -#if DEBUG - "../../../../../Tgstation.Server.Migrator.Comms/bin/Debug/net472/win-x86/" + -#else - "Comms/" + -#endif - "Tgstation.Server.Migrator.Comms.exe"; - - Console.WriteLine($"Checking {PathToCommsBinary} exists..."); - if (!File.Exists(PathToCommsBinary)) - { - Console.WriteLine("Could not find WCF comms binary!"); - ExitPause(7); - } - - static int RunComms(string command) - { - using var commsProcess = new Process(); - commsProcess.StartInfo.FileName = PathToCommsBinary; - commsProcess.StartInfo.Arguments = command; - commsProcess.Start(); - commsProcess.WaitForExit(); - return commsProcess.ExitCode; - } - - Console.WriteLine("Checking for TGS3 service..."); - const string OldServiceName = "TG Station Server"; - const string NewServiceName = Constants.CanonicalPackageName; - - static ServiceController GetTgs3Service(bool checkNewOneIsntInstalled) - { - var allServices = ServiceController.GetServices(); - var tgs3Service = allServices.FirstOrDefault(service => service.ServiceName == OldServiceName); - foreach (var service in allServices) - { - if (service == tgs3Service) - continue; - - using (service) - if (checkNewOneIsntInstalled && (service.ServiceName == NewServiceName || service.ServiceName == "tgstation-server-4")) - { - Console.WriteLine("Detected existing TGS4+ install! Cannot continue. Please uninstall any versions of TGS4+ before continuing."); - ExitPause(10); - } - } - - if (checkNewOneIsntInstalled) - Console.WriteLine("TGS4+ service install not detected."); - - if (tgs3Service == null) - { - Console.WriteLine("TGS3 is not installed on this machine!"); - ExitPause(5); - } - - return tgs3Service; - } - - using var tgs3Service = GetTgs3Service(true); - - if (tgs3Service.Status != ServiceControllerStatus.Running) - { - Console.WriteLine("TGS3 service is installed but not running! Please start the service before continuing."); - ExitPause(9); - } - - // TGS3 CONNECTION CHECK - Console.WriteLine("Checking TGS3 connection..."); - var commsExitCode = RunComms("--verify-connection"); - if(commsExitCode != 0) - { - Console.WriteLine("Could not connect to TGS3 as administrator!"); - ExitPause(6); - } - - // USER INPUT - Console.WriteLine("We've confirmed you have have both TGS3 installed and TGS4+ service UNinstalled on THIS machine."); - Console.WriteLine(); - Console.WriteLine("Please read all of the following CAREFULLY before proceeding:"); - Console.WriteLine("Confirm you want to migrate to the latest version installing the necessary prerequisite .NET version along the way."); - Console.WriteLine("Please note that this is a one way upgrade and will not keep your DreamDaemon servers running throughout it."); - Console.WriteLine("All TGS3 instances will be migrated in place. The following components will be preserved:"); - Console.WriteLine("- Repository (No test merge data or SSH key)"); - Console.WriteLine("- BYOND version (redownloaded from byond.com)"); - Console.WriteLine("- A FEW server configuration settings (Committer info, Autostart, Webclient, Game Port, Security Level)"); - Console.WriteLine("- EventHandlers"); - Console.WriteLine("- Chat Bots, if enabled"); - Console.WriteLine(" - TGS4+ doesn't support individual user/group identification. Admin channels will be used instead"); - Console.WriteLine(" - IRC authentication information cannot be copied and must be manually adjusted"); - Console.WriteLine("- Static Files"); - Console.WriteLine("- Code Modifications"); - Console.WriteLine("Remaining components such as logins, game builds, etc. can be recreated once the migration is complete."); - Console.WriteLine("IMPORTANT NOTES:"); - Console.WriteLine("- INSTANCES CANNOT HAVE GAME PORTS OFFSET 111 UNITS FROM EACH OTHER OR HIGHER THAN 65423! WE AREN'T CORRECTING FOR THIS WHILE MIGRATING!"); - Console.WriteLine("- DISABLED INSTANCES WILL NOT BE MIGRATED! PLEASE ENABLE ALL INSTANCES YOU WISH TO MIGRATE BEFORE CONTINUING!"); - Console.WriteLine("- INSTANCE AUTO UPDATE CAN INTERFERE WITH THE MIGRATION! PLEASE DISABLE IT ON ALL INSTANCES BEING MIGRATED BEFORE CONTINUING!"); - Console.WriteLine("- DO NOT ATTEMPT TO USE TGS3 VIA NORMAL METHODS WHILE THIS MIGRATION IS TAKING PLACE OR YOU COULD CORRUPT YOUR DATA!"); - Console.WriteLine("Side note: You can skip the TGS6 setup wizard step by copying your premade appsettings.Production.yml file next to this .exe NOW."); - if (!PromptYesOrNo("Proceed with upgrade?")) - { - Console.WriteLine("Prerequisite not met."); - ExitPause(0); - } - - string? tgsInstallPath = null; - do - { - Console.WriteLine("Please enter the directory where you would like the TGS binaries installed."); - Console.Write("This may be anywhere but should be empty: "); - tgsInstallPath = Console.ReadLine(); - if (!String.IsNullOrWhiteSpace(tgsInstallPath)) - { - if (!Path.IsPathRooted(tgsInstallPath)) - { - Console.WriteLine("Please do not use a relative path for this. Enter the full path including the drive letter."); - tgsInstallPath = null; - } - else if (Path.GetInvalidPathChars().Any(invalidChar => tgsInstallPath.Contains(invalidChar))) - { - Console.WriteLine("Invalid characters detected!"); - tgsInstallPath = null; - } - } - } - while (String.IsNullOrWhiteSpace(tgsInstallPath)); - - Console.WriteLine("Attempting to create TGS install directory..."); - Directory.CreateDirectory(tgsInstallPath); - - // ASP.NET 8.0 RUNTIME CHECK - Console.WriteLine("Next step, we need to ensure the ASP.NET Core 6 runtime is installed on your machine."); - Console.WriteLine("We're going to download it for you."); - Console.WriteLine("Yes, this program runs .NET 6, but it contains the entire runtime embedded into it. You will need a system-wide install for TGS."); - - var runtimeInstalled = true; // assume for now - using (var dotnetRuntimeCheck = new Process()) - { - dotnetRuntimeCheck.StartInfo.FileName = "C:/Program Files/dotnet/dotnet.exe"; - dotnetRuntimeCheck.StartInfo.Arguments = "--list-runtimes"; - dotnetRuntimeCheck.StartInfo.RedirectStandardOutput = true; - - try - { - dotnetRuntimeCheck.Start(); - - dotnetRuntimeCheck.WaitForExit(); - } - catch (Win32Exception ex) when (ex.NativeErrorCode == 2) - { - Console.WriteLine("Dotnet does not appear to be installed at all."); - runtimeInstalled = false; - } - - if (runtimeInstalled) - { - var versions = await dotnetRuntimeCheck.StandardOutput.ReadToEndAsync(); - var regex = new Regex("Microsoft\\.AspNetCore\\.App 8\\.0\\.[0-9]+"); - - if (!regex.IsMatch(versions)) - runtimeInstalled = false; - } - } - - // ASP.NET 8.0 RUNTIME SETUP - var assemblyName = currentAssembly.GetName(); - var productInfoHeaderValue = - new ProductInfoHeaderValue( - assemblyName.Name!, - assemblyName.Version!.Semver().ToString()); - var httpClientFactory = new HttpClientFactory(productInfoHeaderValue); - if (!runtimeInstalled) - { - // RUNTIME DONWLOAD - Console.WriteLine("The version we are installing is the latest circa 26-09-2022, feel free to update it later if you want but that is not necessary."); - - var x64 = Environment.Is64BitOperatingSystem; - var xSubstitution = x64 ? "64" : "86"; - Console.WriteLine($"Running on an x{xSubstitution} system."); - - var downloadUri = RuntimeDistributableAttribute.Instance.RuntimeDistributableUrl; - - var dotnetDownloadFilePath = $"dotnet-hosting-bundle-installer.exe"; - - Console.WriteLine($"Downloading {downloadUri} to {Path.GetFullPath(dotnetDownloadFilePath)}..."); - - using var httpClient = httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri); - var webRequestTask = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, default); - using var response = await webRequestTask; - response.EnsureSuccessStatusCode(); - await using (var responseStream = await response.Content.ReadAsStreamAsync()) - { - await using var fileStream = new FileStream( - dotnetDownloadFilePath, - FileMode.Create, - FileAccess.Write, - FileShare.ReadWrite | FileShare.Delete, - 4096, - FileOptions.Asynchronous | FileOptions.SequentialScan); - await responseStream.CopyToAsync(fileStream); - } - - // RUNTIME INSTALLATION - Console.WriteLine("Runtime downloaded. Running silent installation..."); - bool silentInstallSuccess = true; - using var silentInstallProcess = new Process(); - { - silentInstallProcess.StartInfo.UseShellExecute = false; - silentInstallProcess.StartInfo.FileName = dotnetDownloadFilePath; - silentInstallProcess.StartInfo.Arguments = "/install /quiet /norestart"; - silentInstallProcess.Start(); - silentInstallProcess.WaitForExit(); - - if (silentInstallProcess.ExitCode != 0) - { - Console.WriteLine("Silent installation failed! Please install the runtime interactively."); - Console.WriteLine("Launching install dialog"); - silentInstallSuccess = false; - } - } - - if (!silentInstallSuccess) - { - using var installProcess = new Process(); - installProcess.StartInfo.FileName = dotnetDownloadFilePath; - installProcess.Start(); - installProcess.WaitForExit(); - - if (!PromptYesOrNo("Was the installation successful?")) - { - Console.WriteLine("Cannot continue without ASP.NET 8.0 runtime installed."); - ExitPause(2); - } - } - } - else - { - Console.WriteLine("Runtime detected successfully. Continuing..."); - } - - - // TGS6 ONLINE LOCATING - Console.WriteLine("Now we're going to locate the latest version of the TGS service."); - Console.WriteLine("(This migrator does not support the console runner, but you may switch the installation to it after completion)"); - - Console.WriteLine("Determining latest version of TGS 5.X.X..."); - - var gitHubClient = new GitHubClient(new Octokit.ProductHeaderValue(productInfoHeaderValue.Product!.Name, productInfoHeaderValue.Product.Version)); - - string? gitHubPat = Environment.GetEnvironmentVariable("TGS_MIGRATOR_GITHUB_PAT"); - if (gitHubPat != null) - gitHubClient.Credentials = new Credentials(gitHubPat); - - const int TgstationServerRepoId = 92952846; - - var allReleases = await gitHubClient.Repository.Release.GetAll(TgstationServerRepoId); - - const string VersionFiveTagPrefix = "tgstation-server-v5."; - var allVersionFiveReleases = allReleases - .Where(release => release.TagName.StartsWith(VersionFiveTagPrefix)); - var latestVersionFiveRelease = allVersionFiveReleases - .OrderByDescending(release => Version.Parse(release.TagName[(VersionFiveTagPrefix.Length - 2)..])) - .FirstOrDefault(); - - if (latestVersionFiveRelease == null) - { - Console.WriteLine("Unable to determine latest version 5 release!"); - ExitPause(3); - } - - Console.WriteLine($"Latest V5 version: {latestVersionFiveRelease.TagName}"); - - var serverServiceAsset = latestVersionFiveRelease.Assets.FirstOrDefault(asset => asset.Name == "ServerService.zip"); - if (serverServiceAsset == null) - { - Console.WriteLine("Unable to determine ServerService.zip release asset!"); - ExitPause(4); - } - - // TGS6 SETUP WIZARD - Console.WriteLine("We are now going to run the TGS setup wizard to generate your new server configuration file."); - - var serverFactory = Tgstation.Server.Host.Core.Application.CreateDefaultServerFactory(); - _ = await serverFactory.CreateServer(new[] { $"General:SetupWizardMode={SetupWizardMode.Only}" }, null, default); // This is where the wizard actually runs - - // TGS6 DOWNLOAD AND UNZIP - Console.WriteLine("Downloading TGS6..."); - - using (var loggerFactory = LoggerFactory.Create(builder => { })) - { - BufferedFileStreamProvider tgsFiveZipBuffer; - { - var fileDownloader = new FileDownloader(httpClientFactory, loggerFactory.CreateLogger()); - await using var tgsFiveZipDownload = fileDownloader.DownloadFile(new Uri(serverServiceAsset.BrowserDownloadUrl), null); - tgsFiveZipBuffer = new BufferedFileStreamProvider( - await tgsFiveZipDownload.GetResult(default)); - } - - await using (tgsFiveZipBuffer) - { - Console.WriteLine("Unzipping TGS6..."); - await serverFactory.IOManager.ZipToDirectory( - tgsInstallPath, - await tgsFiveZipBuffer.GetResult(default), - default); - } - } - - // TGS6 CONFIG SETUP - const string ConfigurationFileName = "appsettings.Production.yml"; - Console.WriteLine("Extracting API port from configuration..."); - ushort configuredApiPort; - { - var configFileContents = await File.ReadAllTextAsync(ConfigurationFileName); - var match = Regex.Match(configFileContents, "ApiPort: ([0-9]+)"); - if (!match.Success) - { - Console.WriteLine("Unable to extract ApiPort setting!"); - ExitPause(12); - } - - configuredApiPort = ushort.Parse(match.Groups[1].Value); - } - - Console.WriteLine("Moving configuration file from setup wizard to installation folder..."); - File.Copy(ConfigurationFileName, Path.Combine(tgsInstallPath, ConfigurationFileName)); - - // TGS6 SERVICE SETUP - Console.WriteLine("Installing TGS6 service..."); - using (var processInstaller = new ServiceProcessInstaller()) - using (var installer = new ServiceInstaller()) - { - processInstaller.Account = ServiceAccount.LocalSystem; - - installer.Context = new InstallContext( - "tgs-migrate-install.log", - new string[] - { - $"assemblypath={Path.Combine(tgsInstallPath, "Tgstation.Server.Host.Service.exe")}" - }); - installer.Description = "/tg/station 13 server running as a windows service"; - installer.DisplayName = "tgstation-server"; - installer.StartType = ServiceStartMode.Automatic; - installer.ServicesDependedOn = new string[] { "Tcpip", "Dhcp", "Dnscache" }; - installer.ServiceName = NewServiceName; - installer.Parent = processInstaller; - - var state = new ListDictionary(); - installer.Install(state); - } - - Console.WriteLine("Starting TGS6 service..."); - var allServices = ServiceController.GetServices(); - using (var TGS6Service = allServices.FirstOrDefault(service => service.ServiceName == NewServiceName)) - { - if (TGS6Service == null) - { - Console.WriteLine("Unable to locate newly installed TGS6 service!"); - ExitPause(11); - } - - foreach (var service in allServices) - { - if (service == TGS6Service) - continue; - - service.Dispose(); - } - - TGS6Service.Start(); - TGS6Service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromMinutes(2)); - } - - // TGS6 CLIENT CONNECTION - const int MaxWaitMinutes = 5; - Console.WriteLine($"Connecting to TGS6 (Max {MaxWaitMinutes} minute wait)..."); - var giveUpAt = DateTimeOffset.UtcNow.AddMinutes(MaxWaitMinutes); - - var serverUrl = new Uri($"http://localhost:{configuredApiPort}"); - var clientFactory = new RestServerClientFactory(productInfoHeaderValue.Product); - IRestServerClient TGS6Client; - for (var I = 1; ; ++I) - { - try - { - Console.WriteLine($"Attempt {I}..."); - TGS6Client = await clientFactory.CreateFromLogin( - serverUrl, - DefaultCredentials.AdminUserName, - DefaultCredentials.DefaultAdminUserPassword); - break; - } - catch (HttpRequestException) - { - //migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1)); - } - catch (ServiceUnavailableException) - { - // migrating, to be expected - if (DateTimeOffset.UtcNow > giveUpAt) - throw; - await Task.Delay(TimeSpan.FromSeconds(1)); - } - } - - Console.WriteLine("Successfully connected to TGS6!"); - - // COMMS MIGRATION - Console.WriteLine("Deferring to Comms binary to migrate instances..."); - - commsExitCode = RunComms($"--migrate {configuredApiPort}"); - if (commsExitCode != 0) - { - Console.WriteLine("Could not connect to TGS3 as administrator!"); - ExitPause(commsExitCode); - } - - // TGS3 SHUTDOWN - Console.WriteLine("Shutting down TGS3 service..."); - tgs3Service.Stop(); - tgs3Service.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromMinutes(2)); - tgs3Service.Dispose(); - - Console.WriteLine("Disabling TGS3 service..."); - using (var managementObject = new ManagementObject(string.Format("Win32_Service.Name=\"{0}\"", OldServiceName))) - { - managementObject.InvokeMethod("ChangeStartMode", new object[] { "Disabled" }); - } - - if(tgs3Service.StartType != ServiceStartMode.Disabled) - Console.WriteLine("Failed to disable TGS3 service! This isn't critical, however."); - - Console.WriteLine("Migration complete! Please continue uninstall TGS3 using Add/Remove Programs."); - Console.WriteLine("Then configure TGS6 using an interactive client to build and start your server."); - ExitPause(0); -} -catch (Exception ex) -{ - Console.WriteLine("An error occurred in the migration!"); - Console.WriteLine(ex); - ExitPause(1); -} diff --git a/tools/Tgstation.Server.Migrator/Properties/RuntimeDistributableAttribute.cs b/tools/Tgstation.Server.Migrator/Properties/RuntimeDistributableAttribute.cs deleted file mode 100644 index d4434c61bb6..00000000000 --- a/tools/Tgstation.Server.Migrator/Properties/RuntimeDistributableAttribute.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Reflection; - -namespace Tgstation.Server.Migrator.Properties -{ - /// - /// Attribute for bringing in the runtime redistributable download link - /// - [AttributeUsage(AttributeTargets.Assembly)] - sealed class RuntimeDistributableAttribute : Attribute - { - /// - /// Return the 's instance of the . - /// - public static RuntimeDistributableAttribute Instance => Assembly - .GetExecutingAssembly() - .GetCustomAttribute()!; - - /// - /// The of the current runtime distributable. - /// - public Uri RuntimeDistributableUrl { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - public RuntimeDistributableAttribute( - string runtimeDistributableUrl) - { - RuntimeDistributableUrl = new Uri(runtimeDistributableUrl ?? throw new ArgumentNullException(nameof(runtimeDistributableUrl))); - } - } -} diff --git a/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj b/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj deleted file mode 100644 index 2511e50c18c..00000000000 --- a/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Exe - $(TgsFrameworkVersion) - win-x86 - true - $(TgsMigratorVersion) - enable - CA1416 - false - - - - - - - - - - - <_Parameter1>$(TgsDotnetRedistUrl) - - - - - - - - - - - - - - diff --git a/tools/Tgstation.Server.ReleaseNotes/Program.cs b/tools/Tgstation.Server.ReleaseNotes/Program.cs index 3cceaf22377..062ee8813a2 100644 --- a/tools/Tgstation.Server.ReleaseNotes/Program.cs +++ b/tools/Tgstation.Server.ReleaseNotes/Program.cs @@ -315,6 +315,39 @@ static async Task Main(string[] args) Description = "Next patch version" }); + + async ValueTask RelocateOpenIssues(Milestone originalMilestone, int moveToMilestoneNumber) + { + if (originalMilestone.OpenIssues + originalMilestone.ClosedIssues > 0) + { + var issuesInUnusedMilestone = await client.Search.SearchIssues(new SearchIssuesRequest + { + Milestone = originalMilestone.Title, + Repos = { { RepoOwner, RepoName } } + }); + + var issueUpdateTasks = new List(); + foreach (var I in issuesInUnusedMilestone.Items) + { + if (I.State.Value != ItemState.Closed) + issueUpdateTasks.Add(client.Issue.Update(RepoOwner, RepoName, I.Number, new IssueUpdate + { + Milestone = moveToMilestoneNumber + })); + + if (I.PullRequest != null && I.PullRequest.Merged) + { + Console.WriteLine($"Adding additional merged PR #{I.Number}..."); + var task = GetReleaseNotesFromPR(client, I, doNotCloseMilestone, false, false); + noteTasks.Add(task); + allTasks.Add(task); + } + } + + await Task.WhenAll(issueUpdateTasks).ConfigureAwait(false); + } + } + if (version.Build == 0) { // close the patch milestone if it exists @@ -326,39 +359,10 @@ static async Task Main(string[] args) async ValueTask DeleteMilestone(Milestone milestoneToDelete, int moveToMilestoneNumber) { Console.WriteLine($"Moving {milestoneToDelete.OpenIssues} open issues and {milestoneToDelete.ClosedIssues} closed issues from unused patch milestone {milestoneToDelete.Title} to upcoming ones and deleting..."); - if (milestoneToDelete.OpenIssues + milestoneToDelete.ClosedIssues > 0) - { - var issuesInUnusedMilestone = await client.Search.SearchIssues(new SearchIssuesRequest - { - Milestone = milestoneToDelete.Title, - Repos = { { RepoOwner, RepoName } } - }); - - var issueUpdateTasks = new List(); - foreach (var I in issuesInUnusedMilestone.Items) - { - if (I.State.Value != ItemState.Closed) - issueUpdateTasks.Add(client.Issue.Update(RepoOwner, RepoName, I.Number, new IssueUpdate - { - Milestone = moveToMilestoneNumber - })); - - if (I.PullRequest != null) - { - Console.WriteLine($"Adding additional merged PR #{I.Number}..."); - var task = GetReleaseNotesFromPR(client, I, doNotCloseMilestone, false, false); - noteTasks.Add(task); - allTasks.Add(task); - } - } - - await Task.WhenAll(issueUpdateTasks).ConfigureAwait(false); - } - + await RelocateOpenIssues(milestoneToDelete, moveToMilestoneNumber); allTasks.Add(client.Issue.Milestone.Delete(RepoOwner, RepoName, milestoneToDelete.Number)); } - var unreleasedNextPatchMilestone = milestones.FirstOrDefault(x => x.Title.StartsWith($"v{highestReleaseVersion.Major}.{highestReleaseVersion.Minor}.")); if (unreleasedNextPatchMilestone != null) await DeleteMilestone(unreleasedNextPatchMilestone, nextPatchMilestone.Number); @@ -401,7 +405,11 @@ async ValueTask DeleteMilestone(Milestone milestoneToDelete, int moveToMilestone if (unreleasedNextMinorMilestone != null) await DeleteMilestone(unreleasedNextMinorMilestone, nextMinorMilestone.Number); } + else + await RelocateOpenIssues(milestone, nextMinorMilestone.Number); } + else + await RelocateOpenIssues(milestone, nextPatchMilestone.Number); } newNotes.Append(milestone.HtmlUrl); @@ -799,7 +807,7 @@ static async Task Winget(IGitHubClient client, Uri actionUrl, string expect var versionsPropertyGroup = project.Elements().First(x => x.Name == xmlNamespace + "PropertyGroup"); var coreVersion = Version.Parse(versionsPropertyGroup.Element(xmlNamespace + "TgsCoreVersion").Value); - const string BodyForPRSha = "5ffc3ff5901db66d782aa0e8ed2a74b16f896091"; + const string BodyForPRSha = "88f8f016fc9e79ac66d69d6656409f7d6aac5dcf"; var prBody = $@"# Automated Pull Request This pull request was generated by our [deployment pipeline]({actionUrl}) as a result of the release of [tgstation-server-v{coreVersion}](https://github.com/tgstation/tgstation-server/releases/tag/tgstation-server-v{coreVersion}). Validation was performed as part of the process. @@ -815,7 +823,7 @@ The user account that created this pull request is available to correct any issu - [x] This PR only modifies one (1) manifest - [x] Have you [validated](https://github.com/microsoft/winget-pkgs/blob/master/doc/Authoring.md#validation) your manifest locally with `winget validate --manifest `? - [x] Have you tested your manifest locally with `winget install --manifest `? -- [x] Does your manifest conform to the [1.6 schema](https://github.com/microsoft/winget-pkgs/tree/master/doc/manifest/schema/1.6.0)? +- [x] Does your manifest conform to the [1.9 schema](https://github.com/microsoft/winget-pkgs/tree/master/doc/manifest/schema/1.9.0)? Note: `` is the directory's name containing the manifest you're submitting.