Merge upstream

This commit is contained in:
Jordan Brown
2018-09-24 22:34:47 -04:00
157 changed files with 6515 additions and 1317 deletions
+1
View File
@@ -6,3 +6,4 @@
packages
*/bin
*/obj
tests
+14 -4
View File
@@ -22,7 +22,20 @@ matrix:
- libstdc++6:i386
- env:
- DMAPI=false
name: "Linux Build"
- CONFIG=Debug
name: "Test Server Debug"
language: csharp
mono: none
dotnet: 2.1.300
services:
- mysql
cache:
directories:
- $HOME/.nuget/packages:
- env:
- DMAPI=false
- CONFIG=Release
name: "Test Server Release"
language: csharp
mono: none
dotnet: 2.1.300
@@ -31,9 +44,6 @@ matrix:
cache:
directories:
- $HOME/.nuget/packages:
os:
- linux
- osx
install:
- if [ $DMAPI = true ]; then build/install_byond.sh; fi
+141 -5
View File
@@ -25,13 +25,35 @@ Generally, updates force a live tracking of the configured git repo, resetting l
3. Extract the .zip file to where you want the server to run from. Note the account running the server must have write access to the `lib` subdirectory.
4. If using the ServerService package, run `Tgstation.Server.Host.Service.exe`. It should prompt you to install the service. Click `Yes` and accept a potential UAC elevation prompt. You should now be able to control the service using the Windows service control commandlet.
#### Linux
[We recommend using Docker for Linux installations](https://github.com/tgstation/tgstation-server#docker). The content of this parent section may be skipped if you choose to do so
The following dependencies are required to run tgstation-server on Linux alongside the .NET Core runtime
- gcc-multilib (on 64-bit systems for running BYOND)
Note that tgstation-server has only ever been tested on Linux via it's [docker environment](https://github.com/tgstation/tgstation-server/blob/master/build/Dockerfile#L22). If you are having trouble with something, or figure out a required workaround, please contact project maintainers so this documentation may be better updated.
#### Docker
tgstation-server supports running in a docker container on linux systems and is the preferred deployment method to avoid [native dependency hell with libgit2](https://github.com/libgit2/libgit2sharp/issues/1533). The official image repository is located at https://hub.docker.com/r/tgstation/server it can be built locally, however, by running `docker build . -f build/Dockerfile` in the repository root.
tgstation-server supports running in a docker container and is the recommended deployment method for Linux systems due being the only tested environment. The official image repository is located at https://hub.docker.com/r/tgstation/server. It can also be built locally by running `docker build . -f build/Dockerfile` in the repository root.
To create a container run `docker create --restart=always -p <public port>:80 -v /path/to/your/appsettings.Production.json:/config_data -v path/to/your/log/folder:/tgs_logs tgstation/server` with any additional options you desire (i.e. You'll have to expose more ports in order to actually host servers, add a volume to create instances on, and create a volume for the SQLite database if that is what you're using).
To create a container run
```
docker create \
--restart=always \ #if you want maximum uptime
--network="host" \ #if your sql server is on the same machine
-p <tgs port>:80 \
-p 0.0.0.0:<public game port>:<internal game port> \
-v /path/to/store/instances:/tgs4_instances \
-v /path/to/your/appsettings.Production.json:/config_data \
-v path/to/your/log/folder:/tgs_logs \
tgstation/server
```
with any additional options you desire (i.e. You'll have to expose more game ports in order to host more than one instance).
Note that due to the nature of docker. If the container restarts, you will be sent back to the version of TGS you installed with the initial command. Server updates will have to be reapplied. For this reason it is NOT RECOMMENDED to use the live update feature with a docker host.
Note although `/app/lib` is specified as a volume mount point in the `Dockerfile`, unless you REALLY know what you're doing. Do not mount any volumes over this for fear of breaking your container.
### Configuring
@@ -41,7 +63,11 @@ Create an `appsettings.Production.json` file next to `appsettings.json`. This wi
- `General:MinimumPasswordLength`: Minimum password length requirement for database users
- `Logging:LogLevel:Default`: Can be one of `Trace`, `Debug`, `Information`, `Warning`, `Error`, or `Critical`. Restricts what is put into the log files. Currently `Debug` is reccommended for help with error reporting.
- `General:GitHubAccessToken`: Specify a GitHub personal access token with no scopes here to highly mitigate the possiblity of 429 response codes from GitHub requests
- `General:LogFileLevel`: Can be one of `Trace`, `Debug`, `Information`, `Warning`, `Error`, or `Critical`. Restricts what is put into the log files. Currently `Debug` is reccommended for help with error reporting.
- `Kestrel:Endpoints:Http:Url`: The URL (i.e. interface and ports) your application should listen on. General use case should be `http://localhost:<port>` for restricted local connections. See the Remote Access section for configuring public access to the World Wide Web. This doesn't need to be changed using the docker setup and should be mapped with the `-p` option instead
- `Database:DatabaseType`: Can be one of `SqlServer`, `MariaDB`, or `MySql`
@@ -55,6 +81,8 @@ If using MySQL, our provider library [recommends you set 'utf8mb4' as your defau
The user created for the application will need the privilege to create databases on the first run. Once the initial set of migrations is run, the create right may be revoked. The user should maintain DDL rights though for applying future migrations
Note that the ratio of application installations to databases is 1:1. Do not attempt to share a database amongst multiple TGS installations.
### Starting
For the Windows service version start the `tgstation-server-4` service
@@ -79,9 +107,117 @@ A breaking change from V3: tgstation-server 4 now REQUIRES the DMAPI to be integ
The DMAPI is fully backwards compatible and should function with any tgstation-server version to date. Updates can be performed in the same manner. Using the `TGS_EXTERNAL_CONFIGURATION` is recommended in order to make the process as easy as replacing `tgs.dm` and the `tgs` folder with a new version
### Example
Here is a bare minimum example project that implements the essential code changes for integrating the DMAPI
Before `tgs.dm`:
```
//Remember, every codebase is different, you probably have better methods for these defines than the ones given here
#define TGS_EXTERNAL_CONFIGURATION
#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value) var/global/##Name = ##Value
#define TGS_READ_GLOBAL(Name) global.##Name
#define TGS_WRITE_GLOBAL(Name, Value) global.##Name = ##Value
#define TGS_WORLD_ANNOUNCE(message) world << ##message
#define TGS_INFO_LOG(message) world.log << "TGS Info: [##message]"
#define TGS_ERROR_LOG(message) world.log << "TGS Error: [##message]"
#define TGS_NOTIFY_ADMINS(event) world.log << "TGS Admin Message: [##event]"
#define TGS_CLIENT_COUNT global.client_cout
#define TGS_PROTECT_DATUM(Path) // Leave blank if your codebase doesn't give administrators code reflection capabilities
```
Anywhere else:
```dm
var/global/client_count = 0
/world/New()
..()
TgsNew()
TgsInitializationsComplete()
/world/Reboot()
TgsReboot()
..()
/world/Topic()
TGS_TOPIC
..()
/client/New()
..()
++global.client_count
/client/Del()
..()
--global.client_count
```
## Remote Access
tgstation-server is an [ASP.Net Core](https://docs.microsoft.com/en-us/aspnet/core/) based on the Kestrel web server. This section is meant to serve as a general use case overview, but the entire Kestrel configuration can be modified to your liking with the configuration JSON. See [the official documentation](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel) for details.
Exposing the builtin kestrel server to the internet directly over HTTP is highly not reccommended due to the lack of security. The recommended way to expose tgstation-server to the internet is to host it through a reverse proxy with HTTPS support. Here are some step by step examples to achieve this for major web servers.
System administrators will most likely have their own configuration plans, but here are some basic guides for beginners.
Once complete, test that your configuration worked by visiting your proxy site from a different computer. You should recieve a 401 Unauthorized response.
### IIS (Reccommended for Windows)
1. Acquire an HTTPS certificate. The easiet free way for Windows is [win-acme](https://github.com/PKISharp/win-acme) (requires you to set up the website first)
2. Install the [Web Platform Installer](https://www.microsoft.com/web/downloads/platform.aspx)
3. Open the web platform installer in the IIS Manager and install the Application Request Routing 3.0 module
4. Create a new website, bind it to HTTPS only with your chosen certificate and exposed port. The physical path won't matter since it won't be used. Use `Require Server Name Indication` if you want to limit requests to a specific URL prefix.
5. Close and reopen the IIS Manager
5. Open the site and navigate to the `URL Rewrite` module
6. In the `Actions` Pane on the right click `Add Rule(s)...`
7. For the rule template, select `Reverse Proxy` under `Inbound and Outbound Rules` and click `OK`
8. You may get a prompt about enabling proxy functionality. Click `OK`
9. In the window that appears set the `Inbound Rules` textbox to the URL of your tgstation-server i.e. `http://localhost:5000`. Ensure `Enable SSL Offloading` is checked, then click `OK`
### Caddy (Reccommended for Linux, or those unfamilar with configuring NGINX or Apache)
1. Setup a basic website configuration. Instructions on how to do so are out of scope.
2. In your Caddyfile, under a server entry, add the following (replace 8080 with the port TGS is hosted on):
```
proxy /tgs localhost:8080 {
transparent
}
```
See https://caddyserver.com/docs/proxy
### NGINX (Reccommended for Linux)
1. Setup a basic website configuration. Instructions on how to do so are out of scope.
2. Acquire an HTTPS certificate, likely via Let's Encrypt, and configure NGINX to use it.
3. Setup a path under a server like the following (replace 8080 with the port TGS is hosted on):
```
location /tgs {
proxy_pass http://127.0.0.1:8080;
break;
}
```
See https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
### Apache
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):
```
ProxyPass / http://127.0.0.1:8080
ProxyPassReverse / http://127.0.0.1:8080
```
See https://httpd.apache.org/docs/2.4/howto/reverse_proxy.html
## Usage
tgstation-sever v4 is controlled via a RESTful HTTP json API. Documentation on this API can be found [here](https://tgstation.github.io/tgstation-server/api.html). This section serves to document the concepts of the server.
tgstation-server v4 is controlled via a RESTful HTTP json API. Documentation on this API can be found [here](https://tgstation.github.io/tgstation-server/api.html). This section serves to document the concepts of the server.
### Users
+3 -3
View File
@@ -4,7 +4,7 @@ pull_requests:
environment:
TGS4_TEST_DATABASE_TYPE: SqlServer
TGS4_TEST_CONNECTION_STRING: Server=(local)\SQL2017;Initial Catalog=TGS_Test;User ID=sa;Password=Password12!
repo_token:
TGS4_TEST_GITHUB_TOKEN:
secure: lJNGAXwiB5HlWdthz3K4PetqpTG5IEAyRgKaiKxFMQ8HW8CcOjRtB97B05op7BsK
branches:
only:
@@ -43,7 +43,7 @@ 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.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*]*" -output:".\host_coverage.xml" -oldstyle
- 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.Models.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
@@ -57,7 +57,7 @@ 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.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 /p:DebugType=full tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Tests*]*" -output:".\server_coverage.xml" -oldstyle
- 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.Tests/Tgstation.Server.Tests.csproj" -filter:"+[Tgstation.Server*]* -[Tgstation.Server.Tests*]* -[Tgstation.Server.Host]Tgstation.Server.Host.Models.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))
after_test:
+1 -1
View File
@@ -30,6 +30,6 @@ if($publish_dox){
echo "" > .nojekyll
git add --all
git commit -m "Deploy code docs to GitHub Pages for Appveyor build $Env:APPVEYOR_BUILD_NUMBER" -m "Commit: $Env:APPVEYOR_REPO_COMMIT"
git push -f "https://$Env:repo_token@$github_url" 2>&1 | out-null
git push -f "https://$Env:TGS4_TEST_GITHUB_TOKEN@$github_url" 2>&1 | out-null
cd "$bf"
}
+9 -8
View File
@@ -1,4 +1,5 @@
FROM microsoft/dotnet:2.1-sdk AS build
WORKDIR /src
COPY tgstation-server.sln ./
@@ -8,11 +9,6 @@ COPY src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj sr
COPY src/Tgstation.Server.Host/Tgstation.Server.Host.csproj src/Tgstation.Server.Host/
COPY src/Tgstation.Server.Api/Tgstation.Server.Api.csproj src/Tgstation.Server.Api/
COPY tests/Tgstation.Server.Api.Tests/Tgstation.Server.Api.Tests.csproj tests/Tgstation.Server.Api.Tests/
COPY tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj tests/Tgstation.Server.Host.Tests/
COPY tests/Tgstation.Server.Host.Watchdog.Tests/Tgstation.Server.Host.Watchdog.Tests.csproj tests/Tgstation.Server.Host.Watchdog.Tests/
COPY tests/Tgstation.Server.Host.Console.Tests/Tgstation.Server.Host.Console.Tests.csproj tests/Tgstation.Server.Host.Console.Tests/
RUN dotnet restore -nowarn:MSB3202,nu1503 -p:RestoreUseSkipNonexistentTargets=false
COPY . .
@@ -26,13 +22,18 @@ RUN dotnet publish -c Release -o /app/lib/Default && mv /app/lib/Default/appsett
FROM microsoft/dotnet:2.1-aspnetcore-runtime
EXPOSE 80
#needed for byond
RUN apt-get update \
&& apt-get install -y \
gcc-multilib \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app .
COPY --from=build /src/build/tgs.docker.sh tgs.sh
COPY --from=build /src/src/Tgstation.Server.Host/appsettings.Docker.json .
RUN mkdir /config_data && mv appsettings.Docker.json /config_data/appsettings.Production.json
VOLUME ["/config_data", "/tgs_logs"]
RUN mkdir /config_data
VOLUME ["/config_data", "/tgs_logs", "/app/lib"]
ENTRYPOINT ["./tgs.sh"]
+26 -7
View File
@@ -1,28 +1,47 @@
#!/bin/bash
set -e
dotnet tool install --global coverlet.console
mkdir TestResults
cd tests/Tgstation.Server.Api.Tests
dotnet test
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.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 test
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.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 test
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.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.Models.Migrations.*"
cd ../Tgstation.Server.Host.Watchdog.Tests
dotnet test
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.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 test
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.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 ../Tgstation.Server.Tests
export TGS4_TEST_DATABASE_TYPE=MySql
export TGS4_TEST_CONNECTION_STRING="server=127.0.0.1;uid=root;pwd=;database=tgs_test"
dotnet test
#token set in CI settings
dotnet build -c $CONFIG
$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/server.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*"
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
bash <(curl -s https://codecov.io/bash) -f server.xml -F integration
+1 -2
View File
@@ -1,6 +1,5 @@
#!/bin/sh
mkdir /config_data
cp -r /config_data/* ./
ln -s /config_data/appsettings.Production.json /app/appsettings.Production.json
exec dotnet Tgstation.Server.Host.Console.dll "$@"
+17 -13
View File
@@ -24,7 +24,6 @@ Last off, if anything in this API doesn't seem to hold true when tested against
This document will reference the canonical C# models in the @ref Tgstation.Server.Api.Models namespace. Note that these models are built to mirror the JSON requests and responses with a couple caveats.
- The first letter of every field name will/must be lowercase in JSON models
- Fields in the C# model marked with "DenyWrite = true" cannot be changed using POST requests
- Fields marked 'Required' should be ignored, this is a semantic for the backing SQL database
- Id fields must be specified when making POST requests
- All other fields are optional and may be absent from responses or requests unless otherwise specified
@@ -37,7 +36,7 @@ TGS4 expects this set of headers. Failure to provide them may result in 400 erro
- Accept: application/json
- Api: Another product header value representing the version of the API to use. Currently this must be: Tgstation.Server.Api/4.0.0.0
For POST and PUT requests you must also include the content type. Currently only json is supported
For POST, PATCH, and PUT requests you must also include the content type. Currently only json is supported
- Content-Type: application/json
@@ -63,6 +62,7 @@ TGS will only every return the response codes listed here
- 409: Conflict. Documented in the requests that use them
- 410: Gone. Attempted to access/modify a resource that ideally should have been ready, but isn't or no longer is
- 422: Unprocessable Entity: Used specifically when an operation that requires a server restart is unable to be performed due to the @ref Tgstation.Server.Host.Watchdog not being present in the deployment. Blame MSO. Response body contains an @ref Tgstation.Server.Api.Models.ErrorMessage
- 424: Failed Dependency: When a request that depends on the GitHub API fails for a reason other than rate limiting. Check server logs, usually this indicates a bad access token.
- 426: Upgrade required: Used when the client's API version is not compatible with the server's. Response body contains an @ref Tgstation.Server.Api.Models.ErrorMessage
- 429: Rate limited. Used with operations that rely on GitHub.com. If a rate limit is hit for an operation this will be returned. Response will contain a Retry-After header
- 500: Server error. Please report the request and response body to the code repository
@@ -232,26 +232,26 @@ I DELETE "/Job/{JobId}" => OK
@subsection api_chat Chat Bots
Each chat bot is represented by a @ref Tgstation.Server.Api.Models.ChatSettings object
Each chat bot is represented by a @ref Tgstation.Server.Api.Models.ChatBot object
Chat bots can be created/updated/deleted with the following requests respectively
I PUT "/Chat" @ref Tgstation.Server.Api.Models.ChatSettings => Tgstation.Server.Api.Models.ChatSettings
I POST "/Chat" @ref Tgstation.Server.Api.Models.ChatSettings => Tgstation.Server.Api.Models.ChatSettings
I DELETE "/Chat/{ChatSettingsId}" => OK
I PUT "/Chat" @ref Tgstation.Server.Api.Models.ChatBot => Tgstation.Server.Api.Models.ChatBot
I POST "/Chat" @ref Tgstation.Server.Api.Models.ChatBot => Tgstation.Server.Api.Models.ChatBot
I DELETE "/Chat/{ChatBotId}" => OK
The @ref Tgstation.Server.Api.Models.Internal.ChatSettings.ConnectionString must differ based on what kind of chat bot you wish to create
The @ref Tgstation.Server.Api.Models.Internal.ChatBot.ConnectionString must differ based on what kind of chat bot you wish to create. Each @ref Tgstation.Server.Api.Models.ChatProvider has a @ref Tgstation.Server.Api.Models.Internal.ChatConnectionStringBuilder that dictates how to form it
For IRC chat bots it should be in the following format:
`"<Server URL or IP address>;<Server Port>;<Bot nickname>;<1 to use SSL, 0 otherwise>[;<`The @ref Tgstation.Server.Api.Models.IrcPasswordType`;<The password>]"`
For IRC chat bots see @ref Tgstation.Server.Api.Models.IrcConnectionStringBuilder
For Discord chat bots see @ref Tgstation.Server.Api.Models.DiscordConnectionStringBuilder
For Discord chat bots it should be the <a href="https://discordapp.com/developers/docs/topics/oauth2#bots">bot's Token</a>
A specific bot's settings may be retrieved with:
I GET "/Chat/{ChatSettingsId}" => @ref Tgstation.Server.Api.Models.ChatSettings
I GET "/Chat/{ChatBotId}" => @ref Tgstation.Server.Api.Models.ChatBot
Also note that if the @ref Tgstation.Server.Api.Models.ChatSettings.Channels is present in a POST request, the list will fully replace any active channels
Also note that if the @ref Tgstation.Server.Api.Models.ChatBot.Channels is present in a POST request, the list will fully replace any active channels
@subsection api_byond Byond Version Management
@@ -287,7 +287,7 @@ Modifications to the repository are done with the following request:
I POST "/Repository" => @ref Tgstation.Server.Api.Models.Repository => @ref Tgstation.Server.Api.Models.Repository
Each update creates a job specified in the @ref Tgstation.Server.Api.Models.Repository.ActiveJob field jobs will be queued in succession. See below for post examples.
Each update that requires git changes creates a job specified in the @ref Tgstation.Server.Api.Models.Repository.ActiveJob field jobs will be queued in succession. See below for POST examples.
@subsubsection api_repopost Repository Commands for Git Aliases
@@ -448,7 +448,11 @@ To start the watchdog use the following request:
I PUT "/DreamDaemon" Empty => @ref Tgstation.Server.Api.Models.Job
The returned job represents the startup process for the watchdog
To restart the watchdog use the following request:
I PATCH "/DreamDaemon" Empty => @ref Tgstation.Server.Api.Models.Job
The returned jobs represents the startup process for the watchdog
To stop the watchdog use the following request:
+10 -1
View File
@@ -55,11 +55,16 @@
#define TGS_REBOOT_MODE_SHUTDOWN 1
#define TGS_REBOOT_MODE_RESTART 2
#define TGS_SECURITY_TRUSTED 0
#define TGS_SECURITY_SAFE 1
#define TGS_SECURITY_ULTRASAFE 2
//REQUIRED HOOKS
//Call this somewhere in /world/New() that is always run
//event_handler: optional user defined event handler. The default behaviour is to broadcast the event in english to all connected admin channels
/world/proc/TgsNew(datum/tgs_event_handler/event_handler)
//minimum_required_security_level: The minimum required security level to run the game in which the DMAPI is integrated
/world/proc/TgsNew(datum/tgs_event_handler/event_handler, minimum_required_security_level = TGS_SECURITY_ULTRASAFE)
return
//Call this when your initializations are complete and your game is ready to play before any player interactions happen
@@ -155,6 +160,10 @@
/world/proc/TgsRevision()
return
//Get the current BYOND security level
/world/proc/TgsSecurityLevel()
return
//Gets a list of active `/datum/tgs_revision_information/test_merge`s
/world/proc/TgsTestMerges()
return
+7 -2
View File
@@ -1,4 +1,4 @@
/world/TgsNew(datum/tgs_event_handler/event_handler)
/world/TgsNew(datum/tgs_event_handler/event_handler, minimum_required_security_level = TGS_SECURITY_ULTRASAFE)
var/current_api = TGS_READ_GLOBAL(tgs)
if(current_api)
TGS_ERROR_LOG("TgsNew(): TGS API datum already set ([current_api])!")
@@ -18,7 +18,7 @@
TGS_WRITE_GLOBAL(tgs, new_api)
var/result = new_api.OnWorldNew(event_handler ? event_handler : new /datum/tgs_event_handler/tgs_default)
var/result = new_api.OnWorldNew(event_handler ? event_handler : new /datum/tgs_event_handler/tgs_default, minimum_required_security_level)
if(!result || result == TGS_UNIMPLEMENTED)
TGS_WRITE_GLOBAL(tgs, null)
TGS_ERROR_LOG("Failed to activate API!")
@@ -127,6 +127,11 @@
if(api)
api.ChatPrivateMessage(message, user)
/world/TgsSecurityLevel()
var/datum/tgs_api/api = TGS_READ_GLOBAL(tgs)
if(api)
api.SecurityLevel()
/*
The MIT License
+3
View File
@@ -46,6 +46,9 @@ TGS_PROTECT_DATUM(/datum/tgs_api)
/datum/tgs_api/proc/ChatPrivateMessage(message, admin_only)
return TGS_UNIMPLEMENTED
/datum/tgs_api/proc/SecurityLevel()
return TGS_UNIMPLEMENTED
/*
The MIT License
+4 -1
View File
@@ -56,7 +56,7 @@
/datum/tgs_api/v3210/proc/file2list(filename)
return splittext(trim_left(trim_right(file2text(filename))), "\n")
/datum/tgs_api/v3210/OnWorldNew(datum/tgs_event_handler/event_handler) //don't use event handling in this version
/datum/tgs_api/v3210/OnWorldNew(datum/tgs_event_handler/event_handler, minimum_required_security_level) //don't use event handling in this version
. = FALSE
comms_key = world.params[SERVICE_WORLD_PARAM]
@@ -191,6 +191,9 @@
/datum/tgs_api/v3210/ChatPrivateMessage(message, datum/tgs_chat_user/user)
return TGS_UNIMPLEMENTED
/datum/tgs_api/v3210/SecurityLevel()
return TGS_SECURITY_TRUSTED
#undef REBOOT_MODE_NORMAL
#undef REBOOT_MODE_HARD
#undef REBOOT_MODE_SHUTDOWN
+20 -9
View File
@@ -32,6 +32,9 @@
var/chat_commands_json_path
var/server_commands_json_path
var/reboot_mode = TGS_REBOOT_MODE_NORMAL
var/security_level
var/requesting_new_port = FALSE
var/list/intercepted_message_queue
@@ -48,7 +51,7 @@
/datum/tgs_api/v4/ApiVersion()
return "4.0.0.0"
/datum/tgs_api/v4/OnWorldNew(datum/tgs_event_handler/event_handler)
/datum/tgs_api/v4/OnWorldNew(datum/tgs_event_handler/event_handler, minimum_required_security_level)
json_path = world.params[TGS4_PARAM_INFO_JSON]
if(!json_path)
TGS_ERROR_LOG("Missing [TGS4_PARAM_INFO_JSON] world parameter!")
@@ -63,14 +66,14 @@
return
access_identifier = cached_json["accessIdentifier"]
instance_name = text2num(cached_json["instanceName"])
server_commands_json_path = cached_json["serverCommandsJson"]
if(cached_json["apiValidateOnly"])
TGS_INFO_LOG("Validating API and exiting...")
Export(TGS4_COMM_VALIDATE)
Export(TGS4_COMM_VALIDATE, list(TGS4_PARAMETER_DATA = "[minimum_required_security_level]"))
del(world)
security_level = cached_json["securityLevel"]
chat_channels_json_path = cached_json["chatChannelsJson"]
chat_commands_json_path = cached_json["chatCommandsJson"]
src.event_handler = event_handler
@@ -165,6 +168,7 @@
event_handler.HandleEvent(TGS_EVENT_PORT_SWAP, new_port)
if(!world.OpenPort(new_port))
return "Port change failed!"
return
if(TGS4_TOPIC_CHANGE_REBOOT_MODE)
var/new_reboot_mode = text2num(params[TGS4_PARAMETER_DATA])
event_handler.HandleEvent(TGS_EVENT_REBOOT_MODE_CHANGE, reboot_mode, new_reboot_mode)
@@ -173,27 +177,31 @@
return "Unknown command: [command]"
/datum/tgs_api/v4/proc/Export(command, list/data)
/datum/tgs_api/v4/proc/Export(command, list/data, override_requesting_new_port = FALSE)
if(!data)
data = list()
data[TGS4_PARAMETER_COMMAND] = command
var/json = json_encode(data)
while(requesting_new_port && !override_requesting_new_port)
sleep(1)
//we need some port open at this point to facilitate return communication
if(!world.port)
requesting_new_port = TRUE
if(!world.OpenPort(0)) //open any port
TGS_ERROR_LOG("Unable to open random port to retrieve new port![TGS4_PORT_CRITFAIL_MESSAGE]")
del(world)
//request a new port
export_lock = FALSE
var/list/new_port_json = Export(TGS4_COMM_NEW_PORT, list("current_port" = "[world.port]")) //stringify this on purpose
var/list/new_port_json = Export(TGS4_COMM_NEW_PORT, list(TGS4_PARAMETER_DATA = "[world.port]"), TRUE) //stringify this on purpose
if(!new_port_json)
TGS_ERROR_LOG("No new port response from server![TGS4_PORT_CRITFAIL_MESSAGE]")
del(world)
var/new_port = new_port_json["port"]
var/new_port = new_port_json[TGS4_PARAMETER_DATA]
if(!isnum(new_port) || new_port <= 0)
TGS_ERROR_LOG("Malformed new port json ([json_encode(new_port_json)])![TGS4_PORT_CRITFAIL_MESSAGE]")
del(world)
@@ -201,6 +209,7 @@
if(new_port != world.port && !world.OpenPort(new_port))
TGS_ERROR_LOG("Unable to open port [new_port]![TGS4_PORT_CRITFAIL_MESSAGE]")
del(world)
requesting_new_port = FALSE
while(export_lock)
sleep(1)
@@ -221,14 +230,13 @@
export_lock = FALSE
/datum/tgs_api/v4/OnReboot()
var/json = Export(TGS4_COMM_WORLD_REBOOT)
var/list/result = json_decode(json)
var/list/result = Export(TGS4_COMM_WORLD_REBOOT)
if(!result)
return
//okay so the standard TGS4 proceedure is: right before rebooting change the port to whatever was sent to us in the above json's data parameter
var/port = json[TGS4_PARAMETER_DATA]
var/port = result[TGS4_PARAMETER_DATA]
if(!isnum(port))
return //this is valid, server may just want use to reboot
@@ -295,6 +303,9 @@
channel.custom_tag = channel_json["tag"]
return channel
/datum/tgs_api/v4/SecurityLevel()
return security_level
/*
The MIT License
+3 -3
View File
@@ -15,15 +15,15 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// Validates <see cref="Channels"/> are correct for the <see cref="Internal.ChatBot.Provider"/>
/// </summary>
/// <returns></returns>
/// <returns><see langword="true"/> if the <see cref="Channels"/> are valid for the <see cref="Internal.ChatBot.Provider"/>, <see langword="false"/> otherwise</returns>
public bool ValidateProviderChannelTypes()
{
switch (Provider)
{
case ChatProvider.Discord:
return Channels.Select(x => x.DiscordChannelId.HasValue && x.IrcChannel == null).All(x => x);
return Channels?.Select(x => x.DiscordChannelId.HasValue && x.IrcChannel == null).All(x => x) ?? true;
case ChatProvider.Irc:
return Channels.Select(x => !x.DiscordChannelId.HasValue && x.IrcChannel != null).All(x => x);
return Channels?.Select(x => !x.DiscordChannelId.HasValue && x.IrcChannel != null).All(x => x) ?? true;
default:
throw new InvalidOperationException("Invalid provider type!");
}
@@ -1,51 +0,0 @@
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Status of the <see cref="DreamMaker"/> for an <see cref="Instance"/>
/// </summary>
#pragma warning disable CA1717 // Only FlagsAttribute enums should have plural names
public enum CompilerStatus
#pragma warning restore CA1717 // Only FlagsAttribute enums should have plural names
{
/// <summary>
/// The <see cref="DreamMaker"/> is idle
/// </summary>
Idle,
/// <summary>
/// The <see cref="Repository"/> is being copied
/// </summary>
Copying,
/// <summary>
/// Pre-compile scripts are running
/// </summary>
PreCompile,
/// <summary>
/// The .dme is having it's server side modifications applied
/// </summary>
Modifying,
/// <summary>
/// DreamMaker is running
/// </summary>
Compiling,
/// <summary>
/// The DMAPI is being verified
/// </summary>
Verifying,
/// <summary>
/// Post-compile scripts are running
/// </summary>
PostCompile,
/// <summary>
/// The compile results are being duplicated
/// </summary>
Duplicating,
/// <summary>
/// The configuration is being linked to the compile results
/// </summary>
Symlinking,
/// <summary>
/// A failed compile job is being erased
/// </summary>
Cleanup
}
}
@@ -0,0 +1,37 @@
using System;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// <see cref="ChatConnectionStringBuilder"/> for <see cref="ChatProvider.Discord"/>
/// </summary>
public sealed class DiscordConnectionStringBuilder : ChatConnectionStringBuilder
{
/// <inheritdoc />
public override bool Valid => !String.IsNullOrEmpty(BotToken);
/// <summary>
/// The Discord bot token
/// </summary>
/// <remarks>See https://discordapp.com/developers/docs/topics/oauth2#bots</remarks>
public string BotToken { get; set; }
/// <summary>
/// Construct a <see cref="DiscordConnectionStringBuilder"/>
/// </summary>
public DiscordConnectionStringBuilder() { }
/// <summary>
/// Construct a <see cref="DiscordConnectionStringBuilder"/> from a <paramref name="connectionString"/>
/// </summary>
/// <param name="connectionString">The connection string</param>
public DiscordConnectionStringBuilder(string connectionString)
{
BotToken = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
/// <inheritdoc />
public override string ToString() => BotToken;
}
}
@@ -23,7 +23,7 @@ namespace Tgstation.Server.Api.Models
public bool? Running { get; set; }
/// <summary>
/// The current <see cref="DreamDaemonSecurity"/> of <see cref="DreamDaemon"/>
/// The current <see cref="DreamDaemonSecurity"/> of <see cref="DreamDaemon"/>. May be downgraded due to requirements of <see cref="ActiveCompileJob"/>
/// </summary>
public DreamDaemonSecurity? CurrentSecurity { get; set; }
+16 -4
View File
@@ -1,15 +1,27 @@
using Tgstation.Server.Api.Models.Internal;
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents the state of the DreamMaker compiler. Create action starts a new compile. Delete action cancels the current compile
/// </summary>
public sealed class DreamMaker : DreamMakerSettings
public class DreamMaker
{
/// <summary>
/// The <see cref="CompilerStatus"/> of the compiler
/// The .dme file <see cref="DreamMaker"/> tries to compile with without the extension
/// </summary>
public CompilerStatus Status { get; set; }
public string ProjectName { get; set; }
/// <summary>
/// The port used during compilation to validate the DMAPI
/// </summary>
[Required]
public ushort? ApiValidationPort { get; set; }
/// <summary>
/// The <see cref="DreamDaemonSecurity"/> level used to validate the DMAPI
/// </summary>
[Required]
public DreamDaemonSecurity? ApiValidationSecurityLevel { get; set; }
}
}
@@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Tgstation.Server.Api.Models.Internal
{
@@ -33,5 +35,33 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
[Required]
public string ConnectionString { get; set; }
/// <summary>
/// The <see cref="ChatConnectionStringBuilder"/> which maps to the <see cref="ConnectionString"/>
/// </summary>
[NotMapped]
public ChatConnectionStringBuilder ConnectionStringBuilder
{
get
{
if (ConnectionString == null)
return null;
switch (Provider)
{
case ChatProvider.Discord:
return new DiscordConnectionStringBuilder(ConnectionString);
case ChatProvider.Irc:
return new IrcConnectionStringBuilder(ConnectionString);
default:
throw new InvalidOperationException("Invalid Provider!");
}
}
set
{
if (value?.Valid == false)
throw new InvalidOperationException("Cannot set invalid ChatConnectionStringBuilder!");
ConnectionString = value?.ToString();
}
}
}
}
@@ -0,0 +1,19 @@
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Helper for building <see cref="ChatBot.ConnectionString"/>s
/// </summary>
public abstract class ChatConnectionStringBuilder
{
/// <summary>
/// If the <see cref="ChatConnectionStringBuilder"/> evaluates to a valid <see cref="ChatBot.ConnectionString"/>
/// </summary>
public abstract bool Valid { get; }
/// <summary>
/// Gets the <see cref="ChatBot.ConnectionString"/> associated with the <see cref="ChatConnectionStringBuilder"/>
/// </summary>
/// <returns></returns>
public abstract override string ToString();
}
}
@@ -1,4 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Api.Models.Internal
{
@@ -15,16 +16,25 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// The .dme file used for compilation
/// </summary>
[Required]
public string DmeName { get; set; }
/// <summary>
/// Textual output of DM
/// </summary>
[Required]
public string Output { get; set; }
/// <summary>
/// The Game folder the results were compiled into
/// </summary>
[Required]
public Guid? DirectoryName { get; set; }
/// <summary>
/// The minimum <see cref="DreamDaemonSecurity"/> required to run the <see cref="CompileJob"/>'s output
/// </summary>
[Required]
public DreamDaemonSecurity? MinimumSecurityLevel { get; set; }
}
}
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System;
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Api.Models.Internal
{
@@ -36,5 +37,17 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
[Required]
public uint? StartupTimeout { get; set; }
/// <summary>
/// Check if we match a given set of <paramref name="otherParameters"/>
/// </summary>
/// <param name="otherParameters">The <see cref="DreamDaemonLaunchParameters"/> to compare against</param>
/// <returns><see langword="true"/> if they match, <see langword="false"/> otherwise</returns>
public bool Match(DreamDaemonLaunchParameters otherParameters) =>
AllowWebClient == otherParameters.AllowWebClient
&& SecurityLevel == otherParameters.SecurityLevel
&& PrimaryPort == otherParameters.PrimaryPort
&& SecondaryPort == otherParameters.SecondaryPort
&& StartupTimeout == otherParameters.StartupTimeout;
}
}
@@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Tgstation.Server.Api.Models.Internal
{
/// <summary>
/// Configurable settings for <see cref="DreamMaker"/>
/// </summary>
public class DreamMakerSettings
{
/// <summary>
/// The .dme file <see cref="DreamMakerSettings"/> tries to compile with without the extension
/// </summary>
public string ProjectName { get; set; }
/// <summary>
/// The port used during compilation to validate the DMAPI
/// </summary>
[Required]
public ushort? ApiValidationPort { get; set; }
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Text;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// <see cref="ChatConnectionStringBuilder"/> for <see cref="ChatProvider.Irc"/>
/// </summary>
public sealed class IrcConnectionStringBuilder : ChatConnectionStringBuilder
{
/// <inheritdoc />
public override bool Valid => Address != null && Port.HasValue && Port != 0 && UseSsl.HasValue && (PasswordType.HasValue ^ Password == null);
/// <summary>
/// The IP address or URL of the IRC server
/// </summary>
public string Address { get; set; }
/// <summary>
/// The port the server runs on
/// </summary>
public ushort? Port { get; set; }
/// <summary>
/// The nickname for the bot to use
/// </summary>
public string Nickname { get; set; }
/// <summary>
/// If the connection should be made using SSL
/// </summary>
public bool? UseSsl { get; set; }
/// <summary>
/// The optional <see cref="IrcPasswordType"/> to use
/// </summary>
public IrcPasswordType? PasswordType { get; set; }
/// <summary>
/// The optional password to use
/// </summary>
public string Password { get; set; }
/// <summary>
/// Construct an <see cref="IrcConnectionStringBuilder"/>
/// </summary>
public IrcConnectionStringBuilder() { }
/// <summary>
/// Construct a <see cref="DiscordConnectionStringBuilder"/> from a <paramref name="connectionString"/>
/// </summary>
/// <param name="connectionString">The connection string</param>
public IrcConnectionStringBuilder(string connectionString)
{
if (connectionString == null)
throw new ArgumentNullException(nameof(connectionString));
var splits = connectionString.Split(';');
Address = splits[0];
if (splits.Length < 2)
return;
if (UInt16.TryParse(splits[1], out var port))
Port = port;
if (splits.Length < 3)
return;
Nickname = splits[2];
if (splits.Length < 4)
return;
if (Int32.TryParse(splits[3], out var intSsl))
UseSsl = Convert.ToBoolean(intSsl);
if (splits.Length < 5)
return;
if (Enum.TryParse<IrcPasswordType>(splits[4], out var passwordType))
switch (passwordType)
{
case IrcPasswordType.NickServ:
case IrcPasswordType.Sasl:
case IrcPasswordType.Server:
PasswordType = passwordType;
break;
}
if (splits.Length < 6)
return;
var rest = new List<string>(splits);
rest.RemoveRange(0, 5);
Password = String.Join(";", rest);
}
/// <inheritdoc />
public override string ToString()
{
var sb = new StringBuilder();
sb.Append(Address);
sb.Append(';');
sb.Append(Port);
sb.Append(';');
sb.Append(Nickname);
sb.Append(';');
if(UseSsl.HasValue)
sb.Append(Convert.ToInt32(UseSsl.Value));
if (PasswordType.HasValue)
{
sb.Append(';');
sb.Append((int)PasswordType);
sb.Append(';');
sb.Append(Password);
}
return sb.ToString();
}
}
}
@@ -1,9 +1,9 @@
namespace Tgstation.Server.Host.Components.Chat.Providers
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents the type of a password passed to the constructor of <see cref="IrcProvider"/>
/// Represents the type of a password for a <see cref="ChatProvider.Irc"/>
/// </summary>
enum IrcPasswordType
public enum IrcPasswordType
{
/// <summary>
/// Use server authentication
@@ -23,9 +23,14 @@ namespace Tgstation.Server.Api.Models
public RevisionInformation RevisionInformation { get; set; }
/// <summary>
/// If the repository was cloned from GitHub.com. If <see langword="true"/> this enables test merge functionality
/// If the repository was cloned from GitHub.com this will be set with the owner of the repository
/// </summary>
public bool? IsGitHub { get; set; }
public string GitHubOwner { get; set; }
/// <summary>
/// If the repository was cloned from GitHub.com this will be set with the name of the repository
/// </summary>
public string GitHubName { get; set; }
/// <summary>
/// The <see cref="Job"/> started by the <see cref="Repository"/> if any
@@ -25,16 +25,20 @@ namespace Tgstation.Server.Api.Rights
/// </summary>
CancelCompile = 4,
/// <summary>
/// User may modify <see cref="Models.Internal.DreamMakerSettings.ProjectName"/>
/// User may modify <see cref="Models.DreamMaker.ProjectName"/>
/// </summary>
SetDme = 8,
/// <summary>
/// User may modify <see cref="Models.Internal.DreamMakerSettings.ApiValidationPort"/>
/// User may modify <see cref="Models.DreamMaker.ApiValidationPort"/>
/// </summary>
SetApiValidationPort = 16,
/// <summary>
/// User may list and read all <see cref="Models.CompileJob"/>s
/// </summary>
CompileJobs = 32
CompileJobs = 32,
/// <summary>
/// User may modify <see cref="Models.DreamMaker.ApiValidationSecurityLevel"/>
/// </summary>
SetSecurityLevel = 64
}
}
+10
View File
@@ -48,6 +48,16 @@ namespace Tgstation.Server.Api
/// </summary>
public const string Configuration = Root + "Config";
/// <summary>
/// To be paired with <see cref="Configuration"/> for accessing <see cref="Models.ConfigurationFile"/>s
/// </summary>
public const string File = "File";
/// <summary>
/// Full combination of <see cref="Configuration"/> and <see cref="File"/>
/// </summary>
public const string ConfigurationFile = Configuration + "/" + File;
/// <summary>
/// The <see cref="Models.InstanceUser"/> controller
/// </summary>
@@ -17,7 +17,7 @@
<FileVersion>4.0.0.0</FileVersion>
<PackageTags>json web api tgstation-server tgstation ss13 byond</PackageTags>
<PackageReleaseNotes>Prototype release</PackageReleaseNotes>
<Version>4.0.0.0-preview6001</Version>
<Version>4.0.0.0-preview6007</Version>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
+19 -6
View File
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using System;
using System.Collections.Generic;
@@ -32,7 +33,7 @@ namespace Tgstation.Server.Client
/// <summary>
/// The <see cref="HttpClient"/> for the <see cref="ApiClient"/>
/// </summary>
readonly HttpClient httpClient;
readonly IHttpClient httpClient;
/// <summary>
/// The <see cref="IRequestLogger"/>s used by the <see cref="ApiClient"/>
@@ -42,14 +43,15 @@ namespace Tgstation.Server.Client
/// <summary>
/// Construct an <see cref="ApiClient"/>
/// </summary>
/// <param name="httpClient">The value of <see cref="httpClient"/></param>
/// <param name="url">The value of <see cref="Url"/></param>
/// <param name="apiHeaders">The value of <see cref="ApiHeaders"/></param>
public ApiClient(Uri url, ApiHeaders apiHeaders)
public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
Url = url ?? throw new ArgumentNullException(nameof(url));
Headers = apiHeaders ?? throw new ArgumentNullException(nameof(apiHeaders));
httpClient = new HttpClient();
requestLoggers = new List<IRequestLogger>();
}
@@ -80,7 +82,8 @@ namespace Tgstation.Server.Client
var serializerSettings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = new[] { new VersionConverter() }
};
if (body != null)
@@ -161,7 +164,14 @@ namespace Tgstation.Server.Client
if (String.IsNullOrWhiteSpace(json))
json = JsonConvert.SerializeObject(new object());
return JsonConvert.DeserializeObject<TResult>(json, serializerSettings);
try
{
return JsonConvert.DeserializeObject<TResult>(json, serializerSettings);
}
catch (JsonException)
{
throw new UnrecognizedResponseException(json, response.StatusCode);
}
}
/// <inheritdoc />
@@ -206,6 +216,9 @@ namespace Tgstation.Server.Client
/// <inheritdoc />
public Task<TResult> Create<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), HttpMethod.Put, instanceId, cancellationToken);
/// <inheritdoc />
public Task<TResult> Patch<TResult>(string route, long instanceId, CancellationToken cancellationToken) => RunRequest<TResult>(route, new object(), new HttpMethod("PATCH"), instanceId, cancellationToken);
/// <inheritdoc />
public void AddRequestLogger(IRequestLogger requestLogger) => requestLoggers.Add(requestLogger ?? throw new ArgumentNullException(nameof(requestLogger)));
}
@@ -7,6 +7,6 @@ namespace Tgstation.Server.Client
sealed class ApiClientFactory : IApiClientFactory
{
/// <inheritdoc />
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders) => new ApiClient(url, apiHeaders);
public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders) => new ApiClient(new HttpClient(), url, apiHeaders);
}
}
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Tgstation.Server.Client.Tests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
@@ -30,9 +31,12 @@ namespace Tgstation.Server.Client.Components
}
/// <inheritdoc />
public Task<Byond> Read(CancellationToken cancellationToken) => apiClient.Read<Byond>(Routes.Byond, instance.Id, cancellationToken);
public Task<Byond> ActiveVersion(CancellationToken cancellationToken) => apiClient.Read<Byond>(Routes.Byond, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<Byond> Update(Byond byond, CancellationToken cancellationToken) => apiClient.Update<Byond, Byond>(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken);
public Task<IReadOnlyList<Byond>> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Byond>>(Routes.List(Routes.Byond), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<Byond> SetActiveVersion(Byond byond, CancellationToken cancellationToken) => apiClient.Update<Byond, Byond>(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken);
}
}
@@ -37,9 +37,12 @@ namespace Tgstation.Server.Client.Components
public Task Delete(ChatBot settings, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Chat, settings?.Id ?? throw new ArgumentNullException(nameof(settings))), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<ChatBot>> List(CancellationToken cancellationToken) => apiClient.Create<IReadOnlyList<ChatBot>>(Routes.List(Routes.Chat), instance.Id, cancellationToken);
public Task<IReadOnlyList<ChatBot>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ChatBot>>(Routes.List(Routes.Chat), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ChatBot> Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update<ChatBot, ChatBot>(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ChatBot> GetId(ChatBot settings, CancellationToken cancellationToken) => apiClient.Read<ChatBot>(Routes.SetID(Routes.Chat, (settings ?? throw new ArgumentNullException(nameof(settings))).Id), instance.Id, cancellationToken);
}
}
@@ -47,6 +47,9 @@ namespace Tgstation.Server.Client.Components
/// <inheritdoc />
public Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Delete(Routes.Configuration, directory, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ConfigurationFile> CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create<ConfigurationFile, ConfigurationFile>(Routes.Configuration, directory, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.List(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken);
@@ -55,7 +58,7 @@ namespace Tgstation.Server.Client.Components
{
if (file == null)
throw new ArgumentNullException(nameof(file));
return apiClient.Read<ConfigurationFile>(Routes.Configuration + SanitizeGetPath(file.Path), instance.Id, cancellationToken);
return apiClient.Read<ConfigurationFile>(Routes.ConfigurationFile + SanitizeGetPath(file.Path), instance.Id, cancellationToken);
}
/// <inheritdoc />
@@ -33,7 +33,10 @@ namespace Tgstation.Server.Client.Components
public Task Shutdown(CancellationToken cancellationToken) => apiClient.Delete(Routes.DreamDaemon, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<DreamDaemon> Start(CancellationToken cancellationToken) => apiClient.Create<DreamDaemon>(Routes.DreamDaemon, instance.Id, cancellationToken);
public Task<Job> Start(CancellationToken cancellationToken) => apiClient.Create<Job>(Routes.DreamDaemon, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<Job> Restart(CancellationToken cancellationToken) => apiClient.Patch<Job>(Routes.DreamDaemon, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<DreamDaemon> Read(CancellationToken cancellationToken) => apiClient.Read<DreamDaemon>(Routes.DreamDaemon, instance.Id, cancellationToken);
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
@@ -12,11 +13,12 @@ namespace Tgstation.Server.Client.Components
/// <summary>
/// The <see cref="IApiClient"/> for the <see cref="DreamMakerClient"/>
/// </summary>
private IApiClient apiClient;
readonly IApiClient apiClient;
/// <summary>
/// The <see cref="Instance"/> for the <see cref="DreamMakerClient"/>
/// </summary>
private Instance instance;
readonly Instance instance;
/// <summary>
/// Construct a <see cref="DreamMakerClient"/>
@@ -32,6 +34,12 @@ namespace Tgstation.Server.Client.Components
/// <inheritdoc />
public Task<Job> Compile(CancellationToken cancellationToken) => apiClient.Create<Job>(Routes.DreamMaker, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<CompileJob> GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken) => apiClient.Read<CompileJob>(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<CompileJob>> GetJobIds(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<CompileJob>>(Routes.List(Routes.DreamMaker), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<DreamMaker> Read(CancellationToken cancellationToken) => apiClient.Read<DreamMaker>(Routes.DreamMaker, instance.Id, cancellationToken);
@@ -1,4 +1,5 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
@@ -10,11 +11,18 @@ namespace Tgstation.Server.Client.Components
public interface IByondClient
{
/// <summary>
/// Get the <see cref="Byond"/> information
/// Get the <see cref="Byond"/> active <see cref="System.Version"/> information
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Byond"/> information</returns>
Task<Byond> Read(CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Byond"/> active <see cref="System.Version"/> information</returns>
Task<Byond> ActiveVersion(CancellationToken cancellationToken);
/// <summary>
/// Get all installed <see cref="Byond"/> <see cref="System.Version"/>s
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in an <see cref="IReadOnlyList{T}"/> of installed <see cref="Byond"/> <see cref="System.Version"/>s</returns>
Task<IReadOnlyList<Byond>> InstalledVersions(CancellationToken cancellationToken);
/// <summary>
/// Updates the <see cref="Byond"/> information
@@ -22,6 +30,6 @@ namespace Tgstation.Server.Client.Components
/// <param name="byond">The <see cref="Byond"/> information to update</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="Byond"/> information</returns>
Task<Byond> Update(Byond byond, CancellationToken cancellationToken);
Task<Byond> SetActiveVersion(Byond byond, CancellationToken cancellationToken);
}
}
@@ -11,7 +11,7 @@ namespace Tgstation.Server.Client.Components
public interface IChatBotsClient
{
/// <summary>
/// List the <see cref="ChatBot"/>
/// List the <see cref="ChatBot"/>s
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of the <see cref="ChatBot"/> of the server</returns>
@@ -26,13 +26,21 @@ namespace Tgstation.Server.Client.Components
Task<ChatBot> Create(ChatBot settings, CancellationToken cancellationToken);
/// <summary>
/// Updates a <see cref="ChatBot"/> setttings
/// Updates a <see cref="ChatBot"/>'s setttings
/// </summary>
/// <param name="settings">The <see cref="ChatBot"/> to update</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="ChatBot"/></returns>
Task<ChatBot> Update(ChatBot settings, CancellationToken cancellationToken);
/// <summary>
/// Get a <see cref="ChatBot"/>'s setttings
/// </summary>
/// <param name="settings">The <see cref="ChatBot"/> to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ChatBot"/></returns>
Task<ChatBot> GetId(ChatBot settings, CancellationToken cancellationToken);
/// <summary>
/// Delete a <see cref="ChatBot"/>
/// </summary>
@@ -41,5 +41,13 @@ namespace Tgstation.Server.Client.Components
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task DeleteEmptyDirectory(ConfigurationFile directory, CancellationToken cancellationToken);
/// <summary>
/// Creates an empty <paramref name="directory"/>
/// </summary>
/// <param name="directory">The <see cref="ConfigurationFile"/> representing the directory to create</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="ConfigurationFile"/></returns>
Task<ConfigurationFile> CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken);
}
}
@@ -20,8 +20,15 @@ namespace Tgstation.Server.Client.Components
/// Start <see cref="DreamDaemon"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="DreamDaemon"/> information</returns>
Task<DreamDaemon> Start(CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Job"/> of the running operation</returns>
Task<Job> Start(CancellationToken cancellationToken);
/// <summary>
/// Restart <see cref="DreamDaemon"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Job"/> of the running operation</returns>
Task<Job> Restart(CancellationToken cancellationToken);
/// <summary>
/// Shutdown <see cref="DreamDaemon"/>
@@ -1,4 +1,5 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
@@ -30,5 +31,20 @@ namespace Tgstation.Server.Client.Components
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Job"/> for the compile</returns>
Task<Job> Compile(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="Api.Models.Internal.CompileJob.Id"/>s of all <see cref="CompileJob"/>s for the instance
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of <see cref="CompileJob"/>s with only the <see cref="Api.Models.Internal.CompileJob.Id"/> field populated</returns>
Task<IReadOnlyList<CompileJob>> GetJobIds(CancellationToken cancellationToken);
/// <summary>
/// Get a <paramref name="compileJob"/>
/// </summary>
/// <param name="compileJob">The <see cref="CompileJob"/> to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="CompileJob"/></returns>
Task<CompileJob> GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken);
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Client
{
/// <inheritdoc />
sealed class HttpClient : IHttpClient
{
/// <inheritdoc />
public TimeSpan Timeout
{
get => httpClient.Timeout;
set => httpClient.Timeout = value;
}
/// <summary>
/// The real <see cref="System.Net.Http.HttpClient"/>
/// </summary>
readonly System.Net.Http.HttpClient httpClient;
/// <summary>
/// Construct an <see cref="HttpClient"/>
/// </summary>
public HttpClient()
{
httpClient = new System.Net.Http.HttpClient();
}
/// <inheritdoc />
public void Dispose() => httpClient.Dispose();
/// <inheritdoc />
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => httpClient.SendAsync(request, cancellationToken);
}
}
@@ -32,6 +32,7 @@ namespace Tgstation.Server.Client
Task<TResult> Create<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken);
Task<TResult> Create<TResult>(string route, long instanceId, CancellationToken cancellationToken);
Task<TResult> Patch<TResult>(string route, long instanceId, CancellationToken cancellationToken);
Task<TResult> Read<TResult>(string route, long instanceId, CancellationToken cancellationToken);
Task<TResult> Update<TBody, TResult>(string route, TBody body, long instanceId, CancellationToken cancellationToken);
Task Delete(string route, long instanceId, CancellationToken cancellationToken);
@@ -0,0 +1,26 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Client
{
/// <summary>
/// For sending HTTP requests
/// </summary>
interface IHttpClient : IDisposable
{
/// <summary>
/// The request timeout
/// </summary>
TimeSpan Timeout { get; set; }
/// <summary>
/// Send an HTTP request
/// </summary>
/// <param name="request">The <see cref="HttpRequestMessage"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="HttpResponseMessage"/> of the request</returns>
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
}
}
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<DebugType>Full</DebugType>
<Version>4.0.0.0-preview9102</Version>
<Version>4.0.0.0-preview9116</Version>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Authors>Cyberboss</Authors>
<Company>/tg/station 13</Company>
@@ -24,6 +24,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<DocumentationFile>bin\Release\netstandard2.0\Tgstation.Server.Client.xml</DocumentationFile>
<WarningLevel>0</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -0,0 +1,40 @@
using System;
using System.Globalization;
using System.Net;
using Tgstation.Server.Api.Models;
namespace Tgstation.Server.Client
{
sealed class UnrecognizedResponseException : ClientException
{
/// <summary>
/// Construct an <see cref="UnrecognizedResponseException"/> with the <paramref name="data"/> of a response body and the <paramref name="statusCode"/>
/// </summary>
/// <param name="data">The body of the response</param>
/// <param name="statusCode">The <see cref="HttpStatusCode"/> for the <see cref="ClientException"/></param>
public UnrecognizedResponseException(string data, HttpStatusCode statusCode) : base(new ErrorMessage
{
Message = String.Format(CultureInfo.InvariantCulture, "Unrecognized response body: {0}", data),
SeverApiVersion = null
}, statusCode)
{ }
/// <summary>
/// Construct a <see cref="UnrecognizedResponseException"/>
/// </summary>
public UnrecognizedResponseException() { }
/// <summary>
/// Construct an <see cref="UnrecognizedResponseException"/> with a <paramref name="message"/>
/// </summary>
/// <param name="message">The message for the <see cref="Exception"/></param>
public UnrecognizedResponseException(string message) : base(message) { }
/// <summary>
/// Construct an <see cref="UnrecognizedResponseException"/> with a <paramref name="message"/> and <paramref name="innerException"/>
/// </summary>
/// <param name="message">The message for the <see cref="Exception"/></param>
/// <param name="innerException">The inner <see cref="Exception"/> for the base <see cref="Exception"/></param>
public UnrecognizedResponseException(string message, Exception innerException) : base(message, innerException) { }
}
}
@@ -11,7 +11,7 @@
"Tgstation.Server.Host.Console": {
"commandName": "Project",
"commandLineArgs": "--attach-host-debugger",
"workingDirectory": "bin\\Debug\\netcoreapp2.0",
"workingDirectory": "bin\\Debug\\netcoreapp2.1",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
<DebugType>Full</DebugType>
<FileVersion>4.0.0.0</FileVersion>
</PropertyGroup>
@@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors />
<DocumentationFile>bin\Release\netcoreapp2.0\Tgstation.Server.Host.Console.xml</DocumentationFile>
<DocumentationFile>bin\Release\netcoreapp2.1\Tgstation.Server.Host.Console.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+58 -58
View File
@@ -35,36 +35,39 @@ namespace Tgstation.Server.Host.Watchdog
{
logger.LogInformation("Host watchdog starting...");
var enviromentPath = Environment.GetEnvironmentVariable("PATH");
var paths = enviromentPath.Split(';');
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var exeName = "dotnet";
IEnumerable<string> enumerator;
if (isWindows)
string updateDirectory = null;
try
{
exeName += ".exe";
enumerator = paths;
}
else
enumerator = paths.Select(x => x.Split(':')).SelectMany(x => x);
var enviromentPath = Environment.GetEnvironmentVariable("PATH");
var paths = enviromentPath.Split(';');
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
enumerator = enumerator.Select(x => Path.Combine(x, exeName));
var exeName = "dotnet";
IEnumerable<string> enumerator;
if (isWindows)
{
exeName += ".exe";
enumerator = paths;
}
else
enumerator = paths.Select(x => x.Split(':')).SelectMany(x => x);
var dotnetPath = enumerator
.Where(x =>
{
logger.LogTrace("Checking for dotnet at {0}", x);
return File.Exists(x);
})
.FirstOrDefault();
enumerator = enumerator.Select(x => Path.Combine(x, exeName));
if (dotnetPath == default)
{
logger.LogCritical("Unable to locate dotnet executable in PATH! Please ensure the .NET Core runtime is installed and is in your PATH!");
return;
}
logger.LogInformation("Detected dotnet executable at {0}", dotnetPath);
var dotnetPath = enumerator
.Where(x =>
{
logger.LogTrace("Checking for dotnet at {0}", x);
return File.Exists(x);
})
.FirstOrDefault();
if (dotnetPath == default)
{
logger.LogCritical("Unable to locate dotnet executable in PATH! Please ensure the .NET Core runtime is installed and is in your PATH!");
return;
}
logger.LogInformation("Detected dotnet executable at {0}", dotnetPath);
var rootLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
@@ -74,41 +77,38 @@ namespace Tgstation.Server.Host.Watchdog
#endif
var defaultAssemblyPath = Path.GetFullPath(Path.Combine(assemblyStoragePath, "Default"));
#if DEBUG
//just copy the shit where it belongs
Directory.Delete(assemblyStoragePath, true);
Directory.CreateDirectory(defaultAssemblyPath);
//just copy the shit where it belongs
Directory.Delete(assemblyStoragePath, true);
Directory.CreateDirectory(defaultAssemblyPath);
var sourcePath = "../../../../Tgstation.Server.Host/bin/Debug/netcoreapp2.0";
foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(dirPath.Replace(sourcePath, defaultAssemblyPath));
var sourcePath = "../../../../Tgstation.Server.Host/bin/Debug/netcoreapp2.1";
foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
Directory.CreateDirectory(dirPath.Replace(sourcePath, defaultAssemblyPath));
foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
File.Copy(newPath, newPath.Replace(sourcePath, defaultAssemblyPath), true);
foreach (string newPath in Directory.GetFiles(sourcePath, "*.*", SearchOption.AllDirectories))
File.Copy(newPath, newPath.Replace(sourcePath, defaultAssemblyPath), true);
const string AppSettingsJson = "appsettings.json";
var rootJson = Path.Combine(rootLocation, AppSettingsJson);
File.Delete(rootJson);
File.Move(Path.Combine(defaultAssemblyPath, AppSettingsJson), rootJson);
const string AppSettingsJson = "appsettings.json";
var rootJson = Path.Combine(rootLocation, AppSettingsJson);
File.Delete(rootJson);
File.Move(Path.Combine(defaultAssemblyPath, AppSettingsJson), rootJson);
#endif
var assemblyName = String.Join(".", nameof(Tgstation), nameof(Server), nameof(Host), "dll");
var assemblyPath = Path.Combine(defaultAssemblyPath, assemblyName);
var assemblyName = String.Join(".", nameof(Tgstation), nameof(Server), nameof(Host), "dll");
var assemblyPath = Path.Combine(defaultAssemblyPath, assemblyName);
if (assemblyPath.Contains("\""))
{
logger.LogCritical("Running from paths with \"'s in the name is not supported!");
return;
}
if (assemblyPath.Contains("\""))
{
logger.LogCritical("Running from paths with \"'s in the name is not supported!");
return;
}
if (!File.Exists(assemblyPath))
{
logger.LogCritical("Unable to locate host assembly!");
return;
}
if (!File.Exists(assemblyPath))
{
logger.LogCritical("Unable to locate host assembly!");
return;
}
string updateDirectory = null;
try
{
while (!cancellationToken.IsCancellationRequested)
using (logger.BeginScope("Host invocation"))
{
@@ -117,12 +117,12 @@ namespace Tgstation.Server.Host.Watchdog
using (var process = new Process())
{
process.StartInfo.FileName = dotnetPath;
process.StartInfo.WorkingDirectory = Environment.CurrentDirectory; //for appsettings
process.StartInfo.WorkingDirectory = rootLocation; //for appsettings
var arguments = new List<string>
{
'"' + assemblyPath + '"',
updateDirectory
'"' + updateDirectory + '"'
};
if (Environment.GetCommandLineArgs().Any(x => x == "--attach-host-debugger"))
@@ -142,7 +142,7 @@ namespace Tgstation.Server.Host.Watchdog
logger.LogInformation("Launching host...");
var iShotTheSheriff = false;
var killedHostProcess = false;
try
{
process.Start();
@@ -176,7 +176,7 @@ namespace Tgstation.Server.Host.Watchdog
{
if (!process.HasExited)
{
iShotTheSheriff = true;
killedHostProcess = true;
process.Kill();
process.WaitForExit();
}
@@ -210,7 +210,7 @@ namespace Tgstation.Server.Host.Watchdog
}
throw new Exception(String.Format(CultureInfo.InvariantCulture, "Host propagated exception: {0}", data));
default:
if (iShotTheSheriff)
if (killedHostProcess)
{
logger.LogWarning("Watchdog forced to kill host process!");
cancellationToken.ThrowIfCancellationRequested();
@@ -1,7 +1,9 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -13,11 +15,14 @@ namespace Tgstation.Server.Host.Components.Byond
/// <inheritdoc />
sealed class ByondManager : IByondManager
{
/// <summary>
/// The path to the BYOND bin folder
/// </summary>
public const string BinPath = "byond/bin";
const string VersionFileName = "Version.txt";
const string ActiveVersionFileName = "ActiveVersion.txt";
const string BinPath = "byond/bin";
/// <inheritdoc />
public Version ActiveVersion { get; private set; }
@@ -120,6 +125,11 @@ namespace Tgstation.Server.Host.Components.Byond
//make sure to do this last because this is what tells us we have a valid version in the future
await ioManager.WriteAllBytes(ioManager.ConcatPath(versionKey, VersionFileName), Encoding.UTF8.GetBytes(version.ToString()), cancellationToken).ConfigureAwait(false);
}
catch (WebException e)
{
//since the user can easily provide non-exitent version numbers, we'll turn this into a JobException
throw new JobException(String.Format(CultureInfo.InvariantCulture, "Error downloading BYOND version: {0}", e.Message));
}
catch (OperationCanceledException)
{
throw;
@@ -2,8 +2,10 @@
using System;
using System.Globalization;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Byond
@@ -23,16 +25,21 @@ namespace Tgstation.Server.Host.Components.Byond
const string ByondCachePath = "~/.byond/cache";
/// <inheritdoc />
public string DreamDaemonName => "DreamDaemon";
public string DreamDaemonName => "DreamDaemon.sh";
/// <inheritdoc />
public string DreamMakerName => "DreamMaker";
public string DreamMakerName => "DreamMaker.sh";
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="IPostWriteHandler"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
readonly IPostWriteHandler postWriteHandler;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
@@ -42,10 +49,12 @@ namespace Tgstation.Server.Host.Components.Byond
/// Construct a <see cref="WindowsByondInstaller"/>
/// </summary>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="postWriteHandler">The value of <see cref="postWriteHandler"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public PosixByondInstaller(IIOManager ioManager, ILogger<PosixByondInstaller> logger)
public PosixByondInstaller(IIOManager ioManager, IPostWriteHandler postWriteHandler, ILogger<PosixByondInstaller> logger)
{
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -88,6 +97,32 @@ namespace Tgstation.Server.Host.Components.Byond
}
/// <inheritdoc />
public Task InstallByond(string path, Version version, CancellationToken cancellationToken) => Task.CompletedTask;
public Task InstallByond(string path, Version version, CancellationToken cancellationToken)
{
//write the scripts for running the ting
//need to add $ORIGIN to LD_LIBRARY_PATH
const string StandardScript = "#!/bin/sh\nexport LD_LIBRARY_PATH=\"\\$ORIGIN:$LD_LIBRARY_PATH\"\nBASEDIR=$(dirname \"$0\")\nexec \"$BASEDIR/{0}\" \"$@\"\n";
const string DreamDaemonExecutableName = "DreamDaemon";
const string DreamMakerExecutableName = "DreamMaker";
var dreamDaemonScript = String.Format(CultureInfo.InvariantCulture, StandardScript, DreamDaemonExecutableName);
var dreamMakerScript = String.Format(CultureInfo.InvariantCulture, StandardScript, DreamMakerExecutableName);
async Task WriteAndMakeExecutable(string fullPath, string script)
{
await ioManager.WriteAllBytes(fullPath, Encoding.ASCII.GetBytes(script), cancellationToken).ConfigureAwait(false);
postWriteHandler.HandleWrite(fullPath);
}
var basePath = ioManager.ConcatPath(path, ByondManager.BinPath);
var task = Task.WhenAll(WriteAndMakeExecutable(ioManager.ConcatPath(basePath, DreamDaemonName), dreamDaemonScript), WriteAndMakeExecutable(ioManager.ConcatPath(basePath, DreamMakerName), dreamMakerScript));
postWriteHandler.HandleWrite(ioManager.ConcatPath(basePath, DreamDaemonExecutableName));
postWriteHandler.HandleWrite(ioManager.ConcatPath(basePath, DreamMakerExecutableName));
return task;
}
}
}
@@ -21,7 +21,7 @@ namespace Tgstation.Server.Host.Components.Byond
/// <summary>
/// Directory to byond installation configuration
/// </summary>
const string ByondConfigDir = "byond/config";
const string ByondConfigDir = "byond/cfg";
/// <summary>
/// BYOND's DreamDaemon config file
/// </summary>
@@ -9,12 +9,13 @@ using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Chat.Commands;
using Tgstation.Server.Host.Components.Chat.Providers;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Chat
{
/// <inheritdoc />
sealed class Chat : IChat
sealed class Chat : IChat, IRestartHandler
{
const string CommonMention = "!tgs";
@@ -33,6 +34,11 @@ namespace Tgstation.Server.Host.Components.Chat
/// </summary>
readonly ICommandFactory commandFactory;
/// <summary>
/// The <see cref="IRestartRegistration"/> for the <see cref="Chat"/>
/// </summary>
readonly IRestartRegistration restartRegistration;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="Chat"/>
/// </summary>
@@ -100,15 +106,20 @@ namespace Tgstation.Server.Host.Components.Chat
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="commandFactory">The value of <see cref="commandFactory"/></param>
/// <param name="serverControl">The <see cref="IServerControl"/> to populate <see cref="restartRegistration"/> with</param>
/// <param name="initialChatBots">The <see cref="IEnumerable{T}"/> used to populate <see cref="initialChatBots"/></param>
public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, ILogger<Chat> logger, IEnumerable<Models.ChatBot> initialChatBots)
public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, IServerControl serverControl, ILogger<Chat> logger, IEnumerable<Models.ChatBot> initialChatBots)
{
this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory));
if (serverControl == null)
throw new ArgumentNullException(nameof(serverControl));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.initialChatBots = initialChatBots?.ToList() ?? throw new ArgumentNullException(nameof(initialChatBots));
restartRegistration = serverControl.RegisterForRestart(this);
builtinCommands = new Dictionary<string, ICommand>();
providers = new Dictionary<long, IProvider>();
mappedChannels = new Dictionary<ulong, ChannelMapping>();
@@ -121,6 +132,7 @@ namespace Tgstation.Server.Host.Components.Chat
/// <inheritdoc />
public void Dispose()
{
restartRegistration.Dispose();
handlerCts.Dispose();
foreach (var I in providers)
I.Value.Dispose();
@@ -195,6 +207,7 @@ namespace Tgstation.Server.Host.Components.Chat
{
//need to add tag and isAdminChannel
var mapping = enumerable.First().Value;
message.User.Channel.Id = mapping.Channel.Id;
message.User.Channel.Tag = mapping.Channel.Tag;
message.User.Channel.IsAdmin = mapping.Channel.IsAdmin;
}
@@ -269,18 +282,18 @@ namespace Tgstation.Server.Host.Components.Chat
var commandHandler = await GetCommand(command).ConfigureAwait(false);
if (commandHandler.AdminOnly && !message.User.Channel.IsAdmin)
{
await SendMessage("Use this command in an admin channel!", new List<ulong> { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false);
return;
}
if (commandHandler == default)
{
await SendMessage(UnknownCommandMessage, new List<ulong> { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false);
return;
}
if (commandHandler.AdminOnly && !message.User.Channel.IsAdmin)
{
await SendMessage("Use this command in an admin channel!", new List<ulong> { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false);
return;
}
var result = await commandHandler.Invoke(arguments, message.User, cancellationToken).ConfigureAwait(false);
if (result != null)
await SendMessage(result, new List<ulong> { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false);
@@ -563,8 +576,9 @@ namespace Tgstation.Server.Host.Components.Chat
}
/// <inheritdoc />
public Task SendBroadcast(string message, CancellationToken cancellationToken)
public Task HandleRestart(Version updateVersion, CancellationToken cancellationToken)
{
var message = updateVersion == null ? "TGS: Restart requested..." : String.Format(CultureInfo.InvariantCulture, "TGS: Updating to version {0}...", updateVersion);
List<ulong> wdChannels;
lock (mappedChannels) //so it doesn't change while we're using it
wdChannels = mappedChannels.Select(x => x.Key).ToList();
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using Tgstation.Server.Host.Components.Chat.Commands;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Chat
@@ -9,42 +10,35 @@ namespace Tgstation.Server.Host.Components.Chat
/// <inheritdoc />
sealed class ChatFactory : IChatFactory
{
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="ChatFactory"/>
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="ILoggerFactory"/> for the <see cref="ChatFactory"/>
/// </summary>
readonly ILoggerFactory loggerFactory;
/// <summary>
/// The <see cref="ICommandFactory"/> for the <see cref="ChatFactory"/>
/// </summary>
readonly ICommandFactory commandFactory;
/// <summary>
/// The <see cref="IProviderFactory"/> for the <see cref="ChatFactory"/>
/// </summary>
readonly IProviderFactory providerFactory;
/// <summary>
/// The <see cref="IServerControl"/> for the <see cref="ChatFactory"/>
/// </summary>
readonly IServerControl serverControl;
/// <summary>
/// Construct a <see cref="ChatFactory"/>
/// </summary>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="commandFactory">The value of <see cref="commandFactory"/></param>
/// <param name="providerFactory">The value of <see cref="providerFactory"/></param>
public ChatFactory(IIOManager ioManager, ILoggerFactory loggerFactory, ICommandFactory commandFactory, IProviderFactory providerFactory)
/// <param name="serverControl">The value of <see cref="serverControl"/></param>
public ChatFactory(ILoggerFactory loggerFactory, IProviderFactory providerFactory, IServerControl serverControl)
{
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory));
this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory));
this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl));
}
/// <inheritdoc />
public IChat CreateChat(IEnumerable<Models.ChatBot> initialChatBots) => new Chat(providerFactory, ioManager, commandFactory, loggerFactory.CreateLogger<Chat>(), initialChatBots);
public IChat CreateChat(IIOManager ioManager, ICommandFactory commandFactory, IEnumerable<Models.ChatBot> initialChatBots) => new Chat(providerFactory, ioManager, commandFactory, serverControl, loggerFactory.CreateLogger<Chat>(), initialChatBots);
}
}
@@ -75,14 +75,6 @@ namespace Tgstation.Server.Host.Components.Chat
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SendUpdateMessage(string message, CancellationToken cancellationToken);
/// <summary>
/// Send a chat to all channels
/// </summary>
/// <param name="message">The message being sent</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SendBroadcast(string message, CancellationToken cancellationToken);
/// <summary>
/// Start tracking json files for commands and channels
/// </summary>
@@ -1,4 +1,6 @@
using System.Collections.Generic;
using Tgstation.Server.Host.Components.Chat.Commands;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Components.Chat
{
@@ -10,8 +12,10 @@ namespace Tgstation.Server.Host.Components.Chat
/// <summary>
/// Create a <see cref="IChat"/>
/// </summary>
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="IChat"/></param>
/// <param name="commandFactory">The <see cref="ICommandFactory"/> for the <see cref="IChat"/></param>
/// <param name="initialChatBots">The initial <see cref="Models.ChatBot"/> for the <see cref="IChat"/></param>
/// <returns>A new <see cref="IChat"/></returns>
IChat CreateChat(IEnumerable<Models.ChatBot> initialChatBots);
IChat CreateChat(IIOManager ioManager, ICommandFactory commandFactory, IEnumerable<Models.ChatBot> initialChatBots);
}
}
@@ -37,52 +37,17 @@ namespace Tgstation.Server.Host.Components.Chat
{
if (settings == null)
throw new ArgumentNullException(nameof(settings));
var builder = settings.ConnectionStringBuilder;
if (builder == null || !builder.Valid)
throw new InvalidOperationException("Invalid ChatConnectionStringBuilder!");
switch (settings.Provider)
{
case ChatProvider.Irc:
//Connection string semicolon delimited until the password field
if (settings.ConnectionString == null)
throw new InvalidOperationException("ConnectionString cannot be null!");
var splits = settings.ConnectionString.Split(';');
if (splits.Length < 4)
throw new InvalidOperationException("Invalid connection string!");
var address = splits[0];
if (!UInt16.TryParse(splits[1], out var port))
throw new InvalidOperationException("Unable to parse port!");
var nick = splits[2];
if (!Int32.TryParse(splits[3], out var intSsl))
throw new InvalidOperationException("Unable to parse ssl option!");
IrcPasswordType? passwordType = null;
string password = null;
if (splits.Length > 4)
{
if (splits.Length < 6)
throw new InvalidOperationException("Invalid connection string!");
if (!Int32.TryParse(splits[4], out var intPasswordType))
throw new InvalidOperationException("Unable to parse password type!");
passwordType = (IrcPasswordType)intPasswordType;
switch (passwordType)
{
case IrcPasswordType.NickServ:
case IrcPasswordType.Sasl:
case IrcPasswordType.Server:
break;
default:
throw new InvalidOperationException("Invalid password type!");
}
var rest = new List<string>(splits);
rest.RemoveRange(0, 5);
password = String.Join(";", rest);
}
return new IrcProvider(loggerFactory.CreateLogger<IrcProvider>(), application, address, port, nick, password, passwordType, intSsl != 0);
var ircBuilder = (IrcConnectionStringBuilder)builder;
return new IrcProvider(loggerFactory.CreateLogger<IrcProvider>(), application, ircBuilder.Address, ircBuilder.Port.Value, ircBuilder.Nickname, ircBuilder.Password, ircBuilder.PasswordType, ircBuilder.UseSsl.Value);
case ChatProvider.Discord:
//discord is just the bot token
return new DiscordProvider(loggerFactory.CreateLogger<DiscordProvider>(), settings.ConnectionString);
var discordBuilder = (DiscordConnectionStringBuilder)builder;
return new DiscordProvider(loggerFactory.CreateLogger<DiscordProvider>(), discordBuilder.BotToken);
default:
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider));
}
@@ -92,7 +92,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
Channel = new Channel
{
RealId = e.Channel.Id,
IsPrivate = true,
IsPrivate = pm,
ConnectionName = pm ? e.Author.Username : (e.Channel as ITextChannel)?.Guild.Name ?? "UNKNOWN",
FriendlyName = e.Channel.Name
//isAdmin and Tag populated by manager
@@ -122,7 +122,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
var channelsAvailable = new TaskCompletionSource<object>();
client.Ready += () =>
{
channelsAvailable.SetResult(null);
channelsAvailable.TrySetResult(null);
return Task.CompletedTask;
};
using (cancellationToken.Register(() => channelsAvailable.SetCanceled()))
@@ -182,7 +182,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
};
};
var enumerator = channels.Select(x => GetChannelForChatChannel(x)).Where(x => x != null);
var enumerator = channels.Select(x => GetChannelForChatChannel(x)).Where(x => x != null).ToList();
lock (this)
{
@@ -190,7 +190,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
mappedChannels.AddRange(enumerator.Select(x => x.RealId));
}
return Task.FromResult<IReadOnlyList<Channel>>(enumerator.ToList());
return Task.FromResult<IReadOnlyList<Channel>>(enumerator);
}
/// <inheritdoc />
@@ -172,7 +172,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
return true;
}))
{
resultId = ++channelIdCounter;
resultId = channelIdCounter++;
dicToCheck.Add(resultId.Value, channelName);
if (dicToCheck == queryChannelIdMap)
channelIdMap.Add(resultId.Value, null);
@@ -258,9 +258,9 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
var recievedPlus = false;
client.OnReadLine += (sender, e) =>
{
if (e.Line.Contains("ACK :sasl"))
if (e.Line.Contains("ACK :sasl", StringComparison.Ordinal))
recievedAck = true;
else if (e.Line.Contains("AUTHENTICATE +"))
else if (e.Line.Contains("AUTHENTICATE +", StringComparison.Ordinal))
recievedPlus = true;
};
@@ -291,16 +291,17 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
listenTask = Task.Factory.StartNew(() =>
{
while (!disconnecting && client.IsConnected)
while (!disconnecting && client.IsConnected && client.Nickname != nickname)
{
client.ListenOnce(true);
if (disconnecting || !client.IsConnected)
break;
client.Listen(false);
//ensure we have the correct nick
if (client.Nickname != nickname && client.GetIrcUser(nickname) == null)
if (client.GetIrcUser(nickname) == null)
client.RfcNick(nickname);
}
client.Listen();
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
}
catch (Exception e)
@@ -359,7 +360,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
return (IReadOnlyList<Channel>)channels.Select(x =>
{
var id = channelIdCounter;
ulong? id = null;
if (!channelIdMap.Any(y =>
{
if (y.Value != x.IrcChannel)
@@ -368,15 +369,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
return true;
}))
{
channelIdMap.Add(id, x.IrcChannel);
++channelIdCounter;
id = channelIdCounter++;
channelIdMap.Add(id.Value, x.IrcChannel);
}
return new Channel
{
RealId = id,
RealId = id.Value,
IsAdmin = x.IsAdminChannel == true,
ConnectionName = address,
FriendlyName = channelIdMap[id],
FriendlyName = channelIdMap[id.Value],
IsPrivate = false,
Tag = x.Tag
};
@@ -131,11 +131,7 @@ namespace Tgstation.Server.Host.Components.Compiler
.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)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); //can't wait to see that query
if (finalCompileJob == null)
//lol git fucked
return;
.FirstAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); //can't wait to see that query
var newProvider = await FromCompileJob(finalCompileJob, cancellationToken).ConfigureAwait(false);
if (newProvider == null)
@@ -169,7 +165,7 @@ namespace Tgstation.Server.Host.Components.Compiler
public Task StartAsync(CancellationToken cancellationToken) => databaseContextFactory.UseContext(async (db) =>
{
//where complete clause not necessary, only successful COMPILEjobs get in the db
var cj = await db.CompileJobs.Where(x => x.Job.Instance.Id == instance.Id && !x.Job.Cancelled.Value && x.Job.ExceptionDetails == null && x.Job.StoppedAt != null)
var cj = await db.CompileJobs.Where(x => x.Job.Instance.Id == instance.Id)
.Include(x => x.Job).ThenInclude(x => x.StartedBy)
.Include(x => x.RevisionInformation).ThenInclude(x => x.PrimaryTestMerge).ThenInclude(x => x.MergedBy)
.Include(x => x.RevisionInformation).ThenInclude(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).ThenInclude(x => x.MergedBy)
@@ -245,7 +241,7 @@ namespace Tgstation.Server.Host.Components.Compiler
//find the uids of locked directories
await databaseContextFactory.UseContext(async db =>
{
jobUidsToNotErase = await db.CompileJobs.Where(x => x.Job.Instance.Id == instance.Id && jobIdsToSkip.Contains(x.Id) && x.DirectoryName.HasValue).Select(x => x.DirectoryName.Value.ToString().ToUpperInvariant()).ToListAsync(cancellationToken).ConfigureAwait(false);
jobUidsToNotErase = await db.CompileJobs.Where(x => x.Job.Instance.Id == instance.Id && jobIdsToSkip.Contains(x.Id)).Select(x => x.DirectoryName.Value.ToString().ToUpperInvariant()).ToListAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
//add the other exemption
@@ -37,9 +37,6 @@ namespace Tgstation.Server.Host.Components.Compiler
/// </summary>
const string DmeExtension = "dme";
/// <inheritdoc />
public CompilerStatus Status { get; private set; }
/// <summary>
/// The <see cref="IByondManager"/> for <see cref="DreamMaker"/>
/// </summary>
@@ -85,6 +82,11 @@ namespace Tgstation.Server.Host.Components.Compiler
/// </summary>
readonly ILogger<DreamMaker> logger;
/// <summary>
/// If a compile job is running
/// </summary>
bool compiling;
/// <summary>
/// Construct <see cref="DreamMaker"/>
/// </summary>
@@ -123,8 +125,8 @@ namespace Tgstation.Server.Host.Components.Compiler
/// <param name="byondLock">The current <see cref="IByondExecutableLock"/></param>
/// <param name="portToUse">The port to use for API validation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="true"/> if the DMAPI was successfully validated, <see langword="false"/> otherwise</returns>
async Task<bool> VerifyApi(uint timeout, DreamDaemonSecurity securityLevel, Models.CompileJob job, IByondExecutableLock byondLock, ushort portToUse, CancellationToken cancellationToken)
/// <returns>A <see cref="Task"/> representing the running operation</returns>
async Task VerifyApi(uint timeout, DreamDaemonSecurity securityLevel, Models.CompileJob job, IByondExecutableLock byondLock, ushort portToUse, CancellationToken cancellationToken)
{
logger.LogTrace("Verifying DMAPI...");
var launchParameters = new DreamDaemonLaunchParameters
@@ -136,6 +138,8 @@ namespace Tgstation.Server.Host.Components.Compiler
};
var dirA = ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName);
job.MinimumSecurityLevel = securityLevel; //needed for the TempDmbProvider
var provider = new TemporaryDmbProvider(ioManager.ResolvePath(dirA), String.Concat(job.DmeName, DmbExtension), job);
var timeoutAt = DateTimeOffset.Now.AddSeconds(timeout);
@@ -152,15 +156,36 @@ namespace Tgstation.Server.Host.Components.Compiler
cancellationToken.ThrowIfCancellationRequested();
}
if (!controller.Lifetime.IsCompleted)
if (controller.Lifetime.IsCompleted)
{
logger.LogDebug("API validation timed out!");
return false;
var validationStatus = controller.ApiValidationStatus;
logger.LogTrace("API validation status: {0}", validationStatus);
switch (validationStatus)
{
case ApiValidationStatus.RequiresUltrasafe:
job.MinimumSecurityLevel = DreamDaemonSecurity.Ultrasafe;
return;
case ApiValidationStatus.RequiresSafe:
if (securityLevel == DreamDaemonSecurity.Ultrasafe)
throw new JobException("This game must be run with at least the 'Safe' DreamDaemon security level!");
job.MinimumSecurityLevel = DreamDaemonSecurity.Safe;
return;
case ApiValidationStatus.RequiresTrusted:
if (securityLevel != DreamDaemonSecurity.Trusted)
throw new JobException("This game must be run with at least the 'Trusted' DreamDaemon security level!");
job.MinimumSecurityLevel = DreamDaemonSecurity.Trusted;
return;
case ApiValidationStatus.NeverValidated:
break;
case ApiValidationStatus.BadValidationRequest:
throw new JobException("Recieved an unrecognized API validation request from DreamDaemon!");
case ApiValidationStatus.UnaskedValidationRequest:
default:
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Session controller returned unexpected ApiValidationStatus: {0}", validationStatus));
}
}
var validated = controller.ApiValidated;
logger.LogTrace("API valid: {0}", validated);
return validated;
throw new JobException("DMAPI validation timed out!");
}
}
@@ -224,12 +249,12 @@ namespace Tgstation.Server.Host.Components.Compiler
for (var I = 0; I < dmeLines.Count; ++I)
{
var line = dmeLines[I];
if (line.Contains("BEGIN_INCLUDE") && dmeModifications.HeadIncludeLine != null)
if (line.Contains("BEGIN_INCLUDE", StringComparison.Ordinal) && dmeModifications.HeadIncludeLine != null)
{
dmeLines.Insert(I + 1, dmeModifications.HeadIncludeLine);
++I;
}
else if (line.Contains("END_INCLUDE") && dmeModifications.TailIncludeLine != null)
else if (line.Contains("END_INCLUDE", StringComparison.Ordinal) && dmeModifications.TailIncludeLine != null)
{
dmeLines.Insert(I, dmeModifications.TailIncludeLine);
break;
@@ -241,7 +266,7 @@ namespace Tgstation.Server.Host.Components.Compiler
}
/// <inheritdoc />
public async Task<Models.CompileJob> Compile(Models.RevisionInformation revisionInformation, DreamMakerSettings dreamMakerSettings, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken)
public async Task<Models.CompileJob> Compile(Models.RevisionInformation revisionInformation, Api.Models.DreamMaker dreamMakerSettings, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken)
{
if (revisionInformation == null)
throw new ArgumentNullException(nameof(revisionInformation));
@@ -252,8 +277,8 @@ namespace Tgstation.Server.Host.Components.Compiler
if (repository == null)
throw new ArgumentNullException(nameof(repository));
if (securityLevel == DreamDaemonSecurity.Ultrasafe)
throw new ArgumentOutOfRangeException(nameof(securityLevel), securityLevel, "Cannot compile with ultrasafe security!");
if (dreamMakerSettings.ApiValidationSecurityLevel == DreamDaemonSecurity.Ultrasafe)
throw new ArgumentOutOfRangeException(nameof(dreamMakerSettings), dreamMakerSettings, "Cannot compile with ultrasafe security!");
logger.LogTrace("Begin Compile");
@@ -268,10 +293,9 @@ namespace Tgstation.Server.Host.Components.Compiler
lock (this)
{
if (Status != CompilerStatus.Idle)
throw new JobException("There is already a compile in progress!");
Status = CompilerStatus.Copying;
if (compiling)
throw new JobException("There is already a compile job in progress!");
compiling = true;
}
try
@@ -302,7 +326,6 @@ namespace Tgstation.Server.Host.Components.Compiler
async Task CleanupFailedCompile(bool cancelled)
{
logger.LogTrace("Cleaning compile directory...");
Status = CompilerStatus.Cleanup;
var chatTask = chat.SendUpdateMessage(cancelled ? "Deploy cancelled!" : "Deploy failed!", cancellationToken);
try
{
@@ -330,13 +353,11 @@ namespace Tgstation.Server.Host.Components.Compiler
using (repository)
await repository.CopyTo(fullDirA, cancellationToken).ConfigureAwait(false);
Status = CompilerStatus.PreCompile;
//run precompile scripts
var resolvedGameDirectory = ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName));
await eventConsumer.HandleEvent(EventType.CompileStart, new List<string> { resolvedGameDirectory, repoOrigin }, cancellationToken).ConfigureAwait(false);
Status = CompilerStatus.Modifying;
//determine the dme
if (job.DmeName == null)
{
logger.LogTrace("Searching for available .dmes...");
@@ -346,45 +367,41 @@ namespace Tgstation.Server.Host.Components.Compiler
var dmeWithExtension = ioManager.GetFileName(path);
job.DmeName = dmeWithExtension.Substring(0, dmeWithExtension.Length - DmeExtension.Length - 1);
}
else if (!await ioManager.FileExists(ioManager.ConcatPath(dirA, String.Join('.', job.DmeName, DmeExtension)), cancellationToken).ConfigureAwait(false))
throw new JobException("Unable to locate specified .dme!");
logger.LogDebug("Selected {0}.dme for compilation!", job.DmeName);
await ModifyDme(job, cancellationToken).ConfigureAwait(false);
Status = CompilerStatus.Compiling;
//run compiler, verify api
job.ByondVersion = byondLock.Version.ToString();
var exitCode = await RunDreamMaker(byondLock.DreamMakerPath, job, cancellationToken).ConfigureAwait(false);
var apiValidated = false;
if (exitCode == 0)
try
{
Status = CompilerStatus.Verifying;
if (exitCode != 0)
throw new JobException(String.Format(CultureInfo.InvariantCulture, "DM exited with a non-zero code: {0}{1}{2}", exitCode, Environment.NewLine, job.Output));
apiValidated = await VerifyApi(apiValidateTimeout, securityLevel, job, byondLock, dreamMakerSettings.ApiValidationPort.Value, cancellationToken).ConfigureAwait(false);
await VerifyApi(apiValidateTimeout, dreamMakerSettings.ApiValidationSecurityLevel.Value, job, byondLock, dreamMakerSettings.ApiValidationPort.Value, cancellationToken).ConfigureAwait(false);
}
if (!apiValidated)
catch (JobException)
{
//server never validated or compile failed
await eventConsumer.HandleEvent(EventType.CompileFailure, new List<string> { resolvedGameDirectory, exitCode == 0 ? "1" : "0" }, cancellationToken).ConfigureAwait(false);
throw new JobException(exitCode == 0 ? "Validation of the TGS api failed!" : String.Format(CultureInfo.InvariantCulture, "DM exited with a non-zero code: {0}{1}{2}", exitCode, Environment.NewLine, job.Output));
throw;
}
logger.LogTrace("Running post compile event...");
Status = CompilerStatus.PostCompile;
await eventConsumer.HandleEvent(EventType.CompileComplete, new List<string> { ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName)) }, cancellationToken).ConfigureAwait(false);
logger.LogTrace("Duplicating compiled game...");
Status = CompilerStatus.Duplicating;
//duplicate the dmb et al
await ioManager.CopyDirectory(dirA, dirB, null, cancellationToken).ConfigureAwait(false);
logger.LogTrace("Applying static game file symlinks...");
Status = CompilerStatus.Symlinking;
//symlink in the static data
var symATask = configuration.SymlinkStaticFilesTo(fullDirA, cancellationToken);
@@ -411,7 +428,7 @@ namespace Tgstation.Server.Host.Components.Compiler
}
finally
{
Status = CompilerStatus.Idle;
compiling = false;
}
}
}
@@ -1,7 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Repository;
namespace Tgstation.Server.Host.Components.Compiler
@@ -11,21 +10,15 @@ namespace Tgstation.Server.Host.Components.Compiler
/// </summary>
public interface IDreamMaker
{
/// <summary>
/// The <see cref="CompilerStatus"/> of <see cref="IDreamMaker"/>
/// </summary>
CompilerStatus Status { get; }
/// <summary>
/// Starts a compile
/// </summary>
/// <param name="revisionInformation">The <see cref="Models.RevisionInformation"/> being compiled from the <paramref name="repository"/></param>
/// <param name="dreamMakerSettings">The <see cref="DreamMakerSettings"/> for the compile</param>
/// <param name="securityLevel">The <see cref="DreamDaemonSecurity"/> level allowed for API validation</param>
/// <param name="dreamMakerSettings">The <see cref="Api.Models.DreamMaker"/> for the compile</param>
/// <param name="apiValidateTimeout">The time in seconds to wait while validating the API</param>
/// <param name="repository">The <see cref="IRepository"/> to copy from</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the partially populated <see cref="Models.CompileJob"/> for the operation. In particular, note the <see cref="Models.CompileJob.RevisionInformation"/> field will only have it's <see cref="Api.Models.Internal.RevisionInformation.CommitSha"/> field populated</returns>
Task<Models.CompileJob> Compile(Models.RevisionInformation revisionInformation, DreamMakerSettings dreamMakerSettings, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken);
Task<Models.CompileJob> Compile(Models.RevisionInformation revisionInformation, Api.Models.DreamMaker dreamMakerSettings, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken);
}
}
@@ -22,7 +22,7 @@
/// </summary>
RepoMergePullRequest = 3,
/// <summary>
/// Parameters: Absolute path to repository root, committer name, committer email
/// Parameters: Absolute path to repository root
/// </summary>
RepoPreSynchronize = 4,
@@ -1,9 +1,9 @@
using Microsoft.Extensions.Hosting;
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.Compiler;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Components.StaticFiles;
using Tgstation.Server.Host.Components.Watchdog;
@@ -25,12 +25,7 @@ namespace Tgstation.Server.Host.Components
/// The <see cref="IByondManager"/> for the <see cref="IInstance"/>
/// </summary>
IByondManager ByondManager { get; }
/// <summary>
/// The <see cref="IDreamMaker"/> for the <see cref="IInstance"/>
/// </summary>
IDreamMaker DreamMaker { get; }
/// <summary>
/// The <see cref="IWatchdog"/> for the <see cref="IInstance"/>
/// </summary>
@@ -41,11 +36,6 @@ namespace Tgstation.Server.Host.Components
/// </summary>
IChat Chat { get; }
/// <summary>
/// The <see cref="ICompileJobConsumer"/> for the <see cref="IInstance"/>
/// </summary>
ICompileJobConsumer CompileJobConsumer { get; }
/// <summary>
/// The <see cref="StaticFiles.IConfiguration"/> for the <see cref="IInstance"/>
/// </summary>
@@ -57,12 +47,6 @@ namespace Tgstation.Server.Host.Components
/// <returns>The latest <see cref="CompileJob"/> if it exists</returns>
CompileJob LatestCompileJob();
/// <summary>
/// Get the <see cref="Api.Models.Instance"/> associated with the <see cref="IInstance"/>
/// </summary>
/// <returns>The <see cref="Api.Models.Instance"/> associated with the <see cref="IInstance"/></returns>
Api.Models.Instance GetMetadata();
/// <summary>
/// Rename the <see cref="IInstance"/>
/// </summary>
@@ -75,5 +59,15 @@ namespace Tgstation.Server.Host.Components
/// <param name="newInterval">The new auto update inteval</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SetAutoUpdateInterval(uint newInterval);
/// <summary>
/// Run the compile job and insert it into the database. Meant to be called by a <see cref="Core.IJobManager"/>
/// </summary>
/// <param name="job">The running <see cref="Job"/></param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the operation</param>
/// <param name="progressReporter">The <see cref="Action{T1}"/> to report compilation progress</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task CompileProcess(Job job, IDatabaseContext databaseContext, Action<int> progressReporter, CancellationToken cancellationToken);
}
}
+232 -56
View File
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Compiler;
@@ -49,6 +50,11 @@ namespace Tgstation.Server.Host.Components
/// </summary>
readonly IDmbFactory dmbFactory;
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="Instance"/>
/// </summary>
readonly IJobManager jobManager;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="Instance"/>
/// </summary>
@@ -81,8 +87,9 @@ namespace Tgstation.Server.Host.Components
/// <param name="compileJobConsumer">The value of <see cref="CompileJobConsumer"/></param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="dmbFactory">The value of <see cref="dmbFactory"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public Instance(Api.Models.Instance metadata, IRepositoryManager repositoryManager, IByondManager byondManager, IDreamMaker dreamMaker, IWatchdog watchdog, IChat chat, StaticFiles.IConfiguration configuration, ICompileJobConsumer compileJobConsumer, IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, ILogger<Instance> logger)
public Instance(Api.Models.Instance metadata, IRepositoryManager repositoryManager, IByondManager byondManager, IDreamMaker dreamMaker, IWatchdog watchdog, IChat chat, StaticFiles.IConfiguration configuration, ICompileJobConsumer compileJobConsumer, IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, IJobManager jobManager, ILogger<Instance> logger)
{
this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
RepositoryManager = repositoryManager ?? throw new ArgumentNullException(nameof(repositoryManager));
@@ -94,6 +101,7 @@ namespace Tgstation.Server.Host.Components
CompileJobConsumer = compileJobConsumer ?? throw new ArgumentNullException(nameof(compileJobConsumer));
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.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -108,6 +116,64 @@ namespace Tgstation.Server.Host.Components
RepositoryManager.Dispose();
}
/// <inheritdoc />
public async Task CompileProcess(Job job, IDatabaseContext databaseContext, Action<int> progressReporter, CancellationToken cancellationToken)
{
//DO NOT FOLLOW THE SUGGESTION FOR A THROW EXPRESSION HERE
if (job == null)
throw new ArgumentNullException(nameof(job));
if (databaseContext == null)
throw new ArgumentNullException(nameof(databaseContext));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
var ddSettingsTask = databaseContext.DreamDaemonSettings.Where(x => x.InstanceId == metadata.Id).Select(x => new DreamDaemonSettings
{
StartupTimeout = x.StartupTimeout,
}).FirstOrDefaultAsync(cancellationToken);
var dreamMakerSettings = await databaseContext.DreamMakerSettings.Where(x => x.InstanceId == metadata.Id).FirstAsync(cancellationToken).ConfigureAwait(false);
if (dreamMakerSettings == default)
throw new JobException("Missing DreamMakerSettings in DB!");
var ddSettings = await ddSettingsTask.ConfigureAwait(false);
if (ddSettings == default)
throw new JobException("Missing DreamDaemonSettings in DB!");
CompileJob compileJob;
RevisionInformation revInfo;
using (var repo = await RepositoryManager.LoadRepository(cancellationToken).ConfigureAwait(false))
{
if (repo == null)
throw new JobException("Missing Repository!");
var repoSha = repo.Head;
revInfo = await databaseContext.RevisionInformations.Where(x => x.CommitSha == repoSha && x.Instance.Id == metadata.Id).Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).FirstOrDefaultAsync().ConfigureAwait(false);
if (revInfo == default)
{
revInfo = new RevisionInformation
{
CommitSha = repoSha,
OriginCommitSha = repoSha,
Instance = new Models.Instance
{
Id = metadata.Id
}
};
logger.LogWarning(Repository.Repository.OriginTrackingErrorTemplate, repoSha);
databaseContext.Instances.Attach(revInfo.Instance);
}
compileJob = await DreamMaker.Compile(revInfo, dreamMakerSettings, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false);
}
compileJob.Job = job;
databaseContext.CompileJobs.Add(compileJob); //will be saved by job context
job.PostComplete = ct => CompileJobConsumer.LoadCompileJob(compileJob, ct);
}
/// <summary>
/// Pull the repository and compile for every set of given <paramref name="minutes"/>
/// </summary>
@@ -119,91 +185,203 @@ namespace Tgstation.Server.Host.Components
while (true)
try
{
await Task.Delay(new TimeSpan(0, minutes > Int32.MaxValue ? Int32.MaxValue : (int)minutes, 0), cancellationToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromMinutes(minutes > Int32.MaxValue ? Int32.MaxValue : (int)minutes), cancellationToken).ConfigureAwait(false);
logger.LogDebug("Beginning auto update...");
try
{
CompileJob job = null;
//need this the whole time
await databaseContextFactory.UseContext(async (db) =>
Models.User user = null;
await databaseContextFactory.UseContext(async (db) => user = await db.Users.FirstAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false);
var repositoryUpdateJob = new Job
{
//start up queries we'll need in the future
var instanceQuery = db.Instances.Where(x => x.Id == metadata.Id);
var ddSettingsTask = instanceQuery.Select(x => x.DreamDaemonSettings).Select(x => new DreamDaemonSettings
Instance = new Models.Instance
{
StartupTimeout = x.StartupTimeout,
SecurityLevel = x.SecurityLevel
}).FirstAsync(cancellationToken);
var dmSettingsTask = instanceQuery.Select(x => x.DreamMakerSettings).FirstAsync(cancellationToken);
var repositorySettingsTask = instanceQuery.Select(x => x.RepositorySettings).FirstAsync(cancellationToken);
Id = metadata.Id
},
Description = "Scheduled repository update",
CancelRightsType = RightsType.Repository,
CancelRight = (ulong)RepositoryRights.CancelPendingChanges,
StartedBy = user
};
using (var repo = await RepositoryManager.LoadRepository(cancellationToken).ConfigureAwait(false))
string deploySha = null;
await jobManager.RegisterOperation(repositoryUpdateJob, async (paramJob, databaseContext, progressReporter, jobCancellationToken) =>
{
var repositorySettingsTask = databaseContext.RepositorySettings.Where(x => x.InstanceId == metadata.Id).FirstAsync(jobCancellationToken);
//assume 5 steps with synchronize
const int ProgressSections = 7;
const int ProgressStep = 100 / ProgressSections;
const int NumSteps = 3;
var doneSteps = 0;
Action<int> NextProgressReporter()
{
var tmpDoneSteps = doneSteps;
++doneSteps;
return progress => progressReporter((progress + 100 * tmpDoneSteps) / NumSteps);
};
using (var repo = await RepositoryManager.LoadRepository(jobCancellationToken).ConfigureAwait(false))
{
if (repo == null)
{
logger.LogTrace("Aborting repo update, no repository!");
return;
}
//start the rev info query
var startSha = repo.Head;
var revInfoTask = instanceQuery.SelectMany(x => x.RevisionInformations).Where(x => x.CommitSha == startSha).FirstOrDefaultAsync(cancellationToken);
if (!repo.Tracking)
{
logger.LogTrace("Aborting repo update, not tracking origin!");
deploySha = startSha;
return;
}
//need repo setting to fetch
var repositorySettings = await repositorySettingsTask.ConfigureAwait(false);
await repo.FetchOrigin(repositorySettings.AccessUser, repositorySettings.AccessToken, null, cancellationToken).ConfigureAwait(false);
//the main point of auto update is to pull the remote
await repo.FetchOrigin(repositorySettings.AccessUser, repositorySettings.AccessToken, NextProgressReporter(), jobCancellationToken).ConfigureAwait(false);
RevisionInformation currentRevInfo = null;
bool hasDbChanges = false;
Task<RevisionInformation> LoadRevInfo() => databaseContext.RevisionInformations
.Where(x => x.CommitSha == startSha && x.Instance.Id == metadata.Id)
.Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge)
.FirstOrDefaultAsync(cancellationToken);
async Task UpdateRevInfo(string currentHead, bool onOrigin)
{
if(currentRevInfo == null)
currentRevInfo = await LoadRevInfo().ConfigureAwait(false);
if (currentRevInfo == default)
{
logger.LogWarning(Repository.Repository.OriginTrackingErrorTemplate, currentHead);
onOrigin = true;
}
var attachedInstance = new Models.Instance
{
Id = metadata.Id
};
var oldRevInfo = currentRevInfo;
currentRevInfo = new RevisionInformation
{
CommitSha = currentHead,
OriginCommitSha = onOrigin ? currentHead : oldRevInfo.OriginCommitSha,
Instance = attachedInstance
};
if (!onOrigin)
currentRevInfo.ActiveTestMerges = new List<RevInfoTestMerge>(oldRevInfo.ActiveTestMerges);
databaseContext.Instances.Attach(attachedInstance);
databaseContext.RevisionInformations.Add(currentRevInfo);
hasDbChanges = true;
}
//take appropriate auto update actions
bool shouldSyncTracked;
if (repositorySettings.AutoUpdatesKeepTestMerges.Value)
{
var result = await repo.MergeOrigin(repositorySettings.CommitterName, repositorySettings.CommitterEmail, cancellationToken).ConfigureAwait(false);
logger.LogTrace("Preserving test merges...");
var currentRevInfoTask = LoadRevInfo();
var result = await repo.MergeOrigin(repositorySettings.CommitterName, repositorySettings.CommitterEmail, NextProgressReporter(), jobCancellationToken).ConfigureAwait(false);
if (!result.HasValue)
return;
shouldSyncTracked = result.Value;
throw new JobException("Merge conflict while preserving test merges!");
currentRevInfo = await currentRevInfoTask.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);
shouldSyncTracked = stillOnOrigin;
}
else
shouldSyncTracked = false;
}
else
{
await repo.ResetToOrigin(cancellationToken).ConfigureAwait(false);
logger.LogTrace("Not preserving test merges...");
await repo.ResetToOrigin(NextProgressReporter(), jobCancellationToken).ConfigureAwait(false);
var currentHead = repo.Head;
currentRevInfo = await databaseContext.RevisionInformations
.Where(x => x.CommitSha == currentHead && x.Instance.Id == metadata.Id)
.FirstOrDefaultAsync(jobCancellationToken).ConfigureAwait(false);
if (currentHead != startSha && currentRevInfo != default)
await UpdateRevInfo(currentHead, true).ConfigureAwait(false);
shouldSyncTracked = true;
}
//synch if necessary
if (repositorySettings.AutoUpdatesSynchronize.Value && startSha != repo.Head)
await repo.Sychronize(repositorySettings.AccessUser, repositorySettings.AccessToken, shouldSyncTracked, cancellationToken).ConfigureAwait(false);
//finish other queries
var dmSettings = await dmSettingsTask.ConfigureAwait(false);
var ddSettings = await ddSettingsTask.ConfigureAwait(false);
var revInfo = await revInfoTask.ConfigureAwait(false);
//null rev info handling
if (revInfo == default)
{
var currentSha = repo.Head;
revInfo = new RevisionInformation
{
CommitSha = currentSha,
OriginCommitSha = currentSha,
Instance = new Models.Instance
{
Id = metadata.Id
},
ActiveTestMerges = new List<RevInfoTestMerge>(),
CompileJobs = new List<CompileJob>()
};
db.Instances.Attach(revInfo.Instance);
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);
}
//finally start compile
job = await DreamMaker.Compile(revInfo, dmSettings, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false);
if(hasDbChanges)
try
{
await databaseContext.Save(cancellationToken).ConfigureAwait(false);
}
catch
{
await repo.ResetToSha(startSha, progressReporter, default).ConfigureAwait(false);
throw;
}
progressReporter(5 * ProgressStep);
deploySha = repo.Head;
}
}, cancellationToken).ConfigureAwait(false);
db.CompileJobs.Add(job);
await db.Save(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await jobManager.WaitForJobCompletion(repositoryUpdateJob, user, cancellationToken, default).ConfigureAwait(false);
await CompileJobConsumer.LoadCompileJob(job, cancellationToken).ConfigureAwait(false);
if (deploySha == null)
{
logger.LogTrace("Aborting auto update, repository error!");
continue;
}
if(deploySha == LatestCompileJob()?.RevisionInformation.CommitSha)
{
logger.LogTrace("Aborting auto update, same revision as latest CompileJob");
continue;
}
//finally set up the job
var compileProcessJob = new Job
{
StartedBy = user,
Instance = repositoryUpdateJob.Instance,
Description = "Scheduled code deployment",
CancelRightsType = RightsType.DreamMaker,
CancelRight = (ulong)DreamMakerRights.CancelCompile
};
await jobManager.RegisterOperation(compileProcessJob, CompileProcess, cancellationToken).ConfigureAwait(false);
await jobManager.WaitForJobCompletion(compileProcessJob, user, cancellationToken, default).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
logger.LogDebug("Cancelled auto update job!");
throw;
}
catch (Exception e)
@@ -216,11 +394,9 @@ namespace Tgstation.Server.Host.Components
{
break;
}
logger.LogTrace("Leaving auto update loop...");
}
/// <inheritdoc />
public Api.Models.Instance GetMetadata() => metadata.CloneMetadata();
/// <inheritdoc />
public void Rename(string newName)
{
@@ -240,7 +416,7 @@ namespace Tgstation.Server.Host.Components
CompileJob latestCompileJob = null;
await databaseContextFactory.UseContext(async db =>
{
latestCompileJob = await db.CompileJobs.Where(x => x.Job.Instance.Id == metadata.Id && x.Job.ExceptionDetails == null).OrderByDescending(x => x.Job.StoppedAt).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
latestCompileJob = await db.CompileJobs.Where(x => x.Job.Instance.Id == metadata.Id).OrderByDescending(x => x.Job.StoppedAt).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}).ConfigureAwait(false);
await dmbFactory.CleanUnusedCompileJobs(latestCompileJob, cancellationToken).ConfigureAwait(false);
}
@@ -8,7 +8,6 @@ using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Chat.Commands;
using Tgstation.Server.Host.Components.Compiler;
using Tgstation.Server.Host.Components.Repository;
using Tgstation.Server.Host.Components.StaticFiles;
using Tgstation.Server.Host.Components.Watchdog;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.IO;
@@ -44,11 +43,6 @@ namespace Tgstation.Server.Host.Components
/// </summary>
readonly IByondTopicSender byondTopicSender;
/// <summary>
/// The <see cref="IServerControl"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly IServerControl serverUpdater;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="InstanceFactory"/>
/// </summary>
@@ -72,7 +66,7 @@ namespace Tgstation.Server.Host.Components
/// <summary>
/// The <see cref="IProviderFactory"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly IProviderFactory providerFactory;
readonly IChatFactory chatFactory;
/// <summary>
/// The <see cref="IProcessExecutor"/> for the <see cref="InstanceFactory"/>
@@ -84,6 +78,26 @@ namespace Tgstation.Server.Host.Components
/// </summary>
readonly IPostWriteHandler postWriteHandler;
/// <summary>
/// The <see cref="IWatchdogFactory"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly IWatchdogFactory watchdogFactory;
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly IJobManager jobManager;
/// <summary>
/// The <see cref="ICredentialsProvider"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly ICredentialsProvider credentialsProvider;
/// <summary>
/// The <see cref="INetworkPromptReaper"/> for the <see cref="InstanceFactory"/>
/// </summary>
readonly INetworkPromptReaper networkPromptReaper;
/// <summary>
/// Construct an <see cref="InstanceFactory"/>
/// </summary>
@@ -92,29 +106,35 @@ namespace Tgstation.Server.Host.Components
/// <param name="application">The value of <see cref="application"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="byondTopicSender">The value of <see cref="byondTopicSender"/></param>
/// <param name="serverUpdater">The value of <see cref="serverUpdater"/></param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/></param>
/// <param name="synchronousIOManager">The value of <see cref="synchronousIOManager"/></param>
/// <param name="symlinkFactory">The value of <see cref="symlinkFactory"/></param>
/// <param name="byondInstaller">The value of <see cref="byondInstaller"/></param>
/// <param name="providerFactory">The value of <see cref="providerFactory"/></param>
/// <param name="chatFactory">The value of <see cref="chatFactory"/></param>
/// <param name="processExecutor">The value of <see cref="processExecutor"/></param>
/// <param name="postWriteHandler">The value of <see cref="postWriteHandler"/></param>
public InstanceFactory(IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, IApplication application, ILoggerFactory loggerFactory, IByondTopicSender byondTopicSender, IServerControl serverUpdater, ICryptographySuite cryptographySuite, ISynchronousIOManager synchronousIOManager, ISymlinkFactory symlinkFactory, IByondInstaller byondInstaller, IProviderFactory providerFactory, IProcessExecutor processExecutor, IPostWriteHandler postWriteHandler)
/// <param name="watchdogFactory">The value of <see cref="watchdogFactory"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="credentialsProvider">The value of <see cref="credentialsProvider"/></param>
/// <param name="networkPromptReaper">The value of <see cref="networkPromptReaper"/></param>
public InstanceFactory(IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, IApplication application, ILoggerFactory loggerFactory, IByondTopicSender byondTopicSender, ICryptographySuite cryptographySuite, ISynchronousIOManager synchronousIOManager, ISymlinkFactory symlinkFactory, IByondInstaller byondInstaller, IChatFactory chatFactory, IProcessExecutor processExecutor, IPostWriteHandler postWriteHandler, IWatchdogFactory watchdogFactory, IJobManager jobManager, ICredentialsProvider credentialsProvider, INetworkPromptReaper networkPromptReaper)
{
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.application = application ?? throw new ArgumentNullException(nameof(application));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.synchronousIOManager = synchronousIOManager ?? throw new ArgumentNullException(nameof(synchronousIOManager));
this.symlinkFactory = symlinkFactory ?? throw new ArgumentNullException(nameof(symlinkFactory));
this.byondInstaller = byondInstaller ?? throw new ArgumentNullException(nameof(byondInstaller));
this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory));
this.chatFactory = chatFactory ?? throw new ArgumentNullException(nameof(chatFactory));
this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
this.postWriteHandler = postWriteHandler ?? throw new ArgumentNullException(nameof(postWriteHandler));
this.watchdogFactory = watchdogFactory ?? throw new ArgumentNullException(nameof(watchdogFactory));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.credentialsProvider = credentialsProvider ?? throw new ArgumentNullException(nameof(credentialsProvider));
this.networkPromptReaper = networkPromptReaper ?? throw new ArgumentNullException(nameof(networkPromptReaper));
}
/// <inheritdoc />
@@ -135,28 +155,26 @@ namespace Tgstation.Server.Host.Components
var dmbFactory = new DmbFactory(databaseContextFactory, gameIoManager, loggerFactory.CreateLogger<DmbFactory>(), metadata.CloneMetadata());
try
{
var repoManager = new RepositoryManager(metadata.RepositorySettings, repoIoManager, eventConsumer);
var repoManager = new RepositoryManager(metadata.RepositorySettings, repoIoManager, eventConsumer, credentialsProvider, loggerFactory.CreateLogger<Repository.Repository>(), loggerFactory.CreateLogger<RepositoryManager>());
try
{
var byond = new ByondManager(byondIOManager, byondInstaller, loggerFactory.CreateLogger<ByondManager>());
var commandFactory = new CommandFactory(application, byond, repoManager, databaseContextFactory, metadata);
var chatFactory = new ChatFactory(instanceIoManager, loggerFactory, commandFactory, providerFactory);
var chat = chatFactory.CreateChat(metadata.ChatSettings);
var chat = chatFactory.CreateChat(instanceIoManager, commandFactory, metadata.ChatSettings);
try
{
var sessionControllerFactory = new SessionControllerFactory(processExecutor, byond, byondTopicSender, cryptographySuite, application, gameIoManager, chat, loggerFactory, metadata.CloneMetadata());
var reattachInfoHandler = new ReattachInfoHandler(databaseContextFactory, dmbFactory, metadata.CloneMetadata());
var watchdogFactory = new WatchdogFactory(chat, sessionControllerFactory, serverUpdater, loggerFactory, reattachInfoHandler, databaseContextFactory, byondTopicSender, eventConsumer, metadata.CloneMetadata());
var watchdog = watchdogFactory.CreateWatchdog(dmbFactory, metadata.DreamDaemonSettings);
var sessionControllerFactory = new SessionControllerFactory(processExecutor, byond, byondTopicSender, cryptographySuite, application, gameIoManager, chat, networkPromptReaper, loggerFactory, metadata.CloneMetadata());
var reattachInfoHandler = new ReattachInfoHandler(databaseContextFactory, dmbFactory, loggerFactory.CreateLogger<ReattachInfoHandler>(), metadata.CloneMetadata());
var watchdog = watchdogFactory.CreateWatchdog(chat, dmbFactory, reattachInfoHandler, configuration, sessionControllerFactory, metadata.CloneMetadata(), metadata.DreamDaemonSettings);
eventConsumer.SetWatchdog(watchdog);
commandFactory.SetWatchdog(watchdog);
try
{
var dreamMaker = new DreamMaker(byond, gameIoManager, configuration, sessionControllerFactory, dmbFactory, application, eventConsumer, chat, processExecutor, watchdog, loggerFactory.CreateLogger<DreamMaker>());
return new Instance(metadata.CloneMetadata(), repoManager, byond, dreamMaker, watchdog, chat, configuration, dmbFactory, databaseContextFactory, dmbFactory, loggerFactory.CreateLogger<Instance>());
return new Instance(metadata.CloneMetadata(), repoManager, byond, dreamMaker, watchdog, chat, configuration, dmbFactory, databaseContextFactory, dmbFactory, jobManager, loggerFactory.CreateLogger<Instance>());
}
catch
{
@@ -49,16 +49,6 @@ namespace Tgstation.Server.Host.Components
/// </summary>
readonly Dictionary<long, IInstance> instances;
/// <summary>
/// <see cref="List{T}"/> of <see cref="Task"/>s to finish in <see cref="StopAsync(CancellationToken)"/>
/// </summary>
readonly List<Task> shutdownTasks;
/// <summary>
/// Used as a temporary <see cref="CancellationTokenSource"/> for <see cref="shutdownTasks"/>
/// </summary>
readonly CancellationTokenSource shutdownCancellationTokenSource;
/// <summary>
/// Construct an <see cref="InstanceManager"/>
/// </summary>
@@ -67,31 +57,17 @@ namespace Tgstation.Server.Host.Components
/// <param name="databaseContextFactory">The value of <paramref name="databaseContextFactory"/></param>
/// <param name="application">The value of <see cref="application"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="serverControl">The <see cref="IServerControl"/> for the <see cref="InstanceManager"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public InstanceManager(IInstanceFactory instanceFactory, IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, IApplication application, IJobManager jobManager, IServerControl serverControl, ILogger<InstanceManager> logger)
public InstanceManager(IInstanceFactory instanceFactory, IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, IApplication application, IJobManager jobManager, ILogger<InstanceManager> logger)
{
this.instanceFactory = instanceFactory ?? throw new ArgumentNullException(nameof(instanceFactory));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.application = application ?? throw new ArgumentNullException(nameof(application));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
if (serverControl == null)
throw new ArgumentNullException(nameof(serverControl));
shutdownCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = shutdownCancellationTokenSource.Token;
serverControl.RegisterForRestart(() =>
{
lock (this)
shutdownTasks.AddRange(instances.Select(x => x.Value.Chat.SendBroadcast("TGS: Restart requested...", cancellationToken)));
});
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
instances = new Dictionary<long, IInstance>();
shutdownTasks = new List<Task>();
}
/// <inheritdoc />
@@ -99,7 +75,6 @@ namespace Tgstation.Server.Host.Components
{
foreach (var I in instances)
I.Value.Dispose();
shutdownCancellationTokenSource.Dispose();
}
/// <inheritdoc />
@@ -237,11 +212,7 @@ namespace Tgstation.Server.Host.Components
public async Task StopAsync(CancellationToken cancellationToken)
{
await jobManager.StopAsync(cancellationToken).ConfigureAwait(false);
using (cancellationToken.Register(() => shutdownCancellationTokenSource.Cancel()))
await Task.WhenAll(shutdownTasks).ConfigureAwait(false);
await Task.WhenAll(instances.Select(x => x.Value.StopAsync(cancellationToken))).ConfigureAwait(false);
await instanceFactory.StopAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
namespace Tgstation.Server.Host.Components.Interop
@@ -39,9 +40,14 @@ namespace Tgstation.Server.Host.Components.Interop
public string ServerCommandsJson { get; set; }
/// <summary>
/// The <see cref="RevisionInformation"/> of the launch
/// The <see cref="Api.Models.Internal.RevisionInformation"/> of the launch
/// </summary>
public RevisionInformation Revision { get; set; }
public Api.Models.Internal.RevisionInformation Revision { get; set; }
/// <summary>
/// The <see cref="DreamDaemonSecurity"/> level of the launch
/// </summary>
public DreamDaemonSecurity SecurityLevel { get; set; }
/// <summary>
/// The <see cref="TestMerge"/>s in the launch
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading;
@@ -22,6 +23,11 @@ namespace Tgstation.Server.Host.Components
/// </summary>
readonly IDmbFactory dmbFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="ReattachInfoHandler"/>
/// </summary>
readonly ILogger<ReattachInfoHandler> logger;
/// <summary>
/// The <see cref="Api.Models.Instance"/> for the <see cref="ReattachInfoHandler"/>
/// </summary>
@@ -32,17 +38,24 @@ namespace Tgstation.Server.Host.Components
/// </summary>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="dmbFactory">The value of <see cref="dmbFactory"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="metadata">The value of <see cref="metadata"/></param>
public ReattachInfoHandler(IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, Api.Models.Instance metadata)
public ReattachInfoHandler(IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, ILogger<ReattachInfoHandler> logger, Api.Models.Instance metadata)
{
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
}
/// <inheritdoc />
public Task Save(WatchdogReattachInformation reattachInformation, CancellationToken cancellationToken) => databaseContextFactory.UseContext(async (db) =>
{
if (reattachInformation == null)
throw new ArgumentNullException(nameof(reattachInformation));
logger.LogDebug("Saving reattach information: {0}...", reattachInformation);
var instance = new Models.Instance { Id = metadata.Id };
db.Instances.Attach(instance);
@@ -93,10 +106,15 @@ namespace Tgstation.Server.Host.Components
}).ConfigureAwait(false);
if (result == default)
{
logger.LogDebug("Reattach information not found!");
return null;
}
var bravoDmbTask = dmbFactory.FromCompileJob(result.Bravo.CompileJob, cancellationToken);
return new WatchdogReattachInformation(result, await dmbFactory.FromCompileJob(result.Alpha.CompileJob, cancellationToken).ConfigureAwait(false), await bravoDmbTask.ConfigureAwait(false));
var info = new WatchdogReattachInformation(result, await dmbFactory.FromCompileJob(result.Alpha.CompileJob, cancellationToken).ConfigureAwait(false), await bravoDmbTask.ConfigureAwait(false));
logger.LogDebug("Reattach information loaded: {0}", info);
return info;
}
}
}
@@ -0,0 +1,52 @@
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
using Microsoft.Extensions.Logging;
using System;
namespace Tgstation.Server.Host.Components.Repository
{
/// <inheritdoc />
sealed class CredentialsProvider : ICredentialsProvider
{
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="CredentialsProvider"/>
/// </summary>
readonly ILogger<CredentialsProvider> logger;
/// <summary>
/// Construct a <see cref="CredentialsProvider"/>
/// </summary>
/// <param name="logger">The value of <see cref="logger"/></param>
public CredentialsProvider(ILogger<CredentialsProvider> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public CredentialsHandler GenerateHandler(string username, string password) => (a, b, supportedCredentialTypes) =>
{
var hasCreds = username != null;
var supportsUserPass = supportedCredentialTypes.HasFlag(SupportedCredentialTypes.UsernamePassword);
var supportsAnonymous = supportedCredentialTypes.HasFlag(SupportedCredentialTypes.Default);
logger.LogTrace("Credentials requested. Present: {0}. Supports anonymous: {1}. Supports user/pass: {2}", hasCreds, supportsAnonymous, supportsUserPass);
if (supportsUserPass)
{
if (hasCreds)
return new UsernamePasswordCredentials
{
Username = username,
Password = password
};
}
if (supportsAnonymous)
return new DefaultCredentials();
if (hasCreds)
throw new JobException("Remote does not support anonymous authentication!");
throw new JobException("Server does not support anonymous or username/password authentication!");
};
}
}
@@ -0,0 +1,18 @@
using LibGit2Sharp.Handlers;
namespace Tgstation.Server.Host.Components.Repository
{
/// <summary>
/// For generating <see cref="CredentialsHandler"/>s
/// </summary>
interface ICredentialsProvider
{
/// <summary>
/// Generate a <see cref="CredentialsHandler"/> from a given <paramref name="username"/> and <paramref name="password"/>
/// </summary>
/// <param name="username">The optional username to use in the <see cref="CredentialsHandler"/></param>
/// <param name="password">The optional password to use in the <see cref="CredentialsHandler"/></param>
/// <returns>A new <see cref="CredentialsHandler"/></returns>
CredentialsHandler GenerateHandler(string username, string password);
}
}
@@ -57,9 +57,10 @@ namespace Tgstation.Server.Host.Components.Repository
/// Checks out a given <paramref name="committish"/>
/// </summary>
/// <param name="committish">The sha or reference to checkout</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task CheckoutObject(string committish, CancellationToken cancellationToken);
Task CheckoutObject(string committish, Action<int> progressReporter, CancellationToken cancellationToken);
/// <summary>
/// Attempt to merge a GitHub pull request into HEAD
@@ -70,8 +71,8 @@ namespace Tgstation.Server.Host.Components.Repository
/// <param name="username">The username to fetch from the origin repository</param>
/// <param name="password">The password to fetch from the origin repository</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <param name="progressReporter">Optional function to report 0-100 progress of the clone</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="Nullable{T}"/> <see cref="bool"/> representing the merge result that is <see langword="true"/> after a fast forward or up to date, <see langword="false"/> on a merge, <see langword="null"/> on a conflict</returns>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="Nullable{T}"/> <see cref="bool"/> representing the merge result that is <see langword="true"/> after a fast forward or up to date, <see langword="false"/> on a non-fast-forward, <see langword="null"/> on a conflict</returns>
Task<bool?> AddTestMerge(TestMergeParameters testMergeParameters, string committerName, string committerEmail, string username, string password, Action<int> progressReporter, CancellationToken cancellationToken);
/// <summary>
@@ -79,7 +80,7 @@ namespace Tgstation.Server.Host.Components.Repository
/// </summary>
/// <param name="username">The username to fetch from the origin repository</param>
/// <param name="password">The password to fetch from the origin repository</param>
/// <param name="progressReporter">Optional function to report 0-100 progress of the clone</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task FetchOrigin(string username, string password, Action<int> progressReporter, CancellationToken cancellationToken);
@@ -87,36 +88,42 @@ namespace Tgstation.Server.Host.Components.Repository
/// <summary>
/// Requires the current HEAD to be a tracked reference. Hard resets the reference to what it tracks on the origin repository
/// </summary>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the SHA of the new HEAD</returns>
Task ResetToOrigin(CancellationToken cancellationToken);
Task ResetToOrigin(Action<int> progressReporter, CancellationToken cancellationToken);
/// <summary>
/// Requires the current HEAD to be a reference. Hard resets the reference to the given sha
/// </summary>
/// <param name="sha">The sha hash to reset to</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the SHA of the new HEAD</returns>
Task ResetToSha(string sha, CancellationToken cancellationToken);
Task ResetToSha(string sha, Action<int> progressReporter, CancellationToken cancellationToken);
/// <summary>
/// Requires the current HEAD to be a tracked reference. Merges the reference to what it tracks on the origin repository
/// </summary>
/// <param name="committerName">The name of the merge committer</param>
/// <param name="committerEmail">The e-mail of the merge committer</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="Nullable{T}"/> <see cref="bool"/> representing the merge result that is <see langword="true"/> after a fast forward or up to date, <see langword="false"/> on a merge, <see langword="null"/> on a conflict</returns>
Task<bool?> MergeOrigin(string committerName, string committerEmail, CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="Nullable{T}"/> <see cref="bool"/> representing the merge result that is <see langword="true"/> after a fast forward, <see langword="false"/> on a merge or up to date, <see langword="null"/> on a conflict</returns>
Task<bool?> MergeOrigin(string committerName, string committerEmail, Action<int> progressReporter, CancellationToken cancellationToken);
/// <summary>
/// Runs the synchronize event script and attempts to push any changes made to the <see cref="IRepository"/> if on a tracked branch
/// </summary>
/// <param name="username">The username to fetch from the origin repository</param>
/// <param name="password">The password to fetch from the origin repository</param>
/// <param name="committerName">The name of the potential committer</param>
/// <param name="committerEmail">The e-mail of the potential committer</param>
/// <param name="synchronizeTrackedBranch">If the synchronizations should be made to the tracked reference as opposed to a temporary branch</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task Sychronize(string username, string password, bool synchronizeTrackedBranch, CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="true"/> if commits were pushed to the tracked origin reference, <see langword="false"/> otherwise</returns>
Task<bool> Sychronize(string username, string password, string committerName, string committerEmail, Action<int> progressReporter, bool synchronizeTrackedBranch, CancellationToken cancellationToken);
/// <summary>
/// Copies the current working directory to a given <paramref name="path"/>
@@ -1,4 +1,6 @@
using LibGit2Sharp;
using LibGit2Sharp.Handlers;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
@@ -18,13 +20,18 @@ namespace Tgstation.Server.Host.Components.Repository
/// </summary>
public const string GitHubUrl = "://github.com/";
const string UnknownReference = "<UNKNOWN>";
/// <summary>
/// Template error message for when tracking of the most recent origin commit fails
/// </summary>
public const string OriginTrackingErrorTemplate = "Unable to determine most recent origin commit of {0}. Marking it as an origin commit. This may result in invalid git metadata until the next hard reset to an origin reference.";
/// <summary>
/// The branch name used for publishing testmerge commits
/// </summary>
public const string RemoteTemporaryBranchName = "___TGSTempBranch";
const string UnknownReference = "<UNKNOWN>";
/// <inheritdoc />
public bool IsGitHubRepository { get; }
@@ -61,12 +68,22 @@ namespace Tgstation.Server.Host.Components.Repository
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="ICredentialsProvider"/> for the <see cref="Repository"/>
/// </summary>
readonly ICredentialsProvider credentialsProvider;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="Repository"/>
/// </summary>
readonly ILogger<Repository> logger;
/// <summary>
/// <see cref="Action"/> to be taken when <see cref="Dispose"/> is called
/// </summary>
readonly Action onDispose;
static void GetRepositoryOwnerName(string remote, out string owner, out string name)
void GetRepositoryOwnerName(string remote, out string owner, out string name)
{
//Assume standard gh format: [(git)|(https)]://github.com/owner/repo(.git)[0-1]
//Yes use .git twice in case it was weird
@@ -77,22 +94,35 @@ namespace Tgstation.Server.Host.Components.Repository
var splits = remote.Split('/');
name = splits[splits.Length - 1];
owner = splits[splits.Length - 2].Split('.')[0];
logger.LogTrace("GetRepositoryOwnerName({0}) => {1} / {2}", remote, owner, name);
}
/// <summary>
/// Converts a given <paramref name="progressReporter"/> to a <see cref="LibGit2Sharp.Handlers.CheckoutProgressHandler"/>
/// </summary>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <returns>A <see cref="LibGit2Sharp.Handlers.CheckoutProgressHandler"/> based on <paramref name="progressReporter"/></returns>
static CheckoutProgressHandler CheckoutProgressHandler(Action<int> progressReporter) => (a, completedSteps, totalSteps) => progressReporter((int)((((float)completedSteps) / totalSteps) * 100));
/// <summary>
/// Construct a <see cref="Repository"/>
/// </summary>
/// <param name="repository">The value of <see cref="repository"/></param>
/// <param name="ioMananger">The value of <see cref="ioMananger"/></param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
/// <param name="credentialsProvider">The value of <see cref="credentialsProvider"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="onDispose">The value if <see cref="onDispose"/></param>
public Repository(LibGit2Sharp.IRepository repository, IIOManager ioMananger, IEventConsumer eventConsumer, Action onDispose)
public Repository(LibGit2Sharp.IRepository repository, IIOManager ioMananger, IEventConsumer eventConsumer, ICredentialsProvider credentialsProvider, ILogger<Repository> logger, Action onDispose)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
this.ioMananger = ioMananger ?? throw new ArgumentNullException(nameof(ioMananger));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.credentialsProvider = credentialsProvider ?? throw new ArgumentNullException(nameof(credentialsProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.onDispose = onDispose ?? throw new ArgumentNullException(nameof(onDispose));
IsGitHubRepository = Origin.ToUpperInvariant().Contains(GitHubUrl.ToUpperInvariant());
IsGitHubRepository = Origin.Contains(GitHubUrl, StringComparison.InvariantCultureIgnoreCase);
if (IsGitHubRepository)
{
GetRepositoryOwnerName(Origin, out var owner, out var name);
@@ -104,20 +134,57 @@ namespace Tgstation.Server.Host.Components.Repository
/// <inheritdoc />
public void Dispose()
{
logger.LogTrace("Disposing...");
repository.Dispose();
onDispose.Invoke();
}
/// <summary>
/// Generate a standard set of <see cref="PushOptions"/>
/// </summary>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="username">The username for the <see cref="credentialsProvider"/></param>
/// <param name="password">The password for the <see cref="credentialsProvider"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A new set of <see cref="PushOptions"/></returns>
PushOptions GeneratePushOptions(Action<int> progressReporter, string username, string password, CancellationToken cancellationToken) => new PushOptions
{
OnPackBuilderProgress = (stage, current, total) =>
{
var baseProgress = stage == PackBuilderStage.Counting ? 0 : 25;
progressReporter(baseProgress + ((int)(25 * ((float)current) / total)));
return !cancellationToken.IsCancellationRequested;
},
OnNegotiationCompletedBeforePush = (a) => !cancellationToken.IsCancellationRequested,
OnPushTransferProgress = (a, sentBytes, totalBytes) =>
{
progressReporter(50 + ((int)(50 * ((float)sentBytes) / totalBytes)));
return !cancellationToken.IsCancellationRequested;
},
CredentialsProvider = credentialsProvider.GenerateHandler(username, password)
};
/// <summary>
/// Runs a blocking force checkout to <paramref name="committish"/>
/// </summary>
/// <param name="committish">The committish to checkout</param>
void RawCheckout(string committish)
/// <param name="progressReporter">Progress reporter <see cref="Action{T}"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
void RawCheckout(string committish, Action<int> progressReporter, CancellationToken cancellationToken)
{
logger.LogTrace("Checkout: {0}", committish);
progressReporter(0);
cancellationToken.ThrowIfCancellationRequested();
Commands.Checkout(repository, committish, new CheckoutOptions
{
CheckoutModifiers = CheckoutModifiers.Force
CheckoutModifiers = CheckoutModifiers.Force,
OnCheckoutProgress = CheckoutProgressHandler(progressReporter)
});
cancellationToken.ThrowIfCancellationRequested();
repository.RemoveUntrackedFiles();
}
@@ -126,22 +193,25 @@ namespace Tgstation.Server.Host.Components.Repository
{
if (testMergeParameters == null)
throw new ArgumentNullException(nameof(testMergeParameters));
if (committerName == null)
throw new ArgumentNullException(nameof(committerName));
if (committerEmail == null)
throw new ArgumentNullException(nameof(committerEmail));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
logger.LogDebug("Begin AddTestMerge: #{0} at {1} ({4}) by <{2} ({3})>", testMergeParameters.Number, testMergeParameters.PullRequestRevision?.Substring(0, 7), committerName, committerEmail, testMergeParameters.Comment);
if (!IsGitHubRepository)
throw new InvalidOperationException("Test merging is only available on GitHub hosted origin repositories!");
var commitMessage = String.Format(CultureInfo.InvariantCulture, "Test merge of pull request #{0}{1}{2}", testMergeParameters.Number.Value, testMergeParameters.Comment != null ? Environment.NewLine : String.Empty, testMergeParameters.Comment ?? String.Empty);
var prBranchName = String.Format(CultureInfo.InvariantCulture, "pr-{0}", testMergeParameters.Number);
var localBranchName = String.Format(CultureInfo.InvariantCulture, "pull/{0}/headrefs/heads/{1}", testMergeParameters.Number, prBranchName);
var Refspec = new List<string> { String.Format(CultureInfo.InvariantCulture, "pull/{0}/head:{1}", testMergeParameters.Number, prBranchName) };
var refSpec = String.Format(CultureInfo.InvariantCulture, "pull/{0}/head:{1}", testMergeParameters.Number, prBranchName);
var refSpecList = new List<string> { refSpec };
var logMessage = String.Format(CultureInfo.InvariantCulture, "Merge remote pull request #{0}", testMergeParameters.Number);
var originalCommit = repository.Head;
@@ -155,39 +225,45 @@ namespace Tgstation.Server.Host.Components.Repository
{
try
{
logger.LogTrace("Fetching refspec {0}...", refSpec);
var remote = repository.Network.Remotes.First();
Commands.Fetch((LibGit2Sharp.Repository)repository, remote.Name, Refspec, new FetchOptions
progressReporter(0);
Commands.Fetch((LibGit2Sharp.Repository)repository, remote.Name, refSpecList, new FetchOptions
{
Prune = true,
OnProgress = (a) => !cancellationToken.IsCancellationRequested,
OnTransferProgress = (a) =>
{
var percentage = 100 * (((float)a.IndexedObjects + a.ReceivedObjects) / (a.TotalObjects * 2));
progressReporter?.Invoke((int)percentage);
var percentage = 50 * (((float)a.IndexedObjects + a.ReceivedObjects) / (a.TotalObjects * 2));
progressReporter((int)percentage);
return !cancellationToken.IsCancellationRequested;
},
OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested,
CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials
{
Username = username,
Password = password
} : new DefaultCredentials()
CredentialsProvider = credentialsProvider.GenerateHandler(username, password)
}, logMessage);
}
catch (UserCancelledException) { }
cancellationToken.ThrowIfCancellationRequested();
repository.RemoveUntrackedFiles();
cancellationToken.ThrowIfCancellationRequested();
testMergeParameters.PullRequestRevision = repository.Lookup(testMergeParameters.PullRequestRevision ?? localBranchName).Sha;
cancellationToken.ThrowIfCancellationRequested();
logger.LogTrace("Merging {0} into {1}...", testMergeParameters.PullRequestRevision.Substring(0, 7), Reference);
result = repository.Merge(testMergeParameters.PullRequestRevision, sig, new MergeOptions
{
CommitOnSuccess = commitMessage == null,
FailOnConflict = true,
FastForwardStrategy = FastForwardStrategy.NoFastForward,
SkipReuc = true
SkipReuc = true,
OnCheckoutProgress = (a, completedSteps, totalSteps) => progressReporter(50 + ((int)((((float)completedSteps) / totalSteps) * 50)))
});
}
finally
@@ -199,7 +275,9 @@ namespace Tgstation.Server.Host.Components.Repository
if (result.Status == MergeStatus.Conflicts)
{
RawCheckout(originalCommit.CanonicalName ?? originalCommit.Tip.Sha);
var revertTo = originalCommit.CanonicalName ?? originalCommit.Tip.Sha;
logger.LogDebug("Merge conflict, aborting and reverting to {0}", revertTo);
RawCheckout(revertTo, progressReporter, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
}
@@ -209,31 +287,45 @@ namespace Tgstation.Server.Host.Components.Repository
if (result.Status == MergeStatus.Conflicts)
{
await eventConsumer.HandleEvent(EventType.RepoMergeConflict, new List<string> { originalCommit.Tip.Sha, testMergeParameters.PullRequestRevision, originalCommit.FriendlyName ?? UnknownReference, prBranchName }, cancellationToken).ConfigureAwait(false);
return false;
return null;
}
if (commitMessage != null)
repository.Commit(commitMessage, sig, sig, new CommitOptions
if (commitMessage != null && result.Status != MergeStatus.UpToDate)
{
logger.LogTrace("Committing merge: \"{0}\"...", commitMessage);
await Task.Factory.StartNew(() => repository.Commit(commitMessage, sig, sig, new CommitOptions
{
PrettifyMessage = true
});
}), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
}
return true;
return result.Status != MergeStatus.NonFastForward;
}
/// <inheritdoc />
public async Task CheckoutObject(string committish, CancellationToken cancellationToken)
public async Task CheckoutObject(string committish, Action<int> progressReporter, CancellationToken cancellationToken)
{
if (committish == null)
throw new ArgumentNullException(nameof(committish));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
logger.LogDebug("Checkout object: {0}...", committish);
await eventConsumer.HandleEvent(EventType.RepoCheckout, new List<string> { committish }, cancellationToken).ConfigureAwait(false);
await Task.Factory.StartNew(() => RawCheckout(committish), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
await Task.Factory.StartNew(() =>
{
repository.RemoveUntrackedFiles();
RawCheckout(committish, progressReporter, cancellationToken);
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
}
/// <inheritdoc />
public Task FetchOrigin(string username, string password, Action<int> progressReporter, CancellationToken cancellationToken) => Task.WhenAll(
eventConsumer.HandleEvent(EventType.RepoFetch, Array.Empty<string>(), cancellationToken),
Task.Factory.StartNew(() =>
public async Task FetchOrigin(string username, string password, Action<int> progressReporter, CancellationToken cancellationToken)
{
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
logger.LogDebug("Fetch origin...");
await eventConsumer.HandleEvent(EventType.RepoFetch, Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
await Task.Factory.StartNew(() =>
{
var remote = repository.Network.Remotes.First();
try
@@ -245,32 +337,31 @@ namespace Tgstation.Server.Host.Components.Repository
OnTransferProgress = (a) =>
{
var percentage = 100 * (((float)a.IndexedObjects + a.ReceivedObjects) / (a.TotalObjects * 2));
progressReporter?.Invoke((int)percentage);
progressReporter((int)percentage);
return !cancellationToken.IsCancellationRequested;
},
OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested,
CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials
{
Username = username,
Password = password
} : new DefaultCredentials()
CredentialsProvider = credentialsProvider.GenerateHandler(username, password)
}, "Fetch origin commits");
}
catch (UserCancelledException)
{
cancellationToken.ThrowIfCancellationRequested();
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current));
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
}
/// <summary>
/// Force push the current repository HEAD to <see cref="Repository.RemoteTemporaryBranchName"/>;
/// Force push the current repository HEAD to <see cref="RemoteTemporaryBranchName"/>;
/// </summary>
/// <param name="username">The username to fetch from the origin repository</param>
/// <param name="password">The password to fetch from the origin repository</param>
/// <param name="progressReporter"><see cref="Action{T1}"/> to report 0-100 <see cref="int"/> progress of the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task PushHeadToTemporaryBranch(string username, string password, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
Task PushHeadToTemporaryBranch(string username, string password, Action<int> progressReporter, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
{
logger.LogInformation("Pushing changes to temporary remote branch...");
var branch = repository.CreateBranch(RemoteTemporaryBranchName);
try
{
@@ -278,17 +369,10 @@ namespace Tgstation.Server.Host.Components.Repository
var remote = repository.Network.Remotes.First();
try
{
repository.Network.Push(remote, String.Format(CultureInfo.InvariantCulture, "+{0}:{0}", branch.CanonicalName), new PushOptions
{
OnPackBuilderProgress = (a, b, c) => !cancellationToken.IsCancellationRequested,
OnNegotiationCompletedBeforePush = (a) => !cancellationToken.IsCancellationRequested,
OnPushTransferProgress = (a, b, c) => !cancellationToken.IsCancellationRequested,
CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials
{
Username = username,
Password = password
} : new DefaultCredentials()
});
var forcePushString = String.Format(CultureInfo.InvariantCulture, "+{0}:{0}", branch.CanonicalName);
repository.Network.Push(remote,forcePushString, GeneratePushOptions(progress => progressReporter((int)(0.9f * progress)), username, password, cancellationToken));
var removalString = String.Format(CultureInfo.InvariantCulture, ":{0}", branch.CanonicalName);
repository.Network.Push(remote, removalString, GeneratePushOptions(progress => progressReporter(90 + (int)(0.1f * progress)), username, password, cancellationToken));
}
catch (UserCancelledException)
{
@@ -302,21 +386,41 @@ namespace Tgstation.Server.Host.Components.Repository
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
/// <inheritdoc />
public async Task ResetToOrigin(CancellationToken cancellationToken)
public async Task ResetToOrigin(Action<int> progressReporter, CancellationToken cancellationToken)
{
if (!repository.Head.IsTracking)
throw new InvalidOperationException("Cannot reset to origin while not on a tracked reference!");
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
if (!Tracking)
throw new JobException("Cannot reset to origin while not on a tracked reference!");
logger.LogTrace("Reset to origin...");
var trackedBranch = repository.Head.TrackedBranch;
await eventConsumer.HandleEvent(EventType.RepoResetOrigin, new List<string> { trackedBranch.FriendlyName, trackedBranch.Tip.Sha }, cancellationToken).ConfigureAwait(false);
await ResetToSha(trackedBranch.Tip.Sha, cancellationToken).ConfigureAwait(false);
await ResetToSha(trackedBranch.Tip.Sha, progressReporter, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public Task ResetToSha(string sha, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
public Task ResetToSha(string sha, Action<int> progressReporter, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
{
repository.Reset(ResetMode.Hard, sha);
cancellationToken.ThrowIfCancellationRequested();
if (sha == null)
throw new ArgumentNullException(nameof(sha));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
logger.LogDebug("Reset to sha: {0}", sha.Substring(0, 7));
repository.RemoveUntrackedFiles();
cancellationToken.ThrowIfCancellationRequested();
var gitObject = repository.Lookup(sha, ObjectType.Commit);
cancellationToken.ThrowIfCancellationRequested();
if (gitObject == null)
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Cannot reset to non-existent SHA: {0}", sha));
repository.Reset(ResetMode.Hard, gitObject.Peel<Commit>(), new CheckoutOptions
{
OnCheckoutProgress = CheckoutProgressHandler(progressReporter)
});
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
/// <inheritdoc />
@@ -324,36 +428,51 @@ namespace Tgstation.Server.Host.Components.Repository
{
if (path == null)
throw new ArgumentNullException(nameof(path));
logger.LogTrace("Copying to {0}...", path);
await ioMananger.CopyDirectory(".", path, new List<string> { ".git" }, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool?> MergeOrigin(string committerName, string committerEmail, CancellationToken cancellationToken)
public async Task<bool?> MergeOrigin(string committerName, string committerEmail, Action<int> progressReporter, CancellationToken cancellationToken)
{
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
MergeResult result = null;
Branch trackedBranch = null;
var oldHead = repository.Head;
var oldTip = oldHead.Tip;
await Task.Factory.StartNew(() =>
{
if (!repository.Head.IsTracking)
throw new InvalidOperationException("Cannot reset to origin while not on a tracked reference!");
trackedBranch = repository.Head.TrackedBranch;
if (!Tracking)
throw new JobException("Cannot reset to origin while not on a tracked reference!");
repository.RemoveUntrackedFiles();
cancellationToken.ThrowIfCancellationRequested();
trackedBranch = repository.Head.TrackedBranch;
logger.LogDebug("Merge origin/{2}: <{0} ({1})>", committerName, committerEmail, trackedBranch.FriendlyName);
result = repository.Merge(trackedBranch, new Signature(new Identity(committerName, committerEmail), DateTimeOffset.Now), new MergeOptions
{
CommitOnSuccess = true,
FailOnConflict = true,
FastForwardStrategy = FastForwardStrategy.Default,
SkipReuc = true,
OnCheckoutProgress = CheckoutProgressHandler(progressReporter)
});
cancellationToken.ThrowIfCancellationRequested();
if (result.Status == MergeStatus.Conflicts)
{
RawCheckout(oldHead.CanonicalName);
logger.LogDebug("Merge conflict, aborting and reverting to {0}", oldHead.FriendlyName);
repository.Reset(ResetMode.Hard, oldTip, new CheckoutOptions
{
OnCheckoutProgress = CheckoutProgressHandler(progressReporter)
});
cancellationToken.ThrowIfCancellationRequested();
}
@@ -362,18 +481,29 @@ namespace Tgstation.Server.Host.Components.Repository
if (result.Status == MergeStatus.Conflicts)
{
await eventConsumer.HandleEvent(EventType.RepoMergeConflict, new List<string> { oldHead.Tip.Sha, trackedBranch.Tip.Sha, oldHead.FriendlyName ?? UnknownReference, trackedBranch.FriendlyName }, cancellationToken).ConfigureAwait(false);
await eventConsumer.HandleEvent(EventType.RepoMergeConflict, new List<string> { oldTip.Sha, trackedBranch.Tip.Sha, oldHead.FriendlyName ?? UnknownReference, trackedBranch.FriendlyName }, cancellationToken).ConfigureAwait(false);
return null;
}
return result.Status != MergeStatus.NonFastForward;
return result.Status == MergeStatus.FastForward;
}
/// <inheritdoc />
public async Task Sychronize(string username, string password, bool synchronizeTrackedBranch, CancellationToken cancellationToken)
public async Task<bool> Sychronize(string username, string password, string committerName, string committerEmail, Action<int> progressReporter, bool synchronizeTrackedBranch, CancellationToken cancellationToken)
{
if (committerName == null)
throw new ArgumentNullException(nameof(committerName));
if (committerEmail == null)
throw new ArgumentNullException(nameof(committerEmail));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
if (username == null && password == null)
return;
{
logger.LogTrace("Not synchronizing due to lack of credentials!");
return false;
}
logger.LogTrace("Begin Synchronize...");
if (username == null)
throw new ArgumentNullException(nameof(username));
@@ -382,39 +512,71 @@ namespace Tgstation.Server.Host.Components.Repository
var startHead = Head;
if (!await eventConsumer.HandleEvent(EventType.RepoPreSynchronize, new List<string> { ioMananger.ResolvePath(".") }, cancellationToken).ConfigureAwait(false))
return;
logger.LogTrace("Configuring <{0} ({1})> as author/committer", committerName, committerEmail);
await Task.Factory.StartNew(() =>
{
repository.Config.Set("user.name", committerName);
cancellationToken.ThrowIfCancellationRequested();
repository.Config.Set("user.email", committerEmail);
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!await eventConsumer.HandleEvent(EventType.RepoPreSynchronize, new List<string> { ioMananger.ResolvePath(".") }, cancellationToken).ConfigureAwait(false))
{
logger.LogDebug("Aborted synchronize due to event handler response!");
return false;
}
}
finally
{
logger.LogTrace("Resetting and cleaning untracked files...");
await Task.Factory.StartNew(() =>
{
repository.RemoveUntrackedFiles();
cancellationToken.ThrowIfCancellationRequested();
repository.Reset(ResetMode.Hard, repository.Head.Tip, new CheckoutOptions
{
OnCheckoutProgress = CheckoutProgressHandler(progress => progressReporter(progress / 10))
});
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
}
void FinalReporter(int progress) => progressReporter((int)(((float)progress) / 100 * 90));
if (!synchronizeTrackedBranch)
{
await PushHeadToTemporaryBranch(username, password, cancellationToken).ConfigureAwait(false);
return;
await PushHeadToTemporaryBranch(username, password, FinalReporter, cancellationToken).ConfigureAwait(false);
return false;
}
if (Head == startHead || !repository.Head.IsTracking)
return;
await Task.Factory.StartNew(() =>
var sameHead = Head == startHead;
if (sameHead || !Tracking)
{
logger.LogTrace("Aborted synchronize due to {0}!", sameHead ? "lack of changes" : "not being on tracked reference");
return false;
}
logger.LogInformation("Synchronizing with origin...");
return await Task.Factory.StartNew(() =>
{
cancellationToken.ThrowIfCancellationRequested();
var remote = repository.Network.Remotes.First();
try
{
repository.Network.Push(repository.Head, new PushOptions
{
OnPackBuilderProgress = (a, b, c) => !cancellationToken.IsCancellationRequested,
OnNegotiationCompletedBeforePush = (a) => !cancellationToken.IsCancellationRequested,
OnPushTransferProgress = (a, b, c) => !cancellationToken.IsCancellationRequested,
CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials
{
Username = username,
Password = password
} : new DefaultCredentials()
});
repository.Network.Push(repository.Head, GeneratePushOptions(FinalReporter, username, password, cancellationToken));
return true;
}
catch (UserCancelledException)
catch (NonFastForwardException)
{
logger.LogInformation("Synchronize aborted, non-fast forward!");
return false;
}
catch (UserCancelledException e)
{
cancellationToken.ThrowIfCancellationRequested();
throw new InvalidOperationException("Caught UserCancelledException without cancellationToken triggering", e);
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
}
@@ -1,4 +1,5 @@
using LibGit2Sharp;
using Microsoft.Extensions.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -27,6 +28,21 @@ namespace Tgstation.Server.Host.Components.Repository
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="ICredentialsProvider"/> for the <see cref="RepositoryManager"/>
/// </summary>
readonly ICredentialsProvider credentialsProvider;
/// <summary>
/// The <see cref="ILogger"/> created <see cref="Repository"/>s
/// </summary>
readonly ILogger<Repository> repositoryLogger;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="RepositoryManager"/>
/// </summary>
readonly ILogger<RepositoryManager> logger;
/// <summary>
/// The <see cref="RepositorySettings"/> for the <see cref="RepositoryManager"/>
/// </summary>
@@ -43,20 +59,36 @@ namespace Tgstation.Server.Host.Components.Repository
/// <param name="repositorySettings">The value of <see cref="repositorySettings"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
public RepositoryManager(RepositorySettings repositorySettings, IIOManager ioManager, IEventConsumer eventConsumer)
/// <param name="credentialsProvider">The value of <see cref="credentialsProvider"/></param>
/// <param name="repositoryLogger">The value of <see cref="repositoryLogger"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public RepositoryManager(RepositorySettings repositorySettings, IIOManager ioManager, IEventConsumer eventConsumer, ICredentialsProvider credentialsProvider, ILogger<Repository> repositoryLogger, ILogger<RepositoryManager> logger)
{
this.repositorySettings = repositorySettings ?? throw new ArgumentNullException(nameof(repositorySettings));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.credentialsProvider = credentialsProvider ?? throw new ArgumentNullException(nameof(credentialsProvider));
this.repositoryLogger = repositoryLogger ?? throw new ArgumentNullException(nameof(repositoryLogger));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
semaphore = new SemaphoreSlim(1);
}
/// <inheritdoc />
public void Dispose() => semaphore.Dispose();
public void Dispose()
{
logger.LogTrace("Disposing...");
semaphore.Dispose();
}
/// <inheritdoc />
public async Task<IRepository> CloneRepository(Uri url, string initialBranch, string username, string password, Action<int> progressReporter, CancellationToken cancellationToken)
{
if (url == null)
throw new ArgumentNullException(nameof(url));
if (progressReporter == null)
throw new ArgumentNullException(nameof(progressReporter));
logger.LogInformation("Begin clone {0} (Branch: {1})", url, initialBranch);
lock (this)
{
if (CloneInProgress)
@@ -66,6 +98,8 @@ namespace Tgstation.Server.Host.Components.Repository
try
{
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
logger.LogTrace("Semaphore acquired");
if (!await ioManager.DirectoryExists(".", cancellationToken).ConfigureAwait(false))
try
{
@@ -87,11 +121,7 @@ namespace Tgstation.Server.Host.Components.Repository
OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested,
RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested,
BranchName = initialBranch,
CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials
{
Username = username,
Password = password
} : new DefaultCredentials()
CredentialsProvider = credentialsProvider.GenerateHandler(username, password)
});
}
catch (UserCancelledException) { }
@@ -102,13 +132,22 @@ namespace Tgstation.Server.Host.Components.Repository
{
try
{
logger.LogTrace("Deleting partially cloned repository...");
await ioManager.DeleteDirectory(".", default).ConfigureAwait(false);
}
catch { }
catch (Exception e)
{
logger.LogDebug("Error deleting partially cloned repository! Exception: {0}", e);
}
throw;
}
else
{
logger.LogDebug("Repository exists, clone aborted!");
return null;
}
}
logger.LogInformation("Clone complete!");
}
finally
{
@@ -120,6 +159,7 @@ namespace Tgstation.Server.Host.Components.Repository
/// <inheritdoc />
public async Task<IRepository> LoadRepository(CancellationToken cancellationToken)
{
logger.LogTrace("Begin LoadRepository...");
lock (this)
if (CloneInProgress)
throw new InvalidOperationException("The repository is being cloned!");
@@ -129,28 +169,41 @@ namespace Tgstation.Server.Host.Components.Repository
{
try
{
logger.LogTrace("Creating LibGit2Sharp.Repository...");
repo = new LibGit2Sharp.Repository(ioManager.ResolvePath("."));
}
catch (RepositoryNotFoundException) { }
catch (RepositoryNotFoundException e)
{
logger.LogDebug("Repository not found!");
logger.LogTrace("Exception: {0}", e);
}
catch
{
semaphore.Release();
throw;
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
if (repo == null)
{
semaphore.Release();
return null;
}
var localSemaphore = semaphore;
return new Repository(repo, ioManager, eventConsumer, () =>
return new Repository(repo, ioManager, eventConsumer, credentialsProvider, repositoryLogger, () =>
{
localSemaphore?.Release();
localSemaphore = null;
logger.LogTrace("Releasing semaphore due to Repository disposal...");
semaphore.Release();
});
}
/// <inheritdoc />
public async Task DeleteRepository(CancellationToken cancellationToken)
{
logger.LogInformation("Deleting repository...");
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
logger.LogTrace("Semaphore acquired, deleting Repository directory...");
await ioManager.DeleteDirectory(".", cancellationToken).ConfigureAwait(false);
}
}
}
}
@@ -159,6 +159,9 @@ namespace Tgstation.Server.Host.Components.StaticFiles
await EnsureDirectories(cancellationToken).ConfigureAwait(false);
var path = ValidateConfigRelativePath(configurationRelativePath);
if (configurationRelativePath == null)
configurationRelativePath = "/";
List<ConfigurationFile> result = new List<ConfigurationFile>();
void ListImpl()
@@ -169,7 +172,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
result.AddRange(enumerator.Select(x => new ConfigurationFile
{
IsDirectory = true,
Path = ioManager.ConcatPath(path, x),
Path = ioManager.ConcatPath(configurationRelativePath, x),
}));
}
catch (IOException e)
@@ -182,7 +185,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
result.AddRange(enumerator.Select(x => new ConfigurationFile
{
IsDirectory = false,
Path = ioManager.ConcatPath(path, x),
Path = ioManager.ConcatPath(configurationRelativePath, x),
}));
}
@@ -74,7 +74,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
/// <param name="data">The data to write. If <see langword="null"/>, the file is deleted</param>
/// <param name="previousHash">The hash any existing file must match in order for the write to succeed</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation. Usage may result in partial writes</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="ConfigurationFile"/></returns>
/// <returns>A <see cref="Task{TResult}"/> resulting in the updated <see cref="ConfigurationFile"/> or <see langword="null"/> if the write failed due to <see cref="ConfigurationFile.LastReadHash"/> conflicts</returns>
Task<ConfigurationFile> Write(string configurationRelativePath, ISystemIdentity systemIdentity, byte[] data, string previousHash, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,33 @@
namespace Tgstation.Server.Host.Components.Watchdog
{
/// <summary>
/// Status of DMAPI validation
/// </summary>
enum ApiValidationStatus
{
/// <summary>
/// The DMAPI never contacted the server for validation
/// </summary>
NeverValidated,
/// <summary>
/// The server was contacted for validation but it was never requested
/// </summary>
UnaskedValidationRequest,
/// <summary>
/// The validation request was malformed
/// </summary>
BadValidationRequest,
/// <summary>
/// Valid API. The game must be run with a minimum security level of <see cref="Api.Models.DreamDaemonSecurity.Safe"/>
/// </summary>
RequiresSafe,
/// <summary>
/// Valid API. The game must be run with a security level of <see cref="Api.Models.DreamDaemonSecurity.Trusted"/>
/// </summary>
RequiresTrusted,
/// <summary>
/// Valid API. The game must be run with a minimum security level of <see cref="Api.Models.DreamDaemonSecurity.Ultrasafe"/>
/// </summary>
RequiresUltrasafe
}
}
@@ -0,0 +1,16 @@
using Tgstation.Server.Host.Core;
namespace Tgstation.Server.Host.Components.Watchdog
{
/// <summary>
/// On Windows, DreamDaemon will show an unskippable prompt when using /world/proc/OpenPort(). This looks out for those prompts and immediately clicks "Yes" if the owning process has registered for it
/// </summary>
interface INetworkPromptReaper
{
/// <summary>
/// Register a given <paramref name="process"/> for network prompt reaping
/// </summary>
/// <param name="process">The <see cref="IProcess"/> to register</param>
void RegisterProcess(IProcess process);
}
}
@@ -25,9 +25,9 @@ namespace Tgstation.Server.Host.Components.Watchdog
bool TerminationWasRequested { get; }
/// <summary>
/// If the DMAPI was validated. This field may only be access once <see cref="IProcessBase.Lifetime"/> completes
/// The DMAPI <see cref="Components.Watchdog.ApiValidationStatus"/>
/// </summary>
bool ApiValidated { get; }
ApiValidationStatus ApiValidationStatus { get; }
/// <summary>
/// The <see cref="IDmbProvider"/> being used
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Create a <see cref="ISessionController"/> from a freshly launch DreamDaemon instance
/// </summary>
/// <param name="launchParameters">The <see cref="DreamDaemonLaunchParameters"/> to use</param>
/// <param name="launchParameters">The <see cref="DreamDaemonLaunchParameters"/> to use. <see cref="DreamDaemonLaunchParameters.SecurityLevel"/> will be updated with the minumum required security level for the launch</param>
/// <param name="dmbProvider">The <see cref="IDmbProvider"/> to use</param>
/// <param name="currentByondLock">The current <see cref="IByondExecutableLock"/> if any</param>
/// <param name="primaryPort">If the <see cref="DreamDaemonLaunchParameters.PrimaryPort"/> of <paramref name="launchParameters"/> should be used</param>
@@ -56,7 +56,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Changes the <see cref="ActiveLaunchParameters"/>. If currently <see cref="Running"/> triggers a graceful restart
/// </summary>
/// <param name="launchParameters">The new <see cref="DreamDaemonLaunchParameters"/></param>
/// <param name="launchParameters">The new <see cref="DreamDaemonLaunchParameters"/>. May be modified</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task ChangeSettings(DreamDaemonLaunchParameters launchParameters, CancellationToken cancellationToken);
@@ -76,5 +76,12 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task Terminate(bool graceful, CancellationToken cancellationToken);
/// <summary>
/// Cancels pending graceful actions
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task ResetRebootState(CancellationToken cancellationToken);
}
}
@@ -1,4 +1,5 @@
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Compiler;
namespace Tgstation.Server.Host.Components.Watchdog
@@ -11,9 +12,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Creates a <see cref="IWatchdog"/>
/// </summary>
/// <param name="chat">The <see cref="IChat"/> for the <see cref="IWatchdog"/></param>
/// <param name="dmbFactory">The <see cref="IDmbFactory"/> for the <see cref="IWatchdog"/> with</param>
/// <param name="reattachInfoHandler">The <see cref="IReattachInfoHandler"/> for the <see cref="IWatchdog"/></param>
/// <param name="eventConsumer">The <see cref="IEventConsumer"/> for the <see cref="IWatchdog"/></param>
/// <param name="sessionControllerFactory">The <see cref="ISessionControllerFactory"/> for the <see cref="IWatchdog"/></param>
/// <param name="instance">The <see cref="Instance"/> for the <see cref="IWatchdog"/></param>
/// <param name="settings">The initial <see cref="DreamDaemonSettings"/> for the <see cref="IWatchdog"/></param>
/// <returns>A new <see cref="IWatchdog"/></returns>
IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings);
IWatchdog CreateWatchdog(IChat chat, IDmbFactory dmbFactory, IReattachInfoHandler reattachInfoHandler, IEventConsumer eventConsumer, ISessionControllerFactory sessionControllerFactory, Api.Models.Instance instance, DreamDaemonSettings settings);
}
}
@@ -0,0 +1,12 @@
using Tgstation.Server.Host.Core;
namespace Tgstation.Server.Host.Components.Watchdog
{
//POSIX BYOND doesn't prompt you when you change the port
/// <inheritdoc />
sealed class PosixNetworkPromptReaper : INetworkPromptReaper
{
/// <inheritdoc />
public void RegisterProcess(IProcess process) { }
}
}
@@ -29,13 +29,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
/// <inheritdoc />
public bool ApiValidated
public ApiValidationStatus ApiValidationStatus
{
get
{
if (!Lifetime.IsCompleted)
throw new InvalidOperationException("ApiValidated cannot be checked while Lifetime is incomplete!");
return apiValidated;
return apiValidationStatus;
}
}
@@ -126,6 +126,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
readonly ILogger<SessionController> logger;
/// <summary>
/// The <see cref="DreamDaemonSecurity"/> level the <see cref="process"/> was launched with
/// </summary>
readonly DreamDaemonSecurity? launchSecurityLevel;
/// <summary>
/// The <see cref="TaskCompletionSource{TResult}"/> <see cref="SetPort(ushort, CancellationToken)"/> waits on when DreamDaemon currently has it's ports closed
/// </summary>
@@ -151,9 +156,9 @@ namespace Tgstation.Server.Host.Components.Watchdog
bool disposed;
/// <summary>
/// If the DMAPI was validated
/// The <see cref="ApiValidationStatus"/> for the <see cref="SessionController"/>
/// </summary>
bool apiValidated;
ApiValidationStatus apiValidationStatus;
/// <summary>
/// If <see cref="process"/> should be kept alive instead
@@ -171,8 +176,9 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="chatJsonTrackingContext">The value of <see cref="chatJsonTrackingContext"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="launchSecurityLevel">The value of <see cref="launchSecurityLevel"/></param>
/// <param name="startupTimeout">The optional time to wait before failing the <see cref="LaunchResult"/></param>
public SessionController(ReattachInformation reattachInformation, IProcess process, IByondExecutableLock byondLock, IByondTopicSender byondTopicSender, IJsonTrackingContext chatJsonTrackingContext, ICommContext interopContext, IChat chat, ILogger<SessionController> logger, uint? startupTimeout)
public SessionController(ReattachInformation reattachInformation, IProcess process, IByondExecutableLock byondLock, IByondTopicSender byondTopicSender, IJsonTrackingContext chatJsonTrackingContext, ICommContext interopContext, IChat chat, ILogger<SessionController> logger, DreamDaemonSecurity? launchSecurityLevel, uint? startupTimeout)
{
this.chatJsonTrackingContext = chatJsonTrackingContext; //null valid
this.reattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
@@ -183,11 +189,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.launchSecurityLevel = launchSecurityLevel;
interopContext.RegisterHandler(this);
portClosedForReboot = false;
disposed = false;
apiValidated = false;
apiValidationStatus = ApiValidationStatus.NeverValidated;
released = false;
rebootTcs = new TaskCompletionSource<object>();
@@ -270,6 +278,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
object content;
Action postRespond = null;
ushort? overrideResponsePort = null;
if (query.TryGetValue(Constants.DMParameterCommand, out var method))
{
content = new object();
@@ -289,6 +298,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
/////UHHHH
logger.LogWarning("DreamDaemon sent new port command without providing it's own!");
content = new ErrorMessage { Message = "Missing stringified port as data parameter!" };
break;
}
@@ -301,6 +311,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
//if it fails it'll kill itself
content = new Dictionary<string, ushort> { { Constants.DMParameterData, nextPort.Value } };
reattachInformation.Port = nextPort.Value;
overrideResponsePort = currentPort;
nextPort = null;
//we'll also get here from SetPort so complete that task
@@ -314,7 +325,30 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
break;
case Constants.DMCommandApiValidate:
apiValidated = true;
if (!launchSecurityLevel.HasValue)
{
logger.LogWarning("DreamDaemon requested API validation but no intial security level was passed to the session controller!");
apiValidationStatus = ApiValidationStatus.UnaskedValidationRequest;
content = new ErrorMessage { Message = "Invalid API validation request!" };
break;
}
if (!query.TryGetValue(Constants.DMParameterData, out var stringMinimumSecurityLevel) || !Enum.TryParse<DreamDaemonSecurity>(stringMinimumSecurityLevel, out var minimumSecurityLevel))
apiValidationStatus = ApiValidationStatus.BadValidationRequest;
else
switch (minimumSecurityLevel)
{
case DreamDaemonSecurity.Safe:
apiValidationStatus = ApiValidationStatus.RequiresSafe;
break;
case DreamDaemonSecurity.Ultrasafe:
apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
break;
case DreamDaemonSecurity.Trusted:
apiValidationStatus = ApiValidationStatus.RequiresTrusted;
break;
default:
throw new InvalidOperationException("Enum.TryParse failed to validate the DreamDaemonSecurity range!");
}
break;
case Constants.DMCommandWorldReboot:
if (ClosePortOnReboot)
@@ -335,7 +369,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
content = new ErrorMessage { Message = "Missing command parameter!" };
var json = JsonConvert.SerializeObject(content);
var response = await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(Constants.DMTopicInteropResponse), byondTopicSender.SanitizeString(Constants.DMParameterData), byondTopicSender.SanitizeString(json)), cancellationToken).ConfigureAwait(false);
var response = await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(Constants.DMTopicInteropResponse), byondTopicSender.SanitizeString(Constants.DMParameterData), byondTopicSender.SanitizeString(json)), overrideResponsePort, cancellationToken).ConfigureAwait(false);
if (response != Constants.DMResponseSuccess)
logger.LogWarning("Recieved error response while responding to interop: {0}", response);
@@ -367,7 +401,9 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
/// <inheritdoc />
public async Task<string> SendCommand(string command, CancellationToken cancellationToken)
public Task<string> SendCommand(string command, CancellationToken cancellationToken) => SendCommand(command, null, cancellationToken);
async Task<string> SendCommand(string command, ushort? overridePort, CancellationToken cancellationToken)
{
try
{
@@ -379,10 +415,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
//intentionally don't sanitize command, that's up to the caller
command);
logger.LogTrace("Export to :{0}. Query: {1}", reattachInformation.Port, commandString);
var targetPort = overridePort ?? reattachInformation.Port;
logger.LogTrace("Export to :{0}. Query: {1}", targetPort, commandString);
return await byondTopicSender.SendTopic(
new IPEndPoint(IPAddress.Loopback, reattachInformation.Port),
new IPEndPoint(IPAddress.Loopback, targetPort),
commandString,
cancellationToken).ConfigureAwait(false);
}
@@ -57,6 +57,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
readonly IChat chat;
/// <summary>
/// The <see cref="INetworkPromptReaper"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly INetworkPromptReaper networkPromptReaper;
/// <summary>
/// The <see cref="ILoggerFactory"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
@@ -98,8 +103,9 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="instance">The value of <see cref="instance"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="networkPromptReaper">The value of <see cref="networkPromptReaper"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
public SessionControllerFactory(IProcessExecutor processExecutor, IByondManager byond, IByondTopicSender byondTopicSender, ICryptographySuite cryptographySuite, IApplication application, IIOManager ioManager, IChat chat, ILoggerFactory loggerFactory, Api.Models.Instance instance)
public SessionControllerFactory(IProcessExecutor processExecutor, IByondManager byond, IByondTopicSender byondTopicSender, ICryptographySuite cryptographySuite, IApplication application, IIOManager ioManager, IChat chat, INetworkPromptReaper networkPromptReaper, ILoggerFactory loggerFactory, Api.Models.Instance instance)
{
this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
this.byond = byond ?? throw new ArgumentNullException(nameof(byond));
@@ -109,6 +115,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
this.instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.networkPromptReaper = networkPromptReaper ?? throw new ArgumentNullException(nameof(networkPromptReaper));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
@@ -131,6 +138,22 @@ namespace Tgstation.Server.Host.Components.Watchdog
//i changed this back from guids, hopefully i don't regret that
string JsonFile(string name) => String.Format(CultureInfo.InvariantCulture, "{0}.{1}", name, JsonPostfix);
var securityLevelToUse = launchParameters.SecurityLevel.Value;
switch (dmbProvider.CompileJob.MinimumSecurityLevel)
{
case DreamDaemonSecurity.Ultrasafe:
break;
case DreamDaemonSecurity.Safe:
if (securityLevelToUse == DreamDaemonSecurity.Ultrasafe)
securityLevelToUse = DreamDaemonSecurity.Safe;
break;
case DreamDaemonSecurity.Trusted:
securityLevelToUse = DreamDaemonSecurity.Trusted;
break;
default:
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid DreamDaemonSecurity value: {0}", dmbProvider.CompileJob.MinimumSecurityLevel));
}
//setup interop files
var interopInfo = new JsonFile
{
@@ -140,6 +163,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
ChatCommandsJson = JsonFile("chat_commands"),
ServerCommandsJson = JsonFile("server_commands"),
InstanceName = instance.Name,
SecurityLevel = securityLevelToUse,
Revision = new Api.Models.Internal.RevisionInformation
{
CommitSha = dmbProvider.CompileJob.RevisionInformation.CommitSha,
@@ -182,15 +206,17 @@ namespace Tgstation.Server.Host.Components.Watchdog
dmbProvider.DmbName,
primaryPort ? launchParameters.PrimaryPort : launchParameters.SecondaryPort,
launchParameters.AllowWebClient.Value ? "-webclient " : String.Empty,
SecurityWord(launchParameters.SecurityLevel.Value),
SecurityWord(securityLevelToUse),
parameters);
//launch dd
var process = processExecutor.LaunchProcess(byondLock.DreamDaemonPath, basePath, arguments);
var process = processExecutor.LaunchProcess(byondLock.DreamDaemonPath, basePath, arguments, noShellExecute: true);
try
{
networkPromptReaper.RegisterProcess(process);
//return the session controller for it
return new SessionController(new ReattachInformation
var result = new SessionController(new ReattachInformation
{
AccessIdentifier = accessIdentifier,
Dmb = dmbProvider,
@@ -200,7 +226,12 @@ namespace Tgstation.Server.Host.Components.Watchdog
ChatChannelsJson = interopInfo.ChatChannelsJson,
ChatCommandsJson = interopInfo.ChatCommandsJson,
ServerCommandsJson = interopInfo.ServerCommandsJson,
}, process, byondLock, byondTopicSender, chatJsonTrackingContext, context, chat, loggerFactory.CreateLogger<SessionController>(), launchParameters.StartupTimeout);
}, process, byondLock, byondTopicSender, chatJsonTrackingContext, context, chat, loggerFactory.CreateLogger<SessionController>(), launchParameters.SecurityLevel, launchParameters.StartupTimeout);
//writeback launch parameter's fixed security level
launchParameters.SecurityLevel = securityLevelToUse;
return result;
}
catch
{
@@ -247,7 +278,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
var process = processExecutor.GetProcess(reattachInformation.ProcessId);
try
{
return new SessionController(reattachInformation, process, byondLock, byondTopicSender, chatJsonTrackingContext, context, chat, loggerFactory.CreateLogger<SessionController>(), null);
networkPromptReaper.RegisterProcess(process);
return new SessionController(reattachInformation, process, byondLock, byondTopicSender, chatJsonTrackingContext, context, chat, loggerFactory.CreateLogger<SessionController>(), null, null);
}
catch
{
@@ -1,4 +1,5 @@
using Byond.TopicSender;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
@@ -10,6 +11,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Compiler;
using Tgstation.Server.Host.Components.Interop;
@@ -18,7 +20,7 @@ using Tgstation.Server.Host.Core;
namespace Tgstation.Server.Host.Components.Watchdog
{
/// <inheritdoc />
sealed class Watchdog : IWatchdog, ICustomCommandHandler
sealed class Watchdog : IWatchdog, ICustomCommandHandler, IRestartHandler
{
/// <summary>
/// The time in seconds to wait from starting <see cref="alphaServer"/> to start <see cref="bravoServer"/>. Does not take responsiveness into account
@@ -86,6 +88,16 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="Watchdog"/>
/// </summary>
readonly IJobManager jobManager;
/// <summary>
/// The <see cref="IRestartRegistration"/> for the <see cref="Watchdog"/>
/// </summary>
readonly IRestartRegistration restartRegistration;
/// <summary>
/// The <see cref="SemaphoreSlim"/> for the <see cref="Watchdog"/>
/// </summary>
@@ -136,16 +148,17 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="sessionControllerFactory">The value of <see cref="sessionControllerFactory"/></param>
/// <param name="dmbFactory">The value of <see cref="dmbFactory"/></param>
/// <param name="serverUpdater">The <see cref="IServerControl"/> for the <see cref="Watchdog"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="reattachInfoHandler">The value of <see cref="reattachInfoHandler"/></param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="byondTopicSender">The value of <see cref="byondTopicSender"/></param>
/// <param name="initialLaunchParameters">The initial value of <see cref="ActiveLaunchParameters"/></param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="serverControl">The <see cref="IServerControl"/> to populate <see cref="restartRegistration"/> with</param>
/// <param name="initialLaunchParameters">The initial value of <see cref="ActiveLaunchParameters"/>. May be modified</param>
/// <param name="instance">The value of <see cref="instance"/></param>
/// <param name="autoStart">The value of <see cref="autoStart"/></param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, IServerControl serverUpdater, ILogger<Watchdog> logger, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, IEventConsumer eventConsumer, DreamDaemonLaunchParameters initialLaunchParameters, Api.Models.Instance instance, bool autoStart)
public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, ILogger<Watchdog> logger, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, IEventConsumer eventConsumer, IJobManager jobManager, IServerControl serverControl, DreamDaemonLaunchParameters initialLaunchParameters, Api.Models.Instance instance, bool autoStart)
{
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.sessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory));
@@ -155,13 +168,14 @@ namespace Tgstation.Server.Host.Components.Watchdog
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.autoStart = autoStart;
if (serverUpdater == null)
throw new ArgumentNullException(nameof(serverUpdater));
if (serverControl == null)
throw new ArgumentNullException(nameof(serverControl));
serverUpdater.RegisterForRestart(() => releaseServers = true);
restartRegistration = serverControl.RegisterForRestart(this);
chat.RegisterCommandHandler(this);
@@ -177,6 +191,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
DisposeAndNullControllers();
semaphore.Dispose();
restartRegistration.Dispose();
}
/// <summary>
@@ -206,6 +221,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
var chatTask = announce ? chat.SendWatchdogMessage("Terminating...", cancellationToken) : Task.CompletedTask;
await StopMonitor().ConfigureAwait(false);
DisposeAndNullControllers();
LastLaunchParameters = null;
await chatTask.ConfigureAwait(false);
return;
}
@@ -431,11 +447,10 @@ namespace Tgstation.Server.Host.Components.Watchdog
monitorState.NextAction = MonitorAction.Continue;
break;
case MonitorActivationReason.NewDmbAvailable:
monitorState.InactiveServerHasStagedDmb = true;
await UpdateAndRestartInactiveServer(true).ConfigureAwait(false); //next case does same thing
break;
monitorState.InactiveServerHasStagedDmb = true;
goto case MonitorActivationReason.ActiveLaunchParametersUpdated;
case MonitorActivationReason.ActiveLaunchParametersUpdated:
await UpdateAndRestartInactiveServer(false).ConfigureAwait(false);
await UpdateAndRestartInactiveServer(true).ConfigureAwait(false);
break;
}
}
@@ -607,6 +622,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
if (launchParameters.Match(ActiveLaunchParameters))
return;
ActiveLaunchParameters = launchParameters;
if (Running)
//queue an update
@@ -700,15 +717,16 @@ namespace Tgstation.Server.Host.Components.Watchdog
await Task.WhenAny(allTask, cancelTcs.Task).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
//both servers are now running, alpha is the active server, huzzah
AlphaIsActive = doReattach ? reattachInfo.AlphaIsActive : true;
//both servers are now running, alpha is the active server(unless reattach), huzzah
AlphaIsActive = doReattach ? reattachInfo?.AlphaIsActive ?? true : true;
LastLaunchResult = alphaLrt.Result;
(AlphaIsActive ? alphaServer : bravoServer).ClosePortOnReboot = true;
logger.LogInformation("Launched servers successfully");
Running = true;
if (startMonitor)
{
await StopMonitor().ConfigureAwait(false);
monitorCts = new CancellationTokenSource();
monitorTask = MonitorLifetimes(monitorCts.Token);
}
@@ -731,6 +749,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
}
catch (Exception e)
{
var originalChatTask = chatTask;
async Task ChainChatTaskWithErrorMessage()
{
await originalChatTask.ConfigureAwait(false);
await chat.SendWatchdogMessage("Startup failed!", cancellationToken).ConfigureAwait(false);
}
chatTask = ChainChatTaskWithErrorMessage();
logger.LogWarning("Failed to start watchdog: {0}", e.ToString());
throw;
}
@@ -752,9 +777,23 @@ namespace Tgstation.Server.Host.Components.Watchdog
return await LaunchNoLock(true, true, false, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ResetRebootState(CancellationToken cancellationToken)
{
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
if (!Running)
return;
var toClear = AlphaIsActive ? alphaServer : bravoServer;
if (toClear != null)
toClear.ResetRebootState();
}
}
/// <inheritdoc />
public async Task<WatchdogLaunchResult> Restart(bool graceful, CancellationToken cancellationToken)
{
logger.LogTrace("Begin Restart. Graceful: {0}", graceful);
using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false))
{
if (!graceful || !Running)
@@ -772,12 +811,10 @@ namespace Tgstation.Server.Host.Components.Watchdog
return result;
}
var toReboot = AlphaIsActive ? alphaServer : bravoServer;
var other = AlphaIsActive ? bravoServer : alphaServer;
if (toReboot != null)
{
if (!await toReboot.SetRebootState(Components.Watchdog.RebootState.Restart, cancellationToken).ConfigureAwait(false))
logger.LogWarning("Unable to send reboot state change event!");
}
return null;
}
@@ -793,8 +830,27 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (autoStart)
await LaunchNoLock(true, true, true, cancellationToken).ConfigureAwait(false);
if (!autoStart)
return;
long? adminUserId = null;
await databaseContextFactory.UseContext(async db => adminUserId = await db.Users.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
},
Description = "Instance startup watchdog launch",
CancelRight = (ulong)DreamDaemonRights.Shutdown,
CancelRightsType = RightsType.DreamDaemon
};
await jobManager.RegisterOperation(job, (j, databaseContext, progressFunction, ct) => Launch(ct), cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -885,5 +941,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
return await activeServer.SendCommand(command, cancellationToken).ConfigureAwait(false) ?? "ERROR: Bad topic exchange!";
}
}
/// <inheritdoc />
public async Task HandleRestart(Version updateVersion, CancellationToken cancellationToken)
{
releaseServers = true;
if (Running)
await chat.SendWatchdogMessage("Detaching...", cancellationToken).ConfigureAwait(false);
}
}
}
@@ -11,31 +11,16 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <inheritdoc />
sealed class WatchdogFactory : IWatchdogFactory
{
/// <summary>
/// The <see cref="IChat"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly IChat chat;
/// <summary>
/// The <see cref="ISessionControllerFactory"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly ISessionControllerFactory sessionControllerFactory;
/// <summary>
/// The <see cref="IServerControl"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly IServerControl serverUpdater;
readonly IServerControl serverControl;
/// <summary>
/// The <see cref="ILoggerFactory"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly ILoggerFactory loggerFactory;
/// <summary>
/// The <see cref="IReattachInfoHandler"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly IReattachInfoHandler reattachInfoHandler;
/// <summary>
/// The <see cref="IDatabaseContextFactory"/> for the <see cref="WatchdogFactory"/>
/// </summary>
@@ -47,42 +32,28 @@ namespace Tgstation.Server.Host.Components.Watchdog
readonly IByondTopicSender byondTopicSender;
/// <summary>
/// The <see cref="IEventConsumer"/> for the <see cref="WatchdogFactory"/>
/// The <see cref="IJobManager"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="Api.Models.Instance"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly Api.Models.Instance instance;
readonly IJobManager jobManager;
/// <summary>
/// Construct a <see cref="WatchdogFactory"/>
/// </summary>
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="sessionControllerFactory">The value of <see cref="sessionControllerFactory"/></param>
/// <param name="serverUpdater">The value of <see cref="serverUpdater"/></param>
/// <param name="serverControl">The value of <see cref="serverControl"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="reattachInfoHandler">The value of <see cref="reattachInfoHandler"/></param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="byondTopicSender">The value of <see cref="byondTopicSender"/></param>
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
/// <param name="instance">The value of <see cref="instance"/></param>
public WatchdogFactory(IChat chat, ISessionControllerFactory sessionControllerFactory, IServerControl serverUpdater, ILoggerFactory loggerFactory, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, IEventConsumer eventConsumer, Api.Models.Instance instance)
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
public WatchdogFactory(IServerControl serverControl, ILoggerFactory loggerFactory, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, IJobManager jobManager)
{
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.sessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory));
this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater));
this.serverControl = serverControl ?? throw new ArgumentNullException(nameof(serverControl));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.reattachInfoHandler = reattachInfoHandler ?? throw new ArgumentNullException(nameof(reattachInfoHandler));
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
}
/// <inheritdoc />
public IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings) => new Watchdog(chat, sessionControllerFactory, dmbFactory, serverUpdater, loggerFactory.CreateLogger<Watchdog>(), reattachInfoHandler, databaseContextFactory, byondTopicSender, eventConsumer, settings, instance, settings.AutoStart.Value);
public IWatchdog CreateWatchdog(IChat chat, IDmbFactory dmbFactory, IReattachInfoHandler reattachInfoHandler, IEventConsumer eventConsumer, ISessionControllerFactory sessionControllerFactory, Api.Models.Instance instance, DreamDaemonSettings settings) => new Watchdog(chat, sessionControllerFactory, dmbFactory, loggerFactory.CreateLogger<Watchdog>(), reattachInfoHandler, databaseContextFactory, byondTopicSender, eventConsumer, jobManager, serverControl, settings, instance, settings.AutoStart.Value);
}
}
@@ -1,4 +1,6 @@
using Tgstation.Server.Host.Models;
using System;
using System.Globalization;
using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Components.Watchdog
{
@@ -35,5 +37,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
if (copy.Bravo != null)
Bravo = new ReattachInformation(copy.Bravo, dmbBravo);
}
/// <inheritdoc />
public override string ToString() => String.Format(CultureInfo.InvariantCulture, "Alpha: {0}, Bravo {1}", Alpha, Bravo);
}
}
@@ -0,0 +1,200 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Host.Core;
namespace Tgstation.Server.Host.Components.Watchdog
{
/// <inheritdoc />
sealed class WindowsNetworkPromptReaper : IHostedService, INetworkPromptReaper, IDisposable
{
/// <summary>
/// Number of times to send the button click message. Should be at least 2 or it may fail to focus the window
/// </summary>
const int SendMessageCount = 5;
/// <summary>
/// Check for prompts each time this amount of milliseconds pass
/// </summary>
const int RecheckDelayMs = 250;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="WindowsNetworkPromptReaper"/>
/// </summary>
readonly ILogger<WindowsNetworkPromptReaper> logger;
/// <summary>
/// The <see cref="CancellationTokenSource"/> for the <see cref="WindowsNetworkPromptReaper"/>
/// </summary>
readonly CancellationTokenSource cancellationTokenSource;
/// <summary>
/// The list of <see cref="IProcess"/>s registered
/// </summary>
readonly List<IProcess> registeredProcesses;
/// <summary>
/// The <see cref="Task"/> representing the lifetime of the <see cref="WindowsNetworkPromptReaper"/>
/// </summary>
Task runTask;
static bool EnumWindow(IntPtr hWnd, IntPtr lParam)
{
var gcChildhandlesList = GCHandle.FromIntPtr(lParam);
if (gcChildhandlesList == null || gcChildhandlesList.Target == null)
return false;
var childHandles = (List <IntPtr>)gcChildhandlesList.Target;
childHandles.Add(hWnd);
return true;
}
static List<IntPtr> GetAllChildHandles(IntPtr main)
{
var childHandles = new List<IntPtr>();
var gcChildhandlesList = GCHandle.Alloc(childHandles);
var pointerChildHandlesList = GCHandle.ToIntPtr(gcChildhandlesList);
try
{
NativeMethods.EnumWindowProc childProc = new NativeMethods.EnumWindowProc(EnumWindow);
NativeMethods.EnumChildWindows(main, childProc, pointerChildHandlesList);
}
finally
{
gcChildhandlesList.Free();
}
return childHandles;
}
/// <summary>
/// Construct a <see cref="WindowsNetworkPromptReaper"/>
/// </summary>
/// <param name="logger">The value of <see cref="logger"/></param>
public WindowsNetworkPromptReaper(ILogger<WindowsNetworkPromptReaper> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
registeredProcesses = new List<IProcess>();
cancellationTokenSource = new CancellationTokenSource();
}
/// <inheritdoc />
public void Dispose() => cancellationTokenSource.Dispose();
async Task Run(CancellationToken cancellationToken)
{
logger.LogDebug("Starting network prompt reaper...");
try
{
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMilliseconds(RecheckDelayMs), cancellationToken).ConfigureAwait(false);
IntPtr window;
int processId;
lock (registeredProcesses)
{
if (registeredProcesses.Count == 0)
continue;
window = NativeMethods.FindWindow(null, "Network Accessibility");
if (window == IntPtr.Zero)
continue;
//found a bitch
var threadId = NativeMethods.GetWindowThreadProcessId(window, out processId);
if (!registeredProcesses.Any(x => x.Id == processId))
//not our bitch
continue;
}
logger.LogTrace("Identified \"Network Accessibility\" window in owned process {0}", processId);
var found = false;
foreach (var I in GetAllChildHandles(window))
{
const int MaxLength = 10;
var stringBuilder = new StringBuilder(MaxLength + 1);
if (NativeMethods.GetWindowText(I, stringBuilder, MaxLength) == 0)
{
logger.LogWarning("Error calling GetWindowText! Exception: {0}", new Win32Exception(Marshal.GetLastWin32Error()));
continue;
}
var windowText = stringBuilder.ToString();
if (windowText == "Yes")
{
//smash_button_meme.jpg
logger.LogTrace("Sending \"Yes\" button clicks...");
for (var J = 0; J < SendMessageCount; ++J)
{
const int BM_CLICK = 0x00F5;
var result = NativeMethods.SendMessage(I, BM_CLICK, IntPtr.Zero, IntPtr.Zero);
}
found = true;
break;
}
}
if (!found)
logger.LogDebug("Unable to find \"Yes\" button for \"Network Accessibility\" window in owned process {0}!", processId);
}
}
catch (OperationCanceledException) { }
finally
{
logger.LogDebug("Exiting network prompt reaper...");
}
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
runTask = Run(cancellationTokenSource.Token);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
logger.LogTrace("Stopping network prompt reaper...");
cancellationTokenSource.Cancel();
await runTask.ConfigureAwait(false);
registeredProcesses.Clear();
}
/// <inheritdoc />
public void RegisterProcess(IProcess process)
{
if (process == null)
throw new ArgumentNullException(nameof(process));
lock (registeredProcesses)
{
if (registeredProcesses.Contains(process))
throw new InvalidOperationException("This process has already been registered for network prompt reaping!");
logger.LogTrace("Registering process {0}...", process.Id);
registeredProcesses.Add(process);
}
process.Lifetime.ContinueWith(x =>
{
logger.LogTrace("Unregistering process {0}...", process.Id);
lock (registeredProcesses)
registeredProcesses.Remove(process);
}, TaskScheduler.Current);
}
}
}
@@ -15,6 +15,11 @@
/// </summary>
public string LogFileDirectory { get; set; }
/// <summary>
/// The stringified <see cref="Microsoft.Extensions.Logging.LogLevel"/> for file logging
/// </summary>
public string LogFileLevel { get; set; }
/// <summary>
/// If file logging is disabled
/// </summary>
@@ -24,5 +29,10 @@
/// Minimum length of database user passwords
/// </summary>
public uint MinimumPasswordLength { get; set; }
/// <summary>
/// A GitHub personal access token to use for bypassing rate limits on requests. Requires no scopes
/// </summary>
public string GitHubAccessToken { get; set; }
}
}
@@ -30,10 +30,12 @@ namespace Tgstation.Server.Host.Controllers
{
const string RestartNotSupportedException = "This deployment of tgstation-server is lacking the Tgstation.Server.Host.Watchdog component. Restarts and version changes cannot be completed!";
const string OctokitException = "Bad GitHub API response, check configuration! Exception: {0}";
/// <summary>
/// The <see cref="IGitHubClient"/> for the <see cref="AdministrationController"/>
/// The <see cref="IGitHubClientFactory"/> for the <see cref="AdministrationController"/>
/// </summary>
readonly IGitHubClient gitHubClient;
readonly IGitHubClientFactory gitHubClientFactory;
/// <summary>
/// The <see cref="IServerControl"/> for the <see cref="AdministrationController"/>
@@ -55,24 +57,31 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly UpdatesConfiguration updatesConfiguration;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="AdministrationController"/>
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// Construct an <see cref="AdministrationController"/>
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="gitHubClient">The value of <see cref="gitHubClient"/></param>
/// <param name="gitHubClientFactory">The value of <see cref="gitHubClientFactory"/></param>
/// <param name="serverUpdater">The value of <see cref="serverUpdater"/></param>
/// <param name="application">The value of <see cref="application"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
/// <param name="updatesConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="updatesConfiguration"/></param>
public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClient gitHubClient, IServerControl serverUpdater, IApplication application, IIOManager ioManager, ILogger<AdministrationController> logger, IOptions<UpdatesConfiguration> updatesConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false)
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="generalConfiguration"/></param>
public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, IServerControl serverUpdater, IApplication application, IIOManager ioManager, ILogger<AdministrationController> logger, IOptions<UpdatesConfiguration> updatesConfigurationOptions, IOptions<GeneralConfiguration> generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false)
{
this.gitHubClient = gitHubClient ?? throw new ArgumentNullException(nameof(gitHubClient));
this.gitHubClientFactory = gitHubClientFactory ?? throw new ArgumentNullException(nameof(gitHubClientFactory));
this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater));
this.application = application ?? throw new ArgumentNullException(nameof(application));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
StatusCodeResult RateLimit(RateLimitExceededException exception)
@@ -83,6 +92,8 @@ namespace Tgstation.Server.Host.Controllers
return StatusCode(429);
}
IGitHubClient GetGitHubClient() => String.IsNullOrEmpty(generalConfiguration.GitHubAccessToken) ? gitHubClientFactory.CreateClient() : gitHubClientFactory.CreateClient(generalConfiguration.GitHubAccessToken);
/// <inheritdoc />
[TgsAuthorize]
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
@@ -93,6 +104,7 @@ namespace Tgstation.Server.Host.Controllers
Uri repoUrl = null;
try
{
var gitHubClient = GetGitHubClient();
var repositoryTask = gitHubClient.Repository.Get(updatesConfiguration.GitHubRepositoryId);
var releases = (await gitHubClient.Repository.Release.GetAll(updatesConfiguration.GitHubRepositoryId).ConfigureAwait(false)).Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture));
@@ -118,6 +130,11 @@ namespace Tgstation.Server.Host.Controllers
{
return RateLimit(e);
}
catch (ApiException e)
{
Logger.LogWarning(OctokitException, e);
return StatusCode((int)HttpStatusCode.FailedDependency);
}
}
/// <inheritdoc />
@@ -137,12 +154,18 @@ namespace Tgstation.Server.Host.Controllers
IEnumerable<Release> releases;
try
{
var gitHubClient = GetGitHubClient();
releases = (await gitHubClient.Repository.Release.GetAll(updatesConfiguration.GitHubRepositoryId).ConfigureAwait(false)).Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture));
}
catch (RateLimitExceededException e)
{
return RateLimit(e);
}
catch (ApiException e)
{
Logger.LogWarning(OctokitException, e);
return StatusCode((int)HttpStatusCode.FailedDependency);
}
Logger.LogTrace("Release query complete!");
foreach (var release in releases)
@@ -157,7 +180,7 @@ namespace Tgstation.Server.Host.Controllers
try
{
Logger.LogDebug("Extracting server update...");
if (!await serverUpdater.ApplyUpdate(assetBytes, ioManager, cancellationToken).ConfigureAwait(false))
if (!await serverUpdater.ApplyUpdate(version, assetBytes, ioManager, cancellationToken).ConfigureAwait(false))
return UnprocessableEntity(new ErrorMessage
{
Message = RestartNotSupportedException
@@ -178,23 +201,23 @@ namespace Tgstation.Server.Host.Controllers
/// <inheritdoc />
[HttpDelete]
[TgsAuthorize(AdministrationRights.RestartHost)]
public Task<IActionResult> Delete()
public async Task<IActionResult> Delete()
{
try
{
var result = serverUpdater.Restart();
var result = await serverUpdater.Restart().ConfigureAwait(false);
if (result)
Logger.LogInformation("Restarting host by request...");
else
Logger.LogDebug("Restart request failed due to lack of host watchdog!");
return Task.FromResult(result ? (IActionResult)Ok() : UnprocessableEntity(new ErrorMessage
return result ? (IActionResult)Ok() : UnprocessableEntity(new ErrorMessage
{
Message = RestartNotSupportedException
}));
});
}
catch (InvalidOperationException)
{
return Task.FromResult<IActionResult>(StatusCode((int)HttpStatusCode.ServiceUnavailable));
return StatusCode((int)HttpStatusCode.ServiceUnavailable);
}
}
}
@@ -1,20 +1,14 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
@@ -57,64 +51,6 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly bool requireInstance;
/// <summary>
/// Runs after a <see cref="Token"/> has been validated. Creates the <see cref="IAuthenticationContext"/> for the <see cref="ControllerBase.Request"/>
/// </summary>
/// <param name="context">The <see cref="TokenValidatedContext"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
public static async Task OnTokenValidated(TokenValidatedContext context)
{
var databaseContext = context.HttpContext.RequestServices.GetRequiredService<IDatabaseContext>();
var authenticationContextFactory = context.HttpContext.RequestServices.GetRequiredService<IAuthenticationContextFactory>();
var userIdClaim = context.Principal.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim == default(Claim))
throw new InvalidOperationException("Missing required claim!");
long userId;
try
{
userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture);
}
catch (Exception e)
{
throw new InvalidOperationException("Failed to parse user ID!", e);
}
ApiHeaders apiHeaders;
try
{
apiHeaders = new ApiHeaders(context.HttpContext.Request.GetTypedHeaders());
}
catch
{
//let OnActionExecutionAsync handle the reponse
return;
}
await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, context.SecurityToken.ValidFrom, context.HttpContext.RequestAborted).ConfigureAwait(false);
var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext;
var enumerator = Enum.GetValues(typeof(RightsType));
var claims = new List<Claim>();
foreach (RightsType I in enumerator)
{
//if there's no instance user, do a weird thing and add all the instance roles
//we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid
//if user is null that means they got the token with an expired password
var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0U : authenticationContext.GetRight(I);
var rightEnum = RightsHelper.RightToType(I);
var right = (Enum)Enum.ToObject(rightEnum, rightInt);
foreach (Enum J in Enum.GetValues(rightEnum))
if (right.HasFlag(J))
claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J)));
}
context.Principal.AddIdentity(new ClaimsIdentity(claims));
}
/// <summary>
/// Construct an <see cref="ApiController"/>
/// </summary>
@@ -210,6 +146,7 @@ namespace Tgstation.Server.Host.Controllers
catch (OperationCanceledException e)
{
Logger.LogDebug("Request cancelled! Exception: {0}", e);
throw;
}
}
}
@@ -95,7 +95,7 @@ namespace Tgstation.Server.Host.Controllers
CancelRight = (ulong)ByondRights.CancelInstall,
Instance = Instance
};
await jobManager.RegisterOperation(job, (paramJob, serviceProvicer, progressHandler, ct) => byondManager.ChangeVersion(installingVersion, ct), cancellationToken).ConfigureAwait(false);
await jobManager.RegisterOperation(job, (paramJob, databaseContext, progressHandler, ct) => byondManager.ChangeVersion(installingVersion, ct), cancellationToken).ConfigureAwait(false);
result.InstallJob = job.ToApi();
}
result.Version = byondManager.ActiveVersion;
@@ -53,7 +53,8 @@ namespace Tgstation.Server.Host.Controllers
IrcChannel = api.IrcChannel,
IsAdminChannel = api.IsAdminChannel ?? false,
IsWatchdogChannel = api.IsWatchdogChannel ?? false,
IsUpdatesChannel = api.IsUpdatesChannel ?? false
IsUpdatesChannel = api.IsUpdatesChannel ?? false,
Tag = api.Tag
};
/// <inheritdoc />
@@ -81,12 +82,11 @@ namespace Tgstation.Server.Host.Controllers
return BadRequest(new ErrorMessage { Message = "Invalid provider!" });
}
if (!model.Enabled.HasValue)
return BadRequest(new ErrorMessage { Message = "enabled cannot be null!" });
if (!model.ValidateProviderChannelTypes())
return BadRequest(new ErrorMessage { Message = "One or more of channels aren't formatted correctly for the given provider!" });
model.Enabled = model.Enabled ?? false;
//try to update das db first
var dbModel = new Models.ChatBot
{
@@ -210,6 +210,8 @@ namespace Tgstation.Server.Host.Controllers
return false;
};
var oldProvider = current.Provider;
if (CheckModified(x => x.ConnectionString, ChatBotRights.WriteConnectionString)
|| CheckModified(x => x.Enabled, ChatBotRights.WriteEnabled)
|| CheckModified(x => x.Name, ChatBotRights.WriteName)
@@ -217,12 +219,18 @@ namespace Tgstation.Server.Host.Controllers
|| (model.Channels != null && !userRights.HasFlag(ChatBotRights.WriteChannels)))
return Forbid();
if (model.Channels != null)
var hasChannels = model.Channels != null;
if (hasChannels || (model.Provider.HasValue && model.Provider != oldProvider))
{
DatabaseContext.ChatChannels.RemoveRange(current.Channels);
var dbChannels = model.Channels.Select(x => ConvertApiChatChannel(x)).ToList();
DatabaseContext.ChatChannels.AddRange(dbChannels);
current.Channels = dbChannels;
if (hasChannels)
{
var dbChannels = model.Channels.Select(x => ConvertApiChatChannel(x)).ToList();
DatabaseContext.ChatChannels.AddRange(dbChannels);
current.Channels = dbChannels;
}
else
current.Channels.Clear();
}
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
@@ -68,7 +68,7 @@ namespace Tgstation.Server.Host.Controllers
if (newFile == null)
return Conflict(new ErrorMessage
{
Message = ""
Message = "This file has been updated since you last viewed it!"
});
newFile.Content = null;
@@ -95,7 +95,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="filePath">The path of the file to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation</returns>
[HttpGet("File/{*filePath}")]
[HttpGet(Routes.File + "/{*filePath}")]
[TgsAuthorize(ConfigurationRights.Read)]
public async Task<IActionResult> File(string filePath, CancellationToken cancellationToken)
{
@@ -171,6 +171,7 @@ namespace Tgstation.Server.Host.Controllers
try
{
model.IsDirectory = true;
return await instanceManager.GetInstance(Instance).Configuration.CreateDirectory(model.Path, AuthenticationContext.SystemIdentity, cancellationToken).ConfigureAwait(false) ? (IActionResult)Json(model) : StatusCode((int)HttpStatusCode.Created, model);
}
catch (NotImplementedException)
@@ -67,11 +67,11 @@ namespace Tgstation.Server.Host.Controllers
StartedBy = AuthenticationContext.User
};
await jobManager.RegisterOperation(job,
async (paramJob, serviceProvider, progressHandler, innerCt) =>
async (paramJob, databaseContext, progressHandler, innerCt) =>
{
var result = await instance.Watchdog.Launch(innerCt).ConfigureAwait(false);
if (result == null)
throw new InvalidOperationException("Watchdog already running!");
throw new JobException("Watchdog already running!");
},
cancellationToken).ConfigureAwait(false);
return Accepted(job.ToApi());
@@ -119,13 +119,15 @@ namespace Tgstation.Server.Host.Controllers
result.SecurityLevel = settings.SecurityLevel;
result.SoftRestart = rstate == RebootState.Restart;
result.SoftShutdown = rstate == RebootState.Shutdown;
result.StartupTimeout = settings.StartupTimeout;
};
if (revision)
{
result.ActiveCompileJob = dd.ActiveCompileJob?.ToApi();
var compileJob = instance.LatestCompileJob();
result.StagedCompileJob = compileJob?.ToApi();
var latestCompileJob = instance.LatestCompileJob();
result.ActiveCompileJob = (dd.ActiveCompileJob ?? latestCompileJob)?.ToApi();
if (latestCompileJob?.Id != result.ActiveCompileJob?.Id)
result.StagedCompileJob = latestCompileJob?.ToApi();
}
return Json(result);
@@ -149,6 +151,12 @@ namespace Tgstation.Server.Host.Controllers
if (model == null)
throw new ArgumentNullException(nameof(model));
if (model.PrimaryPort == 0)
return BadRequest(new ErrorMessage { Message = "Primary port cannot be 0!" });
if (model.SecurityLevel == DreamDaemonSecurity.Ultrasafe)
return BadRequest(new ErrorMessage { Message = "This version of TGS does not support the ultrasafe DreamDaemon configuration!" });
//alias for changing DD settings
var current = await DatabaseContext.Instances.Where(x => x.Id == Instance.Id).Select(x => x.DreamDaemonSettings).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
@@ -185,22 +193,47 @@ namespace Tgstation.Server.Host.Controllers
|| CheckModified(x => x.StartupTimeout, DreamDaemonRights.SetStartupTimeout))
return Forbid();
if (current.SecurityLevel == DreamDaemonSecurity.Ultrasafe)
return BadRequest(new ErrorMessage { Message = "This version of TGS does not support the ultrasafe DreamDaemon configuration!" });
if (current.PrimaryPort == current.SecondaryPort)
return BadRequest(new ErrorMessage { Message = "Primary port and secondary port cannot be the same!" });
var wd = instanceManager.GetInstance(Instance).Watchdog;
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
//run this second because current may be modified by it
await wd.ChangeSettings(current, cancellationToken).ConfigureAwait(false);
//run these in parallel because they are equally as important
await Task.WhenAll(DatabaseContext.Save(cancellationToken), wd.ChangeSettings(current, cancellationToken)).ConfigureAwait(false);
//soft shutdown/restart can't be cancelled because of how many things rely on them
//They can be alternated though
if (!oldSoftRestart.Value && current.SoftRestart.Value)
await wd.Restart(true, cancellationToken).ConfigureAwait(false);
else if (!oldSoftShutdown.Value && current.SoftShutdown.Value)
await wd.Terminate(true, cancellationToken).ConfigureAwait(false);
else if ((oldSoftRestart.Value && !current.SoftRestart.Value) || (oldSoftShutdown.Value && !current.SoftShutdown.Value))
await wd.ResetRebootState(cancellationToken).ConfigureAwait(false);
return await ReadImpl(current, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Handle a HTTP PATCH to the <see cref="DreamDaemonController"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request</returns>
[HttpPatch]
[TgsAuthorize(DreamDaemonRights.Restart)]
public async Task<IActionResult> Restart(CancellationToken cancellationToken)
{
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;
await jobManager.RegisterOperation(job, (paramJob, databaseContext, progressReporter, ct) => watchdog.Restart(false, ct), cancellationToken).ConfigureAwait(false);
return Accepted(job.ToApi());
}
}
}
@@ -1,14 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Core;
@@ -51,12 +50,8 @@ namespace Tgstation.Server.Host.Controllers
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
{
var instance = instanceManager.GetInstance(Instance);
var projectName = await DatabaseContext.DreamMakerSettings.Where(x => x.InstanceId == Instance.Id).Select(x => x.ProjectName).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return Json(new Api.Models.DreamMaker
{
ProjectName = projectName,
Status = instance.DreamMaker.Status
});
var dreamMakerSettings = await DatabaseContext.DreamMakerSettings.Where(x => x.InstanceId == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return Json(dreamMakerSettings.ToApi());
}
/// <inheritdoc />
@@ -78,7 +73,7 @@ namespace Tgstation.Server.Host.Controllers
[TgsAuthorize(DreamMakerRights.CompileJobs)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
{
var compileJobs = await DatabaseContext.CompileJobs.Where(x => x.Job.Instance.Id == Instance.Id).OrderByDescending(x => x.Job.StartedAt).Select(x => new Api.Models.CompileJob
var compileJobs = await DatabaseContext.CompileJobs.Where(x => x.Job.Instance.Id == Instance.Id).OrderByDescending(x => x.Job.StoppedAt).Select(x => new Api.Models.CompileJob
{
Id = x.Id
}).ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -87,9 +82,9 @@ namespace Tgstation.Server.Host.Controllers
/// <inheritdoc />
[TgsAuthorize(DreamMakerRights.Compile)]
public override async Task<IActionResult> Create([FromBody] Api.Models.DreamMaker model, CancellationToken cancellationToken)
public override async Task<IActionResult> Create([FromBody] DreamMaker model, CancellationToken cancellationToken)
{
var job = new Job
var job = new Models.Job
{
Description = "Compile active repository code",
StartedBy = AuthenticationContext.User,
@@ -97,14 +92,20 @@ namespace Tgstation.Server.Host.Controllers
CancelRight = (ulong)DreamMakerRights.CancelCompile,
Instance = Instance
};
await jobManager.RegisterOperation(job, (paramJob, serviceProvider, progressReporter, ct) => RunCompile(paramJob, serviceProvider, Instance, ct), cancellationToken).ConfigureAwait(false);
await jobManager.RegisterOperation(job, instanceManager.GetInstance(Instance).CompileProcess, cancellationToken).ConfigureAwait(false);
return Accepted(job.ToApi());
}
/// <inheritdoc />
[TgsAuthorize(DreamMakerRights.SetDme | DreamMakerRights.SetApiValidationPort)]
public override async Task<IActionResult> Update([FromBody] Api.Models.DreamMaker model, CancellationToken cancellationToken)
[TgsAuthorize(DreamMakerRights.SetDme | DreamMakerRights.SetApiValidationPort | DreamMakerRights.SetApiValidationPort)]
public override async Task<IActionResult> Update([FromBody] DreamMaker model, CancellationToken cancellationToken)
{
if (model.ApiValidationPort == 0)
return BadRequest(new ErrorMessage { Message = "API Validation port cannot be 0!" });
if (model.ApiValidationSecurityLevel == DreamDaemonSecurity.Ultrasafe)
return BadRequest(new ErrorMessage { Message = "This version of TGS does not support the ultrasafe DreamDaemon configuration!" });
var hostModel = await DatabaseContext.DreamMakerSettings.Where(x => x.InstanceId == Instance.Id).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (hostModel == null)
return StatusCode((int)HttpStatusCode.Gone);
@@ -126,74 +127,15 @@ namespace Tgstation.Server.Host.Controllers
hostModel.ApiValidationPort = model.ApiValidationPort;
}
if (model.ApiValidationSecurityLevel.HasValue)
{
if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetSecurityLevel))
return Forbid();
hostModel.ApiValidationSecurityLevel = model.ApiValidationSecurityLevel;
}
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
return await Read(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Run the compile job and insert it into the database
/// </summary>
/// <param name="job">The running <see cref="Job"/></param>
/// <param name="serviceProvider">The <see cref="IServiceProvider"/> for the operation</param>
/// <param name="instanceModel">The <see cref="Models.Instance"/> for the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
async Task RunCompile(Job job, IServiceProvider serviceProvider, Models.Instance instanceModel, CancellationToken cancellationToken)
{
var instanceManager = serviceProvider.GetRequiredService<IInstanceManager>();
var databaseContext = serviceProvider.GetRequiredService<IDatabaseContext>();
var ddSettingsTask = databaseContext.DreamDaemonSettings.Where(x => x.InstanceId == instanceModel.Id).Select(x => new DreamDaemonSettings
{
StartupTimeout = x.StartupTimeout,
SecurityLevel = x.SecurityLevel
}).FirstOrDefaultAsync(cancellationToken);
var dreamMakerSettings = await databaseContext.DreamMakerSettings.Where(x => x.InstanceId == instanceModel.Id).FirstAsync(cancellationToken).ConfigureAwait(false);
if (dreamMakerSettings == default)
throw new JobException("Missing DreamMakerSettings in DB!");
var ddSettings = await ddSettingsTask.ConfigureAwait(false);
if (ddSettings == default)
throw new JobException("Missing DreamDaemonSettings in DB!");
var instance = instanceManager.GetInstance(instanceModel);
CompileJob compileJob;
RevisionInformation revInfo;
using (var repo = await instance.RepositoryManager.LoadRepository(cancellationToken).ConfigureAwait(false))
{
if (repo == null)
throw new JobException("Missing Repository!");
var repoSha = repo.Head;
revInfo = await databaseContext.RevisionInformations.Where(x => x.CommitSha == repoSha).Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge).FirstOrDefaultAsync().ConfigureAwait(false);
if (revInfo == default)
{
revInfo = new RevisionInformation
{
CommitSha = repoSha,
OriginCommitSha = repoSha,
Instance = new Models.Instance
{
Id = Instance.Id
},
ActiveTestMerges = new List<RevInfoTestMerge>(),
CompileJobs = new List<CompileJob>()
};
databaseContext.Instances.Attach(revInfo.Instance);
}
compileJob = await instance.DreamMaker.Compile(revInfo, dreamMakerSettings, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false);
}
compileJob.Job = job;
databaseContext.CompileJobs.Add(compileJob);
await databaseContext.Save(cancellationToken).ConfigureAwait(false);
await instance.CompileJobConsumer.LoadCompileJob(compileJob, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -136,7 +136,8 @@ namespace Tgstation.Server.Host.Controllers
},
DreamMakerSettings = new DreamMakerSettings
{
ApiValidationPort = 1339
ApiValidationPort = 1339,
ApiValidationSecurityLevel = DreamDaemonSecurity.Safe
},
Name = model.Name,
Online = false,
@@ -294,7 +295,9 @@ namespace Tgstation.Server.Host.Controllers
}
}
var oldAutoUpdateInterval = originalModel.AutoUpdateInterval.Value;
var originalOnline = originalModel.Online.Value;
var renamed = model.Name != null && originalModel.Name != model.Name;
if (CheckModified(x => x.AutoUpdateInterval, InstanceManagerRights.SetAutoUpdate)
|| CheckModified(x => x.ConfigurationType, InstanceManagerRights.SetConfiguration)
@@ -311,6 +314,9 @@ namespace Tgstation.Server.Host.Controllers
await DatabaseContext.Save(cancellationToken).ConfigureAwait(false);
if (renamed)
instanceManager.GetInstance(originalModel).Rename(originalModel.Name);
var oldAutoStart = originalModel.DreamDaemonSettings.AutoStart;
try
{
@@ -347,10 +353,13 @@ namespace Tgstation.Server.Host.Controllers
StartedBy = AuthenticationContext.User
};
await jobManager.RegisterOperation(job, (paramJob, serviceProvider, progressHandler, ct) => instanceManager.MoveInstance(originalModel, rawPath, ct), cancellationToken).ConfigureAwait(false);
await jobManager.RegisterOperation(job, (paramJob, databaseContext, progressHandler, ct) => instanceManager.MoveInstance(originalModel, rawPath, 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);
return Json(api);
}

Some files were not shown because too many files have changed in this diff Show More