diff --git a/.github/workflows/artifact-cleanup.yml b/.github/workflows/artifact-cleanup.yml new file mode 100644 index 0000000000..bee44d2fa6 --- /dev/null +++ b/.github/workflows/artifact-cleanup.yml @@ -0,0 +1,15 @@ +name: 'Delete Old Artifacts' +on: + schedule: + - cron: '0 * * * *' # every hour + +jobs: + delete-artifacts: + name: Delete Artifacts + runs-on: ubuntu-latest + steps: + - name: Delete Artifacts + uses: kolpav/purge-artifacts-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + expire-in: 30days # Setting this to 0 will delete all artifacts diff --git a/.github/workflows/ci-suite.yml b/.github/workflows/ci-suite.yml new file mode 100644 index 0000000000..137825ed8a --- /dev/null +++ b/.github/workflows/ci-suite.yml @@ -0,0 +1,785 @@ +name: 'CI' + +on: + push: + branches: + - dev + - master + pull_request: + branches: + - dev + - master + +env: + TGS4_TEST_DISCORD_CHANNEL: ${{ secrets.DISCORD_CHANNEL_ID }} + TGS4_TEST_DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + TGS4_TEST_IRC_CHANNEL: ${{ secrets.IRC_CHANNEL }} + TGS4_TEST_IRC_CONNECTION_STRING: ${{ secrets.IRC_CONNECTION_STRING }} + TGS4_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + dmapi-build: + name: Build DMAPI + env: + BYOND_MAJOR: 513 + BYOND_MINOR: 1527 + runs-on: ubuntu-latest + steps: + - name: Install x86 libc Dependencies + run: | + sudo dpkg --add-architecture i386 + sudo apt-get update + sudo apt-get install -y libc6-i386 libstdc++6:i386 + + - name: Install BYOND + if: steps.cache-byond.outputs.cache-hit != 'true' + run: | + echo "Setting up BYOND." + mkdir -p "$HOME/BYOND-${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}" + cd "$HOME/BYOND-${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}" + curl "http://www.byond.com/download/build/${{ env.BYOND_MAJOR }}/${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}_byond_linux.zip" -o byond.zip + unzip byond.zip + cd byond + make here + exit 0 + + - name: Checkout + uses: actions/checkout@v1 + + - name: Build DMAPI Test Project + run: | + set -e + retval=1 + source $HOME/BYOND-${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}/byond/bin/byondsetup + + if hash DreamMaker 2>/dev/null + then + DreamMaker tests/DMAPI/BasicOperation/basic_operation_test.dme 2>&1 | tee result.log + retval=$? + if ! grep '\- 0 errors, 0 warnings' result.log + then + retval=1 + fi + else + echo "Couldn't find the DreamMaker executable, aborting." + retval=2 + fi + exit $retval + + dox-build: + name: Build Doxygen Site + runs-on: ubuntu-latest + env: + DOXDIR: ~/tgsdox + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Patch Doxyfile + run: | + VERSION=$(cat "build/Version.props" | grep -oPm1 "(?<=)[^<]+") + echo -e "\nPROJECT_NUMBER = $VERSION\nINPUT = .\nOUTPUT_DIRECTORY = $DOXDIR\nPROJECT_LOGO = ./build/tgs.ico\nHAVE_DOT=YES" >> "docs/Doxyfile" + + - name: Create DOXDIR + if: github.event_name != 'push' || github.ref != 'dev' + run: mkdir -p $DOXDIR + + - name: Prep gh-pages Repository + if: github.event_name == 'push' && github.ref == 'dev' + run: | + git clone -b gh-pages --single-branch "https://git@github.com/tgstation/tgstation-server" "$DOXDIR" + rm -r "$DOXDIR/*" + + - name: Doxygen Build + uses: mattnotmitt/doxygen-action@v1 + with: + doxyfile-path: 'docs/Doxyfile' + + - name: gh-pages push + if: github.event_name == 'push' && github.ref == 'dev' + run: | + cd $DOXDIR + git config --global push.default simple + git config user.name "tgstation-server" + git config user.email "tgstation-server@tgstation13.org" + echo '# THIS BRANCH IS AUTO GENERATED BY GITHUB ACTIONS' > README.md + + # Need to create a .nojekyll file to allow filenames starting with an underscore + # to be seen on the gh-pages site. Therefore creating an empty .nojekyll file. + echo "" > .nojekyll + git add --all + git commit -m "Deploy code docs to GitHub Pages for Travis build $TRAVIS_BUILD_NUMBER" -m "Commit: $TRAVIS_COMMIT" + git push -f "https://${{ secrets.GITHUB_TOKEN }}}@$GITHUB_URL" 2>&1 | /dev/null + + docker-build: + name: Build Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Build Docker Image + run: docker build . -f build/Dockerfile + + linux-unit-tests: + name: Linux Unit Tests + strategy: + matrix: + configuration: [ 'Debug', 'Release' ] + runs-on: ubuntu-latest + steps: + - name: Install Node 12.X + uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Setup dotnet 3.1.X + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.x + + - name: Checkout + uses: actions/checkout@v1 + + - name: Build + run: dotnet build -c ${{ matrix.configuration }}NoService + + - name: Run Unit Tests + run: sudo dotnet test tgstation-server.sln --no-build --filter FullyQualifiedName!~IntegrationTest -l "console;verbosity=detailed;noprogress=true" -c ${{ matrix.configuration }}NoService --collect:"XPlat Code Coverage" --settings build/coverlet.runsettings -r ./TestResults + + - name: Store Code Coverage + uses: actions/upload-artifact@v2 + with: + name: linux-unit-test-coverage-${{ matrix.configuration }} + path: ./TestResults/ + + windows-unit-tests: + name: Windows Unit Tests + strategy: + matrix: + configuration: [ 'Debug', 'Release' ] + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Build + run: dotnet build -c ${{ matrix.configuration }} + + - name: Run Unit Tests + run: dotnet test tgstation-server.sln --no-build --filter FullyQualifiedName!~IntegrationTest -l "console;verbosity=detailed;noprogress=true" -c ${{ matrix.configuration }} --collect:"XPlat Code Coverage" --settings build/coverlet.runsettings -r ./TestResults + + - name: Store Code Coverage + uses: actions/upload-artifact@v2 + with: + name: windows-unit-test-coverage-${{ matrix.configuration }} + path: ./TestResults/ + + linux-integration-tests: + name: Linux Integration Tests + needs: dmapi-build + services: # We start all dbs here so we can just code the stuff once + postgres: + image: postgres + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mariadb: + image: mariadb + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: mariadb + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=2s + --health-retries=3 + mysql: + image: mysql:5.7.31 + ports: + - 3307:3306 + env: + MYSQL_ROOT_PASSWORD: mysql + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + strategy: + matrix: + database-type: [ 'Sqlite', 'PostgresSql', 'MariaDB', 'MySql' ] + watchdog-type: [ 'Basic', 'System' ] + configuration: [ 'Debug', 'Release' ] + runs-on: ubuntu-latest + steps: + - name: Disable ptrace_scope + run: echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope + + - name: Install Native Dependencies + run: | + sudo dpkg --add-architecture i386 + sudo apt-get update + sudo apt-get install -y libc6-i386 libstdc++6:i386 gdb + + - name: Install Node 12.X + uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ matrix.dotnet }} + + - name: Set Sqlite Connection Info + if: ${{ matrix.database-type == 'Sqlite' }} + run: | + echo "::set-env name=TGS4_TEST_DATABASE_TYPE::Sqlite" + echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Data Source=TravisTestDB.sqlite3;Mode=ReadWriteCreate" + + - name: Set PostgresSql Connection Info + if: ${{ matrix.database-type == 'PostgresSql' }} + run: | + echo "::set-env name=TGS4_TEST_DATABASE_TYPE::PostgresSql" + echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Application Name=tgstation-server;Host=127.0.0.1;Username=postgres;Password=postgres;Database=TGS_Test" + + - name: Set MariaDB Connection Info + if: ${{ matrix.database-type == 'MariaDB' }} + run: | + echo "::set-env name=TGS4_TEST_DATABASE_TYPE::MariaDB" + echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Server=127.0.0.1;uid=root;pwd=mariadb;database=tgs_test" + + - name: Set MySQL Connection Info + if: ${{ matrix.database-type == 'MySql' }} + run: | + echo "::set-env name=TGS4_TEST_DATABASE_TYPE::MySql" + echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Server=127.0.0.1;Port=3307;uid=root;pwd=mysql;database=tgs_test" + echo "::set-env name=Database__ServerVersion::5.7.31" + + - name: Set General__UseBasicWatchdog + if: ${{ matrix.watchdog-type == 'Basic' }} + run: echo "::set-env name=General__UseBasicWatchdog::true" + + - name: Checkout + uses: actions/checkout@v1 + + - name: Set TGS4_TEST_PULL_REQUEST_NUMBER + if: ${{ github.event_name == 'pull_request' }} + run: echo "::set-env name=TGS4_TEST_PULL_REQUEST_NUMBER::${{ github.event.number }}" + + - name: Run Integration Test + run: | + cd tests/Tgstation.Server.Tests + sleep 10 + dotnet test -c ${{ matrix.configuration }} -l "console;verbosity=detailed;noprogress=true" --collect:"XPlat Code Coverage" --settings ../../build/coverlet.runsettings -r ./TestResults + + - name: Store Code Coverage + uses: actions/upload-artifact@v2 + with: + name: linux-integration-test-coverage-${{ matrix.configuration }}-${{ matrix.watchdog-type }}-${{ matrix.database-type }} + path: tests/Tgstation.Server.Tests/TestResults/ + + - name: Package Server Console + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'MariaDB' }} + run: | + cd src/Tgstation.Server.Host.Console + dotnet publish -c ${{ matrix.configuration }} -o ../../Artifacts/Console + cd ../Tgstation.Server.Host + dotnet publish -c ${{ matrix.configuration }} --no-build -o ../../Artifacts/Console/lib/Default + + - name: Package Server Update Package + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'PostgresSql' }} + run: | + cd src/Tgstation.Server.Host + dotnet publish -c ${{ matrix.configuration }} --no-build -o ../../Artifacts/ServerUpdate + + - name: Store Server Console + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'MariaDB' }} + uses: actions/upload-artifact@v2 + with: + name: ServerConsole + path: Artifacts/Console/ + + - name: Store Server Update Package + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' && matrix.database-type == 'PostgresSql' }} + uses: actions/upload-artifact@v2 + with: + name: ServerUpdatePackage + path: Artifacts/ServerUpdate/ + + windows-integration-test: + name: Windows Integration Test + needs: dmapi-build + env: + TGS4_TEST_CONNECTION_STRING: Server=(localdb)\MSSQLLocalDB;Integrated Security=true;Initial Catalog=TGS_Test;Application Name=tgstation-server + TGS4_TEST_DATABASE_TYPE: SqlServer + TGS4_TEST_DUMP_API_SPEC: yes + strategy: + matrix: + watchdog-type: [ 'Basic', 'System' ] + configuration: [ 'Debug', 'Release' ] + runs-on: windows-latest + steps: + - name: Set General__UseBasicWatchdog + if: ${{ matrix.watchdog-type == 'Basic' }} + run: echo "::set-env name=General__UseBasicWatchdog::true" + + - name: Checkout + uses: actions/checkout@v1 + + - name: Set TGS4_TEST_PULL_REQUEST_NUMBER + if: ${{ github.event_name == 'pull_request' }} + run: echo "::set-env name=TGS4_TEST_PULL_REQUEST_NUMBER::${{ github.event.number }}" + + - name: Run Integration Test + run: | + cd tests/Tgstation.Server.Tests + Start-Sleep -Seconds 10 + dotnet test -c ${{ matrix.configuration }} -l "console;verbosity=detailed;noprogress=true" --collect:"XPlat Code Coverage" --settings ../../build/coverlet.runsettings -r ./TestResults + + - name: Store Code Coverage + uses: actions/upload-artifact@v2 + with: + name: windows-integration-test-coverage-${{ matrix.configuration }}-${{ matrix.watchdog-type }} + path: tests/Tgstation.Server.Tests/TestResults/ + + - name: Store OpenAPI Spec + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'System' }} + uses: actions/upload-artifact@v2 + with: + name: openapi-spec + path: C:/swagger.json + + - name: Package Server Service + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'Basic' }} + run: | + cd src/Tgstation.Server.Host.Service + dotnet publish -c ${{ matrix.configuration }} -o ../../Artifacts/Service + cd ../Tgstation.Server.Host + dotnet publish -c ${{ matrix.configuration }} --no-build -o ../../Artifacts/Service/lib/Default + + - name: Store Server Service + if: ${{ matrix.configuration == 'Release' && matrix.watchdog-type == 'Basic' }} + uses: actions/upload-artifact@v2 + with: + name: ServerService + path: Artifacts/Service/ + + validate-openapi-spec: + name: OpenAPI Spec Validation + needs: windows-integration-test + runs-on: windows-latest + steps: + - name: Install IBM OpenAPI Validator + run: npm i -g ibm-openapi-validator + + - name: Checkout + uses: actions/checkout@v1 + + - name: Retrieve OpenAPI Spec + uses: actions/download-artifact@v2 + with: + name: openapi-spec + path: ./swagger.json + + - name: Lint OpenAPI Spec + run: npx lint-openapi -p -c build/OpenApiValidationSettings.json ./swagger.json + + upload-code-coverage: + name: Upload Code Coverage + needs: [linux-unit-tests, linux-integration-tests, windows-unit-tests, windows-integration-test] + runs-on: ubuntu-latest + steps: + - name: Checkout for .codecov.yml + uses: actions/checkout@v1 + + - name: Retrieve Linux Unit Test Coverage (Debug) + uses: actions/download-artifact@v2 + with: + name: linux-unit-test-coverage-Debug + path: ./code_coverage/unit_tests/linux_unit_tests_debug + + - name: Retrieve Linux Unit Test Coverage (Release) + uses: actions/download-artifact@v2 + with: + name: linux-unit-test-coverage-Release + path: ./code_coverage/unit_tests/linux_unit_tests_release + + - name: Retrieve Linux Integration Test Coverage (Debug, Basic, Sqlite) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-Basic-Sqlite + path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_sqlite + + - name: Retrieve Linux Integration Test Coverage (Release, Basic, Sqlite) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-Basic-Sqlite + path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_sqlite + + - name: Retrieve Linux Integration Test Coverage (Debug, System, Sqlite) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-System-Sqlite + path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_sqlite + + - name: Retrieve Linux Integration Test Coverage (Release, System, Sqlite) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-System-Sqlite + path: ./code_coverage/integration_tests/linux_integration_tests_release_system_sqlite + + - name: Retrieve Linux Integration Test Coverage (Debug, Basic, PostgresSql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-Basic-PostgresSql + path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_postgressql + + - name: Retrieve Linux Integration Test Coverage (Release, Basic, PostgresSql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-Basic-PostgresSql + path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_postgressql + + - name: Retrieve Linux Integration Test Coverage (Debug, System, PostgresSql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-System-PostgresSql + path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_postgressql + + - name: Retrieve Linux Integration Test Coverage (Release, System, PostgresSql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-System-PostgresSql + path: ./code_coverage/integration_tests/linux_integration_tests_release_system_mariadb + + - name: Retrieve Linux Integration Test Coverage (Debug, Basic, MariaDB) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-Basic-MariaDB + path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_mariadb + + - name: Retrieve Linux Integration Test Coverage (Release, Basic, MariaDB) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-Basic-MariaDB + path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_mariadb + + - name: Retrieve Linux Integration Test Coverage (Debug, System, MariaDB) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-System-MariaDB + path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_mariadb + + - name: Retrieve Linux Integration Test Coverage (Release, System, MariaDB) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-System-MariaDB + path: ./code_coverage/integration_tests/linux_integration_tests_release_system_mysql + + - name: Retrieve Linux Integration Test Coverage (Debug, Basic, MySql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-Basic-MySql + path: ./code_coverage/integration_tests/linux_integration_tests_debug_basic_mysql + + - name: Retrieve Linux Integration Test Coverage (Release, Basic, MySql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-Basic-MySql + path: ./code_coverage/integration_tests/linux_integration_tests_release_basic_mysql + + - name: Retrieve Linux Integration Test Coverage (Debug, System, MySql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Debug-System-MySql + path: ./code_coverage/integration_tests/linux_integration_tests_debug_system_mysql + + - name: Retrieve Linux Integration Test Coverage (Release, System, MySql) + uses: actions/download-artifact@v2 + with: + name: linux-integration-test-coverage-Release-System-MySql + path: ./code_coverage/integration_tests/linux_integration_tests_release_system_mysql + + - name: Retrieve Windows Unit Test Coverage (Debug) + uses: actions/download-artifact@v2 + with: + name: windows-unit-test-coverage-Debug + path: ./code_coverage/unit_tests/windows_unit_tests_debug + + - name: Retrieve Windows Unit Test Coverage (Release) + uses: actions/download-artifact@v2 + with: + name: windows-unit-test-coverage-Release + path: ./code_coverage/unit_tests/windows_unit_tests_release + + - name: Retrieve Windows Integration Test Coverage (Debug, Basic) + uses: actions/download-artifact@v2 + with: + name: windows-integration-test-coverage-Debug-Basic + path: ./code_coverage/integration_tests/windows_integration_tests_debug_basic + + - name: Retrieve Windows Integration Test Coverage (Release, Basic) + uses: actions/download-artifact@v2 + with: + name: windows-integration-test-coverage-Release-Basic + path: ./code_coverage/integration_tests/windows_integration_tests_release_basic + + - name: Retrieve Windows Integration Test Coverage (Debug, System) + uses: actions/download-artifact@v2 + with: + name: windows-integration-test-coverage-Debug-System + path: ./code_coverage/integration_tests/windows_integration_tests_debug_system + + - name: Retrieve Windows Integration Test Coverage (Release, System) + uses: actions/download-artifact@v2 + with: + name: windows-integration-test-coverage-Release-System + path: ./code_coverage/integration_tests/windows_integration_tests_release_system + + - name: Upload Unit Test Coverage to CodeCov + uses: codecov/codecov-action@v1 + with: + directory: ./code_coverage/unit_tests + flags: unittests + fail_ci_if_error: true + + - name: Upload Integration Test Coverage to CodeCov + uses: codecov/codecov-action@v1 + with: + directory: ./code_coverage/integration_tests + flags: integration + fail_ci_if_error: true + + deploy-http: + name: Deploy HTTP API + needs: [upload-code-coverage, validate-openapi-spec] + runs-on: windows-latest + if: github.event_name == 'push' && github.ref == 'master' && contains(github.event.head_commit.message, '[APIDeploy]') + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Parse API version + shell: powershell + run: | + [XML]$versionXML = Get-Content build/Version.props + $apiVersion = $versionXML.Project.PropertyGroup.TgsApiVersion + Write-Host "::set-env name=TGS_API_VERSION::$apiVersion" + + - name: Retrieve OpenAPI Spec + uses: actions/download-artifact@v2 + with: + name: openapi-spec + path: swagger.json + + - name: Create GitHub Release + uses: juitnow/github-action-create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: api-v${{ env.TGS_API_VERSION }} + release_name: tgstation-server 4 API v${{ env.TGS_API_VERSION }} + body: The TGS HTTP API + committish: ${{ github.event.after }} + + - name: Upload OpenApi Spec + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./swagger.json + asset_name: swagger.json + asset_content_type: application/json + + deploy-dm: + name: Deploy DreamMaker API + needs: [upload-code-coverage, validate-openapi-spec] + runs-on: windows-latest + if: github.event_name == 'push' && github.ref == 'master' && contains(github.event.head_commit.message, '[DMDeploy]') + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Parse DMAPI version + shell: powershell + run: | + [XML]$versionXML = Get-Content build/Version.props + $dmVersion = $versionXML.Project.PropertyGroup.TgsDmapiVersion + Write-Host "::set-env name=TGS_DM_VERSION::$dmVersion" + + - name: Zip DMAPI + shell: powershell + run: Compress-Archive -Path src/DMAPI -DestinationPath DMAPI.zip + + - name: Create GitHub Release + uses: juitnow/github-action-create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: dmapi-v${{ env.TGS_DM_VERSION }} + release_name: tgstation-server 4 DMAPI v${{ env.TGS_DM_VERSION }} + body: The TGS DMAPI + committish: ${{ github.event.after }} + + - name: Upload DMAPI Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./DMAPI.zip + asset_name: my-artifact.zip + asset_content_type: application/zip + + deploy-client: + name: Deploy Nuget Packages + needs: [upload-code-coverage, validate-openapi-spec] + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'master' && contains(github.event.head_commit.message, '[NugetDeploy]') + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Publish API to NuGet + uses: rohith/publish-nuget@v2 + with: + PROJECT_FILE_PATH: src/Tgstation.Server.Api/Tgstation.Server.Api.csproj + TAG_COMMIT: false + INCLUDE_SYMBOLS: true + NUGET_KEY: ${{ secrets.NUGET_API_KEY }} + + - name: Publish Client to NuGet + uses: rohith/publish-nuget@v2 + with: + PROJECT_FILE_PATH: src/Tgstation.Server.Client/Tgstation.Server.Client.csproj + TAG_COMMIT: false + INCLUDE_SYMBOLS: true + NUGET_KEY: ${{ secrets.NUGET_API_KEY }} + + deploy-tgs: + name: Deploy tgstation-server + needs: [deploy-dm, deploy-http, deploy-client] + runs-on: windows-latest + if: github.event_name == 'push' && github.ref == 'master' && contains(github.event.head_commit.message, '[TGSDeploy]') + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Parse TGS version + shell: powershell + run: | + [XML]$versionXML = Get-Content build/Version.props + $tgsVersion = $versionXML.Project.PropertyGroup.TgsCoreVersion + Write-Host "::set-env name=TGS_VERSION::$tgsVersion" + + - name: Retrieve Server Service + uses: actions/download-artifact@v2 + with: + name: ServerService + path: ServerService + + - name: Retrieve Server Console + uses: actions/download-artifact@v2 + with: + name: ServerConsole + path: ServerConsole + + - name: Retrieve Server Update Package + uses: actions/download-artifact@v2 + with: + name: ServerUpdatePackage + path: ServerUpdatePackage + + - name: Retrieve OpenAPI Spec + uses: actions/download-artifact@v2 + with: + name: openapi-spec + path: swagger.json + + - name: Zip Artifacts + shell: powershell + run: | + Compress-Archive -Path src/DMAPI -DestinationPath DMAPI.zip + Compress-Archive -Path ServerService -DestinationPath ServerService.zip + Compress-Archive -Path ServerConsole -DestinationPath ServerConsole.zip + Compress-Archive -Path ServerUpdatePackage -DestinationPath ServerUpdatePackage.zip + + - name: Generate Release Notes + shell: powershell + run: | + dotnet run -c Release -p tools/ReleaseNotes ${{ env.TGS_VERSION }} build/Version.props + $releaseNotes = [IO.File]::ReadAllText("release_notes.md") + Write-Host "::set-env name=TGS_RELEASE_NOTES::$releaseNotes" + + - name: Create GitHub Release + uses: juitnow/github-action-create-release@v1 + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: tgstation-server-v${{ env.TGS_VERSION }} + release_name: tgstation-server 4 DMAPI v${{ env.TGS_VERSION }} + body: ${{ env.TGS_RELEASE_NOTES }} + committish: ${{ github.event.after }} + + - name: Upload Server Console Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./ServerConsole.zip + asset_name: ServerConsole.zip + asset_content_type: application/zip + + - name: Upload Server Service Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./ServerService.zip + asset_name: ServerService.zip + asset_content_type: application/zip + + - name: Upload DMAPI Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./DMAPI.zip + asset_name: DMAPI.zip + asset_content_type: application/zip + + - name: Upload OpenApi Spec Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./swagger.json + asset_name: swagger.json + asset_content_type: application/json + + - name: Upload Server Update Package Artifact + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./ServerUpdatePackage.zip + asset_name: ServerUpdatePackage.zip + asset_content_type: application/zip diff --git a/.github/workflows/suite.yml b/.github/workflows/suite.yml deleted file mode 100644 index 3a0b81c4e6..0000000000 --- a/.github/workflows/suite.yml +++ /dev/null @@ -1,232 +0,0 @@ -name: 'Test Suite' - -on: - push: - branches: - - dev - - master - pull_request: - branches: - - dev - - master - -jobs: - server-unit-tests: - name: Build Server and Run Unit Tests - strategy: - matrix: - dotnet: [ '3.1.x' ] - node: [ '12.x' ] - configuration: [ 'Debug', 'Release' ] - env: - TGS4_TEST_DISCORD_CHANNEL: ${{ secrets.DISCORD_CHANNEL_ID }} - TGS4_TEST_DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} - TGS4_TEST_IRC_CHANNEL: ${{ secrets.IRC_CHANNEL }} - TGS4_TEST_IRC_CONNECTION_STRING: ${{ secrets.IRC_CONNECTION_STRING }} - TGS4_TEST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: ubuntu-latest - steps: - - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node }} - - - name: Setup dotnet - uses: actions/setup-dotnet@v1 - with: - dotnet-version: ${{ matrix.dotnet }} - - - name: Checkout - uses: actions/checkout@v1 - - - name: Build - run: dotnet build -c ${{ matrix.configuration }} - - - name: Run Unit Tests - run: sudo dotnet test tgstation-server.sln --no-build --filter FullyQualifiedName!~IntegrationTests -l "console;verbosity=detailed;noprogress=true" -c ${{ matrix.configuration }} /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput="./unit_tests.xml" - - - name: Publish Integration Test Artifacts - run: | - cd tests/Tgstation.Server.tests - dotnet publish -c ${{ matrix.configuration }} -o Artifacts --no-build - - - name: Store Integration Test Artifacts - uses: actions/upload-artifact@v2 - with: - name: integration-test-bins - path: tests/Tgstation.Server.Tests/Artifacts/ - - - name: Upload Code Coverage - uses: codecov/codecov-action@v1 - with: - file: ./unit_tests.xml - flags: unittests - fail_ci_if_error: true - - integration-tests: - name: Integration Tests - needs: [server-unit-tests, dmapi-build] - services: # We start all dbs here so we can just code the stuff once - postgres: - image: postgres - env: - POSTGRES_PASSWORD: postgres - # Set health checks to wait until postgres has started - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - mariadb: - image: mariadb - env: - MYSQL_ROOT_PASSWORD: mariadb - options: >- - --health-cmd="mysqladmin ping" - --health-interval=5s - --health-timeout=2s - --health-retries=3 - mysql: - image: mysql:5.7.31 - env: - MYSQL_ROOT_PASSWORD: mysql - options: >- - --health-cmd="mysqladmin ping" - --health-interval=10s - --health-timeout=5s - --health-retries=3 - strategy: - matrix: - database-type: [ 'Sqlite', 'PostgresSql', 'MariaDB', 'MySql' ] - basic-watchdog: [ 'yes', 'no' ] - runs-on: ubuntu-latest - steps: - - name: Install Native Dependencies - run: | - sudo dpkg add-architecture i386 - sudo apt-get update - sudo apt-get install -y libc6-i386 libstdc++6:i386 gdb - - - name: Disable ptrace_scope - run: echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope - - - name: Setup dotnet - uses: actions/setup-dotnet@v1 - with: - dotnet-version: ${{ matrix.dotnet }} - - - name: Set Sqlite Connection Info - if: ${{ matrix.database-type == 'Sqlite' }} - run: | - echo "::set-env name=TGS4_TEST_DATABASE_TYPE::Sqlite" - echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Data Source=TravisTestDB.sqlite3;Mode=ReadWriteCreate" - - - name: Set PostgresSql Connection Info - if: ${{ matrix.database-type == 'PostgresSql' }} - run: | - echo "::set-env name=TGS4_TEST_DATABASE_TYPE::PostgresSql" - echo "::set-env name=TGS4_TEST_CONNECTION_STRING::Application Name=tgstation-server;Port=5432;Host=postgres;Username=postgres;Password=postgres;Database=TGS_Test" - - - name: Set MariaDB Connection Info - if: ${{ matrix.database-type == 'MariaDB' }} - run: | - echo "::set-env name=TGS4_TEST_DATABASE_TYPE::MariaDB" - echo "::set-env name=TGS4_TEST_CONNECTION_STRING::server=mariadb;uid=root;pwd=mariadb;database=tgs_test" - - - name: Set MySQL Connection Info - if: ${{ matrix.database-type == 'MySql' }} - run: | - echo "::set-env name=TGS4_TEST_DATABASE_TYPE::MySql" - echo "::set-env name=TGS4_TEST_CONNECTION_STRING::server=mysql;uid=root;pwd=mysql;database=tgs_test" - echo "::set-env name=Database__ServerVersion::5.7.31" - - - name: Set General__UseBasicWatchdog - if: ${{ matrix.basic-watchdog == 'yes' }} - run: echo "::set-env name=General__UseBasicWatchdog::true" - - - name: Checkout - uses: actions/checkout@v1 - - - name: Set TGS4_TEST_PULL_REQUEST_NUMBER - if: ${{ github.event_name == 'pull_request' }} - run: echo "::set-env name=TGS4_TEST_PULL_REQUEST_NUMBER::${{ github.event.issue.number }}" - - - name: Retrieve Integration Test Artifacts - uses: actions/download-artifact@v2 - with: - name: integration-test-bins - path: tests/Tgstation.Server.Tests/Artifacts - - - name: Run Integration Test - run: | - cd tests/Tgstation.Server.Tests - dotnet test Artifacts/Tgstation.Server.Tests.dll -l "console;verbosity=detailed;noprogress=true" -c ${{ matrix.configuration }} /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput="./integration_tests.xml" - - - name: Upload Code Coverage - uses: codecov/codecov-action@v1 - with: - file: ./integration_tests.xml - flags: integration - fail_ci_if_error: true - - docker-build: - name: Build Docker Image - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v1 - - - name: Build Docker Image - run: docker build . -f build/Dockerfile - - dmapi-build: - name: Build DMAPI - env: - BYOND_MAJOR: 513 - BYOND_MINOR: 1527 - runs-on: ubuntu-latest - steps: - - name: Install x86 libc Dependencies - run: | - sudo dpkg add-architecture i386 - sudo apt-get update - sudo apt-get install -y libc6-i386 libstdc++6:i386 - - - name: Cache BYOND - id: cache-byond - uses: actions/cache@v2 - with: - path: byond - key: ${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }} - - - name: Install BYOND - if: steps.cache-byond.outputs.cache-hit != 'true' - run: | - echo "Setting up BYOND." - mkdir -p "$HOME/BYOND-${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}" - cd "$HOME/BYOND-${BYOND_MAJOR}.${BYOND_MINOR}" - curl "http://www.byond.com/download/build/${{ env.BYOND_MAJOR }}/${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}_byond_linux.zip" -o byond.zip - unzip byond.zip - cd byond - make here - cd ~/ - exit 0 - - - name: Build DMAPI Test Project - run: | - set -e - retval=1 - source $HOME/BYOND-${{ env.BYOND_MAJOR }}.${{ env.BYOND_MINOR }}/byond/bin/byondsetup - - if hash DreamMaker 2>/dev/null - then - DreamMaker $DMEName 2>&1 | tee result.log - retval=$? - if ! grep '\- 0 errors, 0 warnings' result.log - then - retval=1 - fi - else - echo "Couldn't find the DreamMaker executable, aborting." - retval=2 - fi - exit $retval diff --git a/.gitignore b/.gitignore index 5885b2ed23..9b47efb6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,5 @@ artifacts/ /tools/ReleaseNotes/release_notes.md /tools/ReleaseNotes/Properties/launchSettings.json release_notes.md - +*nupkg *.sqlite3 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 333ddccbe2..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -os: linux -dist: xenial -language: generic -git: - depth: 1 - -branches: - only: - - dev - - master - -jobs: - include: - - env: - name: "Dox Generation" - addons: - apt: - packages: - - doxygen - - graphviz - -script: - - build/build_dox.sh diff --git a/README.md b/README.md index e69402d972..09032179ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # tgstation-server v4: -[![Build status](https://ci.appveyor.com/api/projects/status/7t1h7bvuha0p9j5f/branch/master?svg=true)](https://ci.appveyor.com/project/Cyberboss/tgstation-server-tools/branch/master) [![Build Status](https://travis-ci.org/tgstation/tgstation-server.svg?branch=master)](https://travis-ci.org/tgstation/tgstation-server) [![codecov](https://codecov.io/gh/tgstation/tgstation-server/branch/master/graph/badge.svg)](https://codecov.io/gh/tgstation/tgstation-server) +![CI](https://github.com/tgstation/tgstation-server/workflows/CI/badge.svg) [![Build Status](https://travis-ci.org/tgstation/tgstation-server.svg?branch=master)](https://travis-ci.org/tgstation/tgstation-server) [![codecov](https://codecov.io/gh/tgstation/tgstation-server/branch/master/graph/badge.svg)](https://codecov.io/gh/tgstation/tgstation-server) [![GitHub license](https://img.shields.io/github/license/tgstation/tgstation-server.svg)](LICENSE) [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/tgstation/tgstation-server.svg)](http://isitmaintained.com/project/tgstation/tgstation-server "Average time to resolve an issue") [![NuGet version](https://img.shields.io/nuget/v/Tgstation.Server.Api.svg)](https://www.nuget.org/packages/Tgstation.Server.Api) [![NuGet version](https://img.shields.io/nuget/v/Tgstation.Server.Client.svg)](https://www.nuget.org/packages/Tgstation.Server.Client) @@ -8,7 +8,7 @@ [![forthebadge](http://forthebadge.com/images/badges/built-with-love.svg)](http://forthebadge.com) [![forthebadge](http://forthebadge.com/images/badges/60-percent-of-the-time-works-every-time.svg)](http://forthebadge.com) -This is a toolset to manage production BYOND servers. It includes the ability to update the server without having to stop or shutdown the server (the update will take effect on a "reboot" of the server) the ability start the server and restart it if it crashes, as well as systems for managing code and game files, and merging GitHub Pull Requests for test deployments. +This is a toolset to manage production BYOND servers. It includes the ability to update the server without having to stop or shutdown the server (the update will take effect on a "reboot" of the server) the ability start the server and restart it if it crashes, as well as systems for managing code and game files, and merging GitHub Pull Requests for test deployments. ### Legacy Servers @@ -78,13 +78,13 @@ If using manual configuration, before starting your container make sure the afor ### Configuring -The first time you run TGS4 you should be prompted with a configuration wizard which will guide you through setting up your appsettings.Production.json +The first time you run TGS4 you should be prompted with a configuration wizard which will guide you through setting up your appsettings.Production.json This wizard will, generally, run whenever the server is launched without detecting the config json. Follow the instructions below to perform this process manually. #### Manual Configuration -Create an `appsettings.Production.json` file next to `appsettings.json`. This will override the default settings in appsettings.json with your production settings. There are a few keys meant to be changed by hosts. Modifying any config files while the server is running will trigger a safe restart (Keeps DreamDaemon's running). Note these are all case-sensitive: +Create an `appsettings.Production.json` file next to `appsettings.json`. This will override the default settings in appsettings.json with your production settings. There are a few keys meant to be changed by hosts. Modifying any config files while the server is running will trigger a safe restart (Keeps DreamDaemon's running). Note these are all case-sensitive: - `General:ConfigVersion`: Suppresses warnings about out of date config versions. You should change this after updating TGS to one with a new config version. The current version can be found on the releases page for your server version (This field did not exist before v4.4.0). @@ -250,7 +250,7 @@ See https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/ ### Apache -1. Ensure the `mod_proxy` extension is installed. +1. Ensure the `mod_proxy` extension is installed. 2. Setup a basic website configuration. Instructions on how to do so are out of scope. 3. Acquire an HTTPS certificate, likely via Let's Encrypt, and configure Apache to use it. 4. Under a VirtualHost entry, setup the following (replace 8080 with the port TGS is hosted on): @@ -283,7 +283,7 @@ tgstation-server v4 is controlled via a RESTful HTTP json API. Documentation on ### Users -All actions apart from logging in must be taken by a user. TGS installs with one default user whose credentials can be found [here](src/Tgstation.Server.Api/Models/User.cs). It is recommended to disable this user ASAP as it is used to create Jobs that are started by the server itself. If access to all users is lost, the default user can be reset using the `Database:ResetAdminPassword` configuration setting. +All actions apart from logging in must be taken by a user. TGS installs with one default user whose credentials can be found [here](src/Tgstation.Server.Api/Models/User.cs). It is recommended to disable this user ASAP as it is used to create Jobs that are started by the server itself. If access to all users is lost, the default user can be reset using the `Database:ResetAdminPassword` configuration setting. Users can be enabled/disabled and have a very granular set of rights associated to them that determine the actions they are allowed to take (i.e. Modify the user list or create instances). Users can be _database based_ or _system based_. Database users are your standard web users with a username and password. System users, on the otherhand, are authenticated with the host OS. These users cannot have their password or names changed by TGS as they are managed by the system (and in reverse, login tokens don't expire when their password changes). The benefit to having these users is it allows the use of system ACLs for static file control. More on that later. @@ -295,7 +295,7 @@ An instance is stored in a single folder anywhere on a system and is made up of ##### Instance Users -All users with access to an instance have an InstanceUser object associated with the two that defines more rights specific to that instance (i.e. Deploy code, modify bots, edit other InstanceUsers). +All users with access to an instance have an InstanceUser object associated with the two that defines more rights specific to that instance (i.e. Deploy code, modify bots, edit other InstanceUsers). #### Repository diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..7bf70fce52 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 4.x.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Vulnerabilities should ideally be reported by directly messaging one of the maintainers on Discord. Maintainers can be found in the [#coderbus](https://discord.gg/Vh8TJp9) guild. Please be sure to provide reproduction steps. + +Here is a list of their discord IDs. + +- Cyberboss#8246 + +Once reported, they will handle the processing of the security advisory. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 13d2da0ad0..0000000000 --- a/appveyor.yml +++ /dev/null @@ -1,132 +0,0 @@ -version: '{build}' -pull_requests: - do_not_increment_build_number: true -environment: - TGS4_TEST_TEMP_DIRECTORY: C:/tgs4_test - TGS4_TEST_DATABASE_TYPE: SqlServer - TGS4_TEST_DUMP_API_SPEC: yes - TGS4_TEST_CONNECTION_STRING: Server=(local)\SQL2017;Initial Catalog=TGS_Test;User ID=sa;Password=Password12! - TGS4_TEST_GITHUB_TOKEN: - secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK - TGS4_TEST_DISCORD_TOKEN: - secure: 5ENtMfhHDI6SOgZ9nP7oHZVoIRFPfTjHQpfbCTB+UC8ePc4og5ew3DNzVCXNYxXvwqGyjz7gz3R302Iy3cfsDw== - TGS4_TEST_DISCORD_CHANNEL: 493119635319947269 - TGS4_TEST_IRC_CONNECTION_STRING: - secure: ralERi6QrigvQYZWkLruZGP37RwuadDIkocs+gvT5OcOMUlBODExyI9FPss3HtGx - TGS4_TEST_IRC_CHANNEL: \#botbus - TGS4_RELEASE_NOTES_TOKEN: - secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK - -branches: - only: - - dev - - master -skip_tags: true -image: Visual Studio 2019 -configuration: - - Release -shallow_clone: true -artifacts: - - path: artifacts/ServerConsole - name: ServerConsole - - path: artifacts/ServerService - name: ServerService - - path: artifacts/ServerHost - name: ServerUpdatePackage - - path: src/DMAPI - name: DMAPI - - path: swagger.json - name: SwaggerSpec -cache: - - ~\.nuget\packages -> **\*.csproj -services: - - mssql2017 -install: - - choco install codecov opencover.portable - - npm i -g ibm-openapi-validator - - nuget restore tgstation-server.sln - - ps: Install-Product node 10 -build: - project: tgstation-server.sln - parallel: false - verbosity: minimal - publish_nuget: true - publish_nuget_symbols: true - use_snupkg_format: true -test_script: - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx /p:DebugType=full tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Api.Tests*]*" -output:".\api_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Api.Tests\TestResults\results.trx)) - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx /p:DebugType=full tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Client.Tests*]*" -output:".\client_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Client.Tests\TestResults\results.trx)) - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx /p:DebugType=full tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Host.Tests*]* -[Tgstation.Server.Host]Tgstation.Server.Host.Database.Migrations*" -output:".\host_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Host.Tests\TestResults\results.trx)) - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx /p:DebugType=full tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Host.Console.Tests*]*" -output:".\console_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Host.Console.Tests\TestResults\results.trx)) - - set path=%ProgramFiles(x86)%\Microsoft Visual Studio\2019\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow;%path% - - vstest.console /logger:trx;LogFileName=results.trx "tests\Tgstation.Server.Host.Service.Tests\bin\%CONFIGURATION%\net472\Tgstation.Server.Host.Service.Tests.dll" /inIsolation /Platform:x64 - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\TestResults\results.trx)) - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx /p:DebugType=full tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Host.Watchdog.Tests*]*" -output:".\watchdog_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Host.Watchdog.Tests\TestResults\results.trx)) - - OpenCover.Console.exe -returntargetcode -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test -c %CONFIGURATION% --logger:trx;LogFileName=results.trx --logger:console;noprogress=true /p:DebugType=full tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Tests*]* -[Tgstation.Server.Host]Tgstation.Server.Host.Database.Migrations..*" -output:".\server_coverage.xml" -oldstyle - - ps: $wc = New-Object 'System.Net.WebClient' - - ps: $wc.UploadFile("https://ci.appveyor.com/api/testresults/mstest/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\tests\Tgstation.Server.Tests\TestResults\results.trx)) - - lint-openapi -p -c build/OpenApiValidationSettings.json C:/swagger.json -after_test: - - ps: Move-Item -path C:/swagger.json swagger.json - - ps: build/UploadCoverage.ps1 - #host updater - - dotnet publish src/Tgstation.Server.Host/Tgstation.Server.Host.csproj -o artifacts/ServerHost -c %CONFIGURATION% - #console - - dotnet publish src/Tgstation.Server.Host.Console/Tgstation.Server.Host.Console.csproj -o artifacts/ServerConsole -c %CONFIGURATION% - - ps: Copy-Item -path "artifacts/ServerHost" -destination artifacts/ServerConsole/lib/Default -recurse - - ps: Move-Item -path artifacts/ServerConsole/lib/Default/appsettings.json -destination artifacts/ServerConsole/ - #service - - ps: Copy-Item -path "src/Tgstation.Server.Host.Service/bin/$env:CONFIGURATION/net472/win" -destination artifacts/ServerService -recurse - - ps: Copy-Item -path "artifacts/ServerHost" -destination artifacts/ServerService/lib/Default -recurse - - ps: Move-Item -path artifacts/ServerService/lib/Default/appsettings.json -destination artifacts/ServerService/ - - ps: Remove-Item artifacts/ServerHost/appsettings.json - #deploy stuff - - ps: build/prep_deployment.ps1 -deploy: - - provider: GitHub - release: "tgstation-server-v$(TGSVersion)" - description: "$(TGSReleaseNotes)" - auth_token: - secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK - artifact: ServerConsole,ServerService,ServerUpdatePackage,DMAPI,SwaggerSpec - draft: $(TGSDraftNotes) - prerelease: false - on: - TGSDeploy: "Do it." - - provider: GitHub - release: "api-v$(APIVersion)" - description: "$(APIReleaseNotes)" - auth_token: - secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK - artifact: SwaggerSpec - draft: false - prerelease: false - on: - APIDeploy: "Do it." - - provider: GitHub - release: "dmapi-v$(DMVersion)" - description: "$(DMReleaseNotes)" - auth_token: - secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK - artifact: DMAPI - draft: false - prerelease: false - on: - DMDeploy: "Do it." - - provider: NuGet - api_key: - secure: DzllxD27WDdbXf/U9myiLcu/DBlJBk1rlrPv629JHluIsffaJUbY1L+TRYo5gwjf - artifact: /.*\.nupkg/ - on: - NugetDeploy: "Do it." diff --git a/build/UploadCoverage.ps1 b/build/UploadCoverage.ps1 deleted file mode 100644 index a74244feee..0000000000 --- a/build/UploadCoverage.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -codecov -f api_coverage.xml --flag unittests -codecov -f client_coverage.xml --flag unittests -codecov -f host_coverage.xml --flag unittests -codecov -f console_coverage.xml --flag unittests -codecov -f watchdog_coverage.xml --flag unittests -#codecov -f service.coveragexml --flag unittests -codecov -f server_coverage.xml --flag integration diff --git a/build/Version.props b/build/Version.props index 07bb599fbd..1fcd4953c8 100644 --- a/build/Version.props +++ b/build/Version.props @@ -2,11 +2,11 @@ - 4.4.1 + 4.4.2 2.0.0 - 7.0.1 - 8.0.0 - 5.2.2 + 7.1.0 + 8.1.0 + 5.2.3 0.4.0 1.1.0 diff --git a/build/build_dox.sh b/build/build_dox.sh deleted file mode 100755 index 9703274177..0000000000 --- a/build/build_dox.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash - -BUILD_FOLDER=$TRAVIS_BUILD_DIR - -DOXDIR=~/tgsdox - -mkdir -p $DOXDIR - -if [ "$TRAVIS_PULL_REQUEST" = false ] && [ "$TRAVIS_BRANCH" = "master" ]; then - PUBLISH_DOX=true -else - PUBLISH_DOX=false -fi - -if [ "$PUBLISH_DOX" = true ] ; then - GITHUB_URL="github.com/$TRAVIS_REPO_SLUG" - echo "Cloning https://git@$GITHUB_URL..." - git clone -b gh-pages --single-branch "https://git@$GITHUB_URL" "$DOXDIR" 2> /dev/null - rm -r "$DOXDIR/*" -fi - -VERSION=$(cat "build/Version.props" | grep -oPm1 "(?<=)[^<]+") - -echo -e "\nPROJECT_NUMBER = $VERSION\nINPUT = $BUILD_FOLDER\nOUTPUT_DIRECTORY = $DOXDIR\nPROJECT_LOGO = $BUILD_FOLDER/build/tgs.ico\nHAVE_DOT=YES" >> "$BUILD_FOLDER/docs/Doxyfile" - -doxygen "$BUILD_FOLDER/docs/Doxyfile" - -if [ "$PUBLISH_DOX" = true ] ; then - cd $DOXDIR - git config --global push.default simple - git config user.name "tgstation-server" - git config user.email "tgstation-server@tgstation13.org" - echo '# THIS BRANCH IS AUTO GENERATED BY TRAVIS CI' > README.md - - # Need to create a .nojekyll file to allow filenames starting with an underscore - # to be seen on the gh-pages site. Therefore creating an empty .nojekyll file. - echo "" > .nojekyll - git add --all - git commit -m "Deploy code docs to GitHub Pages for Travis build $TRAVIS_BUILD_NUMBER" -m "Commit: $TRAVIS_COMMIT" - git push -f "https://$TGS4_GH_PAGES_TOKEN@$GITHUB_URL" 2>&1 | /dev/null - cd "$BUILD_FOLDER" - rm -rf "$DOXDIR/.git" -fi diff --git a/build/coverlet.runsettings b/build/coverlet.runsettings new file mode 100644 index 0000000000..783e8323cc --- /dev/null +++ b/build/coverlet.runsettings @@ -0,0 +1,13 @@ + + + + + + + opencover + false + + + + + diff --git a/build/prep_deployment.ps1 b/build/prep_deployment.ps1 deleted file mode 100644 index 358b72d8c6..0000000000 --- a/build/prep_deployment.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -$bf = $env:APPVEYOR_BUILD_FOLDER -$propsPath = "$bf/build/Version.props" - -[XML]$versionXML = Get-Content $propsPath -$env:TGSVersion = $versionXML.Project.PropertyGroup.TgsCoreVersion -$env:APIVersion = $versionXML.Project.PropertyGroup.TgsApiVersion -$env:DMVersion = $versionXML.Project.PropertyGroup.TgsDmapiVersion - -Write-Host "TGS Version: $env:TGSVersion" - -if (($env:CONFIGURATION -match "Release") -And ($env:APPVEYOR_REPO_BRANCH -match "master") -And ($env:APPVEYOR_REPO_COMMIT_MESSAGE -match "\[TGSDeploy\]")) { - Write-Host "Deploying TGS..." - $env:TGSDeploy = "Do it." - - Write-Host "Generating release notes..." - dotnet run -p "$bf/tools/ReleaseNotes" $env:TGSVersion $propsPath - $env:TGSDraftNotes = !($?) - $releaseNotesPath = "$bf/release_notes.md" - Write-Host "Reading release notes from $releaseNotesPath..." - if (Test-Path $releaseNotesPath -PathType Leaf) { - $env:TGSReleaseNotes = [IO.File]::ReadAllText($releaseNotesPath) - } - else { - Write-Host "Release note generation failed, release will be created as a draft!" - $env:TGSReleaseNotes = "Automatic generation failed, please fill manually!" - } -} - -if (($env:CONFIGURATION -match "Release") -And ($env:APPVEYOR_REPO_COMMIT_MESSAGE -match "\[APIDeploy\]")) { - Write-Host "Deploying API..." - $env:APIDeploy = "Do it." - $env:APIReleaseNotes = "# tgstation-server 4 API v$env:APIVersion" -} - -if (($env:CONFIGURATION -match "Release") -And ($env:APPVEYOR_REPO_COMMIT_MESSAGE -match "\[DMDeploy\]")) { - Write-Host "Deploying DMAPI..." - $env:DMDeploy = "Do it." - $env:DMReleaseNotes = "# tgstation-server 4 DMAPI v$env:DMReleaseNotes" -} - -if (($env:CONFIGURATION -match "Release") -And ($env:APPVEYOR_REPO_COMMIT_MESSAGE -match "\[NugetDeploy\]")) { - $env:NugetDeploy = "Do it." - Write-Host "Nuget deployment enabled" -} \ No newline at end of file diff --git a/build/test_core.sh b/build/test_core.sh deleted file mode 100755 index 0775ff7246..0000000000 --- a/build/test_core.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -set -e - -dotnet tool install --global coverlet.console - -mkdir TestResults - -source ~/.nvm/nvm.sh && nvm install 10 - -if [[ ! -z "${TGS4_TEST_CONNECTION_STRING}" ]]; then - build/integration_test.sh - exit -fi - -cd tests/Tgstation.Server.Api.Tests - -dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp3.1/Tgstation.Server.Api.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/api.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Api.Tests*]*" - -cd ../Tgstation.Server.Client.Tests - -dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp3.1/Tgstation.Server.Client.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/client.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Client.Tests*]*" - -cd ../Tgstation.Server.Host.Tests - -dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp3.1/Tgstation.Server.Host.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/host.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Database.Migrations.*" - -cd ../Tgstation.Server.Host.Watchdog.Tests - -dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp3.1/Tgstation.Server.Host.Watchdog.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/watchdog.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Watchdog.Tests*]*" - -cd ../Tgstation.Server.Host.Console.Tests - -dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp3.1/Tgstation.Server.Host.Console.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/console.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Console.Tests*]*" - -cd ../../TestResults - -bash <(curl -s https://codecov.io/bash) -f api.xml -F unittests -bash <(curl -s https://codecov.io/bash) -f client.xml -F unittests -bash <(curl -s https://codecov.io/bash) -f host.xml -F unittests -bash <(curl -s https://codecov.io/bash) -f watchdog.xml -F unittests -bash <(curl -s https://codecov.io/bash) -f console.xml -F unittests diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index d0cf1618a6..35d9dbefbd 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -1,6 +1,6 @@ // tgstation-server DMAPI -#define TGS_DMAPI_VERSION "5.2.2" +#define TGS_DMAPI_VERSION "5.2.3" // All functions and datums outside this document are subject to change with any version and should not be relied on. diff --git a/src/DMAPI/tgs/v3210/api.dm b/src/DMAPI/tgs/v3210/api.dm index e0d00ad15d..96499fb0a6 100644 --- a/src/DMAPI/tgs/v3210/api.dm +++ b/src/DMAPI/tgs/v3210/api.dm @@ -180,7 +180,7 @@ ExportService(SERVICE_REQUEST_KILL_PROCESS) /datum/tgs_api/v3210/ChatChannelInfo() - return list() + return list() // :omegalul: /datum/tgs_api/v3210/ChatBroadcast(message, list/channels) if(channels) diff --git a/src/DMAPI/tgs/v4/api.dm b/src/DMAPI/tgs/v4/api.dm index 278dfe762f..081543828e 100644 --- a/src/DMAPI/tgs/v4/api.dm +++ b/src/DMAPI/tgs/v4/api.dm @@ -248,7 +248,7 @@ return instance_name /datum/tgs_api/v4/TestMerges() - return cached_test_merges + return cached_test_merges.Copy() /datum/tgs_api/v4/EndProcess() Export(TGS4_COMM_END_PROCESS) diff --git a/src/DMAPI/tgs/v5/api.dm b/src/DMAPI/tgs/v5/api.dm index 6b75089b0f..3cd47ec1a6 100644 --- a/src/DMAPI/tgs/v5/api.dm +++ b/src/DMAPI/tgs/v5/api.dm @@ -16,7 +16,7 @@ var/list/chat_channels /datum/tgs_api/v5/ApiVersion() - return new /datum/tgs_version("5.2.2") + return new /datum/tgs_version("5.2.3") /datum/tgs_api/v5/OnWorldNew(minimum_required_security_level) server_port = world.params[DMAPI5_PARAM_SERVER_PORT] @@ -275,7 +275,7 @@ /datum/tgs_api/v5/TestMerges() RequireInitialBridgeResponse() - return test_merges + return test_merges.Copy() /datum/tgs_api/v5/EndProcess() Bridge(DMAPI5_BRIDGE_COMMAND_KILL) @@ -320,7 +320,7 @@ /datum/tgs_api/v5/ChatChannelInfo() RequireInitialBridgeResponse() - return chat_channels + return chat_channels.Copy() /datum/tgs_api/v5/proc/DecodeChannels(chat_update_json) var/list/chat_channels_json = chat_update_json[DMAPI5_CHAT_UPDATE_CHANNELS] diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 9308ad9976..cecfa88a5a 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using System; @@ -138,7 +138,7 @@ namespace Tgstation.Server.Api void AddError(HeaderTypes headerType, string message) { if (badHeaders != HeaderTypes.None) - errorBuilder.Append(Environment.NewLine); + errorBuilder.AppendLine(); badHeaders |= headerType; errorBuilder.Append(message); } diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index a3f8e0fdc6..174de92a6f 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace Tgstation.Server.Api.Models @@ -291,6 +291,7 @@ namespace Tgstation.Server.Api.Models /// Attempted to update a or without its ID. /// [Description("Missing user ID!")] + [Obsolete("Deprecated in favor of code 2", true)] UserMissingId, /// @@ -551,5 +552,17 @@ namespace Tgstation.Server.Api.Models /// [Description("Cannot perform this operation as DreamDaemon is not currently running!")] DreamDaemonOffline, + + /// + /// Attempted to perform an instance operation with an offline instance. + /// + [Description("The instance associated with the operation is currently offline!")] + InstanceOffline, + + /// + /// An attempt to connect a chat bot failed. + /// + [Description("Failed to connect chat bot!")] + ChatCannotConnectProvider } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/InstanceUser.cs b/src/Tgstation.Server.Api/Models/InstanceUser.cs index a77d6640b8..569d6516e1 100644 --- a/src/Tgstation.Server.Api/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Api/Models/InstanceUser.cs @@ -11,8 +11,7 @@ namespace Tgstation.Server.Api.Models /// /// The of the the belongs to /// - [Required] - public long? UserId { get; set; } + public long UserId { get; set; } /// /// The of the diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index 6c31609fba..231475f728 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; namespace Tgstation.Server.Api diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs index 798ee6cf31..ec1d99513d 100644 --- a/src/Tgstation.Server.Client/AdministrationClient.cs +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using System.Web; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; @@ -39,7 +40,8 @@ namespace Tgstation.Server.Client /// public Task GetLog(LogFile logFile, CancellationToken cancellationToken) => apiClient.Read( Routes.Logs + Routes.SanitizeGetPath( - logFile?.Name ?? throw new ArgumentNullException(nameof(logFile))), + HttpUtility.UrlEncode( + logFile?.Name ?? throw new ArgumentNullException(nameof(logFile)))), cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs index b71b45359f..1dd1659ed7 100644 --- a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs +++ b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs @@ -38,7 +38,7 @@ namespace Tgstation.Server.Client.Components public Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Delete( Routes.SetID( Routes.InstanceUser, - instanceUser.UserId ?? throw new ArgumentException("Missing instanceUser.UserId!", nameof(instanceUser))), + instanceUser.UserId), instance.Id, cancellationToken); diff --git a/src/Tgstation.Server.Client/IUsersClient.cs b/src/Tgstation.Server.Client/IUsersClient.cs index b0260c3174..8cf253f1bd 100644 --- a/src/Tgstation.Server.Client/IUsersClient.cs +++ b/src/Tgstation.Server.Client/IUsersClient.cs @@ -20,10 +20,10 @@ namespace Tgstation.Server.Client /// /// Get a specific /// - /// The to get + /// The to get. /// The for the operation /// A resulting in the requested - Task GetId(User user, CancellationToken cancellationToken); + Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken); /// /// List all s diff --git a/src/Tgstation.Server.Client/InstanceManagerClient.cs b/src/Tgstation.Server.Client/InstanceManagerClient.cs index 6a00038d42..9bd9b4846f 100644 --- a/src/Tgstation.Server.Client/InstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/InstanceManagerClient.cs @@ -16,11 +16,6 @@ namespace Tgstation.Server.Client /// readonly IApiClient apiClient; - /// - /// Map of already created s - /// - readonly Dictionary cachedClients; - /// /// Construct an /// @@ -28,8 +23,6 @@ namespace Tgstation.Server.Client public InstanceManagerClient(IApiClient apiClient) { this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - - cachedClients = new Dictionary(); } /// @@ -51,15 +44,6 @@ namespace Tgstation.Server.Client public Task GrantPermissions(Instance instance, CancellationToken cancellationToken) => apiClient.Patch(Routes.SetID(Routes.InstanceManager, instance?.Id ?? throw new ArgumentNullException(nameof(instance))), cancellationToken); /// - public IInstanceClient CreateClient(Instance instance) - { - if (!cachedClients.TryGetValue(instance?.Id ?? throw new ArgumentNullException(nameof(instance)), out var client)) - { - client = new InstanceClient(apiClient, instance); - cachedClients.Add(instance.Id, client); - } - - return client; - } + public IInstanceClient CreateClient(Instance instance) => new InstanceClient(apiClient, instance); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/UsersClient.cs b/src/Tgstation.Server.Client/UsersClient.cs index 9d4aa53de6..25ff956856 100644 --- a/src/Tgstation.Server.Client/UsersClient.cs +++ b/src/Tgstation.Server.Client/UsersClient.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Client public Task Create(UserUpdate user, CancellationToken cancellationToken) => apiClient.Create(Routes.User, user ?? throw new ArgumentNullException(nameof(user)), cancellationToken); /// - public Task GetId(User user, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); + public Task GetId(Api.Models.Internal.User user, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken); /// public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.ListRoute(Routes.User), cancellationToken); diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index 3c20b6c98e..ac37e65e5a 100644 --- a/src/Tgstation.Server.Host.Service/Program.cs +++ b/src/Tgstation.Server.Host.Service/Program.cs @@ -150,7 +150,10 @@ namespace Tgstation.Server.Host.Service ServiceBase.Run(service); if (Configure) + { + // DCT: None available await WatchdogFactory.CreateWatchdog(loggerFactory).RunAsync(true, Array.Empty(), default).ConfigureAwait(false); + } } } } diff --git a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs index caec57251d..4f2e4ae68d 100644 --- a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Diagnostics; @@ -231,7 +231,7 @@ namespace Tgstation.Server.Host.Watchdog } catch (Exception e) { - logger.LogWarning("Unable to delete exception dump file at {0}! Exception: {1}", updateDirectory, e); + logger.LogWarning(e, "Unable to delete exception dump file at {0}!", updateDirectory); } #pragma warning disable CA2201 // Do not raise reserved exception types @@ -277,12 +277,12 @@ namespace Tgstation.Server.Host.Watchdog } catch (Exception e) { - logger.LogWarning("Error deleting old server at {0}! Exception: {1}", tempPath, e); + logger.LogWarning(e, "Error deleting old server at {0}!", tempPath); } } catch (Exception e) { - logger.LogError("Error moving updated server directory, attempting revert! Exception: {0}", e); + logger.LogError(e, "Error moving updated server directory, attempting revert!"); Directory.Delete(defaultAssemblyPath, true); Directory.Move(tempPath, defaultAssemblyPath); logger.LogInformation("Revert successful!"); @@ -290,22 +290,22 @@ namespace Tgstation.Server.Host.Watchdog } catch (Exception e) { - logger.LogWarning("Failed to move out active host assembly! Exception: {0}", e); + logger.LogWarning(e, "Failed to move out active host assembly!"); } } } } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogDebug("Exiting due to cancellation..."); + logger.LogDebug(ex, "Exiting due to cancellation..."); if (!Directory.Exists(updateDirectory)) File.Delete(updateDirectory); else Directory.Delete(updateDirectory, true); } - catch (Exception e) + catch (Exception ex) { - logger.LogCritical("Watchdog error! Exception: {0}", e); + logger.LogCritical(ex, "Host watchdog error!"); } finally { diff --git a/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs b/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs index fe9bd1c716..2993e1df45 100644 --- a/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs +++ b/src/Tgstation.Server.Host/Components/Byond/ByondInstallerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Globalization; using System.Threading; @@ -69,7 +69,7 @@ namespace Tgstation.Server.Host.Components.Byond } catch (Exception e) { - Logger.LogWarning("Error deleting BYOND cache! Exception: {0}", e); + Logger.LogWarning(e, "Error deleting BYOND cache!"); } } diff --git a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs index 5e1e0312f5..72ea830c82 100644 --- a/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs +++ b/src/Tgstation.Server.Host/Components/Byond/ByondManager.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; @@ -257,7 +257,7 @@ namespace Tgstation.Server.Host.Components.Byond var versionKey = VersionKey(versionToUse, true); var binPathForVersion = ioManager.ConcatPath(versionKey, BinPath); - logger.LogTrace("Creating ByondExecutableLock lock for version {0}", requiredVersion); + logger.LogTrace("Creating ByondExecutableLock lock for version {0}", versionToUse); return new ByondExecutableLock( ioManager, semaphore, diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs index dafe18f360..cec60f89b9 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Serilog.Context; using System; @@ -117,11 +117,6 @@ namespace Tgstation.Server.Host.Components.Chat /// long messagesProcessed; - /// - /// If has been called - /// - bool started; - /// /// Construct a /// @@ -159,13 +154,13 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public void Dispose() + public async ValueTask DisposeAsync() { logger.LogTrace("Disposing..."); restartRegistration.Dispose(); handlerCts.Dispose(); foreach (var I in providers) - I.Value.Dispose(); + await I.Value.DisposeAsync().ConfigureAwait(false); } /// @@ -206,6 +201,21 @@ namespace Tgstation.Server.Host.Components.Chat return provider; } + async Task RemapProvider(IProvider provider, CancellationToken cancellationToken) + { + logger.LogTrace("Remapping channels for provider reconnection..."); + IEnumerable channelsToMap; + long providerId; + lock (providers) + providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First(); + + lock (activeChatBots) + channelsToMap = activeChatBots.FirstOrDefault(x => x.Id == providerId)?.Channels; + + if (channelsToMap?.Any() ?? false) + await ChangeChannels(providerId, channelsToMap, cancellationToken).ConfigureAwait(false); + } + /// /// Processes a /// @@ -213,26 +223,20 @@ namespace Tgstation.Server.Host.Components.Chat /// The to process. If , this indicates the provider reconnected. /// The for the operation /// A representing the running operation - #pragma warning disable CA1502 +#pragma warning disable CA1502 async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken) - #pragma warning restore CA1502 +#pragma warning restore CA1502 { + if (!provider.Connected) + { + logger.LogTrace("Abort message processing because provider is disconnected!"); + return; + } + // provider reconnected, remap channels. if (message == null) { - logger.LogTrace("Remapping channels for provider reconnection..."); - IEnumerable channelsToMap; - lock (activeChatBots) - channelsToMap = activeChatBots.FirstOrDefault()?.Channels; - - if (channelsToMap?.Any() ?? false) - { - long providerId; - lock (providers) - providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First(); - await ChangeChannels(providerId, channelsToMap, cancellationToken).ConfigureAwait(false); - } - + await RemapProvider(provider, cancellationToken).ConfigureAwait(false); return; } @@ -243,9 +247,6 @@ namespace Tgstation.Server.Host.Components.Chat var enumerable = mappedChannels.Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == message.User.Channel.RealId); if (message.User.Channel.IsPrivateChannel) lock (mappedChannels) - { - if (!provider.Connected) - return; if (!enumerable.Any()) { ulong newId; @@ -267,7 +268,6 @@ namespace Tgstation.Server.Host.Components.Chat } else message.User.Channel.RealId = enumerable.First().Key; - } else { // need to add tag and isAdminChannel @@ -285,7 +285,9 @@ namespace Tgstation.Server.Host.Components.Chat address = address.ToUpperInvariant(); - var addressed = address == CommonMention.ToUpperInvariant() || address == provider.BotMention.ToUpperInvariant(); + var addressed = + address == CommonMention.ToUpperInvariant() + || address == provider.BotMention.ToUpperInvariant(); // no mention if (!addressed && !message.User.Channel.IsPrivateChannel) @@ -371,15 +373,15 @@ namespace Tgstation.Server.Host.Components.Chat if (result != null) await SendMessage(result, new List { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogTrace("Command processing canceled!"); + logger.LogTrace(ex, "Command processing canceled!"); throw; } catch (Exception e) { // error bc custom commands should reply about why it failed - logger.LogError("Error processing chat command: {0}", e); + logger.LogError(e, "Error processing chat command"); await SendMessage( "TGS: Internal error processing command! Check server logs!", new List { message.User.Channel.RealId }, @@ -406,7 +408,7 @@ namespace Tgstation.Server.Host.Components.Chat while (!cancellationToken.IsCancellationRequested) { // prune disconnected providers - foreach (var I in messageTasks.Where(x => !x.Key.Connected).ToList()) + foreach (var I in messageTasks.Where(x => !x.Key.Disposed).ToList()) messageTasks.Remove(I.Key); // add new ones @@ -415,7 +417,7 @@ namespace Tgstation.Server.Host.Components.Chat updatedTask = connectionsUpdated.Task; lock (providers) foreach (var I in providers) - if (I.Value.Connected && !messageTasks.ContainsKey(I.Value)) + if (!messageTasks.ContainsKey(I.Value)) messageTasks.Add(I.Value, I.Value.NextMessage(cancellationToken)); if (messageTasks.Count == 0) @@ -438,13 +440,13 @@ namespace Tgstation.Server.Host.Components.Chat } } } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogTrace("Message processing loop cancelled!"); + logger.LogTrace(ex, "Message processing loop cancelled!"); } catch (Exception e) { - logger.LogError("Message loop crashed! Exception: {0}", e); + logger.LogError(e, "Message loop crashed!"); } logger.LogTrace("Leaving message processing loop"); @@ -521,7 +523,7 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public async Task ChangeSettings(ChatBot newSettings, CancellationToken cancellationToken) + public async Task ChangeSettings(Models.ChatBot newSettings, CancellationToken cancellationToken) { if (newSettings == null) throw new ArgumentNullException(nameof(newSettings)); @@ -537,7 +539,7 @@ namespace Tgstation.Server.Host.Components.Chat } finally { - p.Dispose(); + await p.DisposeAsync().ConfigureAwait(false); } } @@ -565,30 +567,23 @@ namespace Tgstation.Server.Host.Components.Chat await disconnectTask.ConfigureAwait(false); - if (started) + lock (synchronizationLock) { - if (newSettings.Enabled.Value) - await provider.Connect(cancellationToken).ConfigureAwait(false); - lock (synchronizationLock) - { - // same thread shennanigans - var oldOne = connectionsUpdated; - connectionsUpdated = new TaskCompletionSource(); - oldOne.SetResult(null); - } + // same thread shennanigans + var oldOne = connectionsUpdated; + connectionsUpdated = new TaskCompletionSource(); + oldOne.SetResult(null); } - Task reconnectionUpdateTask = Task.CompletedTask; + var reconnectionUpdateTask = provider?.SetReconnectInterval( + newSettings.ReconnectionInterval.Value, + newSettings.Enabled.Value) + ?? Task.CompletedTask; lock (activeChatBots) { var originalChatBot = activeChatBots.FirstOrDefault(bot => bot.Id == newSettings.Id); if (originalChatBot != null) - { - if (originalChatBot.ReconnectionInterval != newSettings.ReconnectionInterval) - reconnectionUpdateTask = provider.SetReconnectInterval(newSettings.ReconnectionInterval.Value); - activeChatBots.Remove(originalChatBot); - } activeChatBots.Add(new Models.ChatBot { @@ -699,9 +694,9 @@ namespace Tgstation.Server.Host.Components.Chat catch (Exception ex) { logger.LogWarning( - "Error sending deploy message to provider {0}! Exception: {1}", - channelMapping.ProviderId, - ex); + ex, + "Error sending deploy message to provider {0}!", + channelMapping.ProviderId); } })) .ConfigureAwait(false); @@ -716,10 +711,8 @@ namespace Tgstation.Server.Host.Components.Chat builtinCommands.Add(I.Name.ToUpperInvariant(), I); var initialChatBots = activeChatBots.ToList(); await Task.WhenAll(initialChatBots.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(false); - await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false); await Task.WhenAll(initialChatBots.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(false); chatHandler = MonitorMessages(handlerCts.Token); - started = true; } /// @@ -774,7 +767,7 @@ namespace Tgstation.Server.Host.Components.Chat } finally { - provider.Dispose(); + await provider.DisposeAsync().ConfigureAwait(false); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatManagerFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ChatManagerFactory.cs index 0dc785c687..0ca59ef63c 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatManagerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatManagerFactory.cs @@ -1,6 +1,7 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.Linq; using Tgstation.Server.Host.Components.Chat.Commands; using Tgstation.Server.Host.Components.Chat.Providers; using Tgstation.Server.Host.Core; @@ -38,7 +39,11 @@ namespace Tgstation.Server.Host.Components.Chat /// The value of /// The value of /// The value of - public ChatManagerFactory(IProviderFactory providerFactory, IServerControl serverControl, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory) + public ChatManagerFactory( + IProviderFactory providerFactory, + IServerControl serverControl, + IAsyncDelayer asyncDelayer, + ILoggerFactory loggerFactory) { this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory)); this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); @@ -47,6 +52,18 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public IChatManager CreateChatManager(IIOManager ioManager, ICommandFactory commandFactory, IEnumerable initialChatBots) => new ChatManager(providerFactory, ioManager, commandFactory, serverControl, asyncDelayer, loggerFactory, loggerFactory.CreateLogger(), initialChatBots); + public IChatManager CreateChatManager( + IIOManager ioManager, + ICommandFactory commandFactory, + IEnumerable initialChatBots) + => new ChatManager( + providerFactory, + ioManager, + commandFactory, + serverControl, + asyncDelayer, + loggerFactory, + loggerFactory.CreateLogger(), + initialChatBots.Where(x => x.Enabled.Value)); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs index 062e8fe1bd..d9a37cbe18 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChatManager.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using System; using System.Collections.Generic; using System.Threading; @@ -10,7 +10,7 @@ namespace Tgstation.Server.Host.Components.Chat /// /// For managing connected chat services /// - public interface IChatManager : IHostedService, IDisposable + public interface IChatManager : IHostedService, IAsyncDisposable { /// /// Registers a to use @@ -21,10 +21,10 @@ namespace Tgstation.Server.Host.Components.Chat /// /// Change chat settings. If the is not currently in use, a new connection will be made instead /// - /// The new + /// The new /// The for the operation /// A representing the running operation. Will complete immediately if the property of is - Task ChangeSettings(ChatBot newSettings, CancellationToken cancellationToken); + Task ChangeSettings(Models.ChatBot newSettings, CancellationToken cancellationToken); /// /// Disconnects and deletes a given connection @@ -87,4 +87,4 @@ namespace Tgstation.Server.Host.Components.Chat /// A new . IChatTrackingContext CreateTrackingContext(); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index 246e2ec74e..b115edf45f 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using Discord.WebSocket; using Microsoft.Extensions.Logging; using System; @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; @@ -30,6 +32,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } } + /// + /// Gets the Discord bot token. + /// + string BotToken => ChatBot.ConnectionString; + /// /// The for the . /// @@ -40,11 +47,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly DiscordSocketClient client; - /// - /// The token used for connecting to discord - /// - readonly string botToken; - /// /// of mapped s /// @@ -60,30 +62,28 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Construct a /// + /// The for the . /// The value of . - /// The value of - /// The value of - /// The initial reconnect interval in minutes. + /// The for the . + /// The for the . public DiscordProvider( + IJobManager jobManager, IAssemblyInformationProvider assemblyInformationProvider, ILogger logger, - string botToken, - uint reconnectInterval) - : base(logger, reconnectInterval) + Models.ChatBot chatBot) + : base(jobManager, logger, chatBot) { this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); - this.botToken = botToken ?? throw new ArgumentNullException(nameof(botToken)); client = new DiscordSocketClient(); client.MessageReceived += Client_MessageReceived; mappedChannels = new List(); } /// - public override void Dispose() + public override async ValueTask DisposeAsync() { + await base.DisposeAsync().ConfigureAwait(false); client.Dispose(); - - base.Dispose(); } /// @@ -104,6 +104,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers if (mentionedUs) { Logger.LogTrace("Ignoring mention from {0} ({1}) by {2} ({3}). Channel not mapped!", e.Channel.Id, e.Channel.Name, e.Author.Id, e.Author.Username); + + // DCT: None available await SendMessage(e.Channel.Id, "I do not respond to this channel!", default).ConfigureAwait(false); } @@ -129,22 +131,16 @@ namespace Tgstation.Server.Host.Components.Chat.Providers Mention = NormalizeMentions(e.Author.Mention) } }; + EnqueueMessage(result); } /// - public override async Task Connect(CancellationToken cancellationToken) + protected override async Task Connect(CancellationToken cancellationToken) { - Logger.LogTrace("Connecting..."); - if (Connected) - { - Logger.LogTrace("Already connected not doing connection attempt!"); - return true; - } - try { - await client.LoginAsync(TokenType.Bot, botToken, true).ConfigureAwait(false); + await client.LoginAsync(TokenType.Bot, BotToken, true).ConfigureAwait(false); Logger.LogTrace("Logged in."); cancellationToken.ThrowIfCancellationRequested(); @@ -154,14 +150,22 @@ namespace Tgstation.Server.Host.Components.Chat.Providers Logger.LogTrace("Started."); var channelsAvailable = new TaskCompletionSource(); - client.Ready += () => + Task ReadyCallback() { channelsAvailable.TrySetResult(null); return Task.CompletedTask; - }; - using (cancellationToken.Register(() => channelsAvailable.SetCanceled())) - await channelsAvailable.Task.ConfigureAwait(false); - Logger.LogDebug("Connection established!"); + } + + client.Ready += ReadyCallback; + try + { + using (cancellationToken.Register(() => channelsAvailable.SetCanceled())) + await channelsAvailable.Task.ConfigureAwait(false); + } + finally + { + client.Ready -= ReadyCallback; + } } catch (OperationCanceledException) { @@ -169,28 +173,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception e) { - Logger.LogWarning("Error connecting to Discord: {0}", e); - return false; + throw new JobException(ErrorCode.ChatCannotConnectProvider, e); } - - return true; } /// protected override async Task DisconnectImpl(CancellationToken cancellationToken) { - Logger.LogTrace("Disconnecting..."); - if (!Connected) - { - Logger.LogTrace("Already disconnected not doing disconnection attempt!"); - return; - } - try { + cancellationToken.ThrowIfCancellationRequested(); await client.StopAsync().ConfigureAwait(false); Logger.LogTrace("Stopped."); - cancellationToken.ThrowIfCancellationRequested(); await client.LogoutAsync().ConfigureAwait(false); Logger.LogDebug("Disconnected!"); } @@ -200,7 +194,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception e) { - Logger.LogWarning("Error disconnecting from discord: {0}", e); + Logger.LogWarning(e, "Error disconnecting from discord!"); } } @@ -258,25 +252,29 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { try { - var channel = client.GetChannel(channelId) as IMessageChannel; - await (channel?.SendMessageAsync(message, false, null, new RequestOptions - { - CancelToken = cancellationToken - }) ?? Task.CompletedTask).ConfigureAwait(false); + if (!(client.GetChannel(channelId) is IMessageChannel channel)) + return; + + await channel.SendMessageAsync( + message, + false, + null, + new RequestOptions + { + CancelToken = cancellationToken, + Timeout = 10000 // prevent stupid long hold ups from this + }) + .ConfigureAwait(false); } - catch (OperationCanceledException) + catch (Exception e) when (!(e is OperationCanceledException)) { - throw; - } - catch (Exception e) - { - Logger.LogWarning("Error sending discord message: {0}", e); + Logger.LogWarning(e, "Error sending discord message!"); } } /// public override async Task> SendUpdateMessage( - RevisionInformation revisionInformation, + Models.RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset? estimatedCompletionTime, string gitHubOwner, @@ -396,7 +394,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception ex) { - Logger.LogWarning("Updating deploy embed {0} failed, attempting new post! Exception: {1}", message.Id, ex); + Logger.LogWarning(ex, "Updating deploy embed {0} failed, attempting new post!", message.Id); try { await channel.SendMessageAsync( @@ -407,7 +405,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception ex2) { - Logger.LogWarning("Posting completion deploy embed failed! Exception: {0}", ex2); + Logger.LogWarning(ex2, "Posting completion deploy embed failed!"); } } }; diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs index cca006f663..95264c0f34 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -9,13 +9,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// For interacting with a chat service /// - interface IProvider : IDisposable + interface IProvider : IAsyncDisposable { /// - /// If the + /// If the is currently connected. /// bool Connected { get; } + /// + /// If the was disposed. + /// + bool Disposed { get; } + /// /// The that indicates the was mentioned /// @@ -29,13 +34,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// Note that private messages will come in the form of s not returned in . Do not the on continuations run from the returned . Task NextMessage(CancellationToken cancellationToken); - /// - /// Attempt to connect the - /// - /// The for the operation - /// A resulting in on success, otherwise - Task Connect(CancellationToken cancellationToken); - /// /// Gracefully disconnects the provider. Permanently stops the reconnection timer. /// @@ -61,11 +59,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken); /// - /// Set the interval at which the provider tries to reconnect. + /// Set the interval at which the provider starts jobs to try to reconnect. /// /// The reconnection interval in minutes. + /// If a connection attempt should be made now. /// A representing the running operation. - Task SetReconnectInterval(uint reconnectInterval); + Task SetReconnectInterval(uint reconnectInterval, bool connectNow); /// /// Send the message for a deployment. diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IProviderFactory.cs index 91a83eeb96..9350efd7c6 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IProviderFactory.cs @@ -1,4 +1,4 @@ -using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Components.Chat.Providers { diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index b29eaf15af..aa63d7b6fa 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -1,4 +1,4 @@ -using Meebey.SmartIrc4net; +using Meebey.SmartIrc4net; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components.Chat.Providers @@ -19,6 +20,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// sealed class IrcProvider : Provider { + /// + /// Number of seconds used for several IRC related timeouts. + /// const int TimeoutSeconds = 5; /// @@ -90,45 +94,33 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Construct an /// + /// The for the provider. /// The to get the from /// The value of - /// The value of logger - /// The value of - /// The value of - /// The value of - /// The value of - /// The value of - /// The initial reconnect interval in minutes. - /// If should be used + /// The for the . + /// The for the . public IrcProvider( + IJobManager jobManager, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILogger logger, - string address, - ushort port, - string nickname, - string password, - IrcPasswordType? passwordType, - uint reconnectInterval, - bool useSsl) - : base(logger, reconnectInterval) + Models.ChatBot chatBot) + : base(jobManager, logger, chatBot) { if (assemblyInformationProvider == null) throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); - this.address = address ?? throw new ArgumentNullException(nameof(address)); - this.port = port; - this.nickname = nickname ?? throw new ArgumentNullException(nameof(nickname)); + var builder = chatBot.CreateConnectionStringBuilder(); + if (builder == null || !builder.Valid || !(builder is IrcConnectionStringBuilder ircBuilder)) + throw new InvalidOperationException("Invalid ChatConnectionStringBuilder!"); - if (passwordType.HasValue && password == null) - throw new ArgumentNullException(nameof(password)); + address = ircBuilder.Address; + port = ircBuilder.Port.Value; + nickname = ircBuilder.Nickname; - if (password != null && !passwordType.HasValue) - throw new ArgumentNullException(nameof(passwordType)); - - this.password = password; - this.passwordType = passwordType; + password = ircBuilder.Password; + passwordType = ircBuilder.PasswordType.Value; client = new IrcFeatures { @@ -143,9 +135,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers ActiveChannelSyncing = true, AutoNickHandling = true, CtcpVersion = assemblyInformationProvider.VersionString, - UseSsl = useSsl + UseSsl = ircBuilder.UseSsl.Value }; - if (useSsl) + if (ircBuilder.UseSsl.Value) client.ValidateServerCertificate = true; // dunno if it defaults to that or what client.OnChannelMessage += Client_OnChannelMessage; @@ -154,19 +146,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers channelIdMap = new Dictionary(); queryChannelIdMap = new Dictionary(); channelIdCounter = 1; - disconnecting = false; } /// - public override void Dispose() + public override async ValueTask DisposeAsync() { - if (Connected) - { - disconnecting = true; - client.Disconnect(); // just closes the socket - } + await base.DisposeAsync().ConfigureAwait(false); - base.Dispose(); + // DCT: None available + await HardDisconnect(default).ConfigureAwait(false); } /// @@ -247,124 +235,80 @@ namespace Tgstation.Server.Host.Components.Chat.Providers void Client_OnChannelMessage(object sender, IrcEventArgs e) => HandleMessage(e, false); /// - public override Task Connect(CancellationToken cancellationToken) => Task.Factory.StartNew(() => + protected override async Task Connect(CancellationToken cancellationToken) { disconnecting = false; - lock (client) - try + try + { + client.Connect(address, port); + + cancellationToken.ThrowIfCancellationRequested(); + + Logger.LogTrace("Authenticating ({0})...", passwordType); + switch (passwordType) { - client.Connect(address, port); - - cancellationToken.ThrowIfCancellationRequested(); - - if (passwordType == IrcPasswordType.Server) + case IrcPasswordType.Server: client.Login(nickname, nickname, 0, nickname, password); - else - { - if (passwordType == IrcPasswordType.Sasl) - { - client.WriteLine("CAP REQ :sasl", Priority.Critical); // needs to be put in the buffer before anything else - cancellationToken.ThrowIfCancellationRequested(); - } - + break; + case IrcPasswordType.NickServ: client.Login(nickname, nickname, 0, nickname); - } - - if (passwordType == IrcPasswordType.NickServ) - { cancellationToken.ThrowIfCancellationRequested(); client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password)); - } - else if (passwordType == IrcPasswordType.Sasl) - { - // wait for the sasl ack or timeout - var recievedAck = false; - var recievedPlus = false; - client.OnReadLine += (sender, e) => - { - if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal)) - recievedAck = true; - else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal)) - recievedPlus = true; - }; - - var startTime = DateTimeOffset.Now; - var endTime = DateTimeOffset.Now.AddSeconds(TimeoutSeconds); - cancellationToken.ThrowIfCancellationRequested(); - - var listenTimeSpan = TimeSpan.FromMilliseconds(10); - for (; !recievedAck && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult()) - client.Listen(false); - - client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical); - cancellationToken.ThrowIfCancellationRequested(); - - for (; !recievedPlus && DateTimeOffset.Now <= endTime; asyncDelayer.Delay(listenTimeSpan, cancellationToken).GetAwaiter().GetResult()) - client.Listen(false); - - // Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196 - var authString = String.Format(CultureInfo.InvariantCulture, "{0}{1}{0}{1}{2}", nickname, '\0', password); - var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString)); - var authLine = String.Format(CultureInfo.InvariantCulture, "AUTHENTICATE {0}", b64); - var chars = authLine.ToCharArray(); - client.WriteLine(authLine, Priority.Critical); - - cancellationToken.ThrowIfCancellationRequested(); - client.WriteLine("CAP END", Priority.Critical); - } - - client.Listen(false); - - listenTask = Task.Factory.StartNew(() => + break; + case IrcPasswordType.Sasl: + await SaslAuthenticate(cancellationToken).ConfigureAwait(false); + break; + case null: + break; + default: + throw new InvalidOperationException($"Invalid IrcPasswordType: {passwordType.Value}"); + } + + cancellationToken.ThrowIfCancellationRequested(); + Logger.LogTrace("Processing initial messages..."); + await NonBlockingListen(cancellationToken).ConfigureAwait(false); + + var nickCheckCompleteTcs = new TaskCompletionSource(); + using (cancellationToken.Register(() => nickCheckCompleteTcs.TrySetCanceled())) + { + listenTask = Task.Factory.StartNew( + async () => { + Logger.LogTrace("Entering nick check loop"); while (!disconnecting && client.IsConnected && client.Nickname != nickname) { client.ListenOnce(true); if (disconnecting || !client.IsConnected) break; - client.Listen(false); + await NonBlockingListen(cancellationToken).ConfigureAwait(false); // ensure we have the correct nick if (client.GetIrcUser(nickname) == null) client.RfcNick(nickname); } - client.Listen(); - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - Logger.LogWarning("Unable to connect to IRC: {0}", e); - return false; + nickCheckCompleteTcs.TrySetResult(null); + + Logger.LogTrace("Starting blocking listen..."); + try + { + client.Listen(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "IRC Main Listen Exception!"); + } + + Logger.LogTrace("Exiting listening task..."); + }, + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Current); + + await nickCheckCompleteTcs.Task.ConfigureAwait(false); } - return true; - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); - - /// - protected override async Task DisconnectImpl(CancellationToken cancellationToken) - { - if (!Connected) - return; - try - { - await Task.Factory.StartNew(() => - { - try - { - client.RfcQuit("Mr. Stark, I don't feel so good...", Priority.Critical); // priocritical otherwise it wont go through - } - catch (Exception e) - { - Logger.LogWarning("Error quitting IRC: {0}", e); - } - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); - Dispose(); - await listenTask.ConfigureAwait(false); + Logger.LogTrace("Connection established!"); } catch (OperationCanceledException) { @@ -372,10 +316,173 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception e) { - Logger.LogWarning("Error disconnecting from IRC! Exception: {0}", e); + throw new JobException(ErrorCode.ChatCannotConnectProvider, e); } } + /// + /// Perform a non-blocking . + /// + /// The for the operation. + /// A representing the running operation. + Task NonBlockingListen(CancellationToken cancellationToken) => Task.Factory.StartNew( + () => + { + try + { + client.Listen(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "IRC Non-Blocking Listen Exception!"); + } + }, + cancellationToken, + TaskCreationOptions.None, + TaskScheduler.Current) + .WithToken(cancellationToken); + + /// + /// Run SASL authentication on . + /// + /// The for the operation. + /// A representing the running operation. + async Task SaslAuthenticate(CancellationToken cancellationToken) + { + client.WriteLine("CAP REQ :sasl", Priority.Critical); // needs to be put in the buffer before anything else + cancellationToken.ThrowIfCancellationRequested(); + + Logger.LogTrace("Logging in..."); + client.Login(nickname, nickname, 0, nickname); + cancellationToken.ThrowIfCancellationRequested(); + + // wait for the sasl ack or timeout + var recievedAck = false; + var recievedPlus = false; + + void AuthenticationDelegate(object sender, ReadLineEventArgs e) + { + if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal)) + recievedAck = true; + else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal)) + recievedPlus = true; + } + + Logger.LogTrace("Performing handshake..."); + client.OnReadLine += AuthenticationDelegate; + try + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds)); + var timeoutToken = timeoutCts.Token; + + var listenTimeSpan = TimeSpan.FromMilliseconds(10); + for (; !recievedAck; + await asyncDelayer.Delay(listenTimeSpan, timeoutToken).ConfigureAwait(false)) + await NonBlockingListen(cancellationToken).ConfigureAwait(false); + + client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical); + timeoutToken.ThrowIfCancellationRequested(); + + for (; !recievedPlus; + await asyncDelayer.Delay(listenTimeSpan, timeoutToken).ConfigureAwait(false)) + await NonBlockingListen(cancellationToken).ConfigureAwait(false); + } + finally + { + client.OnReadLine -= AuthenticationDelegate; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196 + Logger.LogTrace("Sending credentials..."); + var authString = String.Format( + CultureInfo.InvariantCulture, + "{0}{1}{0}{1}{2}", + nickname, + '\0', + password); + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString)); + var authLine = $"AUTHENTICATE {b64}"; + client.WriteLine(authLine, Priority.Critical); + cancellationToken.ThrowIfCancellationRequested(); + + Logger.LogTrace("Finishing authentication..."); + client.WriteLine("CAP END", Priority.Critical); + } + + /// + protected override async Task DisconnectImpl(CancellationToken cancellationToken) + { + try + { + await Task.Factory.StartNew( + () => + { + try + { + client.RfcQuit("Mr. Stark, I don't feel so good...", Priority.Critical); // priocritical otherwise it wont go through + } + catch (Exception e) + { + Logger.LogWarning(e, "Error quitting IRC!"); + } + }, + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Current) + .ConfigureAwait(false); + await HardDisconnect(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.LogWarning(e, "Error disconnecting from IRC!"); + } + } + + async Task HardDisconnect(CancellationToken cancellationToken) + { + if (!Connected) + { + Logger.LogTrace("Not hard disconnecting, already offline"); + return; + } + + Logger.LogTrace("Hard disconnect"); + + disconnecting = true; + + // This call blocks permanently randomly sometimes + // Frankly I don't give a shit + var disconnectTask = Task.Factory.StartNew( + () => + { + try + { + client.Disconnect(); + } + catch (Exception e) + { + Logger.LogWarning(e, "Error disconnecting IRC!"); + } + }, + cancellationToken, + TaskCreationOptions.None, + TaskScheduler.Current); + + await Task.WhenAny( + Task.WhenAll( + disconnectTask, + listenTask ?? Task.CompletedTask), + asyncDelayer.Delay(TimeSpan.FromSeconds(TimeoutSeconds), cancellationToken)) + .ConfigureAwait(false); + } + /// public override Task> MapChannels( IEnumerable channels, @@ -464,7 +571,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } catch (Exception e) { - Logger.LogWarning("Unable to send to channel: {0}", e); + Logger.LogWarning(e, "Unable to send to channel {0}!", channelName); } }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs index 070db5a8ef..744e68867d 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs @@ -1,8 +1,11 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Extensions; +using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Components.Chat.Providers @@ -10,10 +13,20 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// abstract class Provider : IProvider { + /// + /// The the is for. + /// + protected ChatBot ChatBot { get; } + /// /// The for the . /// - protected ILogger Logger { get; } + protected ILogger Logger { get; } + + /// + /// The for the . + /// + readonly IJobManager jobManager; /// /// of received s @@ -43,18 +56,20 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Construct a /// + /// The value of . /// The value of . - /// The initial reconnection interval. - protected Provider(ILogger logger, uint reconnectInterval) + /// The value of . + protected Provider(IJobManager jobManager, ILogger logger, ChatBot chatBot) { + this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + ChatBot = chatBot ?? throw new ArgumentNullException(nameof(chatBot)); messageQueue = new Queue(); nextMessage = new TaskCompletionSource(); reconnectTaskLock = new object(); - SetReconnectInterval(reconnectInterval).GetAwaiter().GetResult(); logger.LogTrace("Created."); } @@ -64,6 +79,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// public abstract string BotMention { get; } + /// + public bool Disposed { get; private set; } + /// /// Queues a for /// @@ -78,14 +96,19 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } /// - public virtual void Dispose() + public virtual async ValueTask DisposeAsync() { - StopReconnectionTimer().GetAwaiter().GetResult(); + Disposed = true; + await StopReconnectionTimer().ConfigureAwait(false); Logger.LogTrace("Disposed"); } - /// - public abstract Task Connect(CancellationToken cancellationToken); + /// + /// Attempt to connect the . + /// + /// The for the operation. + /// A representing the running operation. + protected abstract Task Connect(CancellationToken cancellationToken); /// /// Gracefully disconnects the provider. @@ -97,8 +120,13 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// public async Task Disconnect(CancellationToken cancellationToken) { + if (Connected) + { + await DisconnectImpl(cancellationToken).ConfigureAwait(false); + Logger.LogTrace("Disconnected"); + } + await StopReconnectionTimer().ConfigureAwait(false); - await DisconnectImpl(cancellationToken).ConfigureAwait(false); } /// @@ -107,16 +135,17 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// public async Task NextMessage(CancellationToken cancellationToken) { - var cancelTcs = new TaskCompletionSource(); - using (cancellationToken.Register(() => cancelTcs.SetCanceled())) - await Task.WhenAny(nextMessage.Task, cancelTcs.Task).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - lock (messageQueue) + while (true) { - var result = messageQueue.Dequeue(); - if (messageQueue.Count == 0) - nextMessage = new TaskCompletionSource(); - return result; + await nextMessage.Task.WithToken(cancellationToken).ConfigureAwait(false); + lock (messageQueue) + if (messageQueue.Count > 0) + { + var result = messageQueue.Dequeue(); + if (messageQueue.Count == 0) + nextMessage = new TaskCompletionSource(); + return result; + } } } @@ -141,7 +170,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } /// - public Task SetReconnectInterval(uint reconnectInterval) + public Task SetReconnectInterval(uint reconnectInterval, bool connectNow) { if (reconnectInterval == 0) throw new ArgumentOutOfRangeException(nameof(reconnectInterval), reconnectInterval, "Reconnect interval cannot be zero!"); @@ -151,7 +180,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { stopOldTimerTask = StopReconnectionTimer(); reconnectCts = new CancellationTokenSource(); - reconnectTask = ReconnectionLoop(reconnectInterval, reconnectCts.Token); + reconnectTask = ReconnectionLoop(reconnectInterval, connectNow, reconnectCts.Token); } return stopOldTimerTask; @@ -161,21 +190,51 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// Creates a that will attempt to reconnect the every minutes. /// /// The amount of minutes to wait between reconnection attempts. + /// If a connection attempt should be immediately made. /// The for the operation. /// A representing the running operation. - async Task ReconnectionLoop(uint reconnectInterval, CancellationToken cancellationToken) + async Task ReconnectionLoop(uint reconnectInterval, bool connectNow, CancellationToken cancellationToken) { do { try { - await Task.Delay(TimeSpan.FromMinutes(reconnectInterval), cancellationToken).ConfigureAwait(false); + if (!connectNow) + await Task.Delay(TimeSpan.FromMinutes(reconnectInterval), cancellationToken).ConfigureAwait(false); + else + connectNow = false; if (!Connected) { - Logger.LogInformation("Attempting to reconnect provider..."); - await Disconnect(cancellationToken).ConfigureAwait(false); - if (await Connect(cancellationToken).ConfigureAwait(false)) - EnqueueMessage(null); + var job = new Job + { + Description = $"Reconnect chat bot: {ChatBot.Name}", + CancelRight = (ulong)ChatBotRights.WriteEnabled, + CancelRightsType = RightsType.ChatBots, + Instance = ChatBot.Instance + }; + + await jobManager.RegisterOperation( + job, + async (core, databaseContextFactory, paramJob, progressReporter, jobCancellationToken) => + { + if (Connected) + { + Logger.LogTrace("Disconnecting..."); + await DisconnectImpl(jobCancellationToken).ConfigureAwait(false); + } + else + Logger.LogTrace("Already disconnected not doing disconnection attempt!"); + + Logger.LogTrace("Connecting..."); + await Connect(jobCancellationToken).ConfigureAwait(false); + Logger.LogTrace("Connected successfully"); + EnqueueMessage(null); + }, + cancellationToken) + .ConfigureAwait(false); + + // DCT: Always wait for the job to complete here + await jobManager.WaitForJobCompletion(job, null, cancellationToken, default).ConfigureAwait(false); } } catch (OperationCanceledException) diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs index 3a23549cba..bdf1bbc0d0 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/ProviderFactory.cs @@ -1,8 +1,9 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Globalization; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components.Chat.Providers @@ -20,6 +21,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly IAsyncDelayer asyncDelayer; + /// + /// The for the . + /// + readonly IJobManager jobManager; + /// /// The for the /// @@ -28,42 +34,42 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Construct a /// + /// The value of . /// The value of /// The value of /// The value of public ProviderFactory( + IJobManager jobManager, IAssemblyInformationProvider assemblyInformationProvider, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory) { + this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); } /// - public IProvider CreateProvider(Api.Models.Internal.ChatBot settings) + public IProvider CreateProvider(Models.ChatBot settings) { if (settings == null) throw new ArgumentNullException(nameof(settings)); - var builder = settings.CreateConnectionStringBuilder(); - if (builder == null || !builder.Valid) - throw new InvalidOperationException("Invalid ChatConnectionStringBuilder!"); - switch (settings.Provider) + return settings.Provider switch { - case ChatProvider.Irc: - var ircBuilder = (IrcConnectionStringBuilder)builder; - return new IrcProvider(assemblyInformationProvider, asyncDelayer, loggerFactory.CreateLogger(), ircBuilder.Address, ircBuilder.Port.Value, ircBuilder.Nickname, ircBuilder.Password, ircBuilder.PasswordType, settings.ReconnectionInterval.Value, ircBuilder.UseSsl.Value); - case ChatProvider.Discord: - var discordBuilder = (DiscordConnectionStringBuilder)builder; - return new DiscordProvider( - assemblyInformationProvider, - loggerFactory.CreateLogger(), - discordBuilder.BotToken, - settings.ReconnectionInterval.Value); - default: - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider)); - } + ChatProvider.Irc => new IrcProvider( + jobManager, + assemblyInformationProvider, + asyncDelayer, + loggerFactory.CreateLogger(), + settings), + ChatProvider.Discord => new DiscordProvider( + jobManager, + assemblyInformationProvider, + loggerFactory.CreateLogger(), + settings), + _ => throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider)), + }; } } } diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index 1c63870f1b..7e9b0b7767 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -202,26 +202,32 @@ namespace Tgstation.Server.Host.Components.Deployment if (compileJob == null) throw new ArgumentNullException(nameof(compileJob)); - // ensure we have the entire compile job tree + // ensure we have the entire metadata tree logger.LogTrace("Loading compile job {0}...", compileJob.Id); await databaseContextFactory.UseContext( async db => compileJob = await db .CompileJobs .AsQueryable() .Where(x => x.Id == compileJob.Id) - .Include(x => x.Job).ThenInclude(x => x.StartedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.PrimaryTestMerge).ThenInclude(x => x.MergedBy) - .Include(x => x.RevisionInformation).ThenInclude(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).ThenInclude(x => x.MergedBy) + .Include(x => x.Job) + .ThenInclude(x => x.StartedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.PrimaryTestMerge) + .ThenInclude(x => x.MergedBy) + .Include(x => x.RevisionInformation) + .ThenInclude(x => x.ActiveTestMerges) + .ThenInclude(x => x.TestMerge) + .ThenInclude(x => x.MergedBy) .FirstAsync(cancellationToken) .ConfigureAwait(false)) .ConfigureAwait(false); // can't wait to see that query if (!compileJob.Job.StoppedAt.HasValue) { - // This happens if we're told to load the compile job that is currently finished up - // It can constitute an API violation if it's returned by the DreamDaemonController so just set it here - // Bit of a hack, but it should work out to be the same value - logger.LogTrace("Setting missing StoppedAt for CompileJob job..."); + // This happens when we're told to load the compile job that is currently finished up + // It constitutes an API violation if it's returned by the DreamDaemonController so just set it here + // Bit of a hack, but it works out to be nearly if not the same value that's put in the DB + logger.LogTrace("Setting missing StoppedAt for CompileJob.Job #{0}...", compileJob.Job.Id); compileJob.Job.StoppedAt = DateTimeOffset.Now; } @@ -284,9 +290,9 @@ namespace Tgstation.Server.Host.Components.Deployment else jobLockCounts[compileJob.Id] = ++value; - logger.LogTrace("Compile job {0} lock count now: {1}", compileJob.Id, value); - providerSubmitted = true; + + logger.LogTrace("Compile job {0} lock count now: {1}", compileJob.Id, value); return newProvider; } } @@ -352,7 +358,7 @@ namespace Tgstation.Server.Host.Components.Deployment } catch (Exception e) { - logger.LogWarning("Error deleting directory {0}! Exception: {1}", x, e); + logger.LogWarning(e, "Error deleting directory {0}!", x); } }).ToList(); if (deleting > 0) diff --git a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs index 02842f16cf..2f76e1f3a2 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Octokit; using System; @@ -215,30 +215,34 @@ namespace Tgstation.Server.Host.Components.Deployment job.MinimumSecurityLevel = securityLevel; // needed for the TempDmbProvider var timeoutAt = DateTimeOffset.Now.AddSeconds(timeout); - using var provider = new TemporaryDmbProvider(ioManager.ResolvePath(job.DirectoryName.ToString()), String.Concat(job.DmeName, DmbExtension), job); - using var controller = await sessionControllerFactory.LaunchNew(provider, byondLock, launchParameters, true, cancellationToken).ConfigureAwait(false); - var launchResult = await controller.LaunchResult.ConfigureAwait(false); - - var now = DateTimeOffset.Now; - if (now < timeoutAt && launchResult.StartupTime.HasValue) + ApiValidationStatus validationStatus; + using (var provider = new TemporaryDmbProvider(ioManager.ResolvePath(job.DirectoryName.ToString()), String.Concat(job.DmeName, DmbExtension), job)) + await using (var controller = await sessionControllerFactory.LaunchNew(provider, byondLock, launchParameters, true, cancellationToken).ConfigureAwait(false)) { - var timeoutTask = Task.Delay(timeoutAt - now, cancellationToken); + var launchResult = await controller.LaunchResult.ConfigureAwait(false); - await Task.WhenAny(controller.Lifetime, timeoutTask).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); + var now = DateTimeOffset.Now; + if (now < timeoutAt && launchResult.StartupTime.HasValue) + { + var timeoutTask = Task.Delay(timeoutAt - now, cancellationToken); + + await Task.WhenAny(controller.Lifetime, timeoutTask).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (!controller.Lifetime.IsCompleted) + { + if (requireValidate) + throw new JobException(ErrorCode.DreamMakerNeverValidated); + await controller.DisposeAsync().ConfigureAwait(false); + } + + validationStatus = controller.ApiValidationStatus; + logger.LogTrace("API validation status: {0}", validationStatus); + + job.DMApiVersion = controller.DMApiVersion; } - if (!controller.Lifetime.IsCompleted) - { - if (requireValidate) - throw new JobException(ErrorCode.DreamMakerNeverValidated); - controller.Dispose(); - } - - var validationStatus = controller.ApiValidationStatus; - logger.LogTrace("API validation status: {0}", validationStatus); - - job.DMApiVersion = controller.DMApiVersion; switch (validationStatus) { case ApiValidationStatus.RequiresUltrasafe: @@ -288,7 +292,8 @@ namespace Tgstation.Server.Host.Components.Deployment cancellationToken.ThrowIfCancellationRequested(); logger.LogDebug("DreamMaker exit code: {0}", exitCode); - currentDreamMakerOutput = job.Output = dm.GetCombinedOutput(); + job.Output = await dm.GetCombinedOutput(cancellationToken).ConfigureAwait(false); + currentDreamMakerOutput = job.Output; logger.LogDebug("DreamMaker output: {0}{1}", Environment.NewLine, job.Output); return exitCode; } @@ -361,7 +366,7 @@ namespace Tgstation.Server.Host.Components.Deployment } catch (Exception e) { - logger.LogWarning("Error cleaning up compile directory {0}! Exception: {1}", ioManager.ResolvePath(jobPath), e); + logger.LogWarning(e, "Error cleaning up compile directory {0}!", ioManager.ResolvePath(jobPath)); } } @@ -442,11 +447,18 @@ namespace Tgstation.Server.Host.Components.Deployment catch (JobException) { // DD never validated or compile failed - await eventConsumer.HandleEvent(EventType.CompileFailure, new List { resolvedOutputDirectory, exitCode == 0 ? "1" : "0" }, cancellationToken).ConfigureAwait(false); + await eventConsumer.HandleEvent( + EventType.CompileFailure, + new List + { + resolvedOutputDirectory, + exitCode == 0 ? "1" : "0" + }, + cancellationToken) + .ConfigureAwait(false); throw; } - logger.LogTrace("Running post compile event..."); await eventConsumer.HandleEvent(EventType.CompileComplete, new List { resolvedOutputDirectory }, cancellationToken).ConfigureAwait(false); logger.LogTrace("Applying static game file symlinks..."); @@ -629,6 +641,7 @@ namespace Tgstation.Server.Host.Components.Deployment // The difficulty with compile jobs is they have a two part commit await databaseContext.Save(cancellationToken).ConfigureAwait(false); + logger.LogTrace("Created CompileJob {0}", compileJob.Id); try { await compileJobConsumer.LoadCompileJob(compileJob, cancellationToken).ConfigureAwait(false); @@ -637,6 +650,8 @@ namespace Tgstation.Server.Host.Components.Deployment { // So we need to un-commit the compile job if the above throws databaseContext.CompileJobs.Remove(compileJob); + + // DCT: Cancellation token is for job, operation must run regardless await databaseContext.Save(default).ConfigureAwait(false); throw; } @@ -765,6 +780,7 @@ namespace Tgstation.Server.Host.Components.Deployment } catch (OperationCanceledException) { + // DCT: Cancellation token is for job, delaying here is fine await eventConsumer.HandleEvent(EventType.CompileCancelled, null, default).ConfigureAwait(false); throw; } @@ -817,7 +833,7 @@ namespace Tgstation.Server.Host.Components.Deployment } catch (ApiException e) { - logger.LogWarning("Error posting GitHub comment! Exception: {0}", e); + logger.LogWarning(e, "Error posting GitHub comment!"); } } diff --git a/src/Tgstation.Server.Host/Components/IInstance.cs b/src/Tgstation.Server.Host/Components/IInstance.cs index 475c625482..b7e64db9b2 100644 --- a/src/Tgstation.Server.Host/Components/IInstance.cs +++ b/src/Tgstation.Server.Host/Components/IInstance.cs @@ -1,55 +1,12 @@ using Microsoft.Extensions.Hosting; using System; -using System.Threading.Tasks; -using Tgstation.Server.Host.Components.Byond; -using Tgstation.Server.Host.Components.Chat; -using Tgstation.Server.Host.Components.Deployment; -using Tgstation.Server.Host.Components.Repository; -using Tgstation.Server.Host.Components.StaticFiles; -using Tgstation.Server.Host.Components.Watchdog; namespace Tgstation.Server.Host.Components { /// - /// For interacting with the instance services + /// Component version of . /// - public interface IInstance : ILatestCompileJobProvider, IHostedService, IRenameNotifyee, IDisposable + interface IInstance : IInstanceCore, IHostedService, IAsyncDisposable { - /// - /// The for the - /// - IRepositoryManager RepositoryManager { get; } - - /// - /// The for the - /// - IByondManager ByondManager { get; } - - /// - /// The for the . - /// - IDreamMaker DreamMaker { get; } - - /// - /// The for the - /// - IWatchdog Watchdog { get; } - - /// - /// The for the - /// - IChatManager Chat { get; } - - /// - /// The for the - /// - IConfiguration Configuration { get; } - - /// - /// Change the for the - /// - /// The new auto update inteval - /// A representing the running operation - Task SetAutoUpdateInterval(uint newInterval); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Components/IInstanceCore.cs b/src/Tgstation.Server.Host/Components/IInstanceCore.cs new file mode 100644 index 0000000000..27552e5bc6 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/IInstanceCore.cs @@ -0,0 +1,53 @@ +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Byond; +using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Components.StaticFiles; +using Tgstation.Server.Host.Components.Watchdog; + +namespace Tgstation.Server.Host.Components +{ + /// + /// For interacting with the instance services + /// + public interface IInstanceCore : ILatestCompileJobProvider, IRenameNotifyee + { + /// + /// The for the + /// + IRepositoryManager RepositoryManager { get; } + + /// + /// The for the + /// + IByondManager ByondManager { get; } + + /// + /// The for the . + /// + IDreamMaker DreamMaker { get; } + + /// + /// The for the + /// + IWatchdog Watchdog { get; } + + /// + /// The for the + /// + IChatManager Chat { get; } + + /// + /// The for the + /// + IConfiguration Configuration { get; } + + /// + /// Change the for the + /// + /// The new auto update inteval + /// A representing the running operation + Task SetAutoUpdateInterval(uint newInterval); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/IInstanceCoreProvider.cs b/src/Tgstation.Server.Host/Components/IInstanceCoreProvider.cs new file mode 100644 index 0000000000..494aaf130f --- /dev/null +++ b/src/Tgstation.Server.Host/Components/IInstanceCoreProvider.cs @@ -0,0 +1,15 @@ +namespace Tgstation.Server.Host.Components +{ + /// + /// Provider for s + /// + interface IInstanceCoreProvider + { + /// + /// Get the for a given if it's online. + /// + /// The to get the for. + /// The if it is online, otherwise. + IInstanceCore GetInstance(Models.Instance instance); + } +} diff --git a/src/Tgstation.Server.Host/Components/IInstanceFactory.cs b/src/Tgstation.Server.Host/Components/IInstanceFactory.cs index ce13cdf11e..473837ccf2 100644 --- a/src/Tgstation.Server.Host/Components/IInstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/IInstanceFactory.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Hosting; +using System.Threading.Tasks; using Tgstation.Server.Host.Components.Interop.Bridge; namespace Tgstation.Server.Host.Components @@ -13,7 +14,7 @@ namespace Tgstation.Server.Host.Components /// /// The to use. /// The - /// A new - IInstance CreateInstance(IBridgeRegistrar bridgeRegistrar, Models.Instance metadata); + /// A resulting in a new . + Task CreateInstance(IBridgeRegistrar bridgeRegistrar, Models.Instance metadata); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/IInstanceManager.cs b/src/Tgstation.Server.Host/Components/IInstanceManager.cs index 8e2f9b8f2b..1731be40cf 100644 --- a/src/Tgstation.Server.Host/Components/IInstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/IInstanceManager.cs @@ -16,11 +16,11 @@ namespace Tgstation.Server.Host.Components Task Ready { get; } /// - /// Get the associated with given + /// Get the associated with given /// /// The of the desired - /// The associated with the given - IInstance GetInstance(Models.Instance metadata); + /// The associated with the given if it is online, otherwise. + IInstanceReference GetInstanceReference(Models.Instance metadata); /// /// Online an @@ -42,10 +42,10 @@ namespace Tgstation.Server.Host.Components /// /// Move an /// - /// The of the desired - /// The new path of the . will have this set on if the operation completes successfully + /// The of the desired with the updated path. + /// The old path of the . will have this set on if the operation fails. /// The for the operation /// A representing the running operation - Task MoveInstance(Models.Instance metadata, string newPath, CancellationToken cancellationToken); + Task MoveInstance(Models.Instance metadata, string oldPath, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/IInstanceReference.cs b/src/Tgstation.Server.Host/Components/IInstanceReference.cs new file mode 100644 index 0000000000..a6453747f8 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/IInstanceReference.cs @@ -0,0 +1,15 @@ +using System; + +namespace Tgstation.Server.Host.Components +{ + /// + /// Controller version of . + /// + public interface IInstanceReference : IInstanceCore, IDisposable + { + /// + /// A unique ID for the . + /// + public Guid Uid { get; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index 078e3814e4..fbe5466555 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -1,5 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Octokit; using Serilog.Context; using System; using System.Collections.Generic; @@ -13,7 +14,9 @@ using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Watchdog; -using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -23,6 +26,11 @@ namespace Tgstation.Server.Host.Components #pragma warning disable CA1506 // TODO: Decomplexify sealed class Instance : IInstance { + /// + /// Message for the if ever a job starts on a different than the one that queued it. + /// + public const string DifferentCoreExceptionMessage = "Job started on different instance core!"; + /// public IRepositoryManager RepositoryManager { get; } @@ -41,11 +49,6 @@ namespace Tgstation.Server.Host.Components /// public IDreamMaker DreamMaker { get; } - /// - /// The for the - /// - readonly IDatabaseContextFactory databaseContextFactory; - /// /// The for the /// @@ -61,6 +64,11 @@ namespace Tgstation.Server.Host.Components /// readonly IEventConsumer eventConsumer; + /// + /// The for the . + /// + readonly IGitHubClientFactory gitHubClientFactory; + /// /// The for the /// @@ -71,6 +79,11 @@ namespace Tgstation.Server.Host.Components /// readonly Api.Models.Instance metadata; + /// + /// The for the . + /// + readonly GeneralConfiguration generalConfiguration; + /// /// for and . /// @@ -96,11 +109,12 @@ namespace Tgstation.Server.Host.Components /// The value of /// The value of /// The value of - /// The value of /// The value of /// The value of /// The value of + /// The value of . /// The value of + /// The value of . public Instance( Api.Models.Instance metadata, IRepositoryManager repositoryManager, @@ -110,11 +124,12 @@ namespace Tgstation.Server.Host.Components IChatManager chat, StaticFiles.IConfiguration configuration, - IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, IJobManager jobManager, IEventConsumer eventConsumer, - ILogger logger) + IGitHubClientFactory gitHubClientFactory, + ILogger logger, + GeneralConfiguration generalConfiguration) { this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); RepositoryManager = repositoryManager ?? throw new ArgumentNullException(nameof(repositoryManager)); @@ -123,24 +138,25 @@ namespace Tgstation.Server.Host.Components Watchdog = watchdog ?? throw new ArgumentNullException(nameof(watchdog)); Chat = chat ?? throw new ArgumentNullException(nameof(chat)); Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); + this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.generalConfiguration = generalConfiguration ?? throw new ArgumentNullException(nameof(generalConfiguration)); timerLock = new object(); } /// - public void Dispose() + public async ValueTask DisposeAsync() { using (LogContext.PushProperty("Instance", metadata.Id)) { timerCts?.Dispose(); Configuration.Dispose(); - Chat.Dispose(); - Watchdog.Dispose(); + await Chat.DisposeAsync().ConfigureAwait(false); + await Watchdog.DisposeAsync().ConfigureAwait(false); dmbFactory.Dispose(); RepositoryManager.Dispose(); } @@ -164,15 +180,6 @@ namespace Tgstation.Server.Host.Components await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, new List(), cancellationToken).ConfigureAwait(false); try { - User user = null; - await databaseContextFactory.UseContext( - async (db) => user = await db - .Users - .AsQueryable() - .Where(x => x.CanonicalName == User.CanonicalizeName(Api.Models.User.AdminName)) - .FirstAsync(cancellationToken) - .ConfigureAwait(false)) - .ConfigureAwait(false); var repositoryUpdateJob = new Job { Instance = new Models.Instance @@ -181,13 +188,15 @@ namespace Tgstation.Server.Host.Components }, Description = "Scheduled repository update", CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelPendingChanges, - StartedBy = user + CancelRight = (ulong)RepositoryRights.CancelPendingChanges }; string deploySha = null; - await jobManager.RegisterOperation(repositoryUpdateJob, async (paramJob, databaseContextFactory, progressReporter, jobCancellationToken) => + await jobManager.RegisterOperation(repositoryUpdateJob, async (core, databaseContextFactory, paramJob, progressReporter, jobCancellationToken) => { + if (core != this) + throw new InvalidOperationException(DifferentCoreExceptionMessage); + // assume 5 steps with synchronize const int ProgressSections = 7; const int ProgressStep = 100 / ProgressSections; @@ -241,7 +250,7 @@ namespace Tgstation.Server.Host.Components .Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge) .FirstOrDefaultAsync(jobCancellationToken); - async Task UpdateRevInfo(string currentHead, bool onOrigin) + async Task UpdateRevInfo(string currentHead, bool onOrigin, IEnumerable updatedTestMerges) { if (currentRevInfo == null) currentRevInfo = await LoadRevInfo().ConfigureAwait(false); @@ -264,7 +273,8 @@ namespace Tgstation.Server.Host.Components Instance = attachedInstance }; if (!onOrigin) - currentRevInfo.ActiveTestMerges = new List(oldRevInfo.ActiveTestMerges); + currentRevInfo.ActiveTestMerges = new List( + updatedTestMerges ?? oldRevInfo.ActiveTestMerges); databaseContext.Instances.Attach(attachedInstance); databaseContext.RevisionInformations.Add(currentRevInfo); @@ -272,8 +282,9 @@ namespace Tgstation.Server.Host.Components } // take appropriate auto update actions - bool shouldSyncTracked; - if (repositorySettings.AutoUpdatesKeepTestMerges.Value) + bool shouldSyncTracked = false; + bool preserveTestMerges = repositorySettings.AutoUpdatesKeepTestMerges.Value; + if (preserveTestMerges) { logger.LogTrace("Preserving test merges..."); @@ -286,21 +297,29 @@ namespace Tgstation.Server.Host.Components currentRevInfo = await currentRevInfoTask.ConfigureAwait(false); + var updatedTestMerges = await RemoveMergedPullRequests( + repo, + repositorySettings, + currentRevInfo, + cancellationToken) + .ConfigureAwait(false); + var lastRevInfoWasOriginCommit = currentRevInfo == default || currentRevInfo.CommitSha == currentRevInfo.OriginCommitSha; var stillOnOrigin = result.Value && lastRevInfoWasOriginCommit; var currentHead = repo.Head; if (currentHead != startSha) { - await UpdateRevInfo(currentHead, stillOnOrigin).ConfigureAwait(false); + await UpdateRevInfo(currentHead, stillOnOrigin, updatedTestMerges).ConfigureAwait(false); shouldSyncTracked = stillOnOrigin; } else shouldSyncTracked = false; } - else + + if (!preserveTestMerges) { - logger.LogTrace("Not preserving test merges..."); + logger.LogTrace("Resetting to origin..."); await repo.ResetToOrigin(NextProgressReporter(), jobCancellationToken).ConfigureAwait(false); var currentHead = repo.Head; @@ -312,7 +331,7 @@ namespace Tgstation.Server.Host.Components .ConfigureAwait(false); if (currentHead != startSha && currentRevInfo == default) - await UpdateRevInfo(currentHead, true).ConfigureAwait(false); + await UpdateRevInfo(currentHead, true, null).ConfigureAwait(false); shouldSyncTracked = true; } @@ -323,7 +342,7 @@ namespace Tgstation.Server.Host.Components var pushedOrigin = await repo.Sychronize(repositorySettings.AccessUser, repositorySettings.AccessToken, repositorySettings.CommitterName, repositorySettings.CommitterEmail, NextProgressReporter(), shouldSyncTracked, jobCancellationToken).ConfigureAwait(false); var currentHead = repo.Head; if (currentHead != currentRevInfo.CommitSha) - await UpdateRevInfo(currentHead, pushedOrigin).ConfigureAwait(false); + await UpdateRevInfo(currentHead, pushedOrigin, null).ConfigureAwait(false); } repoHead = repo.Head; @@ -335,6 +354,7 @@ namespace Tgstation.Server.Host.Components } catch { + // DCT: Cancellation token is for job, operation must run regardless await repo.ResetToSha(startSha, progressReporter, default).ConfigureAwait(false); throw; } @@ -345,7 +365,8 @@ namespace Tgstation.Server.Host.Components deploySha = repoHead; }, cancellationToken).ConfigureAwait(false); - await jobManager.WaitForJobCompletion(repositoryUpdateJob, user, cancellationToken, default).ConfigureAwait(false); + // DCT: First token will cancel the job, second is for cancelling the cancellation, unwanted + await jobManager.WaitForJobCompletion(repositoryUpdateJob, null, cancellationToken, default).ConfigureAwait(false); if (deploySha == null) { @@ -362,7 +383,6 @@ namespace Tgstation.Server.Host.Components // finally set up the job var compileProcessJob = new Job { - StartedBy = user, Instance = repositoryUpdateJob.Instance, Description = "Scheduled code deployment", CancelRightsType = RightsType.DreamMaker, @@ -371,10 +391,19 @@ namespace Tgstation.Server.Host.Components await jobManager.RegisterOperation( compileProcessJob, - DreamMaker.DeploymentProcess, + (core, databaseContextFactory, job, progressReporter, jobCancellationToken) => + { + if (core != this) + throw new InvalidOperationException(DifferentCoreExceptionMessage); + return DreamMaker.DeploymentProcess( + job, + databaseContextFactory, + progressReporter, + jobCancellationToken); + }, cancellationToken).ConfigureAwait(false); - await jobManager.WaitForJobCompletion(compileProcessJob, user, default, cancellationToken).ConfigureAwait(false); + await jobManager.WaitForJobCompletion(compileProcessJob, null, default, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -383,7 +412,7 @@ namespace Tgstation.Server.Host.Components } catch (Exception e) { - logger.LogWarning("Error in auto update loop! Exception: {0}", e); + logger.LogWarning(e, "Error in auto update loop!"); continue; } } @@ -394,7 +423,69 @@ namespace Tgstation.Server.Host.Components logger.LogTrace("Leaving auto update loop..."); } - #pragma warning restore CA1502 +#pragma warning restore CA1502 + + /// + /// Get the updated list of s for an origin merge. + /// + /// The to use. + /// The . + /// The current . + /// The for the operation. + /// A resulting in the of s that should remain the new . + async Task> RemoveMergedPullRequests( + IRepository repository, + RepositorySettings repositorySettings, + RevisionInformation revisionInformation, + CancellationToken cancellationToken) + { + if (revisionInformation.ActiveTestMerges?.Any() != true) + { + logger.LogTrace("No test merges to remove."); + return Array.Empty(); + } + + var gitHubClient = repositorySettings.AccessToken != null + ? gitHubClientFactory.CreateClient(repositorySettings.AccessToken) + : (String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) + ? gitHubClientFactory.CreateClient() + : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken)); + + var tasks = revisionInformation + .ActiveTestMerges + .Select(x => gitHubClient + .PullRequest + .Get(repository.GitHubOwner, repository.GitHubRepoName, x.TestMerge.Number) + .WithToken(cancellationToken)); + try + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + logger.LogWarning(ex, "Pull requests update check failed!"); + } + + var newList = revisionInformation.ActiveTestMerges.ToList(); + + async Task CheckRemovePR(Task task) + { + var pr = await task.ConfigureAwait(false); + if (!pr.Merged) + return; + + // We don't just assume, actually check the repo contains the merge commit. + if (await repository.ShaIsParent(pr.MergeCommitSha, cancellationToken).ConfigureAwait(false)) + newList.Remove( + newList.First( + potential => potential.TestMerge.Number == pr.Number)); + } + + foreach (var prTask in tasks) + await CheckRemovePR(prTask).ConfigureAwait(false); + + return newList; + } /// public Task InstanceRenamed(string newName, CancellationToken cancellationToken) @@ -472,7 +563,7 @@ namespace Tgstation.Server.Host.Components // race condition, just quit if (timerTask != null) { - logger.LogDebug("Aborting auto update interval change due to race condition!"); + logger.LogWarning("Aborting auto update interval change due to race condition!"); return; } diff --git a/src/Tgstation.Server.Host/Components/InstanceContainer.cs b/src/Tgstation.Server.Host/Components/InstanceContainer.cs new file mode 100644 index 0000000000..3a1e64f92a --- /dev/null +++ b/src/Tgstation.Server.Host/Components/InstanceContainer.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Components +{ + /// + /// Wrapper for managing s + /// + sealed class InstanceContainer + { + /// + /// The . + /// + public IInstance Instance { get; } + + /// + /// A that completes when there are no s active for the . + /// + public Task OnZeroReferences + { + get + { + lock (referenceCountLock) + { + if (referenceCount == 0) + return Task.CompletedTask; + return onZeroReferencesTcs.Task; + } + } + } + + /// + /// for . + /// + readonly object referenceCountLock; + + /// + /// Backing for . + /// + TaskCompletionSource onZeroReferencesTcs; + + /// + /// Count of active s. + /// + ulong referenceCount; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + public InstanceContainer(IInstance instance) + { + Instance = instance ?? throw new ArgumentNullException(nameof(instance)); + + referenceCountLock = new object(); + } + + /// + /// Create a new . + /// + /// A new . + public IInstanceReference AddReference() + { + lock (referenceCountLock) + { + if (referenceCount++ == 0) + onZeroReferencesTcs = new TaskCompletionSource(); + + try + { + return new InstanceWrapper(Instance, () => + { + lock (referenceCountLock) + if (--referenceCount == 0) + onZeroReferencesTcs.SetResult(null); + }); + } + catch + { + --referenceCount; + throw; + } + } + } + } +} diff --git a/src/Tgstation.Server.Host/Components/InstanceFactory.cs b/src/Tgstation.Server.Host/Components/InstanceFactory.cs index a1a34295aa..dc6243b294 100644 --- a/src/Tgstation.Server.Host/Components/InstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/InstanceFactory.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using Tgstation.Server.Host.Components.Interop.Bridge; using Tgstation.Server.Host.Components.Repository; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Components.Watchdog; +using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; @@ -123,6 +125,11 @@ namespace Tgstation.Server.Host.Components /// readonly IServerPortProvider serverPortProvider; + /// + /// The for the . + /// + readonly GeneralConfiguration generalConfiguration; + /// /// Construct an /// @@ -146,6 +153,7 @@ namespace Tgstation.Server.Host.Components /// The value of . /// The value of . /// The value of . + /// The containing the value of . public InstanceFactory( IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, @@ -166,7 +174,8 @@ namespace Tgstation.Server.Host.Components IPlatformIdentifier platformIdentifier, ILibGit2RepositoryFactory repositoryFactory, ILibGit2Commands repositoryCommands, - IServerPortProvider serverPortProvider) + IServerPortProvider serverPortProvider, + IOptions generalConfigurationOptions) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); @@ -188,11 +197,12 @@ namespace Tgstation.Server.Host.Components this.repositoryFactory = repositoryFactory ?? throw new ArgumentNullException(nameof(repositoryFactory)); this.repositoryCommands = repositoryCommands ?? throw new ArgumentNullException(nameof(repositoryCommands)); this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider)); + generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } /// #pragma warning disable CA1506 // TODO: Decomplexify - public IInstance CreateInstance(IBridgeRegistrar bridgeRegistrar, Models.Instance metadata) + public async Task CreateInstance(IBridgeRegistrar bridgeRegistrar, Models.Instance metadata) { // Create the ioManager for the instance var instanceIoManager = new ResolvingIOManager(ioManager, metadata.Path); @@ -284,17 +294,18 @@ namespace Tgstation.Server.Host.Components watchdog, chatManager, configuration, - databaseContextFactory, dmbFactory, jobManager, eventConsumer, - loggerFactory.CreateLogger()); + gitHubClientFactory, + loggerFactory.CreateLogger(), + generalConfiguration); return instance; } catch { - watchdog.Dispose(); + await watchdog.DisposeAsync().ConfigureAwait(false); throw; } } @@ -306,7 +317,7 @@ namespace Tgstation.Server.Host.Components } catch { - chatManager.Dispose(); + await chatManager.DisposeAsync().ConfigureAwait(false); throw; } } diff --git a/src/Tgstation.Server.Host/Components/InstanceManager.cs b/src/Tgstation.Server.Host/Components/InstanceManager.cs index 46f3ca2bd6..3684cdb799 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,8 +11,10 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Interop.Bridge; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Security; @@ -21,11 +23,22 @@ using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components { /// - sealed class InstanceManager : IInstanceManager, IRestartHandler, IHostedService, IBridgeRegistrar, IDisposable + sealed class InstanceManager : + IInstanceManager, + IInstanceCoreProvider, + IRestartHandler, + IHostedService, + IBridgeRegistrar, + IAsyncDisposable { /// public Task Ready => readyTcs.Task; + /// + /// The for the ; + /// + readonly Lazy lazyRestartRegistration; + /// /// The for the /// @@ -71,21 +84,31 @@ namespace Tgstation.Server.Host.Components /// readonly IDatabaseSeeder databaseSeeder; + /// + /// The for the + /// + readonly IServerPortProvider serverPortProvider; + /// /// The for the /// readonly ILogger logger; /// - /// Map of instance s to respective s. Also used as a . + /// Map of instance s to respective s. Also used as a . /// - readonly IDictionary instances; + readonly IDictionary instances; /// /// Map of s to their respective s. /// readonly IDictionary bridgeHandlers; + /// + /// used to guard calls to and . + /// + readonly SemaphoreSlim instanceStateChangeSemaphore; + /// /// The for the . /// @@ -102,7 +125,7 @@ namespace Tgstation.Server.Host.Components Version downgradeVersion; /// - /// If the has been d + /// If the has been 'd /// bool disposed; @@ -118,6 +141,7 @@ namespace Tgstation.Server.Host.Components /// The value of . /// The value of . /// The value of . + /// The value of . /// The containing the value of . /// The value of public InstanceManager( @@ -130,6 +154,7 @@ namespace Tgstation.Server.Host.Components ISystemIdentityFactory systemIdentityFactory, IAsyncDelayer asyncDelayer, IDatabaseSeeder databaseSeeder, + IServerPortProvider serverPortProvider, IOptions generalConfigurationOptions, ILogger logger) { @@ -142,18 +167,20 @@ namespace Tgstation.Server.Host.Components this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.databaseSeeder = databaseSeeder ?? throw new ArgumentNullException(nameof(databaseSeeder)); + this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - serverControl.RegisterForRestart(this); + lazyRestartRegistration = new Lazy(() => serverControl.RegisterForRestart(this)); - instances = new Dictionary(); + instances = new Dictionary(); bridgeHandlers = new Dictionary(); readyTcs = new TaskCompletionSource(); + instanceStateChangeSemaphore = new SemaphoreSlim(1); } /// - public void Dispose() + public async ValueTask DisposeAsync() { lock (instances) { @@ -163,42 +190,96 @@ namespace Tgstation.Server.Host.Components } foreach (var I in instances) - I.Value.Dispose(); + await I.Value.Instance.DisposeAsync().ConfigureAwait(false); + + lazyRestartRegistration.Value.Dispose(); + instanceStateChangeSemaphore.Dispose(); + + logger.LogInformation("Server shutdown"); } /// - public IInstance GetInstance(Models.Instance metadata) + public IInstanceReference GetInstanceReference(Models.Instance metadata) { if (metadata == null) throw new ArgumentNullException(nameof(metadata)); + lock (instances) { - if (!instances.TryGetValue(metadata.Id, out IInstance instance)) - throw new InvalidOperationException("Instance not online!"); - return instance; + if (!instances.TryGetValue(metadata.Id, out var instance)) + { + logger.LogTrace("Cannot reference instance {0} as it is not online!", metadata.Id); + return null; + } + + return instance.AddReference(); } } /// - public async Task MoveInstance(Models.Instance instance, string newPath, CancellationToken cancellationToken) + public async Task MoveInstance(Models.Instance instance, string oldPath, CancellationToken cancellationToken) { - if (newPath == null) - throw new ArgumentNullException(nameof(newPath)); - if (instance.Online.Value) + if (oldPath == null) + throw new ArgumentNullException(nameof(oldPath)); + using var instanceReferenceCheck = GetInstanceReference(instance); + if (instanceReferenceCheck != null) throw new InvalidOperationException("Cannot move an online instance!"); - var oldPath = instance.Path; - await ioManager.CopyDirectory(oldPath, newPath, null, cancellationToken).ConfigureAwait(false); - await databaseContextFactory.UseContext(db => + var newPath = instance.Path; + try { - var targetInstance = new Models.Instance + await ioManager.MoveDirectory(oldPath, newPath, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Error moving instance {0}!", + instance.Id); + try { - Id = instance.Id - }; - db.Instances.Attach(targetInstance); - targetInstance.Path = newPath; - return db.Save(cancellationToken); - }).ConfigureAwait(false); - await ioManager.DeleteDirectory(oldPath, cancellationToken).ConfigureAwait(false); + logger.LogDebug("Reverting instance {0}'s path to {1} in the DB...", instance.Id, oldPath); + + // DCT: Operation must always run + await databaseContextFactory.UseContext(db => + { + var targetInstance = new Models.Instance + { + Id = instance.Id + }; + db.Instances.Attach(targetInstance); + targetInstance.Path = oldPath; + return db.Save(default); + }).ConfigureAwait(false); + } + catch (Exception innerEx) + { + logger.LogCritical( + innerEx, + "Error reverting database after failing to move instance {0}! Attempting to detach..."); + + try + { + // DCT: Operation must always run + await ioManager.WriteAllBytes( + ioManager.ConcatPath(oldPath, InstanceController.InstanceAttachFileName), + Array.Empty(), + default) + .ConfigureAwait(false); + } + catch (Exception tripleEx) + { + logger.LogCritical( + tripleEx, + "Okay, what gamma radiation are you under? Failed to write instance attach file!"); + + throw new AggregateException(tripleEx, innerEx, ex); + } + + throw new AggregateException(ex, innerEx); + } + + throw; + } } /// @@ -206,17 +287,26 @@ namespace Tgstation.Server.Host.Components { if (metadata == null) throw new ArgumentNullException(nameof(metadata)); + + using var lockContext = await SemaphoreSlimContext.Lock(instanceStateChangeSemaphore, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Offlining instance ID {0}", metadata.Id); - IInstance instance; + InstanceContainer container; lock (instances) { - if (!instances.TryGetValue(metadata.Id, out instance)) - throw new InvalidOperationException("Instance not online!"); + if (!instances.TryGetValue(metadata.Id, out container)) + { + logger.LogDebug("Not offlining removed instance {0}", metadata.Id); + return; + } + instances.Remove(metadata.Id); } try { + await container.OnZeroReferences.ConfigureAwait(false); + // we are the one responsible for cancelling his jobs var tasks = new List(); await databaseContextFactory.UseContext(async db => @@ -238,11 +328,11 @@ namespace Tgstation.Server.Host.Components await Task.WhenAll(tasks).ConfigureAwait(false); - await instance.StopAsync(cancellationToken).ConfigureAwait(false); + await container.Instance.StopAsync(cancellationToken).ConfigureAwait(false); } finally { - instance.Dispose(); + await container.Instance.DisposeAsync().ConfigureAwait(false); } } @@ -251,24 +341,47 @@ namespace Tgstation.Server.Host.Components { if (metadata == null) throw new ArgumentNullException(nameof(metadata)); + + using var lockContext = await SemaphoreSlimContext.Lock(instanceStateChangeSemaphore, cancellationToken).ConfigureAwait(false); + lock (instances) + if (instances.ContainsKey(metadata.Id)) + { + logger.LogDebug("Aborting instance creation due to it seemingly already being online"); + return; + } + logger.LogInformation("Onlining instance ID {0} ({1}) at {2}", metadata.Id, metadata.Name, metadata.Path); - var instance = instanceFactory.CreateInstance(this, metadata); + var instance = await instanceFactory.CreateInstance(this, metadata).ConfigureAwait(false); try { - lock (instances) + await instance.StartAsync(cancellationToken).ConfigureAwait(false); + + try { - if (instances.ContainsKey(metadata.Id)) - throw new InvalidOperationException("Instance already online!"); - instances.Add(metadata.Id, instance); + lock (instances) + instances.Add(metadata.Id, new InstanceContainer(instance)); + } + catch (Exception ex) + { + logger.LogError("Unable to commit onlined instance {0} into service, offlining!", metadata.Id); + try + { + // DCT: Must always run + await instance.StopAsync(default).ConfigureAwait(false); + } + catch (Exception innerEx) + { + throw new AggregateException(innerEx, ex); + } + + throw; } } catch { - instance.Dispose(); + await instance.DisposeAsync().ConfigureAwait(false); throw; } - - await instance.StartAsync(cancellationToken).ConfigureAwait(false); } /// @@ -277,6 +390,10 @@ namespace Tgstation.Server.Host.Components { logger.LogInformation(assemblyInformationProvider.VersionString); + // we do this here because making the restart registration triggers a trace log message + // The above log message should be the first one one startup + var _ = lazyRestartRegistration.Value; + try { generalConfiguration.CheckCompatibility(logger); @@ -305,11 +422,14 @@ namespace Tgstation.Server.Host.Components } catch (Exception ex) { - logger.LogError("Failed to online instance {0}! Exception: {0}", ex); + logger.LogError(ex, "Failed to online instance {0}!"); } }) .ToList(); await Task.WhenAll(tasks).ConfigureAwait(false); + + jobManager.Activate(); + logger.LogInformation("Server ready!"); readyTcs.SetResult(null); } @@ -319,7 +439,7 @@ namespace Tgstation.Server.Host.Components } catch (Exception e) { - logger.LogCritical("Instance manager startup error! Exception: {0}", e); + logger.LogCritical(e, "Instance manager startup error!"); try { await serverControl.Die(e).ConfigureAwait(false); @@ -327,7 +447,7 @@ namespace Tgstation.Server.Host.Components } catch (Exception e2) { - logger.LogCritical("Failed to kill server! Exception: {0}", e2); + logger.LogCritical(e2, "Failed to kill server!"); } throw; @@ -338,13 +458,20 @@ namespace Tgstation.Server.Host.Components /// public async Task StopAsync(CancellationToken cancellationToken) { - await jobManager.StopAsync(cancellationToken).ConfigureAwait(false); - await Task.WhenAll(instances.Select(x => x.Value.StopAsync(cancellationToken))).ConfigureAwait(false); - await instanceFactory.StopAsync(cancellationToken).ConfigureAwait(false); + try + { + await jobManager.StopAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(instances.Select(x => x.Value.Instance.StopAsync(cancellationToken))).ConfigureAwait(false); + await instanceFactory.StopAsync(cancellationToken).ConfigureAwait(false); - // downgrade the db if necessary - if (downgradeVersion != null) - await databaseContextFactory.UseContext(db => databaseSeeder.Downgrade(db, downgradeVersion, cancellationToken)).ConfigureAwait(false); + // downgrade the db if necessary + if (downgradeVersion != null) + await databaseContextFactory.UseContext(db => databaseSeeder.Downgrade(db, downgradeVersion, cancellationToken)).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Instance manager stop exception!"); + } } /// @@ -359,9 +486,15 @@ namespace Tgstation.Server.Host.Components /// private void CheckSystemCompatibility() { - using var systemIdentity = systemIdentityFactory.GetCurrent(); - if (!systemIdentity.CanCreateSymlinks) - throw new InvalidOperationException("The user running tgstation-server cannot create symlinks! Please try running as an administrative user!"); + using (var systemIdentity = systemIdentityFactory.GetCurrent()) + { + if (!systemIdentity.CanCreateSymlinks) + throw new InvalidOperationException("The user running tgstation-server cannot create symlinks! Please try running as an administrative user!"); + } + + // This runs before the real socket is opened, ensures we don't perform reattaches unless we're fairly certain the bind won't fail + // If it does fail, DD will be killed. + SocketExtensions.BindTest(serverPortProvider.HttpApiPort, true); } /// @@ -416,5 +549,15 @@ namespace Tgstation.Server.Host.Components } }); } + + /// + public IInstanceCore GetInstance(Models.Instance metadata) + { + lock (instances) + { + instances.TryGetValue(metadata.Id, out var container); + return container?.Instance; + } + } } } diff --git a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs new file mode 100644 index 0000000000..0830098235 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Byond; +using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Deployment; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Components.StaticFiles; +using Tgstation.Server.Host.Components.Watchdog; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Components +{ + /// + /// Warpper around a given with a . + /// + sealed class InstanceWrapper : IInstanceReference + { + /// + public Guid Uid { get; } + + /// + /// The object for . + /// + readonly object disposeLock; + + /// + /// The to take when is called. + /// + Action onDisposed; + + /// + /// The calls are forwarded to. + /// + IInstanceCore actualInstance; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + public InstanceWrapper(IInstanceCore actualInstance, Action onDisposed) + { + this.actualInstance = actualInstance ?? throw new ArgumentNullException(nameof(actualInstance)); + this.onDisposed = onDisposed ?? throw new ArgumentNullException(nameof(onDisposed)); + Uid = Guid.NewGuid(); + disposeLock = new object(); + } + + /// + public void Dispose() + { + lock (disposeLock) + { + onDisposed?.Invoke(); + onDisposed = null; + actualInstance = null; + } + } + + /// + public IRepositoryManager RepositoryManager => actualInstance?.RepositoryManager ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public IByondManager ByondManager => actualInstance?.ByondManager ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public IDreamMaker DreamMaker => actualInstance?.DreamMaker ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public IWatchdog Watchdog => actualInstance?.Watchdog ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public IChatManager Chat => actualInstance?.Chat ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public IConfiguration Configuration => actualInstance?.Configuration ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public Task InstanceRenamed(string newInstanceName, CancellationToken cancellationToken) + => actualInstance?.InstanceRenamed(newInstanceName, cancellationToken) ?? throw new ObjectDisposedException(nameof(InstanceWrapper)); + + /// + public CompileJob LatestCompileJob() + { + if (actualInstance == null) + throw new ObjectDisposedException(nameof(InstanceWrapper)); + return actualInstance.LatestCompileJob(); + } + + /// + public Task SetAutoUpdateInterval(uint newInterval) + { + if (actualInstance == null) + throw new ObjectDisposedException(nameof(InstanceWrapper)); + return actualInstance.SetAutoUpdateInterval(newInterval); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs b/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs index d91d5bdbf7..1dc6e31381 100644 --- a/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs +++ b/src/Tgstation.Server.Host/Components/Interop/DMApiConstants.cs @@ -33,7 +33,7 @@ namespace Tgstation.Server.Host.Components.Interop /// /// The DMAPI being used. /// - public static readonly Version Version = new Version(5, 2, 2); + public static readonly Version Version = new Version(5, 2, 3); /// /// for use when communicating with the DMAPI. diff --git a/src/Tgstation.Server.Host/Components/Repository/IRepository.cs b/src/Tgstation.Server.Host/Components/Repository/IRepository.cs index 44cb5b39db..7790315cc2 100644 --- a/src/Tgstation.Server.Host/Components/Repository/IRepository.cs +++ b/src/Tgstation.Server.Host/Components/Repository/IRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -132,5 +132,14 @@ namespace Tgstation.Server.Host.Components.Repository /// The for the operation /// A representing the running operation Task CopyTo(string path, CancellationToken cancellationToken); + + /// + /// Check if a given is a parent of the current . + /// + /// The SHA to check. + /// The for the operation. + /// A resulting in if is a parent of , otherwise. + /// This function is NOT reentrant. + Task ShaIsParent(string sha, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs b/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs index 69059f93cc..4e24002bb7 100644 --- a/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs +++ b/src/Tgstation.Server.Host/Components/Repository/LibGit2RepositoryFactory.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using LibGit2Sharp.Handlers; using Microsoft.Extensions.Logging; using System; @@ -30,17 +30,7 @@ namespace Tgstation.Server.Host.Components.Repository public LibGit2Sharp.IRepository CreateInMemory() { logger.LogTrace("Creating in-memory libgit2 repository..."); - var repo = new LibGit2Sharp.Repository(); - try - { - logger.LogTrace("Successfully created in-memory libgit2 repository."); - return repo; - } - catch - { - repo.Dispose(); - throw; - } + return new LibGit2Sharp.Repository(); } /// @@ -70,7 +60,7 @@ namespace Tgstation.Server.Host.Components.Repository } catch (UserCancelledException ex) { - logger.LogTrace("Suppressing clone cancellation exception: {0}", ex); + logger.LogTrace(ex, "Suppressing clone cancellation exception"); cancellationToken.ThrowIfCancellationRequested(); } }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); diff --git a/src/Tgstation.Server.Host/Components/Repository/Repository.cs b/src/Tgstation.Server.Host/Components/Repository/Repository.cs index f9d2ccc9b9..1165e0f420 100644 --- a/src/Tgstation.Server.Host/Components/Repository/Repository.cs +++ b/src/Tgstation.Server.Host/Components/Repository/Repository.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using LibGit2Sharp.Handlers; using Microsoft.Extensions.Logging; using System; @@ -22,6 +22,16 @@ namespace Tgstation.Server.Host.Components.Repository /// public const string GitHubUrl = "://github.com/"; + /// + /// The default username for committers. + /// + public const string DefaultCommitterName = "tgstation-server"; + + /// + /// The default password for committers. + /// + public const string DefaultCommitterEmail = "tgstation-server@users.noreply.github.com"; + /// /// Template error message for when tracking of the most recent origin commit fails /// @@ -475,7 +485,7 @@ namespace Tgstation.Server.Host.Components.Repository } catch(LibGit2SharpException e) { - logger.LogWarning("Unable to push to temporary branch! Exception: {0}", e); + logger.LogWarning(e, "Unable to push to temporary branch!"); } } finally @@ -554,7 +564,7 @@ namespace Tgstation.Server.Host.Components.Repository trackedBranch = libGitRepo.Head.TrackedBranch; logger.LogDebug("Merge origin/{2}: <{0} ({1})>", committerName, committerEmail, trackedBranch.FriendlyName); - result = libGitRepo.Merge(trackedBranch, new Signature(new Identity(committerName, committerEmail), DateTimeOffset.Now), new MergeOptions + result = libGitRepo.Merge(trackedBranch, new Signature(committerName, committerEmail, DateTimeOffset.Now), new MergeOptions { CommitOnSuccess = true, FailOnConflict = true, @@ -683,7 +693,7 @@ namespace Tgstation.Server.Host.Components.Repository } catch (LibGit2SharpException e) { - logger.LogWarning("Unable to make synchronization push! Exception: {0}", e); + logger.LogWarning(e, "Unable to make synchronization push!"); return false; } }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); @@ -708,5 +718,43 @@ namespace Tgstation.Server.Host.Components.Repository return true; return false; }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public Task ShaIsParent(string sha, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + var targetCommit = libGitRepo.Lookup(sha); + if(targetCommit == null) + { + logger.LogTrace("Commit {0} not found in repository", sha); + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + var startSha = Head; + var mergeResult = libGitRepo.Merge( + targetCommit, + new Signature( + DefaultCommitterName, + DefaultCommitterEmail, + DateTimeOffset.Now), + new MergeOptions + { + FastForwardStrategy = FastForwardStrategy.FastForwardOnly, + FailOnConflict = true + }); + + if (mergeResult.Status == MergeStatus.UpToDate) + return true; + + commands.Checkout( + libGitRepo, + new CheckoutOptions + { + CheckoutModifiers = CheckoutModifiers.Force + }, + startSha); + + return false; + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); } } diff --git a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs index 55c19fdc82..b3a1b858b1 100644 --- a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs +++ b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs @@ -1,4 +1,4 @@ -using LibGit2Sharp; +using LibGit2Sharp; using Microsoft.Extensions.Logging; using System; using System.Threading; @@ -148,11 +148,13 @@ namespace Tgstation.Server.Host.Components.Repository try { logger.LogTrace("Deleting partially cloned repository..."); + + // DCT: Cancellation token is for job, operation must run regardless await ioManager.DeleteDirectory(repositoryPath, default).ConfigureAwait(false); } catch (Exception e) { - logger.LogDebug("Error deleting partially cloned repository! Exception: {0}", e); + logger.LogDebug(e, "Error deleting partially cloned repository!"); } throw; @@ -207,8 +209,7 @@ namespace Tgstation.Server.Host.Components.Repository } catch (RepositoryNotFoundException e) { - logger.LogDebug("Repository not found!"); - logger.LogTrace("Exception: {0}", e); + logger.LogTrace(e, "Repository not found!"); return null; } } diff --git a/src/Tgstation.Server.Host/Components/Session/ISessionController.cs b/src/Tgstation.Server.Host/Components/Session/ISessionController.cs index 21439f6cc2..778c39ba38 100644 --- a/src/Tgstation.Server.Host/Components/Session/ISessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/ISessionController.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Interop.Topic; +using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components.Session @@ -10,7 +11,7 @@ namespace Tgstation.Server.Host.Components.Session /// /// Handles communication with a DreamDaemon /// - interface ISessionController : IRenameNotifyee, IProcessBase + interface ISessionController : IProcessBase, IRenameNotifyee, IAsyncDisposable { /// /// A that completes when DreamDaemon starts pumping the windows message queue after loading a .dmb or when it crashes @@ -33,14 +34,9 @@ namespace Tgstation.Server.Host.Components.Session Version DMApiVersion { get; } /// - /// The being used + /// Gets the associated with the . /// - IDmbProvider Dmb { get; } - - /// - /// The current port DreamDaemon is listening on - /// - ushort? Port { get; } + CompileJob CompileJob { get; } /// /// If the port should be rotated off when the world reboots @@ -70,8 +66,8 @@ namespace Tgstation.Server.Host.Components.Session /// /// Releases the without terminating it. Also calls /// - /// which can be used to create a new similar to this one - ReattachInformation Release(); + /// A resulting in which can be used to create a new . + Task Release(); /// /// Sends a command to DreamDaemon through /world/Topic() @@ -108,7 +104,7 @@ namespace Tgstation.Server.Host.Components.Session void EnableCustomChatCommands(); /// - /// Replace with a given , disposing the old one. + /// Replace the in use with a given , disposing the old one. /// /// The new . void ReplaceDmbProvider(IDmbProvider newProvider); diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index bdda5618d3..c5c10b6719 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -1,4 +1,4 @@ -using Byond.TopicSender; +using Byond.TopicSender; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Serilog.Context; @@ -39,36 +39,10 @@ namespace Tgstation.Server.Host.Components.Session } /// - public IDmbProvider Dmb - { - get - { - CheckDisposed(); - return reattachInformation.Dmb; - } - } + public Models.CompileJob CompileJob => reattachInformation.Dmb.CompileJob; /// - public ushort? Port - { - get - { - CheckDisposed(); - if (portClosedForReboot) - return null; - return reattachInformation.Port; - } - } - - /// - public RebootState RebootState - { - get - { - CheckDisposed(); - return reattachInformation.RebootState; - } - } + public RebootState RebootState => reattachInformation.RebootState; /// public Version DMApiVersion { get; private set; } @@ -83,7 +57,7 @@ namespace Tgstation.Server.Host.Components.Session public Task LaunchResult { get; } /// - public Task Lifetime => process.Lifetime; + public Task Lifetime { get; } /// public Task OnReboot => rebootTcs.Task; @@ -202,6 +176,7 @@ namespace Tgstation.Server.Host.Components.Session /// The value of /// The for the . /// The value of + /// The returning a to be run after the ends. /// The optional time to wait before failing the /// If this is a reattached session. /// If this is a DMAPI validation session. @@ -216,6 +191,7 @@ namespace Tgstation.Server.Host.Components.Session IChatManager chat, IAssemblyInformationProvider assemblyInformationProvider, ILogger logger, + Func postLifetimeCallback, uint? startupTimeout, bool reattached, bool apiValidate) @@ -253,75 +229,53 @@ namespace Tgstation.Server.Host.Components.Session reattachTopicCts = new CancellationTokenSource(); synchronizationLock = new object(); - _ = process.Lifetime.ContinueWith( - x => - { - lock (synchronizationLock) - if (!disposed) - reattachTopicCts.Cancel(); - chatTrackingContext.Active = false; - }, - TaskScheduler.Current); + async Task WrapLifetime() + { + var exitCode = await process.Lifetime.ConfigureAwait(false); + await postLifetimeCallback().ConfigureAwait(false); + return exitCode; + } + + Lifetime = WrapLifetime(); LaunchResult = GetLaunchResult( assemblyInformationProvider, startupTimeout, reattached); - logger.LogDebug("Created session controller. CommsKey: {0}, Port: {1}", reattachInformation.AccessIdentifier, Port); + logger.LogDebug( + "Created session controller. CommsKey: {0}, Port: {1}", + reattachInformation.AccessIdentifier, + reattachInformation.Port); } - /// - /// Finalizes an instance of the class. - /// - /// The finalizer dispose pattern is necessary so we don't accidentally leak the executable -#pragma warning disable CA1821 // Remove empty Finalizers TODO: remove this when https://github.com/dotnet/roslyn-analyzers/issues/1241 is fixed - ~SessionController() => Dispose(false); -#pragma warning restore CA1821 // Remove empty Finalizers - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Implements the pattern - /// - /// If this function was NOT called by the finalizer - void Dispose(bool disposing) + public async ValueTask DisposeAsync() { lock (synchronizationLock) { if (disposed) return; disposed = true; - logger.LogTrace("Disposing..."); - if (disposing) - { - if (!released) - { - process.Terminate(); - byondLock.Dispose(); - } + } - process.Dispose(); - bridgeRegistration?.Dispose(); - reattachInformation.Dmb?.Dispose(); // will be null when released - chatTrackingContext.Dispose(); - reattachTopicCts.Dispose(); - } - else - { - if (logger != null) - logger.LogError("Being disposed via finalizer!"); - if (!released) - if (process != null) - process.Terminate(); - else if (logger != null) - logger.LogCritical("Unable to terminate active DreamDaemon session due to finalizer ordering!"); - } + logger.LogTrace("Disposing..."); + if (!released) + { + process.Terminate(); + byondLock.Dispose(); + } + + process.Dispose(); + bridgeRegistration?.Dispose(); + reattachInformation.Dmb?.Dispose(); // will be null when released + chatTrackingContext.Dispose(); + reattachTopicCts.Dispose(); + + if (!released) + { + // finish the async callback + await Lifetime.ConfigureAwait(false); } } @@ -362,12 +316,15 @@ namespace Tgstation.Server.Host.Components.Session reattachTopicCts.Token) .ConfigureAwait(false); - if (reattachResponse.InteropResponse?.CustomCommands != null) - chatTrackingContext.CustomCommands = reattachResponse.InteropResponse.CustomCommands; - else if (reattachResponse.InteropResponse != null) - logger.LogWarning( - "DMAPI v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.", - Dmb.CompileJob.DMApiVersion.Semver()); + if (reattachResponse != null) + { + if (reattachResponse.InteropResponse?.CustomCommands != null) + chatTrackingContext.CustomCommands = reattachResponse.InteropResponse.CustomCommands; + else if (reattachResponse.InteropResponse != null) + logger.LogWarning( + "DMAPI v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.", + CompileJob.DMApiVersion.Semver()); + } } return result; @@ -530,7 +487,7 @@ namespace Tgstation.Server.Host.Components.Session } /// - /// Throws an if has been called + /// Throws an if has been called /// void CheckDisposed() { @@ -542,7 +499,7 @@ namespace Tgstation.Server.Host.Components.Session public void EnableCustomChatCommands() => chatTrackingContext.Active = DMApiAvailable; /// - public ReattachInformation Release() + public async Task Release() { CheckDisposed(); @@ -550,7 +507,7 @@ namespace Tgstation.Server.Host.Components.Session var tmpProvider = reattachInformation.Dmb; reattachInformation.Dmb = null; released = true; - Dispose(); + await DisposeAsync().ConfigureAwait(false); byondLock.DoNotDeleteThisSession(); tmpProvider.KeepAlive(); reattachInformation.Dmb = tmpProvider; @@ -609,16 +566,17 @@ namespace Tgstation.Server.Host.Components.Session logger.LogTrace("Interop response: {0}", topicReturn); } - catch + catch(Exception ex) { - logger.LogWarning("Invalid interop response: {0}", topicReturn); + logger.LogWarning(ex, "Invalid interop response: {0}", topicReturn); } return new CombinedTopicResponse(topicResponse, interopResponse); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { logger.LogTrace( + ex, "Topic request {0}!", cancellationToken.IsCancellationRequested ? "aborted" @@ -627,7 +585,7 @@ namespace Tgstation.Server.Host.Components.Session } catch (Exception e) { - logger.LogWarning("Send command exception:{0}{1}", Environment.NewLine, e); + logger.LogWarning(e, "Send command exception!"); } return null; diff --git a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs index 3b7a617e41..c6c4483cb0 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs @@ -1,8 +1,7 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Globalization; using System.Linq; -using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; @@ -16,6 +15,7 @@ using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Interop.Bridge; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Security; @@ -116,15 +116,14 @@ namespace Tgstation.Server.Host.Components.Session /// Check if a given can be bound to. /// /// The port number to test. - static void PortBindTest(ushort port) + void PortBindTest(ushort port) { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - try { - socket.Bind(new IPEndPoint(IPAddress.Any, port)); + logger.LogTrace("Bind test: {0}", port); + SocketExtensions.BindTest(port, false); } - catch (Exception ex) + catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { throw new JobException(ErrorCode.DreamDaemonPortInUse, ex); } @@ -215,7 +214,6 @@ namespace Tgstation.Server.Host.Components.Session { logger.LogDebug( "Launching session with CompileJob {0}...", - byondLock.Version.Semver(), dmbProvider.CompileJob.Id); if (launchParameters.SecurityLevel == DreamDaemonSecurity.Trusted) @@ -268,8 +266,9 @@ namespace Tgstation.Server.Host.Components.Session async Task GetDDOutput() { + // DCT x2: None available if (!platformIdentifier.IsWindows) - return process.GetCombinedOutput(); + return await process.GetCombinedOutput(default).ConfigureAwait(false); var logFilePath = ioManager.ConcatPath(dmbProvider.Directory, logFileGuid.ToString()); try @@ -285,32 +284,31 @@ namespace Tgstation.Server.Host.Components.Session { try { + // DCT: No token available await ioManager.DeleteFile(logFilePath, default).ConfigureAwait(false); } catch (Exception ex) { - logger.LogWarning("Failed to delete DreamDaemon log file {0}: {1}", logFilePath, ex); + logger.LogWarning(ex, "Failed to delete DreamDaemon log file {0}!", logFilePath); } } } // Log DD output - _ = process.Lifetime.ContinueWith( - async x => - { - try - { - var ddOutput = await GetDDOutput().ConfigureAwait(false); - logger.LogTrace( - "DreamDaemon Output:{0}{1}", - Environment.NewLine, ddOutput); - } - catch (Exception ex) - { - logger.LogWarning("Error reading DreamDaemon output: {0}", ex); - } - }, - TaskScheduler.Current); + async Task PostLifetime() + { + try + { + var ddOutput = await GetDDOutput().ConfigureAwait(false); + logger.LogTrace( + "DreamDaemon Output:{0}{1}", + Environment.NewLine, ddOutput); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error reading DreamDaemon output!"); + } + } try { @@ -340,6 +338,7 @@ namespace Tgstation.Server.Host.Components.Session chat, assemblyInformationProvider, loggerFactory.CreateLogger(), + PostLifetime, launchParameters.StartupTimeout, false, apiValidate); @@ -348,9 +347,12 @@ namespace Tgstation.Server.Host.Components.Session } catch { - process.Terminate(); - process.Dispose(); - throw; + using (process) + { + process.Terminate(); + await process.Lifetime.ConfigureAwait(false); + throw; + } } } catch @@ -415,6 +417,7 @@ namespace Tgstation.Server.Host.Components.Session chat, assemblyInformationProvider, loggerFactory.CreateLogger(), + () => Task.CompletedTask, null, true, false); diff --git a/src/Tgstation.Server.Host/Components/Session/SessionPersistor.cs b/src/Tgstation.Server.Host/Components/Session/SessionPersistor.cs index eef018159e..a0513fad10 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionPersistor.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionPersistor.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; using System.Linq; @@ -133,10 +133,11 @@ namespace Tgstation.Server.Host.Components.Session { using var process = processExecutor.GetProcess(reattachInfo.ProcessId); process.Terminate(); + await process.Lifetime.ConfigureAwait(false); } catch (Exception ex) { - logger.LogWarning("Failed to kill process! Exception: {0}", ex); + logger.LogWarning(ex, "Failed to kill process!"); } } diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index a61ba8cea7..c18aee9d6a 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Globalization; @@ -211,7 +211,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles } catch (IOException e) { - logger.LogDebug("IOException while writing {0}: {1}", path, e); + logger.LogDebug(e, "IOException while writing {0}!", path); result = null; return; } @@ -463,7 +463,20 @@ namespace Tgstation.Server.Host.Components.StaticFiles var files = await ioManager.GetFilesWithExtension(EventScriptsSubdirectory, platformIdentifier.ScriptFileExtension, false, cancellationToken).ConfigureAwait(false); var resolvedScriptsDir = ioManager.ResolvePath(EventScriptsSubdirectory); - foreach (var I in files.Select(x => ioManager.GetFileName(x)).Where(x => x.StartsWith(scriptName, StringComparison.Ordinal))) + var scriptFiles = files + .Select(x => ioManager.GetFileName(x)) + .Where(x => x.StartsWith(scriptName, StringComparison.Ordinal)) + .ToList(); + + if (!scriptFiles.Any()) + { + logger.LogTrace("No event scripts starting with \"{0}\" detected", scriptName); + return; + } + + foreach (var I in scriptFiles) + { + logger.LogTrace("Running event script {0}...", I); using (var script = processExecutor.LaunchProcess( ioManager.ConcatPath(resolvedScriptsDir, I), resolvedScriptsDir, @@ -475,12 +488,13 @@ namespace Tgstation.Server.Host.Components.StaticFiles { var exitCode = await script.Lifetime.ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - var scriptOutput = script.GetCombinedOutput(); + var scriptOutput = await script.GetCombinedOutput(cancellationToken).ConfigureAwait(false); if (exitCode != 0) throw new JobException($"Script {I} exited with code {exitCode}:{Environment.NewLine}{scriptOutput}"); else logger.LogDebug("Script output:{0}{1}", Environment.NewLine, scriptOutput); } + } } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs index 90791ab63e..810a3dc740 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Globalization; using System.Threading; @@ -9,7 +9,6 @@ using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -23,9 +22,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public sealed override bool AlphaIsActive => true; - /// - public sealed override Models.CompileJob ActiveCompileJob => Server?.Dmb.CompileJob; - /// public sealed override RebootState? RebootState => Server?.RebootState; @@ -46,7 +42,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . /// The for the . /// The for the . /// The for the . @@ -61,7 +56,6 @@ namespace Tgstation.Server.Host.Components.Watchdog ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, ISessionPersistor sessionPersistor, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IServerControl serverControl, IAsyncDelayer asyncDelayer, @@ -76,7 +70,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - databaseContextFactory, jobManager, serverControl, asyncDelayer, @@ -163,11 +156,15 @@ namespace Tgstation.Server.Host.Components.Watchdog } /// - protected override void DisposeAndNullControllersImpl() + protected override async Task DisposeAndNullControllersImpl() { - Server?.Dispose(); - Server = null; + var disposeTask = Server?.DisposeAsync(); gracefulRebootRequired = false; + if (!disposeTask.HasValue) + return; + + await disposeTask.Value.ConfigureAwait(false); + Server = null; } /// @@ -227,7 +224,9 @@ namespace Tgstation.Server.Host.Components.Watchdog { // kill the controllers bool serverWasActive = Server != null; - DisposeAndNullControllers(); + + // DCT: Operation must always run + await DisposeAndNullControllers(default).ConfigureAwait(false); // server didn't get control of this dmb if (dmbToUse != null && !serverWasActive) @@ -255,7 +254,7 @@ namespace Tgstation.Server.Host.Components.Watchdog protected virtual Task HandleNewDmbAvailable(CancellationToken cancellationToken) { gracefulRebootRequired = true; - if (Server.Dmb.CompileJob.DMApiVersion == null) + if (Server.CompileJob.DMApiVersion == null) return Chat.SendWatchdogMessage( "A new deployment has been made but cannot be applied automatically as the currently running server has no DMAPI. Please manually reboot the server to apply the update.", true, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs index b593667a5b..919f3e0cf9 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using System; using System.Threading; using System.Threading.Tasks; @@ -12,7 +12,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// Runs and monitors the twin server controllers /// - public interface IWatchdog : IHostedService, IDisposable, IEventConsumer, IRenameNotifyee + public interface IWatchdog : IHostedService, IAsyncDisposable, IEventConsumer, IRenameNotifyee { /// /// The current . @@ -25,7 +25,7 @@ namespace Tgstation.Server.Host.Components.Watchdog bool AlphaIsActive { get; } /// - /// The currently running on the server + /// Retrieves the currently running on the server /// Models.CompileJob ActiveCompileJob { get; } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs index 51b6c7a7fe..cc03802ded 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdog.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models.Internal; @@ -7,7 +7,6 @@ using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -30,7 +29,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . /// The for the . /// The for the . /// The for the . @@ -47,7 +45,6 @@ namespace Tgstation.Server.Host.Components.Watchdog ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, ISessionPersistor sessionPersistor, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IServerControl serverControl, IAsyncDelayer asyncDelayer, @@ -64,7 +61,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - databaseContextFactory, jobManager, serverControl, asyncDelayer, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs index 30a8169e97..279fa3b1e9 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/PosixWatchdogFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using Tgstation.Server.Api.Models.Internal; @@ -8,7 +8,6 @@ using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -24,7 +23,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The for the . /// The for the . - /// The for the . /// The for the . /// The for the . /// The for the . @@ -32,7 +30,6 @@ namespace Tgstation.Server.Host.Components.Watchdog public PosixWatchdogFactory( IServerControl serverControl, ILoggerFactory loggerFactory, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IAsyncDelayer asyncDelayer, ISymlinkFactory symlinkFactory, @@ -40,7 +37,6 @@ namespace Tgstation.Server.Host.Components.Watchdog : base( serverControl, loggerFactory, - databaseContextFactory, jobManager, asyncDelayer, symlinkFactory, @@ -63,7 +59,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - DatabaseContextFactory, JobManager, ServerControl, AsyncDelayer, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index 0699f96772..7ade6c1129 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -1,4 +1,3 @@ -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Serilog.Context; using System; @@ -15,7 +14,6 @@ using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Interop.Topic; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -42,15 +40,15 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public abstract bool AlphaIsActive { get; } - /// - public abstract Models.CompileJob ActiveCompileJob { get; } - /// public DreamDaemonLaunchParameters ActiveLaunchParameters { get; protected set; } /// public DreamDaemonLaunchParameters LastLaunchParameters { get; protected set; } + /// + public Models.CompileJob ActiveCompileJob => GetActiveController()?.CompileJob; + /// public abstract RebootState? RebootState { get; } @@ -59,11 +57,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// protected TaskCompletionSource ActiveParametersUpdated { get; set; } - /// - /// The for the . - /// - protected SemaphoreSlim Semaphore { get; } - /// /// The for the . /// @@ -94,16 +87,21 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly Api.Models.Instance instance; + /// + /// The for the . + /// + readonly SemaphoreSlim synchronizationSemaphore; + + /// + /// used for . + /// + readonly SemaphoreSlim controllerDisposeSemaphore; + /// /// The for the /// readonly ISessionPersistor sessionPersistor; - /// - /// The for the - /// - readonly IDatabaseContextFactory databaseContextFactory; - /// /// The for the . /// @@ -124,11 +122,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly IEventConsumer eventConsumer; - /// - /// used for . - /// - readonly object controllerDisposeLock; - /// /// If the should in /// @@ -165,7 +158,7 @@ namespace Tgstation.Server.Host.Components.Watchdog bool releaseServers; /// - /// If the has been d. + /// If the has been 'd. /// bool disposed; @@ -176,7 +169,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The value of /// The value of /// The value of - /// The value of /// The value of /// The to populate with /// The value of . @@ -191,7 +183,6 @@ namespace Tgstation.Server.Host.Components.Watchdog ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, ISessionPersistor sessionPersistor, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IServerControl serverControl, IAsyncDelayer asyncDelayer, @@ -206,7 +197,6 @@ namespace Tgstation.Server.Host.Components.Watchdog SessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory)); DmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory)); this.sessionPersistor = sessionPersistor ?? throw new ArgumentNullException(nameof(sessionPersistor)); - this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); AsyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); this.diagnosticsIOManager = diagnosticsIOManager ?? throw new ArgumentNullException(nameof(diagnosticsIOManager)); @@ -224,16 +214,17 @@ namespace Tgstation.Server.Host.Components.Watchdog ActiveLaunchParameters = initialLaunchParameters; releaseServers = false; ActiveParametersUpdated = new TaskCompletionSource(); - controllerDisposeLock = new object(); restartRegistration = serverControl.RegisterForRestart(this); try { - Semaphore = new SemaphoreSlim(1); + synchronizationSemaphore = new SemaphoreSlim(1); + controllerDisposeSemaphore = new SemaphoreSlim(1); } catch { restartRegistration.Dispose(); + synchronizationSemaphore?.Dispose(); throw; } @@ -241,18 +232,21 @@ namespace Tgstation.Server.Host.Components.Watchdog } /// - public void Dispose() + public async ValueTask DisposeAsync() { Logger.LogTrace("Disposing..."); - Semaphore.Dispose(); + synchronizationSemaphore.Dispose(); restartRegistration.Dispose(); - DisposeAndNullControllers(); + + // DCT: None available, Operation must always run + await DisposeAndNullControllers(default).ConfigureAwait(false); + controllerDisposeSemaphore.Dispose(); monitorCts?.Dispose(); disposed = true; } /// - /// Implementation of . Does not lock + /// Implementation of . Does not lock /// /// If the termination will be delayed until a reboot is detected in the active server's DMAPI and this function will return immediately /// If the termination will be announced using @@ -272,8 +266,6 @@ namespace Tgstation.Server.Host.Components.Watchdog await StopMonitor().ConfigureAwait(false); - DisposeAndNullControllers(); - LastLaunchParameters = null; await chatTask.ConfigureAwait(false); @@ -330,7 +322,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var message4 = $"DEFCON 1: Four heartbeats have been missed! {actionTaken}..."; Logger.LogWarning(message4); await Chat.SendWatchdogMessage(message4, false, cancellationToken).ConfigureAwait(false); - DisposeAndNullControllers(); + await DisposeAndNullControllers(cancellationToken).ConfigureAwait(false); return shouldShutdown ? MonitorAction.Exit : MonitorAction.Restart; default: Logger.LogError("Invalid heartbeats missed count: {0}", heartbeatsMissed); @@ -392,7 +384,7 @@ namespace Tgstation.Server.Host.Components.Watchdog } catch (Exception e) { - // don't try to send chat tasks or warning logs if were suppressing exceptions or cancelled + Logger.LogWarning(e, "Failed to start watchdog!"); var originalChatTask = announceTask; async Task ChainChatTaskWithErrorMessage() { @@ -402,7 +394,6 @@ namespace Tgstation.Server.Host.Components.Watchdog } announceTask = ChainChatTaskWithErrorMessage(); - Logger.LogWarning("Failed to start watchdog: {0}", e.ToString()); throw; } finally @@ -412,9 +403,9 @@ namespace Tgstation.Server.Host.Components.Watchdog { await announceTask.ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - Logger.LogTrace("Announcement task canceled!"); + Logger.LogTrace(ex, "Announcement task canceled!"); } } @@ -477,7 +468,8 @@ namespace Tgstation.Server.Host.Components.Watchdog protected async Task ReattachFailure(Task chatTask, CancellationToken cancellationToken) { // we lost the server, just restart entirely - DisposeAndNullControllers(); + // DCT: Operation must always run + await DisposeAndNullControllers(default).ConfigureAwait(false); const string FailReattachMessage = "Unable to properly reattach to server! Restarting watchdog..."; Logger.LogWarning(FailReattachMessage); @@ -493,16 +485,19 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// Call and null the fields for all s. /// - protected abstract void DisposeAndNullControllersImpl(); + /// A representing the running operation. + protected abstract Task DisposeAndNullControllersImpl(); /// /// Wrapper for under a locked context. /// - protected void DisposeAndNullControllers() + /// The for the operation. + /// A representing the running operation. + protected async Task DisposeAndNullControllers(CancellationToken cancellationToken) { Logger.LogTrace("DisposeAndNullControllers"); - lock (controllerDisposeLock) - DisposeAndNullControllersImpl(); + using (await SemaphoreSlimContext.Lock(controllerDisposeSemaphore, cancellationToken).ConfigureAwait(false)) + await DisposeAndNullControllersImpl().ConfigureAwait(false); } /// @@ -529,14 +524,14 @@ namespace Tgstation.Server.Host.Components.Watchdog private async Task MonitorRestart(CancellationToken cancellationToken) { Logger.LogTrace("Monitor restart!"); - DisposeAndNullControllers(); + await DisposeAndNullControllers(cancellationToken).ConfigureAwait(false); var chatTask = Task.CompletedTask; for (var retryAttempts = 1; ; ++retryAttempts) { Status = WatchdogStatus.Restoring; Exception launchException; - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) try { // use LaunchImplNoLock without announcements or restarting the monitor @@ -558,7 +553,7 @@ namespace Tgstation.Server.Host.Components.Watchdog await chatTask.ConfigureAwait(false); } - Logger.LogWarning("Failed to automatically restart the watchdog! Attempt: {0}, Exception: {1}", retryAttempts, launchException); + Logger.LogWarning(launchException, "Failed to automatically restart the watchdog! Attempt: {0}", retryAttempts); Status = WatchdogStatus.DelayedRestart; var retryDelay = Math.Min( @@ -589,7 +584,7 @@ namespace Tgstation.Server.Host.Components.Watchdog { Logger.LogTrace("Entered MonitorLifetimes"); Status = WatchdogStatus.Online; - using var _ = cancellationToken.Register(() => Logger.LogTrace("Monitor cancellationToken triggered")); + using var cancellationTokenLoggingRegistration = cancellationToken.Register(() => Logger.LogTrace("Monitor cancellationToken triggered")); // this function is responsible for calling HandlerMonitorWakeup when necessary and manitaining the MonitorState try @@ -613,7 +608,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var heartbeat = heartbeatSeconds == 0 || !controller.DMApiAvailable ? Extensions.TaskExtensions.InfiniteTask() - : Task.Delay(TimeSpan.FromSeconds(heartbeatSeconds)); + : Task.Delay(TimeSpan.FromSeconds(heartbeatSeconds), cancellationToken); // cancel waiting if requested var cancelTcs = new TaskCompletionSource(); @@ -633,7 +628,7 @@ namespace Tgstation.Server.Host.Components.Watchdog Logger.LogTrace("Monitor activated"); // always run HandleMonitorWakeup from the context of the semaphore lock - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) { // Set this sooner so chat sends don't hold us up if (activeServerLifetime.IsCompleted) @@ -702,9 +697,9 @@ namespace Tgstation.Server.Host.Components.Watchdog { // really, this should NEVER happen Logger.LogError( - "Monitor crashed! Iteration: {0}, Exception: {1}", - iteration, - e); + e, + "Monitor crashed! Iteration: {0}", + iteration); var nextActionMessage = nextAction != MonitorAction.Exit ? "Recovering" @@ -736,11 +731,12 @@ namespace Tgstation.Server.Host.Components.Watchdog if (releaseServers) { Logger.LogTrace("Detaching servers..."); - releasedReattachInformation = GetActiveController().Release(); + releasedReattachInformation = await GetActiveController().Release().ConfigureAwait(false); } } - DisposeAndNullControllers(); + // DCT: Operation must always run + await DisposeAndNullControllers(default).ConfigureAwait(false); Status = WatchdogStatus.Offline; Logger.LogTrace("Monitor exiting..."); @@ -758,7 +754,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public async Task ChangeSettings(DreamDaemonLaunchParameters launchParameters, CancellationToken cancellationToken) { - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) { bool match = launchParameters.CanApplyWithoutReboot(ActiveLaunchParameters); ActiveLaunchParameters = launchParameters; @@ -808,7 +804,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public async Task HandleChatCommand(string commandName, string arguments, ChatUser sender, CancellationToken cancellationToken) { - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) { if (Status == WatchdogStatus.Offline) return "TGS: Server offline!"; @@ -836,14 +832,14 @@ namespace Tgstation.Server.Host.Components.Watchdog { if (Status != WatchdogStatus.Offline) throw new JobException(ErrorCode.WatchdogRunning); - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) await LaunchNoLock(true, true, true, null, cancellationToken).ConfigureAwait(false); } /// public virtual async Task ResetRebootState(CancellationToken cancellationToken) { - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) { if (Status == WatchdogStatus.Offline) return; @@ -860,7 +856,7 @@ namespace Tgstation.Server.Host.Components.Watchdog throw new JobException(ErrorCode.WatchdogNotRunning); Logger.LogTrace("Begin Restart. Graceful: {0}", graceful); - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) { if (!graceful) { @@ -885,23 +881,8 @@ namespace Tgstation.Server.Host.Components.Watchdog if (!autoStart && reattachInfo == null) return; - long? adminUserId = null; - - await databaseContextFactory.UseContext( - async db => adminUserId = await db - .Users - .AsQueryable() - .Where(x => x.CanonicalName == Models.User.CanonicalizeName(Api.Models.User.AdminName)) - .Select(x => x.Id) - .FirstAsync(cancellationToken) - .ConfigureAwait(false)) - .ConfigureAwait(false); var job = new Models.Job { - StartedBy = new Models.User - { - Id = adminUserId.Value - }, Instance = new Models.Instance { Id = instance.Id @@ -910,11 +891,16 @@ namespace Tgstation.Server.Host.Components.Watchdog CancelRight = (ulong)DreamDaemonRights.Shutdown, CancelRightsType = RightsType.DreamDaemon }; - await jobManager.RegisterOperation(job, async (j, databaseContextFactory, progressFunction, ct) => - { - using (await SemaphoreSlimContext.Lock(Semaphore, ct).ConfigureAwait(false)) - await LaunchNoLock(true, true, true, reattachInfo, ct).ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); + await jobManager.RegisterOperation( + job, + async (core, databaseContextFactory, paramJob, progressFunction, ct) => + { + // core will certainly be null here since jobs started before the instance is onlined can't provide one + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, ct).ConfigureAwait(false)) + await LaunchNoLock(true, true, true, reattachInfo, ct).ConfigureAwait(false); + }, + cancellationToken) + .ConfigureAwait(false); } /// @@ -930,8 +916,8 @@ namespace Tgstation.Server.Host.Components.Watchdog catch (Exception ex) { Logger.LogCritical( - "Failed to persist session reattach information! To repair this, DreamDaemon will need to be manully stopped and then relaunched with TGS. Exception: {0}", - ex); + ex, + "Failed to persist session reattach information! To repair this, DreamDaemon will need to be manully stopped and then relaunched with TGS."); } releasedReattachInformation = null; @@ -942,7 +928,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public async Task Terminate(bool graceful, CancellationToken cancellationToken) { - using (await SemaphoreSlimContext.Lock(Semaphore, cancellationToken).ConfigureAwait(false)) + using (await SemaphoreSlimContext.Lock(synchronizationSemaphore, cancellationToken).ConfigureAwait(false)) await TerminateNoLock(graceful, !releaseServers, cancellationToken).ConfigureAwait(false); } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs index dbf3fe82e9..423892a249 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using Tgstation.Server.Api.Models.Internal; @@ -8,7 +8,6 @@ using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -27,11 +26,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// protected ILoggerFactory LoggerFactory { get; } - /// - /// The for the - /// - protected IDatabaseContextFactory DatabaseContextFactory { get; } - /// /// The for the /// @@ -52,21 +46,18 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The value of /// The value of - /// The value of /// The value of /// The value of /// The containing the value of public WatchdogFactory( IServerControl serverControl, ILoggerFactory loggerFactory, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IAsyncDelayer asyncDelayer, IOptions generalConfigurationOptions) { ServerControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl)); LoggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - DatabaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); JobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); AsyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); GeneralConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); @@ -88,7 +79,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - DatabaseContextFactory, JobManager, ServerControl, AsyncDelayer, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs index e01a610f19..fd6e49c24a 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdog.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; @@ -8,7 +8,6 @@ using Tgstation.Server.Host.Components.Deployment; using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -51,7 +50,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . /// The for the . /// The for the . /// The for the . @@ -68,7 +66,6 @@ namespace Tgstation.Server.Host.Components.Watchdog ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, ISessionPersistor sessionPersistor, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IServerControl serverControl, IAsyncDelayer asyncDelayer, @@ -84,7 +81,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - databaseContextFactory, jobManager, serverControl, asyncDelayer, @@ -102,15 +98,15 @@ namespace Tgstation.Server.Host.Components.Watchdog } catch { - Dispose(); + var _ = DisposeAsync(); throw; } } /// - protected override void DisposeAndNullControllersImpl() + protected override async Task DisposeAndNullControllersImpl() { - base.DisposeAndNullControllersImpl(); + await base.DisposeAndNullControllersImpl().ConfigureAwait(false); // If we reach this point, we can guarantee PrepServerForLaunch will be called before starting again. ActiveSwappable = null; @@ -185,14 +181,14 @@ namespace Tgstation.Server.Host.Components.Watchdog } catch (Exception ex) { - Logger.LogWarning("Exception while suspending server: {0}", ex); + Logger.LogWarning(ex, "Exception while suspending server!"); } await windowsProvider.MakeActive(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - Logger.LogError("Exception while swapping: {0}", ex); + Logger.LogError(ex, "Exception while swapping"); IDmbProvider providerToDispose = windowsProvider ?? compileJobProvider; providerToDispose.Dispose(); throw; @@ -219,7 +215,8 @@ namespace Tgstation.Server.Host.Components.Watchdog // Add another lock to the startup DMB because it'll be used throughout the lifetime of the watchdog startupDmbProvider = await DmbFactory.FromCompileJob(dmbToUse.CompileJob, cancellationToken).ConfigureAwait(false); - ActiveSwappable = pendingSwappable ?? new SwappableDmbProvider(dmbToUse, GameIOManager, symlinkFactory); + pendingSwappable ??= new SwappableDmbProvider(dmbToUse, GameIOManager, symlinkFactory); + ActiveSwappable = pendingSwappable; pendingSwappable = null; try diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs index 1e52e7ecad..a597a80997 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WindowsWatchdogFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using Tgstation.Server.Api.Models.Internal; @@ -8,7 +8,6 @@ using Tgstation.Server.Host.Components.Events; using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -29,7 +28,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The for the . /// The for the . - /// The for the . /// The for the . /// The for the . /// The value of . @@ -37,7 +35,6 @@ namespace Tgstation.Server.Host.Components.Watchdog public WindowsWatchdogFactory( IServerControl serverControl, ILoggerFactory loggerFactory, - IDatabaseContextFactory databaseContextFactory, IJobManager jobManager, IAsyncDelayer asyncDelayer, ISymlinkFactory symlinkFactory, @@ -45,7 +42,6 @@ namespace Tgstation.Server.Host.Components.Watchdog : base( serverControl, loggerFactory, - databaseContextFactory, jobManager, asyncDelayer, generalConfigurationOptions) @@ -69,7 +65,6 @@ namespace Tgstation.Server.Host.Components.Watchdog sessionControllerFactory, dmbFactory, sessionPersistor, - DatabaseContextFactory, JobManager, ServerControl, AsyncDelayer, diff --git a/src/Tgstation.Server.Host/Configuration/ControlPanelConfiguration.cs b/src/Tgstation.Server.Host/Configuration/ControlPanelConfiguration.cs index 719ccef9e0..13320674dc 100644 --- a/src/Tgstation.Server.Host/Configuration/ControlPanelConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/ControlPanelConfiguration.cs @@ -25,6 +25,6 @@ namespace Tgstation.Server.Host.Configuration /// /// Origins allowed for CORS requests /// - public List AllowedOrigins { get; set; } + public ICollection AllowedOrigins { get; set; } } } diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 13b21a8d4c..4083007dec 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -11,6 +11,7 @@ using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using System.Web; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; @@ -30,7 +31,7 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.Administration)] public sealed class AdministrationController : ApiController { - const string OctokitException = "Bad GitHub API response, check configuration! Exception: {0}"; + const string OctokitException = "Bad GitHub API response, check configuration!"; /// /// The for the @@ -116,7 +117,7 @@ namespace Tgstation.Server.Host.Controllers ObjectResult RateLimit(RateLimitExceededException exception) { - Logger.LogWarning("Exceeded GitHub rate limit! Exception {0}", exception); + Logger.LogWarning(exception, "Exceeded GitHub rate limit!"); var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture); Response.Headers.Add("Retry-After", new StringValues(secondsString)); return StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessage(ErrorCode.GitHubApiRateLimit)); @@ -148,7 +149,7 @@ namespace Tgstation.Server.Host.Controllers } catch (ApiException e) { - Logger.LogWarning(OctokitException, e); + Logger.LogWarning(e, OctokitException); return StatusCode(HttpStatusCode.FailedDependency); } @@ -226,7 +227,7 @@ namespace Tgstation.Server.Host.Controllers } catch (NotFoundException e) { - Logger.LogWarning("Not found exception while retrieving upstream repository info: {0}", e); + Logger.LogWarning(e, "Not found exception while retrieving upstream repository info!"); } return Json(new Administration @@ -242,7 +243,7 @@ namespace Tgstation.Server.Host.Controllers } catch (ApiException e) { - Logger.LogWarning(OctokitException, e); + Logger.LogWarning(e, OctokitException); return StatusCode(HttpStatusCode.FailedDependency, new ErrorMessage(ErrorCode.GitHubApiError) { AdditionalData = e.Message @@ -368,7 +369,7 @@ namespace Tgstation.Server.Host.Controllers /// A resulting in the of the request. /// Downloaded successfully. /// An IO error occurred while downloading. - [HttpGet(Routes.Logs + "/{path}")] + [HttpGet(Routes.Logs + "/{*path}")] [TgsAuthorize(AdministrationRights.DownloadLogs)] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(ErrorMessage), 409)] @@ -377,6 +378,13 @@ namespace Tgstation.Server.Host.Controllers if (path == null) throw new ArgumentNullException(nameof(path)); + path = HttpUtility.UrlDecode(path); + + // guard against directory navigation + var sanitizedPath = ioManager.GetFileName(path); + if (path != sanitizedPath) + return Forbid(); + var fullPath = ioManager.ConcatPath( fileLoggingConfiguration.GetFullLogDirectory(ioManager, assemblyInformationProvider, platformIdentifier), path); diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 88309c993a..6c66ec832e 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -7,6 +7,7 @@ using System; using System.Linq; using System.Net; using System.Net.Mime; +using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; @@ -40,7 +41,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the /// - protected ILogger Logger { get; } + protected ILogger Logger { get; } /// /// The for the operation @@ -68,7 +69,7 @@ namespace Tgstation.Server.Host.Controllers public ApiController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, - ILogger logger, + ILogger logger, bool requireInstance, bool requireHeaders = true) { @@ -122,6 +123,14 @@ namespace Tgstation.Server.Host.Controllers /// A with the given . protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload); + /// + /// Performs validation steps for an instance request. + /// + /// The for the operation. + /// A resulting in an appropriate on validation failure, otherwise. + protected virtual Task ValidateInstanceRequest(CancellationToken cancellationToken) + => Task.FromResult(null); + /// /// Response for missing/Invalid headers. /// @@ -157,6 +166,9 @@ namespace Tgstation.Server.Host.Controllers #pragma warning disable CA1506 // TODO: Decomplexify public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { + if (context == null) + throw new ArgumentNullException(nameof(context)); + // ALL valid token and login requests that match a route go through this function // 404 is returned before if (AuthenticationContext != null && AuthenticationContext.User == null) @@ -183,16 +195,16 @@ namespace Tgstation.Server.Host.Controllers if (requireInstance) { + IActionResult errorCase = null; if (!ApiHeaders.InstanceId.HasValue) - { - await BadRequest(new ErrorMessage(ErrorCode.InstanceHeaderRequired)).ExecuteResultAsync(context).ConfigureAwait(false); - return; - } + errorCase = BadRequest(new ErrorMessage(ErrorCode.InstanceHeaderRequired)); + else if (AuthenticationContext.InstanceUser == null) + errorCase = Forbid(); - if (AuthenticationContext.InstanceUser == null) + errorCase ??= await ValidateInstanceRequest(context.HttpContext.RequestAborted).ConfigureAwait(false); + if (errorCase != null) { - // accessing an instance they don't have access to or one that's disabled - await Forbid().ExecuteResultAsync(context).ConfigureAwait(false); + await errorCase.ExecuteResultAsync(context).ConfigureAwait(false); return; } } diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index 1a0e794e9a..0a3c0252f4 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -62,8 +62,12 @@ namespace Tgstation.Server.Host.Controllers public async Task Process([FromQuery]string data, CancellationToken cancellationToken) { // Nothing to see here - if (!IPAddress.IsLoopback(Request.HttpContext.Connection.RemoteIpAddress)) + var remoteIP = Request.HttpContext.Connection.RemoteIpAddress; + if (!IPAddress.IsLoopback(remoteIP)) + { + logger.LogTrace("Ignoring remote bridge request from {0}", remoteIP); return NotFound(); + } using (LogContext.PushProperty("Bridge", Interlocked.Increment(ref requestsProcessed))) { @@ -72,9 +76,9 @@ namespace Tgstation.Server.Host.Controllers { request = JsonConvert.DeserializeObject(data, DMApiConstants.SerializerSettings); } - catch + catch (Exception ex) { - logger.LogWarning("Error deserializing bridge request: {0}", data); + logger.LogWarning(ex, "Error deserializing bridge request: {0}", data); return BadRequest(); } diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 490c6308db..cab2dd1fb2 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -20,13 +20,8 @@ namespace Tgstation.Server.Host.Controllers /// Controller for managing s /// [Route(Routes.Byond)] - public sealed class ByondController : ApiController + public sealed class ByondController : InstanceRequiredController { - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// The for the /// @@ -37,7 +32,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the /// The for the - /// The value of + /// The for the . /// The value of /// The for the public ByondController( @@ -47,12 +42,11 @@ namespace Tgstation.Server.Host.Controllers IJobManager jobManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } @@ -64,11 +58,13 @@ namespace Tgstation.Server.Host.Controllers [HttpGet] [TgsAuthorize(ByondRights.ReadActive)] [ProducesResponseType(typeof(Api.Models.Byond), 200)] - public Task Read() => Task.FromResult( - Json(new Api.Models.Byond - { - Version = instanceManager.GetInstance(Instance).ByondManager.ActiveVersion - })); + public Task Read() + => WithComponentInstance(instance => + Task.FromResult( + Json(new Api.Models.Byond + { + Version = instance.ByondManager.ActiveVersion + }))); /// /// Lists installed versions. @@ -78,16 +74,16 @@ namespace Tgstation.Server.Host.Controllers [HttpGet(Routes.List)] [TgsAuthorize(ByondRights.ListInstalled)] [ProducesResponseType(typeof(IEnumerable), 200)] - public IActionResult List() - => Json( - instanceManager - .GetInstance(Instance) - .ByondManager - .InstalledVersions - .Select(x => new Api.Models.Byond - { - Version = x - })); + public Task List() + => WithComponentInstance(instance => + Task.FromResult( + Json(instance + .ByondManager + .InstalledVersions + .Select(x => new Api.Models.Byond + { + Version = x + })))); /// /// Changes the active BYOND version to the one specified in a given . @@ -118,57 +114,60 @@ namespace Tgstation.Server.Host.Controllers || (!userByondRights.HasFlag(ByondRights.InstallCustomVersion) && model.Content != null)) return Forbid(); - var byondManager = instanceManager.GetInstance(Instance).ByondManager; - // remove cruff fields var result = new Api.Models.Byond(); - - if (model.Content == null && byondManager.InstalledVersions.Any(x => x == model.Version)) - { - Logger.LogInformation( - "User ID {0} changing instance ID {1} BYOND version to {2}", - AuthenticationContext.User.Id, - Instance.Id, - model.Version); - await byondManager.ChangeVersion(model.Version, null, cancellationToken).ConfigureAwait(false); - } - else if (model.Version.Build > 0) - return BadRequest(new ErrorMessage(ErrorCode.ByondNonExistentCustomVersion)); - else - { - var installingVersion = model.Version.Build <= 0 - ? new Version(model.Version.Major, model.Version.Minor) - : model.Version; - - Logger.LogInformation( - "User ID {0} installing BYOND version to {1} on instance ID {2}", - AuthenticationContext.User.Id, - installingVersion, - Instance.Id); - - // run the install through the job manager - var job = new Models.Job + return await WithComponentInstance( + async instance => { - Description = $"Install {(model.Content == null ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Byond, - CancelRight = (ulong)ByondRights.CancelInstall, - Instance = Instance - }; - await jobManager.RegisterOperation( - job, - (paramJob, databaseContextFactory, progressHandler, jobCancellationToken) => byondManager.ChangeVersion( - model.Version, - model.Content, - jobCancellationToken), - cancellationToken) - .ConfigureAwait(false); - result.InstallJob = job.ToApi(); - } + var byondManager = instance.ByondManager; + if (model.Content == null && byondManager.InstalledVersions.Any(x => x == model.Version)) + { + Logger.LogInformation( + "User ID {0} changing instance ID {1} BYOND version to {2}", + AuthenticationContext.User.Id, + Instance.Id, + model.Version); + await byondManager.ChangeVersion(model.Version, null, cancellationToken).ConfigureAwait(false); + } + else if (model.Version.Build > 0) + return BadRequest(new ErrorMessage(ErrorCode.ByondNonExistentCustomVersion)); + else + { + var installingVersion = model.Version.Build <= 0 + ? new Version(model.Version.Major, model.Version.Minor) + : model.Version; - if ((AuthenticationContext.GetRight(RightsType.Byond) & (ulong)ByondRights.ReadActive) != 0) - result.Version = byondManager.ActiveVersion; - return result.InstallJob != null ? (IActionResult)Accepted(result) : Json(result); + Logger.LogInformation( + "User ID {0} installing BYOND version to {1} on instance ID {2}", + AuthenticationContext.User.Id, + installingVersion, + Instance.Id); + + // run the install through the job manager + var job = new Models.Job + { + Description = $"Install {(model.Content == null ? String.Empty : "custom ")}BYOND version {model.Version.Major}.{model.Version.Minor}", + StartedBy = AuthenticationContext.User, + CancelRightsType = RightsType.Byond, + CancelRight = (ulong)ByondRights.CancelInstall, + Instance = Instance + }; + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressHandler, jobCancellationToken) => core.ByondManager.ChangeVersion( + model.Version, + model.Content, + jobCancellationToken), + cancellationToken) + .ConfigureAwait(false); + result.InstallJob = job.ToApi(); + } + + if ((AuthenticationContext.GetRight(RightsType.Byond) & (ulong)ByondRights.ReadActive) != 0) + result.Version = byondManager.ActiveVersion; + return result.InstallJob != null ? (IActionResult)Accepted(result) : Json(result); + }) + .ConfigureAwait(false); } } } diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index f642156e0f..45c2db5d74 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -25,19 +25,14 @@ namespace Tgstation.Server.Host.Controllers /// [Route(Routes.Chat)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class ChatController : ApiController + public sealed class ChatController : InstanceRequiredController { - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// Construct a /// /// The for the /// The for the - /// The value of + /// The for the . /// The for the public ChatController( IDatabaseContext databaseContext, @@ -45,12 +40,11 @@ namespace Tgstation.Server.Host.Controllers IInstanceManager instanceManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } /// @@ -116,25 +110,32 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.ChatBots.Add(dbModel); await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - var instance = instanceManager.GetInstance(Instance); - try - { - // try to create it - await instance.Chat.ChangeSettings(dbModel, cancellationToken).ConfigureAwait(false); + return await WithComponentInstance( + async instance => + { + try + { + // try to create it + await instance.Chat.ChangeSettings(dbModel, cancellationToken).ConfigureAwait(false); - if (dbModel.Channels.Count > 0) - await instance.Chat.ChangeChannels(dbModel.Id, dbModel.Channels, cancellationToken).ConfigureAwait(false); - } - catch - { - // undo the add - DatabaseContext.ChatBots.Remove(dbModel); - await DatabaseContext.Save(default).ConfigureAwait(false); - await instance.Chat.DeleteConnection(dbModel.Id, cancellationToken).ConfigureAwait(false); - throw; - } + if (dbModel.Channels.Count > 0) + await instance.Chat.ChangeChannels(dbModel.Id, dbModel.Channels, cancellationToken).ConfigureAwait(false); + } + catch + { + // undo the add + DatabaseContext.ChatBots.Remove(dbModel); - return StatusCode(HttpStatusCode.Created, dbModel.ToApi()); + // DCTx2: Operations must always run + await DatabaseContext.Save(default).ConfigureAwait(false); + await instance.Chat.DeleteConnection(dbModel.Id, default).ConfigureAwait(false); + throw; + } + + return null; + }) + .ConfigureAwait(false) + ?? StatusCode(HttpStatusCode.Created, dbModel.ToApi()); } /// @@ -148,19 +149,21 @@ namespace Tgstation.Server.Host.Controllers [TgsAuthorize(ChatBotRights.Delete)] [ProducesResponseType(204)] public async Task Delete(long id, CancellationToken cancellationToken) - { - var instance = instanceManager.GetInstance(Instance); - await Task.WhenAll( - instance.Chat.DeleteConnection(id, cancellationToken), - DatabaseContext - .ChatBots - .AsQueryable() - .Where(x => x.Id == id) - .DeleteAsync(cancellationToken)) - .ConfigureAwait(false); - - return NoContent(); - } + => await WithComponentInstance( + async instance => + { + await Task.WhenAll( + instance.Chat.DeleteConnection(id, cancellationToken), + DatabaseContext + .ChatBots + .AsQueryable() + .Where(x => x.Id == id) + .DeleteAsync(cancellationToken)) + .ConfigureAwait(false); + return null; + }) + .ConfigureAwait(false) + ?? NoContent(); /// /// List s. @@ -313,13 +316,21 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - var chat = instanceManager.GetInstance(Instance).Chat; + earlyOut = await WithComponentInstance( + async instance => + { + var chat = instance.Chat; + if (anySettingsModified) + await chat.ChangeSettings(current, cancellationToken).ConfigureAwait(false); // have to rebuild the thing first - if (anySettingsModified) - await chat.ChangeSettings(current, cancellationToken).ConfigureAwait(false); // have to rebuild the thing first + if (model.Channels != null || anySettingsModified) + await chat.ChangeChannels(current.Id, current.Channels, cancellationToken).ConfigureAwait(false); - if (model.Channels != null || anySettingsModified) - await chat.ChangeChannels(current.Id, current.Channels, cancellationToken).ConfigureAwait(false); + return null; + }) + .ConfigureAwait(false); + if (earlyOut != null) + return earlyOut; if (userRights.HasFlag(ChatBotRights.Read)) { diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index a697f36a12..cecd5f2c93 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -20,13 +20,8 @@ namespace Tgstation.Server.Host.Controllers /// The for s /// [Route(Routes.Configuration)] - public sealed class ConfigurationController : ApiController + public sealed class ConfigurationController : InstanceRequiredController { - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// The for the /// @@ -37,7 +32,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the /// The for the - /// The value of + /// The for the . /// The value of /// The for the public ConfigurationController( @@ -47,12 +42,11 @@ namespace Tgstation.Server.Host.Controllers IIOManager ioManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); } @@ -95,16 +89,28 @@ namespace Tgstation.Server.Host.Controllers if (ForbidDueToModeConflicts(model.Path, out var systemIdentity)) return Forbid(); - var config = instanceManager.GetInstance(Instance).Configuration; try { - var newFile = await config.Write(model.Path, systemIdentity, model.Content, model.LastReadHash, cancellationToken).ConfigureAwait(false); - if (newFile == null) - return Conflict(new ErrorMessage(ErrorCode.ConfigurationFileUpdated)); + return await WithComponentInstance( + async instance => + { + var newFile = await instance + .Configuration + .Write( + model.Path, + systemIdentity, + model.Content, + model.LastReadHash, + cancellationToken) + .ConfigureAwait(false); + if (newFile == null) + return Conflict(new ErrorMessage(ErrorCode.ConfigurationFileUpdated)); - newFile.Content = null; + newFile.Content = null; - return model.LastReadHash == null ? (IActionResult)Created(newFile) : Json(newFile); + return model.LastReadHash == null ? (IActionResult)Created(newFile) : Json(newFile); + }) + .ConfigureAwait(false); } catch(IOException e) { @@ -139,11 +145,19 @@ namespace Tgstation.Server.Host.Controllers try { - var result = await instanceManager.GetInstance(Instance).Configuration.Read(filePath, systemIdentity, cancellationToken).ConfigureAwait(false); - if (result == null) - return Gone(); + return await WithComponentInstance( + async instance => + { + var result = await instance + .Configuration + .Read(filePath, systemIdentity, cancellationToken) + .ConfigureAwait(false); + if (result == null) + return Gone(); - return Json(result); + return Json(result); + }) + .ConfigureAwait(false); } catch (IOException e) { @@ -178,11 +192,19 @@ namespace Tgstation.Server.Host.Controllers try { - var result = await instanceManager.GetInstance(Instance).Configuration.ListDirectory(directoryPath, systemIdentity, cancellationToken).ConfigureAwait(false); - if (result == null) - return Gone(); + return await WithComponentInstance( + async instance => + { + var result = await instance + .Configuration + .ListDirectory(directoryPath, systemIdentity, cancellationToken) + .ConfigureAwait(false); + if (result == null) + return Gone(); - return Json(result); + return Json(result); + }) + .ConfigureAwait(false); } catch (NotImplementedException) { @@ -227,13 +249,14 @@ namespace Tgstation.Server.Host.Controllers try { model.IsDirectory = true; - return await instanceManager - .GetInstance(Instance) + return await WithComponentInstance( + async instance => await instance .Configuration .CreateDirectory(model.Path, systemIdentity, cancellationToken) .ConfigureAwait(false) ? (IActionResult)Json(model) - : Created(model); + : Created(model)) + .ConfigureAwait(false); } catch (IOException e) { @@ -273,13 +296,14 @@ namespace Tgstation.Server.Host.Controllers try { - return await instanceManager - .GetInstance(Instance) + return await WithComponentInstance( + async instance => await instance .Configuration .DeleteDirectory(directory.Path, systemIdentity, cancellationToken) .ConfigureAwait(false) ? (IActionResult)NoContent() - : Conflict(new ErrorMessage(ErrorCode.ConfigurationDirectoryNotEmpty)); + : Conflict(new ErrorMessage(ErrorCode.ConfigurationDirectoryNotEmpty))) + .ConfigureAwait(false); } catch (NotImplementedException) { diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 9ae5df0a0e..82e6866312 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -23,25 +23,20 @@ namespace Tgstation.Server.Host.Controllers /// for managing the /// [Route(Routes.DreamDaemon)] - public sealed class DreamDaemonController : ApiController + public sealed class DreamDaemonController : InstanceRequiredController { /// /// The for the /// readonly IJobManager jobManager; - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// Construct a /// /// The for the /// The for the /// The value of - /// The value of + /// The for the . /// The for the public DreamDaemonController( IDatabaseContext databaseContext, @@ -50,13 +45,12 @@ namespace Tgstation.Server.Host.Controllers IInstanceManager instanceManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } /// @@ -68,25 +62,27 @@ namespace Tgstation.Server.Host.Controllers [HttpPut] [TgsAuthorize(DreamDaemonRights.Start)] [ProducesResponseType(typeof(Api.Models.Job), 202)] - public async Task Create(CancellationToken cancellationToken) - { - // alias for launching DD - var instance = instanceManager.GetInstance(Instance); - - if (instance.Watchdog.Status != WatchdogStatus.Offline) - return Conflict(new ErrorMessage(ErrorCode.WatchdogRunning)); - - var job = new Models.Job + public Task Create(CancellationToken cancellationToken) + => WithComponentInstance(async instance => { - Description = "Launch DreamDaemon", - CancelRight = (ulong)DreamDaemonRights.Shutdown, - CancelRightsType = RightsType.DreamDaemon, - Instance = Instance, - StartedBy = AuthenticationContext.User - }; - await jobManager.RegisterOperation(job, (paramJob, databaseContextFactory, progressHandler, innerCt) => instance.Watchdog.Launch(innerCt), cancellationToken).ConfigureAwait(false); - return Accepted(job.ToApi()); - } + if (instance.Watchdog.Status != WatchdogStatus.Offline) + return Conflict(new ErrorMessage(ErrorCode.WatchdogRunning)); + + var job = new Models.Job + { + Description = "Launch DreamDaemon", + CancelRight = (ulong)DreamDaemonRights.Shutdown, + CancelRightsType = RightsType.DreamDaemon, + Instance = Instance, + StartedBy = AuthenticationContext.User + }; + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressHandler, innerCt) => core.Watchdog.Launch(innerCt), + cancellationToken) + .ConfigureAwait(false); + return Accepted(job.ToApi()); + }); /// /// Get the watchdog status. @@ -107,61 +103,61 @@ namespace Tgstation.Server.Host.Controllers /// The to operate on if any /// The for the operation /// A resulting in the of the operation - async Task ReadImpl(DreamDaemonSettings settings, CancellationToken cancellationToken) - { - var instance = instanceManager.GetInstance(Instance); - var dd = instance.Watchdog; - - var metadata = (AuthenticationContext.GetRight(RightsType.DreamDaemon) & (ulong)DreamDaemonRights.ReadMetadata) != 0; - var revision = (AuthenticationContext.GetRight(RightsType.DreamDaemon) & (ulong)DreamDaemonRights.ReadRevision) != 0; - - if (settings == null) + Task ReadImpl(DreamDaemonSettings settings, CancellationToken cancellationToken) + => WithComponentInstance(async instance => { - settings = await DatabaseContext - .Instances - .AsQueryable() - .Where(x => x.Id == Instance.Id) - .Select(x => x.DreamDaemonSettings) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - if (settings == default) - return Gone(); - } + var dd = instance.Watchdog; - var result = new DreamDaemon(); - if (metadata) - { - var alphaActive = dd.AlphaIsActive; - var llp = dd.LastLaunchParameters; - var rstate = dd.RebootState; - result.AutoStart = settings.AutoStart.Value; - result.CurrentPort = llp?.Port.Value; - result.CurrentSecurity = llp?.SecurityLevel.Value; - result.CurrentAllowWebclient = llp?.AllowWebClient.Value; - result.Port = settings.Port.Value; - result.AllowWebClient = settings.AllowWebClient.Value; - result.Status = dd.Status; - result.SecurityLevel = settings.SecurityLevel.Value; - result.SoftRestart = rstate == RebootState.Restart; - result.SoftShutdown = rstate == RebootState.Shutdown; - result.StartupTimeout = settings.StartupTimeout.Value; - result.HeartbeatSeconds = settings.HeartbeatSeconds.Value; - result.TopicRequestTimeout = settings.TopicRequestTimeout.Value; - } + var metadata = (AuthenticationContext.GetRight(RightsType.DreamDaemon) & (ulong)DreamDaemonRights.ReadMetadata) != 0; + var revision = (AuthenticationContext.GetRight(RightsType.DreamDaemon) & (ulong)DreamDaemonRights.ReadRevision) != 0; - if (revision) - { - var latestCompileJob = instance.LatestCompileJob(); - result.ActiveCompileJob = ((instance.Watchdog.Status != WatchdogStatus.Offline - ? dd.ActiveCompileJob - : latestCompileJob) ?? latestCompileJob) - ?.ToApi(); - if (latestCompileJob?.Id != result.ActiveCompileJob?.Id) - result.StagedCompileJob = latestCompileJob?.ToApi(); - } + if (settings == null) + { + settings = await DatabaseContext + .Instances + .AsQueryable() + .Where(x => x.Id == Instance.Id) + .Select(x => x.DreamDaemonSettings) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + if (settings == default) + return Gone(); + } - return Json(result); - } + var result = new DreamDaemon(); + if (metadata) + { + var alphaActive = dd.AlphaIsActive; + var llp = dd.LastLaunchParameters; + var rstate = dd.RebootState; + result.AutoStart = settings.AutoStart.Value; + result.CurrentPort = llp?.Port.Value; + result.CurrentSecurity = llp?.SecurityLevel.Value; + result.CurrentAllowWebclient = llp?.AllowWebClient.Value; + result.Port = settings.Port.Value; + result.AllowWebClient = settings.AllowWebClient.Value; + result.Status = dd.Status; + result.SecurityLevel = settings.SecurityLevel.Value; + result.SoftRestart = rstate == RebootState.Restart; + result.SoftShutdown = rstate == RebootState.Shutdown; + result.StartupTimeout = settings.StartupTimeout.Value; + result.HeartbeatSeconds = settings.HeartbeatSeconds.Value; + result.TopicRequestTimeout = settings.TopicRequestTimeout.Value; + } + + if (revision) + { + var latestCompileJob = instance.LatestCompileJob(); + result.ActiveCompileJob = ((instance.Watchdog.Status != WatchdogStatus.Offline + ? dd.ActiveCompileJob + : latestCompileJob) ?? latestCompileJob) + ?.ToApi(); + if (latestCompileJob?.Id != result.ActiveCompileJob?.Id) + result.StagedCompileJob = latestCompileJob?.ToApi(); + } + + return Json(result); + }); /// /// Stops the Watchdog if it's running. @@ -172,12 +168,12 @@ namespace Tgstation.Server.Host.Controllers [HttpDelete] [TgsAuthorize(DreamDaemonRights.Shutdown)] [ProducesResponseType(204)] - public async Task Delete(CancellationToken cancellationToken) - { - var instance = instanceManager.GetInstance(Instance); - await instance.Watchdog.Terminate(false, cancellationToken).ConfigureAwait(false); - return NoContent(); - } + public Task Delete(CancellationToken cancellationToken) + => WithComponentInstance(async instance => + { + await instance.Watchdog.Terminate(false, cancellationToken).ConfigureAwait(false); + return NoContent(); + }); /// /// Update watchdog settings to be applied at next server reboot. @@ -240,41 +236,43 @@ namespace Tgstation.Server.Host.Controllers return false; } - var instance = instanceManager.GetInstance(Instance); - var dd = instance.Watchdog; - var rebootState = dd.RebootState; - var oldSoftRestart = rebootState == RebootState.Restart; - var oldSoftShutdown = rebootState == RebootState.Shutdown; + return await WithComponentInstance( + async instance => + { + var watchdog = instance.Watchdog; + var rebootState = watchdog.RebootState; + var oldSoftRestart = rebootState == RebootState.Restart; + var oldSoftShutdown = rebootState == RebootState.Shutdown; - if (CheckModified(x => x.AllowWebClient, DreamDaemonRights.SetWebClient) - || CheckModified(x => x.AutoStart, DreamDaemonRights.SetAutoStart) - || CheckModified(x => x.Port, DreamDaemonRights.SetPort) - || CheckModified(x => x.SecurityLevel, DreamDaemonRights.SetSecurity) - || (model.SoftRestart.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftRestart)) - || (model.SoftShutdown.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftShutdown)) - || CheckModified(x => x.StartupTimeout, DreamDaemonRights.SetStartupTimeout) - || CheckModified(x => x.HeartbeatSeconds, DreamDaemonRights.SetHeartbeatInterval) - || CheckModified(x => x.TopicRequestTimeout, DreamDaemonRights.SetTopicTimeout)) - return Forbid(); + if (CheckModified(x => x.AllowWebClient, DreamDaemonRights.SetWebClient) + || CheckModified(x => x.AutoStart, DreamDaemonRights.SetAutoStart) + || CheckModified(x => x.Port, DreamDaemonRights.SetPort) + || CheckModified(x => x.SecurityLevel, DreamDaemonRights.SetSecurity) + || (model.SoftRestart.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftRestart)) + || (model.SoftShutdown.HasValue && !AuthenticationContext.InstanceUser.DreamDaemonRights.Value.HasFlag(DreamDaemonRights.SoftShutdown)) + || CheckModified(x => x.StartupTimeout, DreamDaemonRights.SetStartupTimeout) + || CheckModified(x => x.HeartbeatSeconds, DreamDaemonRights.SetHeartbeatInterval) + || CheckModified(x => x.TopicRequestTimeout, DreamDaemonRights.SetTopicTimeout)) + return Forbid(); - var wd = instanceManager.GetInstance(Instance).Watchdog; + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + // run this second because current may be modified by it + await watchdog.ChangeSettings(current, cancellationToken).ConfigureAwait(false); - // run this second because current may be modified by it - await wd.ChangeSettings(current, cancellationToken).ConfigureAwait(false); + if (!oldSoftRestart && model.SoftRestart == true) + await watchdog.Restart(true, cancellationToken).ConfigureAwait(false); + else if (!oldSoftShutdown && model.SoftShutdown == true) + await watchdog.Terminate(true, cancellationToken).ConfigureAwait(false); + else if ((oldSoftRestart && model.SoftRestart == false) || (oldSoftShutdown && model.SoftShutdown == false)) + await watchdog.ResetRebootState(cancellationToken).ConfigureAwait(false); - if (!oldSoftRestart && model.SoftRestart == true) - await wd.Restart(true, cancellationToken).ConfigureAwait(false); - else if (!oldSoftShutdown && model.SoftShutdown == true) - await wd.Terminate(true, cancellationToken).ConfigureAwait(false); - else if ((oldSoftRestart && model.SoftRestart == false) || (oldSoftShutdown && model.SoftShutdown == false)) - await wd.ResetRebootState(cancellationToken).ConfigureAwait(false); - - return await ReadImpl(current, cancellationToken).ConfigureAwait(false); + return await ReadImpl(current, cancellationToken).ConfigureAwait(false); + }) + .ConfigureAwait(false); } - #pragma warning restore CA1506 - #pragma warning restore CA1502 +#pragma warning restore CA1506 +#pragma warning restore CA1502 /// /// Creates a to restart the Watchdog. It will start if it wasn't already running. @@ -285,25 +283,30 @@ namespace Tgstation.Server.Host.Controllers [HttpPatch] [TgsAuthorize(DreamDaemonRights.Restart)] [ProducesResponseType(typeof(Api.Models.Job), 202)] - public async Task Restart(CancellationToken cancellationToken) - { - var job = new Models.Job + public Task Restart(CancellationToken cancellationToken) + => WithComponentInstance(async instance => { - Instance = Instance, - CancelRightsType = RightsType.DreamDaemon, - CancelRight = (ulong)DreamDaemonRights.Shutdown, - StartedBy = AuthenticationContext.User, - Description = "Restart Watchdog" - }; + var job = new Models.Job + { + Instance = Instance, + CancelRightsType = RightsType.DreamDaemon, + CancelRight = (ulong)DreamDaemonRights.Shutdown, + StartedBy = AuthenticationContext.User, + Description = "Restart Watchdog" + }; - var watchdog = instanceManager.GetInstance(Instance).Watchdog; + var watchdog = instance.Watchdog; - if (watchdog.Status == WatchdogStatus.Offline) - return Conflict(new ErrorMessage(ErrorCode.WatchdogNotRunning)); + if (watchdog.Status == WatchdogStatus.Offline) + return Conflict(new ErrorMessage(ErrorCode.WatchdogNotRunning)); - await jobManager.RegisterOperation(job, (paramJob, databaseContextFactory, progressReporter, ct) => watchdog.Restart(false, ct), cancellationToken).ConfigureAwait(false); - return Accepted(job.ToApi()); - } + await jobManager.RegisterOperation( + job, + (core, paramJob, databaseContextFactory, progressReporter, ct) => core.Watchdog.Restart(false, ct), + cancellationToken) + .ConfigureAwait(false); + return Accepted(job.ToApi()); + }); /// /// Creates a to generate a DreamDaemon process dump. @@ -314,27 +317,28 @@ namespace Tgstation.Server.Host.Controllers [HttpPatch(Routes.Diagnostics)] [TgsAuthorize(DreamDaemonRights.CreateDump)] [ProducesResponseType(typeof(Api.Models.Job), 202)] - public async Task CreateDump(CancellationToken cancellationToken) - { - var job = new Models.Job + public Task CreateDump(CancellationToken cancellationToken) + => WithComponentInstance(async instance => { - Instance = Instance, - CancelRightsType = RightsType.DreamDaemon, - CancelRight = (ulong)DreamDaemonRights.CreateDump, - StartedBy = AuthenticationContext.User, - Description = "Create DreamDaemon Process Dump" - }; + var job = new Models.Job + { + Instance = Instance, + CancelRightsType = RightsType.DreamDaemon, + CancelRight = (ulong)DreamDaemonRights.CreateDump, + StartedBy = AuthenticationContext.User, + Description = "Create DreamDaemon Process Dump" + }; - var watchdog = instanceManager.GetInstance(Instance).Watchdog; + var watchdog = instance.Watchdog; - if (watchdog.Status == WatchdogStatus.Offline) - return Conflict(new ErrorMessage(ErrorCode.WatchdogNotRunning)); + if (watchdog.Status == WatchdogStatus.Offline) + return Conflict(new ErrorMessage(ErrorCode.WatchdogNotRunning)); - await jobManager.RegisterOperation( - job, - (paramJob, databaseContextFactory, progressReporter, ct) => watchdog.CreateDump(ct), cancellationToken) - .ConfigureAwait(false); - return Accepted(job.ToApi()); - } + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressReporter, ct) => core.Watchdog.CreateDump(ct), cancellationToken) + .ConfigureAwait(false); + return Accepted(job.ToApi()); + }); } } diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 7c5fd60e24..7a4ec3bd0c 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -22,25 +22,20 @@ namespace Tgstation.Server.Host.Controllers /// [Route(Routes.DreamMaker)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class DreamMakerController : ApiController + public sealed class DreamMakerController : InstanceRequiredController { /// /// The for the /// readonly IJobManager jobManager; - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// Construct a /// /// The for the /// The for the /// The value of - /// The value of + /// The for the . /// The for the public DreamMakerController( IDatabaseContext databaseContext, @@ -49,13 +44,12 @@ namespace Tgstation.Server.Host.Controllers IInstanceManager instanceManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); } /// @@ -69,7 +63,6 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(DreamMaker), 200)] public async Task Read(CancellationToken cancellationToken) { - var instance = instanceManager.GetInstance(Instance); var dreamMakerSettings = await DatabaseContext .DreamMakerSettings .AsQueryable() @@ -151,10 +144,10 @@ namespace Tgstation.Server.Host.Controllers Instance = Instance }; - IInstance instance = instanceManager.GetInstance(Instance); await jobManager.RegisterOperation( job, - instance.DreamMaker.DeploymentProcess, + (core, databaseContextFactory, paramJob, progressReporter, jobCancellationToken) + => core.DreamMaker.DeploymentProcess(paramJob, databaseContextFactory, progressReporter, jobCancellationToken), cancellationToken) .ConfigureAwait(false); return Accepted(job.ToApi()); diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 21275a96f3..243fe297db 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -1,10 +1,9 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; @@ -33,10 +32,13 @@ namespace Tgstation.Server.Host.Controllers public sealed class InstanceController : ApiController { /// - /// File name to allow attaching instances + /// File name to allow attaching instances. /// - const string InstanceAttachFileName = "TGS4_ALLOW_INSTANCE_ATTACH"; + public const string InstanceAttachFileName = "TGS4_ALLOW_INSTANCE_ATTACH"; + /// + /// Prefix for move s. + /// const string MoveInstanceJobPrefix = "Move instance ID "; /// @@ -54,11 +56,6 @@ namespace Tgstation.Server.Host.Controllers /// readonly IIOManager ioManager; - /// - /// The for the - /// - readonly IAssemblyInformationProvider assemblyInformationProvider; - /// /// The for the /// @@ -77,7 +74,6 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of - /// The value of /// The value of /// The containing the value of . /// The for the @@ -87,7 +83,6 @@ namespace Tgstation.Server.Host.Controllers IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, - IAssemblyInformationProvider assemblyInformationProvider, IPlatformIdentifier platformIdentifier, IOptions generalConfigurationOptions, ILogger logger) @@ -100,11 +95,51 @@ namespace Tgstation.Server.Host.Controllers this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); - this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } + Models.Instance CreateDefaultInstance(Api.Models.Instance initialSettings) + => new Models.Instance + { + ConfigurationType = initialSettings.ConfigurationType ?? ConfigurationType.Disallowed, + DreamDaemonSettings = new DreamDaemonSettings + { + AllowWebClient = false, + AutoStart = false, + Port = 1337, + SecurityLevel = DreamDaemonSecurity.Safe, + StartupTimeout = 60, + HeartbeatSeconds = 60, + TopicRequestTimeout = generalConfiguration.ByondTopicTimeout + }, + DreamMakerSettings = new DreamMakerSettings + { + ApiValidationPort = 1339, + ApiValidationSecurityLevel = DreamDaemonSecurity.Safe, + RequireDMApiValidation = true + }, + Name = initialSettings.Name, + Online = false, + Path = initialSettings.Path, + AutoUpdateInterval = initialSettings.AutoUpdateInterval ?? 0, + ChatBotLimit = initialSettings.ChatBotLimit ?? Models.Instance.DefaultChatBotLimit, + RepositorySettings = new RepositorySettings + { + CommitterEmail = Components.Repository.Repository.DefaultCommitterEmail, + CommitterName = Components.Repository.Repository.DefaultCommitterName, + PushTestMergeCommits = false, + ShowTestMergeCommitters = false, + AutoUpdatesKeepTestMerges = false, + AutoUpdatesSynchronize = false, + PostTestMergeComment = false + }, + InstanceUsers = new List // give this user full privileges on the instance + { + InstanceAdminUser(null) + } + }; + string NormalizePath(string path) { if (path == null) @@ -122,7 +157,7 @@ namespace Tgstation.Server.Host.Controllers if (userToModify == null) userToModify = new Models.InstanceUser() { - UserId = AuthenticationContext.User.Id + UserId = AuthenticationContext.User.Id.Value }; userToModify.ByondRights = RightsHelper.AllRights(); userToModify.ChatBotRights = RightsHelper.AllRights(); @@ -154,7 +189,8 @@ namespace Tgstation.Server.Host.Controllers if (String.IsNullOrWhiteSpace(model.Name)) return BadRequest(new ErrorMessage(ErrorCode.InstanceWhitespaceName)); - var targetInstancePath = NormalizePath(model.Path); + var unNormalizedPath = model.Path; + var targetInstancePath = NormalizePath(unNormalizedPath); model.Path = targetInstancePath; var installationDirectoryPath = NormalizePath(DefaultIOManager.CurrentDirectory); @@ -240,45 +276,7 @@ namespace Tgstation.Server.Host.Controllers else attached = true; - var newInstance = new Models.Instance - { - ConfigurationType = model.ConfigurationType ?? ConfigurationType.Disallowed, - DreamDaemonSettings = new DreamDaemonSettings - { - AllowWebClient = false, - AutoStart = false, - Port = 1337, - SecurityLevel = DreamDaemonSecurity.Safe, - StartupTimeout = 60, - HeartbeatSeconds = 60, - TopicRequestTimeout = generalConfiguration.ByondTopicTimeout - }, - DreamMakerSettings = new DreamMakerSettings - { - ApiValidationPort = 1339, - ApiValidationSecurityLevel = DreamDaemonSecurity.Safe, - RequireDMApiValidation = true - }, - Name = model.Name, - Online = false, - Path = model.Path, - AutoUpdateInterval = model.AutoUpdateInterval ?? 0, - ChatBotLimit = model.ChatBotLimit ?? Models.Instance.DefaultChatBotLimit, - RepositorySettings = new RepositorySettings - { - CommitterEmail = "tgstation-server@users.noreply.github.com", - CommitterName = assemblyInformationProvider.VersionPrefix, - PushTestMergeCommits = false, - ShowTestMergeCommitters = false, - AutoUpdatesKeepTestMerges = false, - AutoUpdatesSynchronize = false, - PostTestMergeComment = false - }, - InstanceUsers = new List // give this user full privileges on the instance - { - InstanceAdminUser(null) - } - }; + var newInstance = CreateDefaultInstance(model); DatabaseContext.Instances.Add(newInstance); try @@ -288,7 +286,7 @@ namespace Tgstation.Server.Host.Controllers try { // actually reserve it now - await ioManager.CreateDirectory(targetInstancePath, cancellationToken).ConfigureAwait(false); + await ioManager.CreateDirectory(unNormalizedPath, cancellationToken).ConfigureAwait(false); await ioManager.DeleteFile(ioManager.ConcatPath(targetInstancePath, InstanceAttachFileName), cancellationToken).ConfigureAwait(false); } catch @@ -296,6 +294,7 @@ namespace Tgstation.Server.Host.Controllers // oh shit delete the model DatabaseContext.Instances.Remove(newInstance); + // DCT: Operation must always run await DatabaseContext.Save(default).ConfigureAwait(false); throw; } @@ -341,7 +340,17 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.Instances.Remove(originalModel); var attachFileName = ioManager.ConcatPath(originalModel.Path, InstanceAttachFileName); - await ioManager.WriteAllBytes(attachFileName, Array.Empty(), default).ConfigureAwait(false); + try + { + await ioManager.WriteAllBytes(attachFileName, Array.Empty(), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // DCT: Operation must always run + await ioManager.DeleteFile(attachFileName, default).ConfigureAwait(false); + throw; + } + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); // cascades everything return NoContent(); } @@ -382,7 +391,12 @@ namespace Tgstation.Server.Host.Controllers }).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (moveJob != default) + { + // don't allow them to cancel it if they can't start it. + if (!AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.Relocate)) + return Forbid(); await jobManager.CancelJob(moveJob, AuthenticationContext.User, true, cancellationToken).ConfigureAwait(false); // cancel it now + } var originalModel = await InstanceQuery() .Include(x => x.RepositorySettings) @@ -393,7 +407,7 @@ namespace Tgstation.Server.Host.Controllers if (originalModel == default(Models.Instance)) return Gone(); - if (ValidateInstanceOnlineStatus(originalModel)) + if (InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, originalModel)) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); var userRights = (InstanceManagerRights)AuthenticationContext.GetRight(RightsType.InstanceManager); @@ -418,7 +432,7 @@ namespace Tgstation.Server.Host.Controllers { rawPath = NormalizePath(model.Path); - if (model.Path != originalModel.Path) + if (rawPath != originalModel.Path) { if (!userRights.HasFlag(InstanceManagerRights.Relocate)) return Forbid(); @@ -430,7 +444,7 @@ namespace Tgstation.Server.Host.Controllers return Conflict(new ErrorMessage(ErrorCode.InstanceAtExistingPath)); originalModelPath = originalModel.Path; - originalModel.Path = model.Path; + originalModel.Path = rawPath; } } @@ -461,7 +475,11 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); if (renamed) - await instanceManager.GetInstance(originalModel).InstanceRenamed(originalModel.Name, cancellationToken).ConfigureAwait(false); + { + using var componentInstance = instanceManager.GetInstanceReference(originalModel); + if (componentInstance != null) + await componentInstance.InstanceRenamed(originalModel.Name, cancellationToken).ConfigureAwait(false); + } var oldAutoStart = originalModel.DreamDaemonSettings.AutoStart; try @@ -479,11 +497,13 @@ namespace Tgstation.Server.Host.Controllers catch (Exception e) { if(!(e is OperationCanceledException)) - Logger.LogError("Error changing instance online state! Exception: {0}", e); + Logger.LogError(e, "Error changing instance online state!"); originalModel.Online = originalOnline; originalModel.DreamDaemonSettings.AutoStart = oldAutoStart; if (originalModelPath != null) originalModel.Path = originalModelPath; + + // DCT: Operation must always run await DatabaseContext.Save(default).ConfigureAwait(false); throw; } @@ -498,19 +518,28 @@ namespace Tgstation.Server.Host.Controllers { var job = new Models.Job { - Description = String.Format(CultureInfo.InvariantCulture, MoveInstanceJobPrefix + "{0} from {1} to {2}", originalModel.Id, originalModel.Path, rawPath), + Description = $"{MoveInstanceJobPrefix}{originalModel.Id} from {originalModelPath} to {rawPath}", Instance = originalModel, CancelRightsType = RightsType.InstanceManager, CancelRight = (ulong)InstanceManagerRights.Relocate, StartedBy = AuthenticationContext.User }; - await jobManager.RegisterOperation(job, (paramJob, databaseContextFactory, progressHandler, ct) => instanceManager.MoveInstance(originalModel, rawPath, ct), cancellationToken).ConfigureAwait(false); + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressHandler, ct) // core will be null here since the instance is offline + => instanceManager.MoveInstance(originalModel, originalModelPath, ct), + cancellationToken) + .ConfigureAwait(false); api.MoveJob = job.ToApi(); } - if (originalModel.Online.Value && model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval) - await instanceManager.GetInstance(originalModel).SetAutoUpdateInterval(model.AutoUpdateInterval.Value).ConfigureAwait(false); + if (model.AutoUpdateInterval.HasValue && oldAutoUpdateInterval != model.AutoUpdateInterval) + { + using var componentInstance = instanceManager.GetInstanceReference(originalModel); + if (componentInstance != null) + await componentInstance.SetAutoUpdateInterval(model.AutoUpdateInterval.Value).ConfigureAwait(false); + } return moving ? (IActionResult)Accepted(api) : Json(api); } @@ -561,7 +590,7 @@ namespace Tgstation.Server.Host.Controllers var needsUpdate = false; foreach (var instance in instances) - needsUpdate |= ValidateInstanceOnlineStatus(instance); + needsUpdate |= InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance); if (needsUpdate) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); @@ -604,7 +633,7 @@ namespace Tgstation.Server.Host.Controllers if (instance == null) return Gone(); - if (ValidateInstanceOnlineStatus(instance)) + if (InstanceRequiredController.ValidateInstanceOnlineStatus(instanceManager, Logger, instance)) await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); if (cantList && !instance.InstanceUsers.Any(instanceUser => instanceUser.UserId == AuthenticationContext.User.Id && @@ -664,39 +693,5 @@ namespace Tgstation.Server.Host.Controllers return NoContent(); } - - /// - /// Corrects discrepencies between the status of s in the database vs the service. - /// - /// The to check. - /// if an unsaved DB update was made, otherwise. - bool ValidateInstanceOnlineStatus(Models.Instance metadata) - { - bool online; - try - { - instanceManager.GetInstance(metadata); - online = true; - } - catch (InvalidOperationException ex) - { - Logger.LogDebug("Expected instance offline exception: {0}", ex); - online = false; - } - - if (metadata.Online.Value == online) - return false; - - const string OfflineWord = "offline"; - const string OnlineWord = "online"; - - Logger.LogWarning( - "Instance {0} is says it's {1} in the database, but it is actually {2} in the service. Updating the database to reflect this...", - online ? OfflineWord : OnlineWord, - online ? OnlineWord : OfflineWord); - - metadata.Online = online; - return true; - } } } diff --git a/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs new file mode 100644 index 0000000000..faba875b7d --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Serilog.Context; +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for operations on an . + /// + public abstract class InstanceRequiredController : ApiController + { + /// + /// The for the . + /// + readonly IInstanceManager instanceManager; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The for the . + /// The for the . + /// The for the + protected InstanceRequiredController( + IInstanceManager instanceManager, + IDatabaseContext databaseContext, + IAuthenticationContextFactory authenticationContextFactory, + ILogger logger) + : base( + databaseContext, + authenticationContextFactory, + logger, + true, + true) + { + this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); + } + + /// + protected override async Task ValidateInstanceRequest(CancellationToken cancellationToken) + { + if (ValidateInstanceOnlineStatus(instanceManager, Logger, Instance)) + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + + using var instanceReferenceCheck = instanceManager.GetInstanceReference(Instance); + if (instanceReferenceCheck == null) + return Conflict(new ErrorMessage(ErrorCode.InstanceOffline)); + return null; + } + + /// + /// Run a given with the relevant . + /// + /// A accepting the and returning a with the . + /// A resulting in the that should be returned. + /// The context of should be as small as possinle so as to avoid race conditions. + protected async Task WithComponentInstance(Func> action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + using var instanceReference = instanceManager.GetInstanceReference(Instance); + using (LogContext.PushProperty("InstanceReference", instanceReference.Uid)) + { + if (instanceReference == null) + return Conflict(new ErrorMessage(ErrorCode.InstanceOffline)); + return await action(instanceReference).ConfigureAwait(false); + } + } + + /// + /// Corrects discrepencies between the status of s in the database vs the service. + /// + /// The to use. + /// The to use. + /// The to check. + /// if an unsaved DB update was made, otherwise. + public static bool ValidateInstanceOnlineStatus(IInstanceManager instanceManager, ILogger logger, Models.Instance metadata) + { + if (instanceManager == null) + throw new ArgumentNullException(nameof(instanceManager)); + if (metadata == null) + throw new ArgumentNullException(nameof(metadata)); + + bool online; + using (var instanceReferenceCheck = instanceManager.GetInstanceReference(metadata)) + online = instanceReferenceCheck != null; + + if (metadata.Online.Value == online) + return false; + + const string OfflineWord = "offline"; + const string OnlineWord = "online"; + + logger.LogWarning( + "Instance {0} is says it's {1} in the database, but it is actually {2} in the service. Updating the database to reflect this...", + online ? OfflineWord : OnlineWord, + online ? OnlineWord : OfflineWord); + + metadata.Online = online; + return true; + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs index dbb3ff8dae..34586ea5cf 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -20,41 +21,27 @@ namespace Tgstation.Server.Host.Controllers /// for managing s. /// [Route(Routes.InstanceUser)] - public sealed class InstanceUserController : ApiController + public sealed class InstanceUserController : InstanceRequiredController { /// /// Construct a /// + /// The for the . /// The for the /// The for the /// The for the public InstanceUserController( + IInstanceManager instanceManager, IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { } - /// - /// Checks a for errors. - /// - /// The to check - /// The to take if this is not a new - IActionResult StandardModelChecks(Api.Models.InstanceUser model) - { - if (model == null) - throw new ArgumentNullException(nameof(model)); - - if (!model.UserId.HasValue) - return BadRequest(new ErrorMessage(ErrorCode.UserMissingId)); - - return null; - } - /// /// Create an . /// @@ -67,8 +54,22 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(Api.Models.InstanceUser), 201)] public async Task Create([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) { - // Don't check the result as how can a new user have an ID - StandardModelChecks(model); + if (model == null) + throw new ArgumentNullException(nameof(model)); + + var userCanonicalName = await DatabaseContext + .Users + .AsQueryable() + .Where(x => x.Id == model.UserId) + .Select(x => x.CanonicalName) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (userCanonicalName == default) + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); + + if (userCanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return Forbid(); var dbUser = new Models.InstanceUser { @@ -104,9 +105,8 @@ namespace Tgstation.Server.Host.Controllers #pragma warning disable CA1506 // TODO: Decomplexify public async Task Update([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken) { - var earlyOut = StandardModelChecks(model); - if (earlyOut != null) - return earlyOut; + if (model == null) + throw new ArgumentNullException(nameof(model)); var originalUser = await DatabaseContext .Instances diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index e6bca24a46..6347617699 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -19,7 +20,7 @@ namespace Tgstation.Server.Host.Controllers /// for s /// [Route(Routes.Jobs)] - public sealed class JobController : ApiController + public sealed class JobController : InstanceRequiredController { /// /// The for the @@ -29,20 +30,22 @@ namespace Tgstation.Server.Host.Controllers /// /// Construct a /// + /// The for the . /// The for the /// The for the /// The value of /// The for the public JobController( + IInstanceManager instanceManager, IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, ILogger logger) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true) + logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 9b14db234b..0dec87903f 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -29,13 +29,8 @@ namespace Tgstation.Server.Host.Controllers /// [Route(Routes.Repository)] #pragma warning disable CA1506 // TODO: Decomplexify - public sealed class RepositoryController : ApiController + public sealed class RepositoryController : InstanceRequiredController { - /// - /// The for the - /// - readonly IInstanceManager instanceManager; - /// /// The for the /// @@ -56,7 +51,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the /// The for the - /// The value of + /// The for the . /// The value of /// The value of /// The for the @@ -70,13 +65,11 @@ namespace Tgstation.Server.Host.Controllers ILogger logger, IOptions generalConfigurationOptions) : base( + instanceManager, databaseContext, authenticationContextFactory, - logger, - true, - true) + logger) { - this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); @@ -192,61 +185,67 @@ namespace Tgstation.Server.Host.Controllers var cloneBranch = model.Reference; var origin = model.Origin; - var repoManager = instanceManager.GetInstance(Instance).RepositoryManager; - - if (repoManager.CloneInProgress) - return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); - - if (repoManager.InUse) - return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); - - using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); - - // clone conflict - if (repo != null) - return Conflict(new ErrorMessage(ErrorCode.RepoExists)); - - var job = new Models.Job - { - Description = String.Format(CultureInfo.InvariantCulture, "Clone branch {1} of repository {0}", origin, cloneBranch ?? "master"), - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelClone, - Instance = Instance - }; - var api = currentModel.ToApi(); - await jobManager.RegisterOperation(job, async (paramJob, databaseContextFactory, progressReporter, ct) => - { - using var repos = await repoManager.CloneRepository( - new Uri(origin), - cloneBranch, - currentModel.AccessUser, - currentModel.AccessToken, - progressReporter, - model.RecurseSubmodules ?? true, - ct) - .ConfigureAwait(false); - if (repos == null) - throw new JobException(ErrorCode.RepoExists); - var instance = new Models.Instance + return await WithComponentInstance( + async instance => { - Id = Instance.Id - }; - await databaseContextFactory.UseContext( - async databaseContext => + var repoManager = instance.RepositoryManager; + + if (repoManager.CloneInProgress) + return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); + + if (repoManager.InUse) + return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); + + using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); + + // clone conflict + if (repo != null) + return Conflict(new ErrorMessage(ErrorCode.RepoExists)); + + var job = new Models.Job { - databaseContext.Instances.Attach(instance); - if (await PopulateApi(api, repos, databaseContext, instance, ct).ConfigureAwait(false)) - await databaseContext.Save(ct).ConfigureAwait(false); - }) - .ConfigureAwait(false); - }, cancellationToken).ConfigureAwait(false); + Description = String.Format(CultureInfo.InvariantCulture, "Clone branch {1} of repository {0}", origin, cloneBranch ?? "master"), + StartedBy = AuthenticationContext.User, + CancelRightsType = RightsType.Repository, + CancelRight = (ulong)RepositoryRights.CancelClone, + Instance = Instance + }; + var api = currentModel.ToApi(); + await jobManager.RegisterOperation(job, async (core, databaseContextFactory, paramJob, progressReporter, ct) => + { + var repoManager = core.RepositoryManager; + using var repos = await repoManager.CloneRepository( + new Uri(origin), + cloneBranch, + currentModel.AccessUser, + currentModel.AccessToken, + progressReporter, + model.RecurseSubmodules ?? true, + ct) + .ConfigureAwait(false); + if (repos == null) + throw new JobException(ErrorCode.RepoExists); + var instance = new Models.Instance + { + Id = Instance.Id + }; + await databaseContextFactory.UseContext( + async databaseContext => + { + databaseContext.Instances.Attach(instance); + if (await PopulateApi(api, repos, databaseContext, instance, ct).ConfigureAwait(false)) + await databaseContext.Save(ct).ConfigureAwait(false); + }) + .ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); - api.Origin = model.Origin; - api.Reference = model.Reference; - api.ActiveJob = job.ToApi(); + api.Origin = model.Origin; + api.Reference = model.Reference; + api.ActiveJob = job.ToApi(); - return Created(api); + return Created(api); + }) + .ConfigureAwait(false); } /// @@ -286,7 +285,11 @@ namespace Tgstation.Server.Host.Controllers Instance = Instance }; var api = currentModel.ToApi(); - await jobManager.RegisterOperation(job, (paramJob, databaseContextFactory, progressReporter, ct) => instanceManager.GetInstance(Instance).RepositoryManager.DeleteRepository(cancellationToken), cancellationToken).ConfigureAwait(false); + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressReporter, ct) => core.RepositoryManager.DeleteRepository(ct), + cancellationToken) + .ConfigureAwait(false); api.ActiveJob = job.ToApi(); return Accepted(api); } @@ -317,23 +320,29 @@ namespace Tgstation.Server.Host.Controllers return Gone(); var api = currentModel.ToApi(); - var repoManager = instanceManager.GetInstance(Instance).RepositoryManager; - if (repoManager.CloneInProgress) - return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); + return await WithComponentInstance( + async instance => + { + var repoManager = instance.RepositoryManager; - if (repoManager.InUse) - return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); + if (repoManager.CloneInProgress) + return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); - using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); - if (repo != null && await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken).ConfigureAwait(false)) - { - // user may have fucked with the repo manually, do what we can - await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); - return Created(api); - } + if (repoManager.InUse) + return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); - return Json(api); + using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); + if (repo != null && await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken).ConfigureAwait(false)) + { + // user may have fucked with the repo manually, do what we can + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + return Created(api); + } + + return Json(api); + }) + .ConfigureAwait(false); } /// @@ -350,8 +359,8 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(Repository), 200)] [ProducesResponseType(typeof(Repository), 202)] [ProducesResponseType(typeof(ErrorMessage), 410)] - #pragma warning disable CA1502, CA1505 // TODO: Decomplexify - public async Task Update([FromBody]Repository model, CancellationToken cancellationToken) +#pragma warning disable CA1502, CA1505 // TODO: Decomplexify + public async Task Update([FromBody] Repository model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -426,23 +435,32 @@ namespace Tgstation.Server.Host.Controllers var canRead = userRights.HasFlag(RepositoryRights.Read); var api = canRead ? currentModel.ToApi() : new Repository(); - var repoManager = instanceManager.GetInstance(Instance).RepositoryManager; - if (canRead) { - if (repoManager.CloneInProgress) - return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); + var earlyOut = await WithComponentInstance( + async instance => + { + var repoManager = instance.RepositoryManager; + if (repoManager.CloneInProgress) + return Conflict(new ErrorMessage(ErrorCode.RepoCloning)); - if (repoManager.InUse) - return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); + if (repoManager.InUse) + return Conflict(new ErrorMessage(ErrorCode.RepoBusy)); - using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); - if (repo == null) - return Conflict(new ErrorMessage(ErrorCode.RepoMissing)); - await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken).ConfigureAwait(false); + using var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false); + if (repo == null) + return Conflict(new ErrorMessage(ErrorCode.RepoMissing)); + await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken).ConfigureAwait(false); - if (model.Origin != null && model.Origin != repo.Origin) - return BadRequest(new ErrorMessage(ErrorCode.RepoCantChangeOrigin)); + if (model.Origin != null && model.Origin != repo.Origin) + return BadRequest(new ErrorMessage(ErrorCode.RepoCantChangeOrigin)); + + return null; + }) + .ConfigureAwait(false); + + if (earlyOut != null) + return earlyOut; } // this is just db stuf so stow it away @@ -471,18 +489,13 @@ namespace Tgstation.Server.Host.Controllers if (description == null) return Json(api); // no git changes - var job = new Models.Job - { - Description = description, - StartedBy = AuthenticationContext.User, - Instance = Instance, - CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelPendingChanges, - }; - - // Time to access git, do it in a job - await jobManager.RegisterOperation(job, async (paramJob, databaseContextFactory, progressReporter, ct) => + async Task UpdateCallbackThatDesperatelyNeedsRefactoring( + IInstanceCore instance, + IDatabaseContextFactory databaseContextFactory, + Action progressReporter, + CancellationToken ct) { + var repoManager = instance.RepositoryManager; using var repo = await repoManager.LoadRepository(ct).ConfigureAwait(false); if (repo == null) throw new JobException(ErrorCode.RepoMissing); @@ -553,7 +566,9 @@ namespace Tgstation.Server.Host.Controllers testMergeToAdd.MergedBy = mergedBy; - lastRevisionInfo.ActiveTestMerges.AddRange(previousRevInfo.ActiveTestMerges); + foreach (var activeTestMerge in previousRevInfo.ActiveTestMerges) + lastRevisionInfo.ActiveTestMerges.Add(activeTestMerge); + lastRevisionInfo.ActiveTestMerges.Add(new RevInfoTestMerge { TestMerge = testMergeToAdd @@ -802,7 +817,7 @@ namespace Tgstation.Server.Host.Controllers } if (exception != null) - Logger.LogWarning("Error retrieving pull request metadata: {0}", exception); + Logger.LogWarning(exception, "Error retrieving pull request metadata!"); // we want to take the earliest truth possible to prevent RCEs, if this fails AddTestMerge will set it if (I.PullRequestRevision == null && pr != null) @@ -849,6 +864,8 @@ namespace Tgstation.Server.Host.Controllers await repo.Sychronize(currentModel.AccessUser, currentModel.AccessToken, currentModel.CommitterName, currentModel.CommitterEmail, NextProgressReporter(), false, ct).ConfigureAwait(false); await UpdateRevInfo().ConfigureAwait(false); } + + return null; } catch { @@ -856,6 +873,7 @@ namespace Tgstation.Server.Host.Controllers numSteps = 2; // Forget what we've done and abort + // DCTx2: Cancellation token is for job, operations should always run await repo.CheckoutObject(startReference ?? startSha, NextProgressReporter(), default).ConfigureAwait(false); if (startReference != null && repo.Head != startSha) await repo.ResetToSha(startSha, NextProgressReporter(), default).ConfigureAwait(false); @@ -863,7 +881,28 @@ namespace Tgstation.Server.Host.Controllers progressReporter(100); throw; } - }, cancellationToken).ConfigureAwait(false); + } + + var job = new Models.Job + { + Description = description, + StartedBy = AuthenticationContext.User, + Instance = Instance, + CancelRightsType = RightsType.Repository, + CancelRight = (ulong)RepositoryRights.CancelPendingChanges, + }; + + // Time to access git, do it in a job + await jobManager.RegisterOperation( + job, + (core, databaseContextFactory, paramJob, progressReporter, ct) => + UpdateCallbackThatDesperatelyNeedsRefactoring( + core, + databaseContextFactory, + progressReporter, + ct), + cancellationToken) + .ConfigureAwait(false); api.ActiveJob = job.ToApi(); return Accepted(api); diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index dc7a1a2b71..5144039371 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -193,7 +193,7 @@ namespace Tgstation.Server.Host.Controllers throw new ArgumentNullException(nameof(model)); if (!model.Id.HasValue) - return BadRequest(new ErrorMessage(ErrorCode.UserMissingId)); + return BadRequest(new ErrorMessage(ErrorCode.ModelValidationFailure)); var callerAdministrationRights = (AdministrationRights)AuthenticationContext.GetRight(RightsType.Administration); var passwordEditOnly = !callerAdministrationRights.HasFlag(AdministrationRights.WriteUsers); @@ -211,6 +211,9 @@ namespace Tgstation.Server.Host.Controllers if (originalUser == default) return NotFound(); + if (originalUser.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return Forbid(); + // Ensure they are only trying to edit password (system identity change will trigger a bad request) if (passwordEditOnly && (model.Id != originalUser.Id @@ -285,7 +288,10 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(IEnumerable), 200)] public async Task List(CancellationToken cancellationToken) { - var users = await DatabaseContext.Users + var users = await DatabaseContext + .Users + .AsQueryable() + .Where(x => x.CanonicalName != Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) .Include(x => x.CreatedBy) .ToListAsync(cancellationToken).ConfigureAwait(false); return Json(users.Select(x => x.ToApi(true))); @@ -318,6 +324,10 @@ namespace Tgstation.Server.Host.Controllers .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (user == default) return NotFound(); + + if (user.CanonicalName == Models.User.CanonicalizeName(Models.User.TgsSystemUserName)) + return Forbid(); + return Json(user.ToApi(true)); } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index cad2b6970f..c1f5852419 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,4 @@ -using Cyberboss.AspNetCore.AsyncInitializer; +using Cyberboss.AspNetCore.AsyncInitializer; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; @@ -17,6 +17,7 @@ using Serilog.Formatting.Display; using System; using System.Globalization; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; @@ -97,6 +98,10 @@ namespace Tgstation.Server.Host.Core // enable options which give us config reloading services.AddOptions(); + // Set the timeout for IHostedService.StopAsync + services.Configure( + opts => opts.ShutdownTimeout = TimeSpan.FromMilliseconds(postSetupServices.GeneralConfiguration.RestartTimeout)); + static LogEventLevel? ConvertSeriLogLevel(LogLevel logLevel) => logLevel switch { @@ -132,7 +137,7 @@ namespace Tgstation.Server.Host.Core var formatter = new MessageTemplateTextFormatter( "{Timestamp:o} " + ServiceCollectionExtensions.SerilogContextTemplate - + ": [{Level:u3}] {SourceContext:l}: {Message} ({EventId:x8}){NewLine}{Exception}", + + "|IR:{InstanceReference}){): [{Level:u3}] {SourceContext:l}: {Message} ({EventId:x8}){NewLine}{Exception}", null); logPath = IOManager.ConcatPath(logPath, "tgs-.log"); @@ -301,6 +306,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(x => new Lazy(() => x.GetRequiredService())); services.AddSingleton(x => x.GetRequiredService()); } @@ -439,4 +445,4 @@ namespace Tgstation.Server.Host.Core // 404 anything that gets this far } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Database/DatabaseCollection.cs b/src/Tgstation.Server.Host/Database/DatabaseCollection.cs index e2ebf973e8..5821cd0ef2 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseCollection.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseCollection.cs @@ -47,7 +47,7 @@ namespace Tgstation.Server.Host.Database public void Attach(TModel model) => dbSet.Attach(model); /// - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => dbSet.AsAsyncEnumerable().GetAsyncEnumerator(); + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => dbSet.AsAsyncEnumerable().GetAsyncEnumerator(cancellationToken); /// public IEnumerator GetEnumerator() => dbSet.AsQueryable().GetEnumerator(); diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index d910d5c4d9..85f00bd68a 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.DependencyInjection; @@ -400,7 +400,7 @@ namespace Tgstation.Server.Host.Database } catch (Exception e) { - logger.LogCritical("Failed to migrate! Exception: {0}", e); + logger.LogCritical(e, "Failed to migrate!"); } } } diff --git a/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs b/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs index 8982ce2205..2bb5265c16 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseSeeder.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; @@ -72,11 +72,38 @@ namespace Tgstation.Server.Host.Database this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + /// + /// Add a default system to a given + /// + /// The to add a system to + /// An existing , if any. + /// The created system . + static User SeedSystemUser(IDatabaseContext databaseContext, User tgsUser = null) + { + bool alreadyExists = tgsUser != null; + tgsUser ??= new User() + { + CreatedAt = DateTimeOffset.Now, + CanonicalName = User.CanonicalizeName(User.TgsSystemUserName), + }; + + tgsUser.Name = User.TgsSystemUserName; + tgsUser.PasswordHash = "_"; // This can't be hashed + tgsUser.Enabled = false; + tgsUser.InstanceManagerRights = InstanceManagerRights.None; + tgsUser.AdministrationRights = AdministrationRights.None; + + if (!alreadyExists) + databaseContext.Users.Add(tgsUser); + return tgsUser; + } + /// /// Add a default admin to a given /// /// The to add an admin to - void SeedAdminUser(IDatabaseContext databaseContext) + /// The created admin . + User SeedAdminUser(IDatabaseContext databaseContext) { var admin = new User { @@ -89,6 +116,7 @@ namespace Tgstation.Server.Host.Database }; cryptographySuite.SetUserPassword(admin, Api.Models.User.DefaultAdminPassword, true); databaseContext.Users.Add(admin); + return admin; } /// @@ -99,12 +127,19 @@ namespace Tgstation.Server.Host.Database /// A representing the running operation async Task SeedDatabase(IDatabaseContext databaseContext, CancellationToken cancellationToken) { - SeedAdminUser(databaseContext); + var adminUser = SeedAdminUser(databaseContext); + + // Save here because we want admin to have the first DB Id + // The system user isn't shown in the API except by references in the admin user and jobs + await databaseContext.Save(cancellationToken).ConfigureAwait(false); + var tgsUser = SeedSystemUser(databaseContext); + adminUser.CreatedBy = tgsUser; + await databaseContext.Save(cancellationToken).ConfigureAwait(false); } /// - /// Correct invalid database data caused by previous versions. + /// Correct invalid database data caused by previous versions (NOT user fuckery). /// /// The to sanitize. /// The for the operation. @@ -119,6 +154,23 @@ namespace Tgstation.Server.Host.Database // https://github.com/JamesNK/Newtonsoft.Json/issues/2301 admin.AdministrationRights &= RightsHelper.AllRights(); admin.InstanceManagerRights &= RightsHelper.AllRights(); + + if (admin.CreatedBy == null) + { + var tgsUser = await databaseContext + .Users + .AsQueryable() + .Where(x => x.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (tgsUser != null) + logger.LogError( + "A user named TGS (Canonically) exists but isn't marked as the admin's creator. This may be because it was created manually. This user is going to be adapted to use as the starter of system jobs."); + + tgsUser = SeedSystemUser(databaseContext, tgsUser); + admin.CreatedBy = tgsUser; + } } if (platformIdentifier.IsWindows) @@ -195,6 +247,7 @@ namespace Tgstation.Server.Host.Database .Users .AsQueryable() .Where(x => x.CanonicalName == User.CanonicalizeName(Api.Models.User.AdminName)) + .Include(x => x.CreatedBy) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); if (admin == default) diff --git a/src/Tgstation.Server.Host/Database/Design/DesignTimeDbContextFactoryHelpers.cs b/src/Tgstation.Server.Host/Database/Design/DesignTimeDbContextFactoryHelpers.cs index 4d4f4411dd..36da823aa4 100644 --- a/src/Tgstation.Server.Host/Database/Design/DesignTimeDbContextFactoryHelpers.cs +++ b/src/Tgstation.Server.Host/Database/Design/DesignTimeDbContextFactoryHelpers.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tgstation.Server.Host.Configuration; @@ -15,17 +15,20 @@ namespace Tgstation.Server.Host.Database.Design /// The to create for. /// The . /// The . + /// The . /// The for the public static DbContextOptions CreateDatabaseContextOptions( DatabaseType databaseType, - string connectionString) + string connectionString, + string serverVersion = null) where TDatabaseContext : DatabaseContext { var dbConfig = new DatabaseConfiguration { DesignTime = true, DatabaseType = databaseType, - ConnectionString = connectionString + ConnectionString = connectionString, + ServerVersion = serverVersion }; var optionsFac = new DbContextOptionsBuilder(); diff --git a/src/Tgstation.Server.Host/Database/Migrations/20200422010115_MYAddCompileJobDMApiVersion.cs b/src/Tgstation.Server.Host/Database/Migrations/20200422010115_MYAddCompileJobDMApiVersion.cs index 16972b87d9..cb14d3256e 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20200422010115_MYAddCompileJobDMApiVersion.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20200422010115_MYAddCompileJobDMApiVersion.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using System; namespace Tgstation.Server.Host.Database.Migrations @@ -55,8 +55,7 @@ namespace Tgstation.Server.Host.Database.Migrations migrationBuilder.AddColumn( name: "ServerCommandsJson", table: "ReattachInformations", - nullable: false, - defaultValue: "server_commands.tgs.json"); + nullable: false); } } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/20200423052709_MYKillJsonsAddDMApiVersion.cs b/src/Tgstation.Server.Host/Database/Migrations/20200423052709_MYKillJsonsAddDMApiVersion.cs index 9c6ed01066..32e03425c8 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20200423052709_MYKillJsonsAddDMApiVersion.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20200423052709_MYKillJsonsAddDMApiVersion.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using System; namespace Tgstation.Server.Host.Database.Migrations @@ -39,19 +39,18 @@ namespace Tgstation.Server.Host.Database.Migrations name: "LaunchSecurityLevel", table: "ReattachInformations"); + // No default values b/c lol mysql migrationBuilder.AddColumn( name: "ChatChannelsJson", table: "ReattachInformations", type: "longtext CHARACTER SET utf8mb4", - nullable: false, - defaultValue: "chat_channels.tgs.json"); + nullable: false); migrationBuilder.AddColumn( name: "ChatCommandsJson", table: "ReattachInformations", type: "longtext CHARACTER SET utf8mb4", - nullable: false, - defaultValue: "chat_commands.tgs.json"); + nullable: false); } } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/20200511012117_SLAddHeartbeat.cs b/src/Tgstation.Server.Host/Database/Migrations/20200511012117_SLAddHeartbeat.cs index 6e4a97ffef..77fc1f8ad4 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20200511012117_SLAddHeartbeat.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20200511012117_SLAddHeartbeat.cs @@ -18,7 +18,7 @@ namespace Tgstation.Server.Host.Database.Migrations name: "HeartbeatSeconds", table: "DreamDaemonSettings", nullable: false, - defaultValue: 0u); + defaultValue: 0U); } /// @@ -27,9 +27,44 @@ namespace Tgstation.Server.Host.Database.Migrations if (migrationBuilder == null) throw new ArgumentNullException(nameof(migrationBuilder)); - migrationBuilder.DropColumn( - name: "HeartbeatSeconds", - table: "DreamDaemonSettings"); + migrationBuilder.RenameTable( + name: "DreamDaemonSettings", + newName: "DreamDaemonSettings_down"); + + migrationBuilder.CreateTable( + name: "DreamDaemonSettings", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AllowWebClient = table.Column(nullable: false), + SecurityLevel = table.Column(nullable: false), + PrimaryPort = table.Column(nullable: false), + SecondaryPort = table.Column(nullable: false), + StartupTimeout = table.Column(nullable: false), + AutoStart = table.Column(nullable: false), + SoftRestart = table.Column(nullable: false), + SoftShutdown = table.Column(nullable: false), + ProcessId = table.Column(nullable: true), + AccessToken = table.Column(nullable: true), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DreamDaemonSettings", x => x.Id); + table.ForeignKey( + name: "FK_DreamDaemonSettings_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + $"INSERT INTO DreamDaemonSettings SELECT Id,AllowWebClient,SecurityLevel,PrimaryPort,SecondaryPort,StartupTimeout,AutoStart,SoftRestart,SoftShutdown,ProcessId,AccessToken,InstanceId FROM DreamDaemonSettings_down"); + + migrationBuilder.DropTable( + name: "DreamDaemonSettings_down"); } } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/20200616180742_SLTopicTimeout.cs b/src/Tgstation.Server.Host/Database/Migrations/20200616180742_SLTopicTimeout.cs index bca299417f..fcdcdf9500 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20200616180742_SLTopicTimeout.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20200616180742_SLTopicTimeout.cs @@ -25,9 +25,42 @@ namespace Tgstation.Server.Host.Database.Migrations { if (migrationBuilder == null) throw new ArgumentNullException(nameof(migrationBuilder)); - migrationBuilder.DropColumn( - name: "TopicRequestTimeout", - table: "DreamDaemonSettings"); + + migrationBuilder.RenameTable( + name: "DreamDaemonSettings", + newName: "DreamDaemonSettings_down"); + + migrationBuilder.CreateTable( + name: "DreamDaemonSettings", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AllowWebClient = table.Column(nullable: false), + SecurityLevel = table.Column(nullable: false), + PrimaryPort = table.Column(nullable: false), + SecondaryPort = table.Column(nullable: false), + StartupTimeout = table.Column(nullable: false), + AutoStart = table.Column(nullable: false), + InstanceId = table.Column(nullable: false), + HeartbeatSeconds = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DreamDaemonSettings", x => x.Id); + table.ForeignKey( + name: "FK_DreamDaemonSettings_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + $"INSERT INTO DreamDaemonSettings SELECT Id,AllowWebClient,SecurityLevel,PrimaryPort,SecondaryPort,StartupTimeout,AutoStart,InstanceId,HeartbeatSeconds FROM DreamDaemonSettings_down"); + + migrationBuilder.DropTable( + name: "DreamDaemonSettings_down"); } } } diff --git a/src/Tgstation.Server.Host/Database/Migrations/20200705163700_SLAllowNullDMApi.cs b/src/Tgstation.Server.Host/Database/Migrations/20200705163700_SLAllowNullDMApi.cs index 422cdcc5c5..e001cc523f 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20200705163700_SLAllowNullDMApi.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20200705163700_SLAllowNullDMApi.cs @@ -131,9 +131,37 @@ namespace Tgstation.Server.Host.Database.Migrations name: "CompileJobs_down", newName: "CompileJobs"); - migrationBuilder.DropColumn( - name: "RequireDMApiValidation", - table: "DreamMakerSettings"); + migrationBuilder.RenameTable( + name: "DreamMakerSettings", + newName: "DreamMakerSettings_down"); + + migrationBuilder.CreateTable( + name: "DreamMakerSettings", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ProjectName = table.Column(maxLength: 10000, nullable: true), + ApiValidationPort = table.Column(nullable: false), + ApiValidationSecurityLevel = table.Column(nullable: false), + InstanceId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DreamMakerSettings", x => x.Id); + table.ForeignKey( + name: "FK_DreamMakerSettings_Instances_InstanceId", + column: x => x.InstanceId, + principalTable: "Instances", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + $"INSERT INTO DreamMakerSettings SELECT Id,ProjectName,ApiValidationPort,ApiValidationSecurityLevel,InstanceId FROM DreamMakerSettings_down"); + + migrationBuilder.DropTable( + name: "DreamMakerSettings_down"); } } } diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index fe3356d981..1165fddf85 100644 --- a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -42,11 +42,11 @@ namespace Tgstation.Server.Host.Extensions { if (e.InnerException is OperationCanceledException) { - logger.LogTrace("Rethrowing DbUpdateException as OperationCanceledException: {0}", e); + logger.LogTrace(e, "Rethrowing DbUpdateException as OperationCanceledException"); throw e.InnerException; } - logger.LogDebug("Database conflict: {0}", e.Message); + logger.LogDebug(e, "Database conflict!"); await new ConflictObjectResult(new ErrorMessage(ErrorCode.DatabaseIntegrityConflict) { AdditionalData = String.Format(CultureInfo.InvariantCulture, (e.InnerException ?? e).Message) @@ -73,9 +73,9 @@ namespace Tgstation.Server.Host.Extensions { await next().ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogDebug("Request cancelled!"); + logger.LogDebug(ex, "Request cancelled!"); } }); } @@ -97,7 +97,7 @@ namespace Tgstation.Server.Host.Extensions } catch (Exception e) { - logger.LogError("Failed request: {0}", e); + logger.LogError(e, "Failed request!"); await new ObjectResult( new ErrorMessage(ErrorCode.InternalServerError) { diff --git a/src/Tgstation.Server.Host/Extensions/DatabaseCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/DatabaseCollectionExtensions.cs new file mode 100644 index 0000000000..6599830847 --- /dev/null +++ b/src/Tgstation.Server.Host/Extensions/DatabaseCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Extensions +{ + /// + /// Extension methods for the . + /// + static class DatabaseCollectionExtensions + { + /// + /// Gets the unattached, unpopulated with the name . + /// + /// The of s to operate on. + /// The for the operation. + /// A resulting in the unattached TGS on success, on failure. + public static Task GetTgsUser(this IDatabaseCollection databaseCollection, CancellationToken cancellationToken) + => databaseCollection + .AsQueryable() + .Where(x => x.CanonicalName == User.CanonicalizeName(User.TgsSystemUserName)) + .Select(x => new User + { + Id = x.Id + }) + .FirstAsync(cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs index 8ff326c398..e98ff88694 100644 --- a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Serilog; @@ -18,7 +18,7 @@ namespace Tgstation.Server.Host.Extensions /// /// Common template used for adding our custom log context to serilog. /// - public const string SerilogContextTemplate = "(Instance:{Instance}|Job:{Job}|Request:{Request}|User:{User}|Monitor:{Monitor}|Bridge:{Bridge}|Chat:{ChatMessage})"; + public const string SerilogContextTemplate = "(Instance:{Instance}|Job:{Job}|Request:{Request}|User:{User}|Monitor:{Monitor}|Bridge:{Bridge}|Chat:{ChatMessage}"; /// /// Add a standard binding @@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Extensions sinkConfiguration.Console( outputTemplate: "[{Timestamp:HH:mm:ss}] {Level:w3}: {SourceContext:l} " + SerilogContextTemplate - + "{NewLine} {Message:lj}{NewLine}{Exception}"); + + "|IR:{InstanceReference}){NewLine} {Message:lj}{NewLine}{Exception}"); sinkConfigurationAction?.Invoke(sinkConfiguration); }); diff --git a/src/Tgstation.Server.Host/Extensions/SocketExtensions.cs b/src/Tgstation.Server.Host/Extensions/SocketExtensions.cs new file mode 100644 index 0000000000..83eec75d21 --- /dev/null +++ b/src/Tgstation.Server.Host/Extensions/SocketExtensions.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Net.Sockets; + +namespace Tgstation.Server.Host.Extensions +{ + /// + /// Extension methods for the . + /// + static class SocketExtensions + { + /// + /// Attempt to exclusively bind to a given . + /// + /// The port number to bind to. + /// If IPV6 should be tested as well. + public static void BindTest(ushort port, bool includeIPv6) + { + using var socket = new Socket( + includeIPv6 + ? AddressFamily.InterNetworkV6 + : AddressFamily.InterNetwork, + SocketType.Stream, + ProtocolType.Tcp); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ExclusiveAddressUse, true); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); + if (includeIPv6) + socket.DualMode = true; + + socket.Bind( + new IPEndPoint( + includeIPv6 + ? IPAddress.IPv6Any + : IPAddress.Any, + port)); + } + } +} diff --git a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs index ad7beb4c35..92421074ba 100644 --- a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs +++ b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs @@ -96,12 +96,17 @@ namespace Tgstation.Server.Host.IO var tasks = new List(); - await dir.EnumerateFiles().ToAsyncEnumerable().ForEachAsync(fileInfo => - { - if (ignore != null && ignore.Contains(fileInfo.Name)) - return; - tasks.Add(CopyFile(fileInfo.FullName, Path.Combine(dest, fileInfo.Name), cancellationToken)); - }).ConfigureAwait(false); + await dir.EnumerateFiles() + .ToAsyncEnumerable() + .ForEachAsync( + fileInfo => + { + if (ignore != null && ignore.Contains(fileInfo.Name)) + return; + tasks.Add(CopyFile(fileInfo.FullName, Path.Combine(dest, fileInfo.Name), cancellationToken)); + }, + cancellationToken) + .ConfigureAwait(false); await Task.WhenAll(tasks).ConfigureAwait(false); } @@ -225,7 +230,7 @@ namespace Tgstation.Server.Host.IO using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, DefaultBufferSize, true); byte[] buf; buf = new byte[file.Length]; - await file.ReadAsync(buf, 0, (int)file.Length, cancellationToken).ConfigureAwait(false); + await file.ReadAsync(buf, cancellationToken).ConfigureAwait(false); return buf; } @@ -240,7 +245,7 @@ namespace Tgstation.Server.Host.IO { path = ResolvePath(path); using var file = OpenWriteStream(path); - await file.WriteAsync(contents, 0, contents.Length, cancellationToken).ConfigureAwait(false); + await file.WriteAsync(contents, cancellationToken).ConfigureAwait(false); } /// diff --git a/src/Tgstation.Server.Host/Jobs/IJobManager.cs b/src/Tgstation.Server.Host/Jobs/IJobManager.cs index a6d3610a9d..567d622d40 100644 --- a/src/Tgstation.Server.Host/Jobs/IJobManager.cs +++ b/src/Tgstation.Server.Host/Jobs/IJobManager.cs @@ -1,8 +1,6 @@ -using Microsoft.Extensions.Hosting; -using System; +using Microsoft.Extensions.Hosting; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Jobs @@ -22,17 +20,17 @@ namespace Tgstation.Server.Host.Jobs /// /// Registers a given and begins running it /// - /// The - /// The operation to run taking the started , a , progress reporter and a + /// The . Should at least have and . If is , the TGS user will be used. + /// The for the . /// The for the operation /// A representing a running operation - Task RegisterOperation(Job job, Func, CancellationToken, Task> operation, CancellationToken cancellationToken); + Task RegisterOperation(Job job, JobEntrypoint operation, CancellationToken cancellationToken); /// /// Wait for a given to complete /// /// The to wait for - /// The to cancel the + /// The to cancel the . If the TGS user will be used. /// A that will cancel the /// The for the operation /// A representing the @@ -44,10 +42,15 @@ namespace Tgstation.Server.Host.Jobs /// Cancels a give /// /// The to cancel - /// The who cancelled the + /// The who cancelled the . If the TGS user will be used. /// If the operation should wait until the job exits before completing /// The for the operation /// A resulting in the updated if it was cancelled, if it couldn't be found. Task CancelJob(Job job, User user, bool blocking, CancellationToken cancellationToken); + + /// + /// Activate the . + /// + void Activate(); } } diff --git a/src/Tgstation.Server.Host/Jobs/JobEntrypoint.cs b/src/Tgstation.Server.Host/Jobs/JobEntrypoint.cs new file mode 100644 index 0000000000..0c1f9fc16f --- /dev/null +++ b/src/Tgstation.Server.Host/Jobs/JobEntrypoint.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Jobs +{ + /// + /// Entrypoint for running a given job. + /// + /// The the job is running on. only when performing an instance move operation. + /// The for the operation. + /// The running . + /// A that will update the progress of the job. + /// The for the operation. + /// A representing the running operation. + public delegate Task JobEntrypoint( + IInstanceCore instance, + IDatabaseContextFactory databaseContextFactory, + Job job, + Action progressReporter, + CancellationToken cancellationToken); +} diff --git a/src/Tgstation.Server.Host/Jobs/JobHandler.cs b/src/Tgstation.Server.Host/Jobs/JobHandler.cs index 072b075fe2..c6b923033c 100644 --- a/src/Tgstation.Server.Host/Jobs/JobHandler.cs +++ b/src/Tgstation.Server.Host/Jobs/JobHandler.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Extensions; namespace Tgstation.Server.Host.Jobs { @@ -14,21 +15,24 @@ namespace Tgstation.Server.Host.Jobs /// readonly CancellationTokenSource cancellationTokenSource; + /// + /// A taking a and returning a that the will wrap + /// + readonly Func jobActivator; + /// /// The being run /// - readonly Task task; + Task task; /// /// Construct a /// - /// A taking a and returning a that the will wrap - public JobHandler(Func job) + /// The value of . + public JobHandler(Func jobActivator) { - if (job == null) - throw new ArgumentNullException(nameof(job)); + this.jobActivator = jobActivator ?? throw new ArgumentNullException(nameof(jobActivator)); cancellationTokenSource = new CancellationTokenSource(); - task = job(cancellationTokenSource.Token); } /// @@ -44,17 +48,30 @@ namespace Tgstation.Server.Host.Jobs /// /// The for the operation /// A representing the running operation - public async Task Wait(CancellationToken cancellationToken) + public Task Wait(CancellationToken cancellationToken) { - TaskCompletionSource tcs = new TaskCompletionSource(); - using (cancellationToken.Register(() => tcs.SetCanceled())) - await Task.WhenAny(tcs.Task, task).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); + if (task == null) + throw new InvalidOperationException("Job not started!"); + + return task.WithToken(cancellationToken); } /// /// Cancels /// public void Cancel() => cancellationTokenSource.Cancel(); + + /// + /// Starts the job. + /// + public void Start() + { + lock (cancellationTokenSource) + { + if (task != null) + throw new InvalidOperationException("Job already started"); + task = jobActivator(cancellationTokenSource.Token); + } + } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/Jobs/JobManager.cs b/src/Tgstation.Server.Host/Jobs/JobManager.cs index 5cfa3d94f9..5066f0cdfb 100644 --- a/src/Tgstation.Server.Host/Jobs/JobManager.cs +++ b/src/Tgstation.Server.Host/Jobs/JobManager.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Serilog.Context; using System; @@ -6,7 +6,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Jobs @@ -24,11 +26,21 @@ namespace Tgstation.Server.Host.Jobs /// readonly ILogger logger; + /// + /// The for the . + /// + readonly Lazy instanceCoreProvider; + /// /// of s to running s /// readonly Dictionary jobs; + /// + /// to delay starting jobs until the server is ready. + /// + readonly TaskCompletionSource activationTcs; + /// /// for various operations. /// @@ -38,12 +50,15 @@ namespace Tgstation.Server.Host.Jobs /// Construct a /// /// The value of + /// The value of . /// The value of - public JobManager(IDatabaseContextFactory databaseContextFactory, ILogger logger) + public JobManager(IDatabaseContextFactory databaseContextFactory, Lazy instanceCoreProvider, ILogger logger) { this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.instanceCoreProvider = instanceCoreProvider ?? throw new ArgumentNullException(nameof(instanceCoreProvider)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); jobs = new Dictionary(); + activationTcs = new TaskCompletionSource(); synchronizationLock = new object(); } @@ -73,46 +88,55 @@ namespace Tgstation.Server.Host.Jobs /// Runner for s /// /// The being run - /// The operation for the + /// The for the /// The for the operation /// A representing the running operation - async Task RunJob(Job job, Func operation, CancellationToken cancellationToken) + async Task RunJob(Job job, JobEntrypoint operation, CancellationToken cancellationToken) { using (LogContext.PushProperty("Job", job.Id)) try { - void LogRegularException() => logger.LogDebug("Job {0} exited with error! Exception: {1}", job.Id, job.ExceptionDetails); + void LogException(Exception ex) => logger.LogDebug(ex, "Job {0} exited with error!", job.Id); try { var oldJob = job; job = new Job { Id = oldJob.Id }; - await operation(job, databaseContextFactory, cancellationToken).ConfigureAwait(false); + void UpdateProgress(int progress) + { + lock (synchronizationLock) + if (jobs.TryGetValue(oldJob.Id, out var handler)) + handler.Progress = progress; + } + + await activationTcs.Task.WithToken(cancellationToken).ConfigureAwait(false); + + logger.LogTrace("Starting job..."); + await operation( + instanceCoreProvider.Value.GetInstance(oldJob.Instance), + databaseContextFactory, + job, + UpdateProgress, + cancellationToken) + .ConfigureAwait(false); logger.LogDebug("Job {0} completed!", job.Id); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogDebug("Job {0} cancelled!", job.Id); + logger.LogDebug(ex, "Job {0} cancelled!", job.Id); job.Cancelled = true; } catch (JobException e) { job.ErrorCode = e.ErrorCode; - job.ExceptionDetails = e.Message; - LogRegularException(); - if (e.InnerException != null) - logger.LogDebug( - "Inner exception for job {0}: {1}", - job.Id, - e.InnerException is JobException - ? e.InnerException.Message - : e.InnerException.ToString()); + job.ExceptionDetails = String.IsNullOrWhiteSpace(e.Message) ? e.InnerException?.Message : e.Message; + LogException(e); } catch (Exception e) { job.ExceptionDetails = e.ToString(); - LogRegularException(); + LogException(e); } await databaseContextFactory.UseContext(async databaseContext => @@ -128,6 +152,7 @@ namespace Tgstation.Server.Host.Jobs attachedJob.ErrorCode = job.ErrorCode; attachedJob.Cancelled = job.Cancelled; + // DCT: Cancellation token is for job, operation should always run await databaseContext.Save(default).ConfigureAwait(false); }).ConfigureAwait(false); } @@ -143,49 +168,60 @@ namespace Tgstation.Server.Host.Jobs } /// - public Task RegisterOperation(Job job, Func, CancellationToken, Task> operation, CancellationToken cancellationToken) => databaseContextFactory.UseContext(async databaseContext => - { - if (job == null) - throw new ArgumentNullException(nameof(job)); - if (operation == null) - throw new ArgumentNullException(nameof(operation)); + public Task RegisterOperation(Job job, JobEntrypoint operation, CancellationToken cancellationToken) + => databaseContextFactory.UseContext( + async databaseContext => + { + if (job == null) + throw new ArgumentNullException(nameof(job)); + if (operation == null) + throw new ArgumentNullException(nameof(operation)); - job.StartedAt = DateTimeOffset.Now; - job.Cancelled = false; + job.StartedAt = DateTimeOffset.Now; + job.Cancelled = false; - job.Instance = new Instance - { - Id = job.Instance.Id - }; - databaseContext.Instances.Attach(job.Instance); + job.Instance = new Models.Instance + { + Id = job.Instance.Id + }; + databaseContext.Instances.Attach(job.Instance); - job.StartedBy = new User - { - Id = job.StartedBy.Id - }; - databaseContext.Users.Attach(job.StartedBy); + if (job.StartedBy == null) + job.StartedBy = await databaseContext + .Users + .GetTgsUser(cancellationToken) + .ConfigureAwait(false); + else + job.StartedBy = new User + { + Id = job.StartedBy.Id + }; + databaseContext.Users.Attach(job.StartedBy); - databaseContext.Jobs.Add(job); + databaseContext.Jobs.Add(job); - await databaseContext.Save(cancellationToken).ConfigureAwait(false); - logger.LogDebug("Starting job {0}: {1}...", job.Id, job.Description); - var jobHandler = new JobHandler(x => RunJob(job, (jobParam, serviceProvider, ct) => - operation(jobParam, serviceProvider, y => - { - lock (synchronizationLock) - if (jobs.TryGetValue(job.Id, out var handler)) - handler.Progress = y; - }, ct), - x)); - lock (synchronizationLock) - jobs.Add(job.Id, jobHandler); - }); + await databaseContext.Save(cancellationToken).ConfigureAwait(false); + + logger.LogDebug("Registering job {0}: {1}...", job.Id, job.Description); + var jobHandler = new JobHandler(jobCancellationToken => RunJob(job, operation, jobCancellationToken)); + try + { + lock (synchronizationLock) + { + jobs.Add(job.Id, jobHandler); + jobHandler.Start(); + } + } + catch + { + jobHandler.Dispose(); + throw; + } + }); /// - public async Task StartAsync(CancellationToken cancellationToken) - { - logger.LogTrace("Starting job manager..."); - await databaseContextFactory.UseContext(async databaseContext => + public Task StartAsync(CancellationToken cancellationToken) + => databaseContextFactory.UseContext(async databaseContext => { // mark all jobs as cancelled var badJobs = await databaseContext @@ -208,18 +244,19 @@ namespace Tgstation.Server.Host.Jobs await databaseContext.Save(cancellationToken).ConfigureAwait(false); } - }).ConfigureAwait(false); - logger.LogDebug("Job manager started!"); - } + }); /// public async Task StopAsync(CancellationToken cancellationToken) { - var joinTasks = jobs.Select(x => - { - x.Value.Cancel(); - return x.Value.Wait(cancellationToken); - }); + var joinTasks = jobs.Select(x => CancelJob( + new Job + { + Id = x.Key, + }, + null, + true, + cancellationToken)); await Task.WhenAll(joinTasks).ConfigureAwait(false); } @@ -228,8 +265,6 @@ namespace Tgstation.Server.Host.Jobs { if (job == null) throw new ArgumentNullException(nameof(job)); - if (user == null) - throw new ArgumentNullException(nameof(user)); JobHandler handler; try { @@ -244,18 +279,27 @@ namespace Tgstation.Server.Host.Jobs handler.Cancel(); // this will ensure the db update is only done once await databaseContextFactory.UseContext(async databaseContext => { + if (user == null) + user = await databaseContext.Users.GetTgsUser(cancellationToken).ConfigureAwait(false); + var updatedJob = new Job { Id = job.Id }; - databaseContext.Jobs.Attach(job); + databaseContext.Jobs.Attach(updatedJob); var attachedUser = new User { Id = user.Id }; - databaseContext.Users.Attach(user); + databaseContext.Users.Attach(attachedUser); updatedJob.CancelledBy = attachedUser; // let either startup or cancellation set job.cancelled await databaseContext.Save(cancellationToken).ConfigureAwait(false); job.CancelledBy = user; }).ConfigureAwait(false); + if (blocking) + { + logger.LogTrace("Waiting on cancelled job #{0}...", job.Id); await handler.Wait(cancellationToken).ConfigureAwait(false); + logger.LogTrace("Done waiting on job #{0}...", job.Id); + } + return job; } @@ -277,8 +321,6 @@ namespace Tgstation.Server.Host.Jobs { if (job == null) throw new ArgumentNullException(nameof(job)); - if (canceller == null) - throw new ArgumentNullException(nameof(canceller)); JobHandler handler; lock (synchronizationLock) { @@ -293,5 +335,12 @@ namespace Tgstation.Server.Host.Jobs if (cancelTask != null) await cancelTask.ConfigureAwait(false); } + + /// + public void Activate() + { + logger.LogTrace("Activating job manager..."); + activationTcs.SetResult(null); + } } } diff --git a/src/Tgstation.Server.Host/Models/ChatBot.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs index b34876bb40..e3565be4e8 100644 --- a/src/Tgstation.Server.Host/Models/ChatBot.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Models /// /// See /// - public List Channels { get; set; } + public ICollection Channels { get; set; } /// /// Convert the to it's API form diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 6a87d51bca..1827a9511d 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -30,22 +30,22 @@ namespace Tgstation.Server.Host.Models /// /// The s in the /// - public List InstanceUsers { get; set; } + public ICollection InstanceUsers { get; set; } /// /// The s for the /// - public List ChatSettings { get; set; } + public ICollection ChatSettings { get; set; } /// /// The s in the /// - public List RevisionInformations { get; set; } + public ICollection RevisionInformations { get; set; } /// /// The s in the /// - public List Jobs { get; set; } + public ICollection Jobs { get; set; } /// /// Convert the to it's API form diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs index f1d3a2c970..6c28d3864a 100644 --- a/src/Tgstation.Server.Host/Models/RevisionInformation.cs +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -31,12 +31,12 @@ namespace Tgstation.Server.Host.Models /// /// See /// - public List ActiveTestMerges { get; set; } + public ICollection ActiveTestMerges { get; set; } /// /// See s made from this /// - public List CompileJobs { get; set; } + public ICollection CompileJobs { get; set; } /// /// Convert the to it's API form diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs index 754951c355..e44b557cb4 100644 --- a/src/Tgstation.Server.Host/Models/TestMerge.cs +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Models /// /// All the for the /// - public List RevisonInformations { get; set; } + public ICollection RevisonInformations { get; set; } /// /// Convert the to it's API form diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index bf443501ed..ed1ec273aa 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -7,6 +7,11 @@ namespace Tgstation.Server.Host.Models /// public sealed class User : Api.Models.Internal.User { + /// + /// Username used when creating jobs automatically. + /// + public const string TgsSystemUserName = "TGS"; + /// /// The hash of the user's password /// @@ -31,17 +36,17 @@ namespace Tgstation.Server.Host.Models /// /// s created by this /// - public List CreatedUsers { get; set; } + public ICollection CreatedUsers { get; set; } /// /// The s for the /// - public List InstanceUsers { get; set; } + public ICollection InstanceUsers { get; set; } /// /// The s made by the /// - public List TestMerges { get; set; } + public ICollection TestMerges { get; set; } /// /// Change a into a . diff --git a/src/Tgstation.Server.Host/Program.cs b/src/Tgstation.Server.Host/Program.cs index 77d2dc2d09..ab0b58943d 100644 --- a/src/Tgstation.Server.Host/Program.cs +++ b/src/Tgstation.Server.Host/Program.cs @@ -82,6 +82,7 @@ namespace Tgstation.Server.Host { if (updatePath != null) { + // DCT: None available, operation should always run await ServerFactory.IOManager.WriteAllBytes(updatePath, Encoding.UTF8.GetBytes(e.ToString()), default).ConfigureAwait(false); return 2; } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 45935ac098..d74ef23d4a 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -91,7 +91,7 @@ namespace Tgstation.Server.Host.Security { instanceUser = await databaseContext.InstanceUsers .AsQueryable() - .Where(x => x.UserId == userId && x.InstanceId == instanceId && x.Instance.Online.Value) + .Where(x => x.UserId == userId && x.InstanceId == instanceId) .Include(x => x.Instance) .FirstOrDefaultAsync(cancellationToken) .ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Security/WindowsSystemIdentityFactory.cs b/src/Tgstation.Server.Host/Security/WindowsSystemIdentityFactory.cs index a3480ddda6..1b9e64e4e6 100644 --- a/src/Tgstation.Server.Host/Security/WindowsSystemIdentityFactory.cs +++ b/src/Tgstation.Server.Host/Security/WindowsSystemIdentityFactory.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System; using System.DirectoryServices.AccountManagement; @@ -70,7 +70,7 @@ namespace Tgstation.Server.Host.Security } catch (Exception e) { - logger.LogWarning("Error loading user for context type {0}! Exception: {1}", contextType, e); + logger.LogWarning(e, "Error loading user for context type {0}!", contextType); } finally { @@ -110,8 +110,10 @@ namespace Tgstation.Server.Host.Security logger.LogTrace("Authenticated username {0} using system identity!", originalUsername); - using (var handle = new SafeAccessTokenHandle(token)) // checked internally, windows identity always duplicates the handle when constructed with a userToken - return (ISystemIdentity)new WindowsSystemIdentity(new WindowsIdentity(handle.DangerousGetHandle())); // https://github.com/dotnet/corefx/blob/6ed61acebe3214fcf79b4274f2bb9b55c0604a4d/src/System.Security.Principal.Windows/src/System/Security/Principal/WindowsIdentity.cs#L271 + // checked internally, windows identity always duplicates the handle when constructed + using var handle = new SafeAccessTokenHandle(token); + return (ISystemIdentity)new WindowsSystemIdentity( + new WindowsIdentity(handle.DangerousGetHandle())); // https://github.com/dotnet/corefx/blob/6ed61acebe3214fcf79b4274f2bb9b55c0604a4d/src/System.Security.Principal.Windows/src/System/Security/Principal/WindowsIdentity.cs#L271 }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); } } diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 2d5dc1bd80..8ce353d0ae 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -225,7 +225,7 @@ namespace Tgstation.Server.Host } catch (Exception e) { - logger.LogError("Error updating server! Exception: {0}", e); + logger.LogError(e, "Error updating server!"); } finally { @@ -290,29 +290,31 @@ namespace Tgstation.Server.Host } if (exception == null) - using (var cts = new CancellationTokenSource()) - { - logger.LogInformation("Restarting server..."); - var cancellationToken = cts.Token; - var eventsTask = Task.WhenAll(restartHandlers.Select(x => x.HandleRestart(newVersion, cancellationToken)).ToList()); + { + logger.LogInformation("Restarting server..."); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(generalConfiguration.RestartTimeout)); + var cancellationToken = cts.Token; + var eventsTask = Task.WhenAll( + restartHandlers.Select( + x => x.HandleRestart(newVersion, cancellationToken)) + .ToList()); - var expiryTask = Task.Delay(TimeSpan.FromMilliseconds(generalConfiguration.RestartTimeout)); - await Task.WhenAny(eventsTask, expiryTask).ConfigureAwait(false); - logger.LogTrace("Joining restart handlers..."); - cts.Cancel(); - try - { - await eventsTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - logger.LogError("Restart timeout hit! Existing DreamDaemon processes will be lost and must be killed manually before being restarted with TGS!"); - } - catch (Exception e) - { - logger.LogError("Restart handlers error! Exception: {0}", e); - } + logger.LogTrace("Joining restart handlers..."); + try + { + await eventsTask.ConfigureAwait(false); } + catch (OperationCanceledException ex) + { + logger.LogError( + ex, + "Restart timeout hit! Existing DreamDaemon processes will be lost and must be killed manually before being restarted with TGS!"); + } + catch (Exception e) + { + logger.LogError(e, "Restart handlers error!"); + } + } logger.LogTrace("Stopping host..."); cancellationTokenSource.Cancel(); diff --git a/src/Tgstation.Server.Host/Setup/SetupWizard.cs b/src/Tgstation.Server.Host/Setup/SetupWizard.cs index 4d4942eef2..fe7fdf4e00 100644 --- a/src/Tgstation.Server.Host/Setup/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Setup/SetupWizard.cs @@ -828,6 +828,7 @@ namespace Tgstation.Server.Host.Setup async Task HandleSetupCancel() { + // DCTx2: Operation should always run await console.WriteAsync(String.Empty, true, default).ConfigureAwait(false); await console.WriteAsync("Aborting setup!", true, default).ConfigureAwait(false); } diff --git a/src/Tgstation.Server.Host/System/IProcess.cs b/src/Tgstation.Server.Host/System/IProcess.cs index b813d95bbc..8392d58377 100644 --- a/src/Tgstation.Server.Host/System/IProcess.cs +++ b/src/Tgstation.Server.Host/System/IProcess.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.System @@ -6,7 +7,7 @@ namespace Tgstation.Server.Host.System /// /// Abstraction over a /// - interface IProcess : IProcessBase + interface IProcess : IProcessBase, IDisposable { /// /// The ' ID @@ -21,24 +22,28 @@ namespace Tgstation.Server.Host.System /// /// Get the stderr output of the /// - /// The stderr output of the - string GetErrorOutput(); + /// The for the operation. + /// A resulting in the stderr output of the + Task GetErrorOutput(CancellationToken cancellationToken); /// /// Get the stdout output of the /// - /// The stdout output of the - string GetStandardOutput(); + /// The for the operation. + /// A resulting in the stdout output of the + Task GetStandardOutput(CancellationToken cancellationToken); /// /// Get the stderr and stdout output of the /// - /// The stderr and stdout output of the - string GetCombinedOutput(); + /// The for the operation. + /// A resulting in the stderr and stdout output of the + Task GetCombinedOutput(CancellationToken cancellationToken); /// - /// Terminates the process + /// Asycnhronously terminates the process. /// + /// To ensure the has ended, use the . void Terminate(); /// @@ -48,4 +53,4 @@ namespace Tgstation.Server.Host.System /// A resulting in the name of the account executing the . Task GetExecutingUsername(CancellationToken cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Host/System/IProcessBase.cs b/src/Tgstation.Server.Host/System/IProcessBase.cs index c861f8ae5f..3530b619b8 100644 --- a/src/Tgstation.Server.Host/System/IProcessBase.cs +++ b/src/Tgstation.Server.Host/System/IProcessBase.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.System @@ -7,7 +6,7 @@ namespace Tgstation.Server.Host.System /// /// Represents process lifetime /// - interface IProcessBase : IDisposable + interface IProcessBase { /// /// The resulting in the exit code of the process diff --git a/src/Tgstation.Server.Host/System/IProcessExecutor.cs b/src/Tgstation.Server.Host/System/IProcessExecutor.cs index e7f0d98388..12bfb896a9 100644 --- a/src/Tgstation.Server.Host/System/IProcessExecutor.cs +++ b/src/Tgstation.Server.Host/System/IProcessExecutor.cs @@ -1,4 +1,4 @@ -namespace Tgstation.Server.Host.System +namespace Tgstation.Server.Host.System { /// /// For launching ' @@ -15,7 +15,13 @@ /// If standard error should be read /// If shell execute should not be used. Must be set if or are set. /// A new - IProcess LaunchProcess(string fileName, string workingDirectory, string arguments = null, bool readOutput = false, bool readError = false, bool noShellExecute = false); + IProcess LaunchProcess( + string fileName, + string workingDirectory, + string arguments = null, + bool readOutput = false, + bool readError = false, + bool noShellExecute = false); /// /// Get a representing the running executable. diff --git a/src/Tgstation.Server.Host/System/IProcessFeatures.cs b/src/Tgstation.Server.Host/System/IProcessFeatures.cs index b45d0831e0..2ba9503f6a 100644 --- a/src/Tgstation.Server.Host/System/IProcessFeatures.cs +++ b/src/Tgstation.Server.Host/System/IProcessFeatures.cs @@ -1,4 +1,4 @@ -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.System diff --git a/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs b/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs index f808e588de..62ba10172a 100644 --- a/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs +++ b/src/Tgstation.Server.Host/System/PosixProcessFeatures.cs @@ -1,10 +1,7 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Mono.Unix; using Mono.Unix.Native; using System; -using System.Globalization; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -47,61 +44,22 @@ namespace Tgstation.Server.Host.System /// public void ResumeProcess(global::System.Diagnostics.Process process) { - try - { - var result = Syscall.kill(process.Id, Signum.SIGCONT); - if (result != 0) - throw new UnixIOException(result); - logger.LogTrace("Resumed PID {0}", process.Id); - } - catch (Exception e) - { - logger.LogError(e, "Failed to resume PID {0}!", process.Id); - throw; - } + var result = Syscall.kill(process.Id, Signum.SIGCONT); + if (result != 0) + throw new UnixIOException(result); } /// public void SuspendProcess(global::System.Diagnostics.Process process) { - try - { - var result = Syscall.kill(process.Id, Signum.SIGSTOP); - if (result != 0) - throw new UnixIOException(result); - logger.LogTrace("Resumed PID {0}", process.Id); - } - catch (Exception e) - { - logger.LogError(e, "Failed to suspend PID {0}!", process.Id); - throw; - } + var result = Syscall.kill(process.Id, Signum.SIGSTOP); + if (result != 0) + throw new UnixIOException(result); } /// - public async Task GetExecutingUsername(global::System.Diagnostics.Process process, CancellationToken cancellationToken) - { - if (process == null) - throw new ArgumentNullException(nameof(process)); - - // Need to read /proc/[pid]/status - // http://man7.org/linux/man-pages/man5/proc.5.html - // https://unix.stackexchange.com/questions/102676/why-is-uid-information-not-in-proc-x-stat - var pid = process.Id; - var statusFile = ioManager.ConcatPath("/proc", pid.ToString(CultureInfo.InvariantCulture), "status"); - var statusBytes = await ioManager.ReadAllBytes(statusFile, cancellationToken).ConfigureAwait(false); - var statusText = Encoding.UTF8.GetString(statusBytes); - var splits = statusText.Split('\n', StringSplitOptions.RemoveEmptyEntries); - var entry = splits.FirstOrDefault(x => x.Trim().StartsWith("Uid:", StringComparison.Ordinal)); - if (entry == default) - return "UNKNOWN"; - - return entry - .Substring(4) - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(x => !String.IsNullOrWhiteSpace(x)) - ?? "UNPARSABLE"; - } + public Task GetExecutingUsername(global::System.Diagnostics.Process process, CancellationToken cancellationToken) + => throw new NotSupportedException(); /// public async Task CreateDump(global::System.Diagnostics.Process process, string outputFile, CancellationToken cancellationToken) @@ -115,7 +73,19 @@ namespace Tgstation.Server.Host.System if (!await ioManager.FileExists(GCorePath, cancellationToken).ConfigureAwait(false)) throw new JobException(ErrorCode.MissingGCore); - var pid = process.Id; + int pid; + try + { + if (process.HasExited) + throw new JobException(ErrorCode.DreamDaemonOffline); + + pid = process.Id; + } + catch (InvalidOperationException ex) + { + throw new JobException(ErrorCode.DreamDaemonOffline, ex); + } + string output; int exitCode; using (var gcoreProc = lazyLoadedProcessExecutor.Value.LaunchProcess( @@ -129,7 +99,7 @@ namespace Tgstation.Server.Host.System using (cancellationToken.Register(() => gcoreProc.Terminate())) exitCode = await gcoreProc.Lifetime.ConfigureAwait(false); - output = gcoreProc.GetCombinedOutput(); + output = await gcoreProc.GetCombinedOutput(cancellationToken).ConfigureAwait(false); logger.LogDebug("gcore output:{0}{1}", Environment.NewLine, output); } diff --git a/src/Tgstation.Server.Host/System/Process.cs b/src/Tgstation.Server.Host/System/Process.cs index 5f2c3cf5a6..60734cf725 100644 --- a/src/Tgstation.Server.Host/System/Process.cs +++ b/src/Tgstation.Server.Host/System/Process.cs @@ -1,9 +1,11 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Win32.SafeHandles; using System; using System.Diagnostics; using System.Text; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Extensions; namespace Tgstation.Server.Host.System { @@ -31,8 +33,10 @@ namespace Tgstation.Server.Host.System readonly global::System.Diagnostics.Process handle; - readonly StringBuilder outputStringBuilder; - readonly StringBuilder errorStringBuilder; + readonly SafeProcessHandle safeHandle; + + readonly Task standardOutputTask; + readonly Task standardErrorTask; readonly StringBuilder combinedStringBuilder; /// @@ -41,8 +45,8 @@ namespace Tgstation.Server.Host.System /// The value of /// The value of /// The value of - /// The value of - /// The value of + /// The value of + /// The value of /// The value of /// The value of /// If was NOT just created @@ -50,91 +54,113 @@ namespace Tgstation.Server.Host.System IProcessFeatures processFeatures, global::System.Diagnostics.Process handle, Task lifetime, - StringBuilder outputStringBuilder, - StringBuilder errorStringBuilder, + Task standardOutputTask, + Task standardErrorTask, StringBuilder combinedStringBuilder, ILogger logger, bool preExisting) { - this.processFeatures = processFeatures ?? throw new ArgumentNullException(nameof(processFeatures)); this.handle = handle ?? throw new ArgumentNullException(nameof(handle)); - this.outputStringBuilder = outputStringBuilder; - this.errorStringBuilder = errorStringBuilder; + // Do this fast because the runtime will bitch if we try to access it after it ends + Id = handle.Id; + + // https://stackoverflow.com/a/47656845 + safeHandle = handle.SafeHandle; + + this.processFeatures = processFeatures ?? throw new ArgumentNullException(nameof(processFeatures)); + + this.standardOutputTask = standardOutputTask; + this.standardErrorTask = standardErrorTask; this.combinedStringBuilder = combinedStringBuilder; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); Lifetime = WrapLifetimeTask(lifetime ?? throw new ArgumentNullException(nameof(lifetime))); - Id = handle.Id; - if (preExisting) { Startup = Task.CompletedTask; return; } - Startup = Task.Factory.StartNew(() => - { - try + Startup = Task.Factory.StartNew( + () => { - handle.WaitForInputIdle(); - } - catch (InvalidOperationException) { } - }, default, TaskCreationOptions.LongRunning, TaskScheduler.Current); + try + { + handle.WaitForInputIdle(); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error on WaitForInputIdle()!"); + } + }, + default, // DCT: None available + TaskCreationOptions.LongRunning, + TaskScheduler.Current); logger.LogTrace("Created process ID: {0}", Id); } /// - public void Dispose() => handle.Dispose(); + public void Dispose() + { + safeHandle.Dispose(); + handle.Dispose(); + } async Task WrapLifetimeTask(Task lifetimeTask) { - var result = await lifetimeTask.ConfigureAwait(false); - logger.LogTrace("PID {0} ended with code {1}", Id, result); - return result; + var exitCode = await lifetimeTask.ConfigureAwait(false); + logger.LogTrace("PID {0} exited with code {1}", Id, exitCode); + return exitCode; } /// - public string GetCombinedOutput() + public async Task GetCombinedOutput(CancellationToken cancellationToken) { if (combinedStringBuilder == null) - throw new InvalidOperationException("Output/Error reading was not enabled!"); + throw new InvalidOperationException("Output/Error stream reading was not enabled!"); + await Task.WhenAll(standardOutputTask, standardErrorTask).WithToken(cancellationToken).ConfigureAwait(false); return combinedStringBuilder.ToString().TrimStart(Environment.NewLine.ToCharArray()); } /// - public string GetErrorOutput() + public Task GetErrorOutput(CancellationToken cancellationToken) { - if (errorStringBuilder == null) - throw new InvalidOperationException("Error reading was not enabled!"); - return errorStringBuilder.ToString().TrimStart(Environment.NewLine.ToCharArray()); + if (standardErrorTask == null) + throw new InvalidOperationException("Error stream reading was not enabled!"); + return standardErrorTask.WithToken(cancellationToken); } /// - public string GetStandardOutput() + public Task GetStandardOutput(CancellationToken cancellationToken) { - if (outputStringBuilder == null) - throw new InvalidOperationException("Output reading was not enabled!"); - return outputStringBuilder.ToString().TrimStart(Environment.NewLine.ToCharArray()); + if (standardOutputTask == null) + throw new InvalidOperationException("Output stream reading was not enabled!"); + return standardOutputTask.WithToken(cancellationToken); } /// public void Terminate() { if (handle.HasExited) + { + logger.LogTrace("PID {0} already exited", Id); return; + } + try { logger.LogTrace("Terminating PID {0}...", Id); handle.Kill(); - handle.WaitForExit(); + if (!handle.WaitForExit(5000)) + logger.LogWarning("WaitForExit() on PID {0} timed out!", Id); } catch (Exception e) { - logger.LogDebug("Process termination exception: {0}", e); + logger.LogDebug(e, "PID {0} termination exception!", Id); } } @@ -148,15 +174,39 @@ namespace Tgstation.Server.Host.System } catch (Exception e) { - logger.LogWarning("Unable to raise process priority for PID {0}! Exception: {1}", Id, e); + logger.LogWarning(e, "Unable to raise process priority for PID {0}!", Id); } } /// - public void Suspend() => processFeatures.SuspendProcess(handle); + public void Suspend() + { + try + { + processFeatures.SuspendProcess(handle); + logger.LogTrace("Suspended PID {0}", Id); + } + catch (Exception e) + { + logger.LogError(e, "Failed to suspend PID {0}!", Id); + throw; + } + } /// - public void Resume() => processFeatures.ResumeProcess(handle); + public void Resume() + { + try + { + processFeatures.ResumeProcess(handle); + logger.LogTrace("Resumed PID {0}", Id); + } + catch (Exception e) + { + logger.LogError(e, "Failed to resume PID {0}!", Id); + throw; + } + } /// public async Task GetExecutingUsername(CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Host/System/ProcessExecutor.cs b/src/Tgstation.Server.Host/System/ProcessExecutor.cs index 9284a0703e..454542ca18 100644 --- a/src/Tgstation.Server.Host/System/ProcessExecutor.cs +++ b/src/Tgstation.Server.Host/System/ProcessExecutor.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using System; +using System.IO; using System.Text; using System.Threading.Tasks; @@ -23,30 +24,56 @@ namespace Tgstation.Server.Host.System /// readonly ILoggerFactory loggerFactory; - /// - /// Create a resulting in the exit code of a given - /// - /// The to attach the for - /// A new resulting in the exit code of - static Task AttachExitHandler(global::System.Diagnostics.Process handle) + async Task> AttachExitHandlerBeforeLaunch(global::System.Diagnostics.Process handle, Task startupTask) + { + var id = -1; + var result = AttachExitHandler(handle, () => id); + await startupTask.ConfigureAwait(false); + id = handle.Id; + return result; + } + + Task AttachExitHandler(global::System.Diagnostics.Process handle, Func idProvider) { handle.EnableRaisingEvents = true; + var tcs = new TaskCompletionSource(); - handle.Exited += (a, b) => + void ExitHandler(object sender, EventArgs args) { - int exitCode; + var id = idProvider(); try { - exitCode = handle.ExitCode; - } - catch (InvalidOperationException) - { - return; - } + if (tcs.Task.IsCompleted) + { + logger.LogTrace("Skipping PID {0} exit handler as the TaskCompletionSource is already set", id); + return; + } - // Try because this can be invoked twice for weird reasons - tcs.TrySetResult(exitCode); - }; + try + { + var exitCode = handle.ExitCode; + + // Try because this can be invoked twice for weird reasons + if (tcs.TrySetResult(exitCode)) + logger.LogTrace("PID {0} termination event completed", id); + else + logger.LogTrace("Ignoring duplicate PID {0} termination event", id); + } + catch (InvalidOperationException ex) + { + if (!tcs.Task.IsCompleted) + throw; + + logger.LogTrace(ex, "Ignoring expected PID {0} exit handler exception!", id); + } + } + catch (Exception ex) + { + logger.LogError(ex, "PID {0} exit handler exception!", id); + } + } + + handle.Exited += ExitHandler; return tcs.Task; } @@ -76,9 +103,9 @@ namespace Tgstation.Server.Host.System { handle = global::System.Diagnostics.Process.GetProcessById(id); } - catch(Exception e) + catch (Exception e) { - logger.LogDebug("Unable to get process {0}! Exception: {1}", id, e); + logger.LogDebug(e, "Unable to get process {0}!", id); return null; } @@ -127,92 +154,71 @@ namespace Tgstation.Server.Host.System handle.StartInfo.UseShellExecute = !noShellExecute; - StringBuilder outputStringBuilder = null, errorStringBuilder = null, combinedStringBuilder = null; + StringBuilder combinedStringBuilder = null; - TaskCompletionSource outputReadTcs = null; - TaskCompletionSource errorReadTcs = null; + Task outputTask = null; + Task errorTask = null; + var processStartTcs = new TaskCompletionSource(); if (readOutput || readError) { combinedStringBuilder = new StringBuilder(); + + async Task ConsumeReader(Func readerFunc) + { + var stringBuilder = new StringBuilder(); + string text; + + await processStartTcs.Task.ConfigureAwait(false); + + var reader = readerFunc(); + while ((text = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + combinedStringBuilder.AppendLine(); + combinedStringBuilder.Append(text); + stringBuilder.AppendLine(); + stringBuilder.Append(text); + } + + return stringBuilder.ToString(); + } + if (readOutput) { - outputStringBuilder = new StringBuilder(); + outputTask = ConsumeReader(() => handle.StandardOutput); handle.StartInfo.RedirectStandardOutput = true; - outputReadTcs = new TaskCompletionSource(); - handle.OutputDataReceived += (sender, e) => - { - if (e.Data == null) - { - outputReadTcs.SetResult(null); - return; - } - - combinedStringBuilder.Append(Environment.NewLine); - combinedStringBuilder.Append(e.Data); - outputStringBuilder.Append(Environment.NewLine); - outputStringBuilder.Append(e.Data); - }; } if (readError) { - errorStringBuilder = new StringBuilder(); + errorTask = ConsumeReader(() => handle.StandardError); handle.StartInfo.RedirectStandardError = true; - errorReadTcs = new TaskCompletionSource(); - handle.ErrorDataReceived += (sender, e) => - { - if (e.Data == null) - { - errorReadTcs.SetResult(null); - return; - } - - combinedStringBuilder.Append(Environment.NewLine); - combinedStringBuilder.Append(e.Data); - errorStringBuilder.Append(Environment.NewLine); - errorStringBuilder.Append(e.Data); - }; } } - var lifetimeTask = AttachExitHandler(handle); - - handle.Start(); - - static async Task AddToLifetimeTask(Task originalTask, TaskCompletionSource tcs) - { - var exitCode = await originalTask.ConfigureAwait(false); - await tcs.Task.ConfigureAwait(false); - return exitCode; - } + var lifetimeTaskTask = AttachExitHandlerBeforeLaunch(handle, processStartTcs.Task); try { - if (readOutput) - { - handle.BeginOutputReadLine(); - lifetimeTask = AddToLifetimeTask(lifetimeTask, outputReadTcs); - } - } - catch (InvalidOperationException) { } - try - { - if (readError) - { - handle.BeginErrorReadLine(); - lifetimeTask = AddToLifetimeTask(lifetimeTask, errorReadTcs); - } - } - catch (InvalidOperationException) { } + handle.Start(); - return new Process( + processStartTcs.SetResult(null); + } + catch (Exception ex) + { + processStartTcs.SetException(ex); + throw; + } + + var process = new Process( processFeatures, handle, - lifetimeTask, - outputStringBuilder, - errorStringBuilder, + lifetimeTaskTask.GetAwaiter().GetResult(), // won't block + outputTask, + errorTask, combinedStringBuilder, loggerFactory.CreateLogger(), false); + + return process; } catch { @@ -232,7 +238,7 @@ namespace Tgstation.Server.Host.System handle = proc; else { - logger.LogTrace("Disposing extra found PID: {0}", proc.Id); + logger.LogTrace("Disposing extra found PID: {0}...", proc.Id); proc.Dispose(); } @@ -251,10 +257,11 @@ namespace Tgstation.Server.Host.System { try { + var pid = handle.Id; return new Process( processFeatures, handle, - AttachExitHandler(handle), + AttachExitHandler(handle, () => pid), null, null, null, diff --git a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs index 1586229119..4fb35a71a8 100644 --- a/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs +++ b/src/Tgstation.Server.Host/System/WindowsNetworkPromptReaper.cs @@ -1,4 +1,4 @@ -using BetterWin32Errors; +using BetterWin32Errors; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; @@ -134,7 +134,7 @@ namespace Tgstation.Server.Host.System if (NativeMethods.GetWindowText(I, stringBuilder, MaxLength) == 0) { - logger.LogWarning("Error calling GetWindowText! Exception: {0}", new Win32Exception()); + logger.LogWarning(new Win32Exception(), "Error calling GetWindowText!"); continue; } @@ -158,9 +158,9 @@ namespace Tgstation.Server.Host.System logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {0}!", processId); } } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogTrace("Cancelled!"); + logger.LogTrace(ex, "Cancelled!"); } finally { diff --git a/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs b/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs index 339e847e75..a01d5e850f 100644 --- a/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs +++ b/src/Tgstation.Server.Host/System/WindowsProcessFeatures.cs @@ -1,5 +1,4 @@ -using BetterWin32Errors; -using Microsoft.Extensions.Logging; +using BetterWin32Errors; using System; using System.Diagnostics; using System.IO; @@ -7,52 +6,35 @@ using System.Linq; using System.Management; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Jobs; namespace Tgstation.Server.Host.System { /// sealed class WindowsProcessFeatures : IProcessFeatures { - /// - /// The for the . - /// - readonly ILogger logger; - - /// - /// Initializes a new instance of the . - /// - /// The value of . - public WindowsProcessFeatures(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - /// public void ResumeProcess(global::System.Diagnostics.Process process) { if (process == null) throw new ArgumentNullException(nameof(process)); - try + foreach (ProcessThread thread in process.Threads) { - foreach (ProcessThread thread in process.Threads) - { - var pOpenThread = NativeMethods.OpenThread(NativeMethods.ThreadAccess.SuspendResume, false, (uint)thread.Id); - if (pOpenThread == IntPtr.Zero) - continue; + var pOpenThread = NativeMethods.OpenThread(NativeMethods.ThreadAccess.SuspendResume, false, (uint)thread.Id); + if (pOpenThread == IntPtr.Zero) + continue; + try + { if (NativeMethods.ResumeThread(pOpenThread) == UInt32.MaxValue) throw new Win32Exception(); - + } + finally + { NativeMethods.CloseHandle(pOpenThread); } - - logger.LogTrace("Resumed PID {0}", process.Id); - } - catch (Exception e) - { - logger.LogError(e, "Failed to resume PID {0}!", process.Id); - throw; } } @@ -62,26 +44,20 @@ namespace Tgstation.Server.Host.System if (process == null) throw new ArgumentNullException(nameof(process)); - try + foreach (ProcessThread thread in process.Threads) { - foreach (ProcessThread thread in process.Threads) + var pOpenThread = NativeMethods.OpenThread(NativeMethods.ThreadAccess.SuspendResume, false, (uint)thread.Id); + if (pOpenThread == IntPtr.Zero) + continue; + try { - var pOpenThread = NativeMethods.OpenThread(NativeMethods.ThreadAccess.SuspendResume, false, (uint)thread.Id); - if (pOpenThread == IntPtr.Zero) - continue; - if (NativeMethods.SuspendThread(pOpenThread) == UInt32.MaxValue) throw new Win32Exception(); - + } + finally + { NativeMethods.CloseHandle(pOpenThread); } - - logger.LogTrace("Suspended PID {0}", process.Id); - } - catch (Exception e) - { - logger.LogError(e, "Failed to suspend PID {0}!", process.Id); - throw; } } @@ -117,6 +93,16 @@ namespace Tgstation.Server.Host.System => Task.Factory.StartNew( () => { + try + { + if (process.HasExited) + throw new JobException(ErrorCode.DreamDaemonOffline); + } + catch (InvalidOperationException ex) + { + throw new JobException(ErrorCode.DreamDaemonOffline, ex); + } + using var fileStream = new FileStream(outputFile, FileMode.CreateNew); if (!NativeMethods.MiniDumpWriteDump( process.Handle, diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 4ab66ef068..ba065d5aa6 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -1,4 +1,4 @@ - + @@ -48,20 +48,20 @@ - - - + + + all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -69,7 +69,7 @@ - + @@ -86,7 +86,7 @@ - + diff --git a/tests/DMAPI/BasicOperation/Test.dm b/tests/DMAPI/BasicOperation/Test.dm index a603a099be..0fc07d72f9 100644 --- a/tests/DMAPI/BasicOperation/Test.dm +++ b/tests/DMAPI/BasicOperation/Test.dm @@ -19,7 +19,9 @@ var/datum/tgs_version/dmapi_version = new /datum/tgs_version(TGS_DMAPI_VERSION) if(!active_version.Equals(dmapi_version)) text2file("DMAPI version [TGS_DMAPI_VERSION] does not match active API version [active_version.raw_parameter]", "test_fail_reason.txt") - + + world.log << "sleep2" + sleep(150) world.log << "Terminating..." world.TgsEndProcess() diff --git a/tests/DMAPI/LongRunning/Test.dm b/tests/DMAPI/LongRunning/Test.dm index d210608d28..5b87b7e5ad 100644 --- a/tests/DMAPI/LongRunning/Test.dm +++ b/tests/DMAPI/LongRunning/Test.dm @@ -31,15 +31,14 @@ /world/proc/HandleTopic(T) TGS_TOPIC - TgsChatBroadcast("Recieved non-tgs topic: [T]") - var/list/data = params2list(T) var/special_tactics = data["tgs_integration_test_special_tactics"] if(special_tactics) RebootAsync() return "ack" - TgsChatBroadcast("Not rebooting...") + TgsChatBroadcast("Recieved non-tgs topic: [T]") + return "feck" /world/Reboot(reason) diff --git a/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj b/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj index 81b42afb96..4ac3e5c0c5 100644 --- a/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj +++ b/tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj @@ -8,9 +8,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive 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 0585c62165..1ce9b9982d 100644 --- a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj +++ b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj @@ -8,9 +8,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj b/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj index a53fb435d8..c37b05c82f 100644 --- a/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj +++ b/tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj @@ -8,9 +8,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj index 68cbe52649..c47523526f 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj +++ b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj @@ -14,9 +14,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs index 8cf32df3e6..a035eee34c 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs @@ -1,9 +1,12 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; +using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Jobs; +using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; namespace Tgstation.Server.Host.Components.Chat.Providers.Tests @@ -11,52 +14,75 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests [TestClass] public sealed class TestDiscordProvider { - string testToken1; + ChatBot testToken1; + IJobManager mockJobManager; [TestInitialize] public void Initialize() { - testToken1 = Environment.GetEnvironmentVariable("TGS4_TEST_DISCORD_TOKEN_1"); + var actualToken = Environment.GetEnvironmentVariable("TGS4_TEST_DISCORD_TOKEN"); + if(!String.IsNullOrWhiteSpace(actualToken)) + testToken1 = new ChatBot + { + ConnectionString = actualToken, + ReconnectionInterval = 1 + }; + + var mockSetup = new Mock(); + mockSetup + .Setup(x => x.RegisterOperation(It.IsNotNull(), It.IsNotNull(), It.IsAny())) + .Callback((job, entrypoint, cancellationToken) => job.StartedBy ??= new User { }) + .Returns(Task.CompletedTask); + mockSetup + .Setup(x => x.WaitForJobCompletion(It.IsNotNull(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + mockJobManager = mockSetup.Object; } [TestMethod] - public void TestConstructionAndDisposal() + public async Task TestConstructionAndDisposal() { - Assert.ThrowsException(() => new DiscordProvider(null, null, null, 1)); + if (testToken1 == null) + Assert.Inconclusive("Required environment variable TGS4_TEST_DISCORD_TOKEN isn't set!"); + + Assert.ThrowsException(() => new DiscordProvider(null, null, null, null)); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, null, null)); var mockAss = new Mock(); - Assert.ThrowsException(() => new DiscordProvider(mockAss.Object, null, null, 1)); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockAss.Object, null, null)); var mockLogger = new Mock>(); - Assert.ThrowsException(() => new DiscordProvider(mockAss.Object, mockLogger.Object, null, 1)); - var mockToken = "asdf"; - Assert.ThrowsException(() => new DiscordProvider(mockAss.Object, mockLogger.Object, mockToken, 0)); - new DiscordProvider(mockAss.Object, mockLogger.Object, mockToken, 1).Dispose(); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, mockLogger.Object, null)); + await new DiscordProvider(mockJobManager, mockAss.Object, mockLogger.Object, testToken1).DisposeAsync(); } + static Task InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (Task)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); + [TestMethod] public async Task TestConnectWithFakeTokenFails() { var mockLogger = new Mock>(); - using var provider = new DiscordProvider(Mock.Of(), mockLogger.Object, "asdf", 1); - Assert.IsFalse(await provider.Connect(default).ConfigureAwait(false)); + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, new ChatBot + { + ReconnectionInterval = 1, + ConnectionString = "asdf" + }); + await Assert.ThrowsExceptionAsync(() => InvokeConnect(provider)); Assert.IsFalse(provider.Connected); } - [Ignore("Broken due to dependency issues after first call to .Connect()")] [TestMethod] public async Task TestConnectAndDisconnect() { if (testToken1 == null) - Assert.Inconclusive("Required environment variable TGS4_TEST_DISCORD_TOKEN_1 isn't set!"); - + Assert.Inconclusive("Required environment variable TGS4_TEST_DISCORD_TOKEN isn't set!"); var mockLogger = new Mock>(); - using var provider = new DiscordProvider(Mock.Of(), mockLogger.Object, testToken1, 1); + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, testToken1); Assert.IsFalse(provider.Connected); await provider.Disconnect(default).ConfigureAwait(false); Assert.IsFalse(provider.Connected); - Assert.IsTrue(await provider.Connect(default).ConfigureAwait(false)); + await InvokeConnect(provider).ConfigureAwait(false); Assert.IsTrue(provider.Connected); - Assert.IsTrue(await provider.Connect(default).ConfigureAwait(false)); + await InvokeConnect(provider).ConfigureAwait(false); Assert.IsTrue(provider.Connected); await provider.Disconnect(default).ConfigureAwait(false); @@ -68,9 +94,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests using var cts = new CancellationTokenSource(); cts.Cancel(); var cancellationToken = cts.Token; - await Assert.ThrowsExceptionAsync(() => provider.Connect(cancellationToken)).ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => InvokeConnect(provider, cancellationToken)).ConfigureAwait(false); Assert.IsFalse(provider.Connected); - Assert.IsTrue(await provider.Connect(default).ConfigureAwait(false)); + await InvokeConnect(provider).ConfigureAwait(false); Assert.IsTrue(provider.Connected); await Assert.ThrowsExceptionAsync(() => provider.Disconnect(cancellationToken)).ConfigureAwait(false); Assert.IsTrue(provider.Connected); diff --git a/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs b/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs index 3326de98fd..a4143ca8cc 100644 --- a/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs +++ b/tests/Tgstation.Server.Host.Tests/Jobs/TestJobHandler.cs @@ -38,12 +38,12 @@ namespace Tgstation.Server.Host.Jobs.Tests var tcs = new TaskCompletionSource(); currentWaitTask = tcs.Task; cts.Cancel(); - using(var handler = new JobHandler(TestJob)) - { - await Assert.ThrowsExceptionAsync(() => handler.Wait(cts.Token)).ConfigureAwait(false); - tcs.SetResult(null); - await handler.Wait(default).ConfigureAwait(false); - } + using var handler = new JobHandler(TestJob); + await Assert.ThrowsExceptionAsync(() => handler.Wait(cts.Token)).ConfigureAwait(false); + handler.Start(); + await Assert.ThrowsExceptionAsync(() => handler.Wait(cts.Token)).ConfigureAwait(false); + tcs.SetResult(null); + await handler.Wait(default).ConfigureAwait(false); } Assert.IsFalse(cancelled); } @@ -70,6 +70,7 @@ namespace Tgstation.Server.Host.Jobs.Tests cancelled = false; using(var handler = new JobHandler(TestJob)) { + handler.Start(); handler.Cancel(); tcs.SetResult(null); await handler.Wait(default).ConfigureAwait(false); diff --git a/tests/Tgstation.Server.Host.Tests/System/TestProcessFeatures.cs b/tests/Tgstation.Server.Host.Tests/System/TestProcessFeatures.cs index cfa6173cce..7dabedf9d2 100644 --- a/tests/Tgstation.Server.Host.Tests/System/TestProcessFeatures.cs +++ b/tests/Tgstation.Server.Host.Tests/System/TestProcessFeatures.cs @@ -1,4 +1,4 @@ -using Castle.Core.Logging; +using Castle.Core.Logging; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -20,15 +20,15 @@ namespace Tgstation.Server.Host.System.Tests public void Init() { features = new PlatformIdentifier().IsWindows - ? (IProcessFeatures)new WindowsProcessFeatures(Mock.Of>()) + ? (IProcessFeatures)new WindowsProcessFeatures() : new PosixProcessFeatures(new Lazy(() => null), new DefaultIOManager(), Mock.Of>()); } [TestMethod] public async Task TestGetUsername() { - if (!String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TRAVIS"))) - Assert.Inconclusive("This test doesn't work on TRAVIS CI!"); + if (!new PlatformIdentifier().IsWindows) + Assert.Inconclusive("This test is buggy on linux and not required"); var username = await features.GetExecutingUsername(global::System.Diagnostics.Process.GetCurrentProcess(), default); Assert.IsTrue(username.Contains(Environment.UserName), $"Exepcted a string containing \"{Environment.UserName}\", got \"{username}\""); 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 243e758b8c..60aba1cdb0 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -8,9 +8,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj b/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj index 0c9e91bb71..c4cfd002c5 100644 --- a/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj +++ b/tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj @@ -14,9 +14,9 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Tgstation.Server.Tests/AdministrationTest.cs b/tests/Tgstation.Server.Tests/AdministrationTest.cs index f3fb7c1155..7562c33d93 100644 --- a/tests/Tgstation.Server.Tests/AdministrationTest.cs +++ b/tests/Tgstation.Server.Tests/AdministrationTest.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Linq; using System.Runtime.InteropServices; @@ -43,6 +43,11 @@ namespace Tgstation.Server.Tests { Name = "very_fake_path.log" }, cancellationToken), ErrorCode.IOError); + + await Assert.ThrowsExceptionAsync(() => client.GetLog(new LogFile + { + Name = "../out_of_bounds.file" + }, cancellationToken)); } async Task TestRead(CancellationToken cancellationToken) diff --git a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs index 6e63bf7b55..578dffa9a5 100644 --- a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Tgstation.Server.Api.Models; using Tgstation.Server.Client.Components; +using Tgstation.Server.Host.System; namespace Tgstation.Server.Tests.Instance { @@ -32,6 +33,13 @@ namespace Tgstation.Server.Tests.Instance await repositoryTask; // by alphabetization rules, it should discover api_free here + if (!new PlatformIdentifier().IsWindows) + await dreamMakerClient.Update(new DreamMaker + { + ProjectName = "tests/DMAPI/ApiFree/api_free", + ApiValidationPort = IntegrationTest.DMPort + }, cancellationToken); + var updatedDD = await dreamDaemonClient.Update(new DreamDaemon { StartupTimeout = 5 diff --git a/tests/Tgstation.Server.Tests/Instance/JobsRequiredTest.cs b/tests/Tgstation.Server.Tests/Instance/JobsRequiredTest.cs index 258e69de57..6594f345e2 100644 --- a/tests/Tgstation.Server.Tests/Instance/JobsRequiredTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/JobsRequiredTest.cs @@ -36,7 +36,8 @@ namespace Tgstation.Server.Tests.Instance } if (expectFailure ^ job.ExceptionDetails != null) - Assert.Fail(job.ExceptionDetails ?? $"Expected job \"{job.Id}\" \"{job.Description}\" to fail but it didn't"); + Assert.Fail(job.ExceptionDetails + ?? $"Expected job \"{job.Id}\" \"{job.Description}\" to fail {(expectedCode.HasValue ? $"with ErrorCode \"{expectedCode.Value}\" " : String.Empty)}but it didn't"); if (expectedCode.HasValue) Assert.AreEqual(expectedCode.Value, job.ErrorCode, job.ExceptionDetails); diff --git a/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs index 822a8f6a3d..4e4098f100 100644 --- a/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/WatchdogTest.cs @@ -1,4 +1,4 @@ -using Byond.TopicSender; +using Byond.TopicSender; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -6,6 +6,7 @@ using System; using System.IO; using System.Linq; using System.Net; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; @@ -13,6 +14,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Client; using Tgstation.Server.Client.Components; using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.System; @@ -35,7 +37,8 @@ namespace Tgstation.Server.Tests.Instance var initialSettings = await instanceClient.DreamDaemon.Update(new DreamDaemon { StartupTimeout = 30, - HeartbeatSeconds = 0 + HeartbeatSeconds = 0, + Port = IntegrationTest.DDPort }, cancellationToken); await ApiAssert.ThrowsException(() => instanceClient.DreamDaemon.Update(new DreamDaemon @@ -73,7 +76,7 @@ namespace Tgstation.Server.Tests.Instance { System.Console.WriteLine("TEST: WATCHDOG DUMP TESTS"); var dumpJob = await instanceClient.DreamDaemon.CreateDump(cancellationToken); - await WaitForJob(dumpJob, 3000, false, null, cancellationToken); + await WaitForJob(dumpJob, 30, false, null, cancellationToken); var dumpFiles = Directory.GetFiles(Path.Combine( instanceClient.Metadata.Path, "Diagnostics", "ProcessDumps"), "*.dmp"); @@ -81,11 +84,18 @@ namespace Tgstation.Server.Tests.Instance File.Delete(dumpFiles.Single()); KillDD(true); - var dumpTask = instanceClient.DreamDaemon.CreateDump(cancellationToken); - while (!dumpTask.IsCompleted) - KillDD(false); - await WaitForJob(await dumpTask, 5, true, ErrorCode.DreamDaemonOffline, cancellationToken); - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + Task dumpTask = null; + var killTask = Task.Run(() => + { + while (dumpTask?.IsCompleted != true) + KillDD(false); + }); + + dumpTask = instanceClient.DreamDaemon.CreateDump(cancellationToken); + await killTask; + var job = await WaitForJob(await dumpTask, 20, true, null, cancellationToken); + Assert.IsTrue(job.ErrorCode == ErrorCode.DreamDaemonOffline || job.ErrorCode == ErrorCode.GCoreFailure, $"{job.ErrorCode}: {job.ExceptionDetails}"); + await Task.Delay(TimeSpan.FromSeconds(20), cancellationToken); var ddStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, ddStatus.Status.Value); @@ -102,9 +112,9 @@ namespace Tgstation.Server.Tests.Instance Assert.IsNull(daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); - await WaitForJob(startJob, 10, false, null, cancellationToken); + await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); @@ -137,16 +147,30 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(DMApiConstants.Version, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Safe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + Job startJob; + if (new PlatformIdentifier().IsWindows) // Can't get address reuse to trigger on linux for some reason + using (var blockSocket = new Socket(SocketType.Stream, ProtocolType.Tcp)) + { + blockSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ExclusiveAddressUse, true); + blockSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); + blockSocket.Bind(new IPEndPoint(IPAddress.Any, IntegrationTest.DDPort)); - await WaitForJob(startJob, 10, false, null, cancellationToken); + // Don't use StartDD here + startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + + await WaitForJob(startJob, 40, true, ErrorCode.DreamDaemonPortInUse, cancellationToken); + } + + startJob = await StartDD(cancellationToken).ConfigureAwait(false); + + await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); Assert.AreEqual(false, daemonStatus.SoftRestart); Assert.AreEqual(false, daemonStatus.SoftShutdown); - await GracefulWatchdogShutdown(30, cancellationToken); + await GracefulWatchdogShutdown(60, cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Offline, daemonStatus.Status.Value); @@ -156,16 +180,16 @@ namespace Tgstation.Server.Tests.Instance async Task RunHeartbeatTest(CancellationToken cancellationToken) { - global::System.Console.WriteLine("TEST: WATCHDOG HEARTBEAT TEST"); + System.Console.WriteLine("TEST: WATCHDOG HEARTBEAT TEST"); // enable heartbeats await instanceClient.DreamDaemon.Update(new DreamDaemon { HeartbeatSeconds = 1, }, cancellationToken); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); - await WaitForJob(startJob, 10, false, null, cancellationToken); + await WaitForJob(startJob, 40, false, null, cancellationToken); // lock on to DD and pause it so it can't heartbeat var ddProcs = System.Diagnostics.Process.GetProcessesByName("DreamDaemon").ToList(); @@ -176,7 +200,7 @@ namespace Tgstation.Server.Tests.Instance IProcessExecutor executor = null; executor = new ProcessExecutor( new PlatformIdentifier().IsWindows - ? (IProcessFeatures)new WindowsProcessFeatures(Mock.Of>()) + ? (IProcessFeatures)new WindowsProcessFeatures() : new PosixProcessFeatures(new Lazy(() => executor), Mock.Of(), Mock.Of>()), Mock.Of>(), LoggerFactory.Create(x => { })); @@ -194,9 +218,9 @@ namespace Tgstation.Server.Tests.Instance ourProcessHandler.Suspend(); - await Task.WhenAny(ourProcessHandler.Lifetime, Task.Delay(TimeSpan.FromSeconds(20))); + await Task.WhenAny(ourProcessHandler.Lifetime, Task.Delay(TimeSpan.FromMinutes(1))); - var timeout = 10; + var timeout = 20; do { await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); @@ -217,6 +241,37 @@ namespace Tgstation.Server.Tests.Instance }, cancellationToken); } + async Task StartDD(CancellationToken cancellationToken) + { + // integration tests may take a while to release the port + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(1)); + while (true) + { + try + { + SocketExtensions.BindTest(IntegrationTest.DDPort, false); + break; + } + catch + { + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + continue; + } + catch (OperationCanceledException) + { + } + + throw; + } + } + await Task.Delay(TimeSpan.FromSeconds(3), cts.Token); + + return await instanceClient.DreamDaemon.Start(cancellationToken); + } + async Task RunLongRunningTestThenUpdate(CancellationToken cancellationToken) { global::System.Console.WriteLine("TEST: WATCHDOG LONG RUNNING WITH UPDATE TEST"); @@ -231,9 +286,9 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(DMApiConstants.Version, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); - await WaitForJob(startJob, 10, false, null, cancellationToken); + await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await DeployTestDme(DmeName, DreamDaemonSecurity.Safe, true, cancellationToken); @@ -246,9 +301,8 @@ namespace Tgstation.Server.Tests.Instance Assert.AreNotEqual(initialCompileJob.Id, newerCompileJob.Id); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, newerCompileJob.MinimumSecurityLevel); - await TellWorldToReboot(cancellationToken); + daemonStatus = await TellWorldToReboot(cancellationToken); - daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreNotEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); Assert.IsNull(daemonStatus.StagedCompileJob); @@ -272,9 +326,9 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(DMApiConstants.Version, daemonStatus.ActiveCompileJob.DMApiVersion); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, daemonStatus.ActiveCompileJob.MinimumSecurityLevel); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); - await WaitForJob(startJob, 10, false, null, cancellationToken); + await WaitForJob(startJob, 40, false, null, cancellationToken); daemonStatus = await DeployTestDme(DmeName + "_copy", DreamDaemonSecurity.Safe, true, cancellationToken); @@ -287,9 +341,8 @@ namespace Tgstation.Server.Tests.Instance Assert.AreNotEqual(initialCompileJob.Id, newerCompileJob.Id); Assert.AreEqual(DreamDaemonSecurity.Ultrasafe, newerCompileJob.MinimumSecurityLevel); - await TellWorldToReboot(cancellationToken); + daemonStatus = await TellWorldToReboot(cancellationToken); - daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreNotEqual(initialCompileJob.Id, daemonStatus.ActiveCompileJob.Id); Assert.IsNull(daemonStatus.StagedCompileJob); @@ -310,7 +363,7 @@ namespace Tgstation.Server.Tests.Instance var initialStatus = await instanceClient.DreamDaemon.Read(cancellationToken); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); await WaitForJob(startJob, 40, false, null, cancellationToken); @@ -340,9 +393,8 @@ namespace Tgstation.Server.Tests.Instance Assert.AreEqual(true, daemonStatus.SoftRestart); - await TellWorldToReboot(cancellationToken); + daemonStatus = await TellWorldToReboot(cancellationToken); - daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(versionToInstall, daemonStatus.ActiveCompileJob.ByondVersion); Assert.IsNull(daemonStatus.StagedCompileJob); @@ -359,12 +411,13 @@ namespace Tgstation.Server.Tests.Instance if(dd.ActiveCompileJob == null) await DeployTestDme("LongRunning/long_running_test", DreamDaemonSecurity.Trusted, true, cancellationToken); - var startJob = await instanceClient.DreamDaemon.Start(cancellationToken).ConfigureAwait(false); + var startJob = await StartDD(cancellationToken).ConfigureAwait(false); await WaitForJob(startJob, 40, false, null, cancellationToken); var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); + Assert.AreEqual(IntegrationTest.DDPort, daemonStatus.CurrentPort); // The measure we use to test dream daemon startup doesn't work on linux currently if (new PlatformIdentifier().IsWindows) @@ -388,7 +441,7 @@ namespace Tgstation.Server.Tests.Instance while (daemonStatus.Status == WatchdogStatus.Online || daemonStatus.Status == WatchdogStatus.Restoring); Assert.AreEqual(WatchdogStatus.DelayedRestart, daemonStatus.Status.Value); - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(15), cancellationToken); daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, daemonStatus.Status.Value); @@ -408,28 +461,45 @@ namespace Tgstation.Server.Tests.Instance return ddProc != null; } - async Task TellWorldToReboot(CancellationToken cancellationToken) + async Task TellWorldToReboot(CancellationToken cancellationToken) { + var daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); + var initialCompileJob = daemonStatus.ActiveCompileJob; + var bts = new TopicClient(new SocketParameters { - SendTimeout = TimeSpan.FromSeconds(7), - ReceiveTimeout = TimeSpan.FromSeconds(7), - ConnectTimeout = TimeSpan.FromSeconds(7), - DisconnectTimeout = TimeSpan.FromSeconds(7) + SendTimeout = TimeSpan.FromSeconds(30), + ReceiveTimeout = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(30), + DisconnectTimeout = TimeSpan.FromSeconds(30) }); try { - global::System.Console.WriteLine("TEST: Sending world reboot topic..."); - var result = await bts.SendTopic(IPAddress.Loopback, "tgs_integration_test_special_tactics=1", 1337, cancellationToken); + System.Console.WriteLine("TEST: Sending world reboot topic..."); + var result = await bts.SendTopic(IPAddress.Loopback, "tgs_integration_test_special_tactics=1", IntegrationTest.DDPort, cancellationToken); Assert.AreEqual("ack", result.StringData); - await Task.Delay(10000, cancellationToken); + using (var tempCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + using (tempCts.Token.Register(() => System.Console.WriteLine("TEST ERROR: Timeout in TellWorldToReboot!"))) + { + tempCts.CancelAfter(TimeSpan.FromMinutes(2)); + var tempToken = tempCts.Token; + + do + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + daemonStatus = await instanceClient.DreamDaemon.Read(cancellationToken); + } + while (initialCompileJob.Id == daemonStatus.ActiveCompileJob.Id && !tempToken.IsCancellationRequested); + } } catch (OperationCanceledException) { throw; } + + return daemonStatus; } async Task DeployTestDme(string dmeName, DreamDaemonSecurity deploymentSecurity, bool requireApi, CancellationToken cancellationToken) diff --git a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs index 208ed2bdb5..fbab7dec20 100644 --- a/tests/Tgstation.Server.Tests/InstanceManagerTest.cs +++ b/tests/Tgstation.Server.Tests/InstanceManagerTest.cs @@ -2,15 +2,12 @@ using System; using System.IO; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Client; -using Tgstation.Server.Client.Components; using Tgstation.Server.Host.Controllers; -using Tgstation.Server.Tests.Instance; namespace Tgstation.Server.Tests { @@ -45,6 +42,9 @@ namespace Tgstation.Server.Tests //check it exists Assert.IsTrue(Directory.Exists(firstTest.Path)); + var firstClient = instanceManagerClient.CreateClient(firstTest); + await ApiAssert.ThrowsException(() => firstClient.DreamDaemon.Start(cancellationToken), ErrorCode.InstanceOffline); + //cant create instances in existent directories var testNonEmpty = Path.Combine(testRootPath, Guid.NewGuid().ToString()); Directory.CreateDirectory(testNonEmpty); @@ -65,6 +65,7 @@ namespace Tgstation.Server.Tests }, cancellationToken).ConfigureAwait(false); await Assert.ThrowsExceptionAsync(() => instanceManagerClient.CreateOrAttach(firstTest, cancellationToken)).ConfigureAwait(false); + Assert.IsTrue(Directory.Exists(firstTest.Path)); //can't create instances in installation directory await ApiAssert.ThrowsException(() => instanceManagerClient.CreateOrAttach(new Api.Models.Instance @@ -79,6 +80,7 @@ namespace Tgstation.Server.Tests Path = Path.Combine(firstTest.Path, "subdir"), Name = "NoOtherInstanceDirTest" }, cancellationToken), ErrorCode.InstanceAtConflictingPath).ConfigureAwait(false); + Assert.IsTrue(Directory.Exists(firstTest.Path)); //can't move to existent directories await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new Api.Models.Instance @@ -112,7 +114,7 @@ namespace Tgstation.Server.Tests firstTest = await instanceManagerClient.GetId(firstTest, cancellationToken).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); } while (firstTest.MoveJob != null); - + Assert.IsTrue(Directory.Exists(firstTest.Path)); //online it for real for component tests firstTest.Online = true; @@ -120,6 +122,7 @@ namespace Tgstation.Server.Tests firstTest = await instanceManagerClient.Update(firstTest, cancellationToken).ConfigureAwait(false); Assert.AreEqual(true, firstTest.Online); Assert.AreEqual(ConfigurationType.HostWrite, firstTest.ConfigurationType); + Assert.IsTrue(Directory.Exists(firstTest.Path)); //can't move online instance await ApiAssert.ThrowsException(() => instanceManagerClient.Update(new Api.Models.Instance @@ -127,6 +130,7 @@ namespace Tgstation.Server.Tests Id = firstTest.Id, Path = initialPath }, cancellationToken), ErrorCode.InstanceRelocateOnline).ConfigureAwait(false); + Assert.IsTrue(Directory.Exists(firstTest.Path)); return firstTest; } @@ -159,8 +163,7 @@ namespace Tgstation.Server.Tests await instanceManagerClient.Detach(firstTest, cancellationToken).ConfigureAwait(false); - var instanceAttachFileName = (string)typeof(InstanceController).GetField("InstanceAttachFileName", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null); - var attachPath = Path.Combine(firstTest.Path, instanceAttachFileName); + var attachPath = Path.Combine(firstTest.Path, InstanceController.InstanceAttachFileName); Assert.IsTrue(File.Exists(attachPath)); //can recreate detached instance diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index 2a2d5ab07b..3f96c59e2d 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -1,3 +1,7 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -8,12 +12,18 @@ using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Sockets; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Client; +using Tgstation.Server.Host.Components.Events; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Database.Migrations; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.System; using Tgstation.Server.Tests.Instance; @@ -87,7 +97,13 @@ namespace Tgstation.Server.Tests { try { - return await clientFactory.CreateFromLogin(url, User.AdminName, User.DefaultAdminPassword, attemptLoginRefresh: false).ConfigureAwait(false); + return await clientFactory.CreateFromLogin( + url, + User.AdminName, + User.DefaultAdminPassword, + attemptLoginRefresh: false, + cancellationToken: cancellationToken) + .ConfigureAwait(false); } catch (HttpRequestException) { @@ -106,6 +122,73 @@ namespace Tgstation.Server.Tests } while (true); } +#if DEBUG + [TestMethod] + public async Task TestDownMigrations() + { + var connectionString = Environment.GetEnvironmentVariable("TGS4_TEST_CONNECTION_STRING"); + + if (String.IsNullOrEmpty(connectionString)) + Assert.Inconclusive("No connection string configured in env var TGS4_TEST_CONNECTION_STRING!"); + + var databaseTypeString = Environment.GetEnvironmentVariable("TGS4_TEST_DATABASE_TYPE"); + if (!Enum.TryParse(databaseTypeString, out var databaseType)) + Assert.Inconclusive("No/invalid database type configured in env var TGS4_TEST_DATABASE_TYPE!"); + + string migrationName = null; + DbContext CreateContext() + { + string serverVersion = Environment.GetEnvironmentVariable($"{DatabaseConfiguration.Section}__{nameof(DatabaseConfiguration.ServerVersion)}"); + if (String.IsNullOrWhiteSpace(serverVersion)) + serverVersion = null; + switch (databaseType) + { + case DatabaseType.MySql: + case DatabaseType.MariaDB: + migrationName = nameof(MYInitialCreate); + return new MySqlDatabaseContext( + Host.Database.Design.DesignTimeDbContextFactoryHelpers.CreateDatabaseContextOptions( + databaseType, + connectionString, + serverVersion)); + case DatabaseType.PostgresSql: + migrationName = nameof(PGCreate); + return new PostgresSqlDatabaseContext( + Host.Database.Design.DesignTimeDbContextFactoryHelpers.CreateDatabaseContextOptions( + databaseType, + connectionString, + serverVersion)); + case DatabaseType.SqlServer: + migrationName = nameof(MSInitialCreate); + return new SqlServerDatabaseContext( + Host.Database.Design.DesignTimeDbContextFactoryHelpers.CreateDatabaseContextOptions( + databaseType, + connectionString, + serverVersion)); + case DatabaseType.Sqlite: + migrationName = nameof(SLRebuild); + return new SqliteDatabaseContext( + Host.Database.Design.DesignTimeDbContextFactoryHelpers.CreateDatabaseContextOptions( + databaseType, + connectionString, + serverVersion)); + } + + return null; + } + + Task Delete(DbContext context) => databaseType == DatabaseType.Sqlite ? Task.CompletedTask : context.Database.EnsureCreatedAsync(); + + using var context = CreateContext(); + await Delete(context); + await context.Database.MigrateAsync(default); + var dbServiceProvider = ((IInfrastructure)context.Database).Instance; + var migrator = dbServiceProvider.GetRequiredService(); + await migrator.MigrateAsync(migrationName, default); + await Delete(context); + } +#endif + [TestMethod] public async Task TestServer() { @@ -119,22 +202,10 @@ namespace Tgstation.Server.Tests using var server = new TestingServer(); - using var hardTimeoutCts = new CancellationTokenSource(); - hardTimeoutCts.CancelAfter(new TimeSpan(0, 9, 45)); - var hardTimeoutCancellationToken = hardTimeoutCts.Token; - hardTimeoutCancellationToken.Register(() => Console.WriteLine($"[{DateTimeOffset.Now}] TEST TIMEOUT HARD!")); - - using var softTimeoutCts = CancellationTokenSource.CreateLinkedTokenSource(hardTimeoutCancellationToken); - softTimeoutCts.CancelAfter(new TimeSpan(0, 9, 15)); - var softTimeoutCancellationToken = softTimeoutCts.Token; - bool tooLateForSoftTimeout = false; - softTimeoutCancellationToken.Register(() => - { - if (!tooLateForSoftTimeout) - Console.WriteLine($"[{DateTimeOffset.Now}] TEST TIMEOUT SOFT!"); - }); - - using var serverCts = CancellationTokenSource.CreateLinkedTokenSource(softTimeoutCancellationToken); + const int MaximumTestMinutes = 20; + using var hardTimeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(MaximumTestMinutes)); + var hardCancellationToken = hardTimeoutCancellationTokenSource.Token; + using var serverCts = CancellationTokenSource.CreateLinkedTokenSource(hardCancellationToken); var cancellationToken = serverCts.Token; TerminateAllDDs(); @@ -179,7 +250,9 @@ namespace Tgstation.Server.Tests var usersTest = FailFast(new UsersTest(adminClient.Users).Run(cancellationToken)); instance = await new InstanceManagerTest(adminClient.Instances, adminClient.Users, server.Directory).RunPreInstanceTest(cancellationToken); + Assert.IsTrue(Directory.Exists(instance.Path)); var instanceClient = adminClient.Instances.CreateClient(instance); + Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); var instanceTests = FailFast(new InstanceTest(instanceClient, adminClient.Instances).RunTests(cancellationToken)); @@ -188,7 +261,28 @@ namespace Tgstation.Server.Tests await adminClient.Administration.Restart(cancellationToken); } - await Task.WhenAny(serverTask, Task.Delay(30000, cancellationToken)); + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); + Assert.IsTrue(serverTask.IsCompleted); + + // http bind test https://github.com/tgstation/tgstation-server/issues/1065 + if (new PlatformIdentifier().IsWindows) + { + using var blockingSocket = new Socket(SocketType.Stream, ProtocolType.Tcp); + blockingSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ExclusiveAddressUse, true); + blockingSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, false); + blockingSocket.Bind(new IPEndPoint(IPAddress.Any, server.Url.Port)); + try + { + await server.Run(cancellationToken); + Assert.Fail("Expected server task to end with a SocketException"); + } + catch (SocketException ex) + { + Assert.AreEqual(ex.SocketErrorCode, SocketError.AddressAlreadyInUse); + } + } + + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); Assert.IsTrue(serverTask.IsCompleted); var preStartupTime = DateTimeOffset.Now; @@ -213,12 +307,12 @@ namespace Tgstation.Server.Tests .ToList(); } - Assert.AreEqual(1, jobs.Count, $"Why are there multiple active jobs? \"{String.Join("\", \"", jobs.Select(x => x.Description))}\""); - - var reattachJob = jobs.Single(); - Assert.IsTrue(reattachJob.StartedAt.Value >= preStartupTime); - - await new JobsRequiredTest(instanceClient.Jobs).WaitForJob(reattachJob, 40, false, null, cancellationToken); + var jrt = new JobsRequiredTest(instanceClient.Jobs); + foreach (var job in jobs) + { + Assert.IsTrue(job.StartedAt.Value >= preStartupTime); + await jrt.WaitForJob(job, 40, false, null, cancellationToken); + } var dd = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(WatchdogStatus.Online, dd.Status.Value); @@ -232,7 +326,7 @@ namespace Tgstation.Server.Tests await adminClient.Administration.Restart(cancellationToken); } - await Task.WhenAny(serverTask, Task.Delay(30000, cancellationToken)); + await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); Assert.IsTrue(serverTask.IsCompleted); preStartupTime = DateTimeOffset.Now; @@ -256,12 +350,12 @@ namespace Tgstation.Server.Tests .ToList(); } - Assert.AreEqual(1, jobs.Count); - - var launchJob = jobs.Single(); - Assert.IsTrue(launchJob.StartedAt.Value >= preStartupTime); - - await new JobsRequiredTest(instanceClient.Jobs).WaitForJob(launchJob, 40, false, null, cancellationToken); + var jrt = new JobsRequiredTest(instanceClient.Jobs); + foreach (var job in jobs) + { + Assert.IsTrue(job.StartedAt.Value >= preStartupTime); + await jrt.WaitForJob(job, 40, false, null, cancellationToken); + } var dd = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -286,17 +380,44 @@ namespace Tgstation.Server.Tests } finally { - tooLateForSoftTimeout = true; serverCts.Cancel(); try { - await serverTask.WithToken(hardTimeoutCancellationToken).ConfigureAwait(false); + await serverTask.WithToken(hardCancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { } TerminateAllDDs(); + } - hardTimeoutCancellationToken.ThrowIfCancellationRequested(); + Assert.IsTrue(serverTask.IsCompleted); + await serverTask; + } + + public static ushort DDPort = FreeTcpPort(); + public static ushort DMPort = GetDMPort(); + + static ushort GetDMPort() + { + ushort result; + do + { + result = FreeTcpPort(); + } while (result == DDPort); + return result; + } + + static ushort FreeTcpPort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + try + { + return (ushort)((IPEndPoint)l.LocalEndpoint).Port; + } + finally + { + l.Stop(); } } @@ -315,8 +436,33 @@ namespace Tgstation.Server.Tests var exitCode = await process.Lifetime.WithToken(cts.Token); Assert.AreEqual(0, exitCode); - Assert.AreEqual(String.Empty, process.GetErrorOutput().Trim()); - Assert.AreEqual("Hello World!", process.GetStandardOutput().Trim()); + Assert.AreEqual(String.Empty, (await process.GetErrorOutput(default)).Trim()); + Assert.AreEqual("Hello World!", (await process.GetStandardOutput(default)).Trim()); + } + + [TestMethod] + public async Task TestRepoParentLookup() + { + using var testingServer = new TestingServer(); + LibGit2Sharp.Repository.Clone("https://github.com/Cyberboss/test", testingServer.Directory); + var libGit2Repo = new LibGit2Sharp.Repository(testingServer.Directory); + using var repo = new Host.Components.Repository.Repository( + libGit2Repo, + new LibGit2Commands(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of>(), + () => { }); + + const string StartSha = "af4da8beb9f9b374b04a3cc4d65acca662e8cc1a"; + await repo.CheckoutObject(StartSha, progress => { }, default); + var result = await repo.ShaIsParent("2f8588a3ca0f6b027704a2a04381215619de3412", default); + Assert.IsTrue(result); + Assert.AreEqual(StartSha, repo.Head); + result = await repo.ShaIsParent("f636418bf47d238d33b0e4a34f0072b23a8aad0e", default); + Assert.IsFalse(result); ; + Assert.AreEqual(StartSha, repo.Head); } } } diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index aad3a9e24f..1fa45873a3 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -109,6 +109,7 @@ namespace Tgstation.Server.Tests public async Task Run(CancellationToken cancellationToken) { + Console.WriteLine("TEST SERVER START"); var firstRun = realServer == null; realServer = await Application .CreateDefaultServerFactory() @@ -125,6 +126,7 @@ namespace Tgstation.Server.Tests } await realServer.Run(cancellationToken); + Console.WriteLine("TEST SERVER END"); } } } diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index 71cd8cadc2..ebea335bde 100644 --- a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj +++ b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj @@ -8,6 +8,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Tgstation.Server.Tests/UsersTest.cs b/tests/Tgstation.Server.Tests/UsersTest.cs index a0e49879d9..7241361cf9 100644 --- a/tests/Tgstation.Server.Tests/UsersTest.cs +++ b/tests/Tgstation.Server.Tests/UsersTest.cs @@ -21,18 +21,34 @@ namespace Tgstation.Server.Tests public async Task Run(CancellationToken cancellationToken) { - await TestRetrieveCurrentUser(cancellationToken).ConfigureAwait(false); - await TestCreateSysUser(cancellationToken); - await TestSpamCreation(cancellationToken).ConfigureAwait(false); + await Task.WhenAll( + BasicTests(cancellationToken), + TestCreateSysUser(cancellationToken), + TestSpamCreation(cancellationToken)).ConfigureAwait(false); } - async Task TestRetrieveCurrentUser(CancellationToken cancellationToken) + async Task BasicTests(CancellationToken cancellationToken) { var user = await this.client.Read(cancellationToken).ConfigureAwait(false); Assert.IsNotNull(user); Assert.AreEqual("Admin", user.Name); Assert.IsNull(user.SystemIdentifier); Assert.AreEqual(true, user.Enabled); + + var systemUser = user.CreatedBy; + Assert.IsNotNull(systemUser); + Assert.AreEqual("TGS", systemUser.Name); + Assert.AreEqual(false, systemUser.Enabled); + + var users = await client.List(cancellationToken); + Assert.IsTrue(users.Count > 0); + Assert.IsFalse(users.Any(x => x.Id == systemUser.Id)); + + await ApiAssert.ThrowsException(() => client.GetId(systemUser, cancellationToken), null); + await ApiAssert.ThrowsException(() => client.Update(new UserUpdate + { + Id = systemUser.Id + }, cancellationToken), null); } async Task TestCreateSysUser(CancellationToken cancellationToken) diff --git a/tgstation-server.sln b/tgstation-server.sln index 39cb8ff54e..aff47c7971 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -156,7 +156,6 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BasicOperation", "BasicOperation", "{F32B9514-AAD9-429D-841A-ED810FC2598C}" ProjectSection(SolutionItems) = preProject tests\DMAPI\BasicOperation\basic_operation_test.dme = tests\DMAPI\BasicOperation\basic_operation_test.dme - tests\DMAPI\BasicOperation\build_byond.sh = tests\DMAPI\BasicOperation\build_byond.sh tests\DMAPI\BasicOperation\Config.dm = tests\DMAPI\BasicOperation\Config.dm tests\DMAPI\BasicOperation\Test.dm = tests\DMAPI\BasicOperation\Test.dm EndProjectSection