1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-12-25 22:38:24 +01:00

97 Commits

Author SHA1 Message Date
9c12661e71 TODO 2025-12-25 20:06:57 +01:00
6a9752acf2 WIP 2025-12-25 11:22:34 +01:00
1a75e3f6bc Update dotnet-ef to 9.0.9 2025-12-25 06:13:31 +01:00
65e763a5be Update Akka.NET to 1.5.51 & use per-actor timers 2025-10-04 04:07:24 +02:00
34ae619e4a Update Serilog to 4.3.0 2025-10-04 03:05:51 +02:00
8eb615b16c Update EF Core to 9.0.9 & update related dependencies 2025-10-04 03:02:21 +02:00
1badad1112 Use ASP.NET Core 9's new static asset convention 2025-10-04 02:48:15 +02:00
ce91c84855 Update ASP.NET Core libraries to 9.0.9 2025-10-04 02:46:56 +02:00
f77b545909 Replace Kajabity.Tools.Java with a custom Properties file reader and writer 2025-10-04 02:20:01 +02:00
e9a815d715 Update MemoryPack to 1.21.4 2025-09-30 20:00:20 +02:00
5c4342f4f2 Negotiate message codes during RPC handshake 2025-09-29 12:47:11 +02:00
186b208bbf Fix Rider syntax style inspections 2025-09-27 10:01:31 +02:00
e5288ec55c Fix exception spam and wrong behavior when detecting online player count on a slow server 2025-09-27 08:36:54 +02:00
f94e21fde8 Replace NetMQ with custom TCP networking 2025-09-26 15:42:11 +02:00
591a6a62ab Reformat code 2025-08-21 20:31:21 +02:00
ae32537d8c Update to .NET 9 and C# 13 2025-08-08 22:04:41 +02:00
8149d31d51 Update Java versions in Dockerfile 2024-07-14 10:40:36 +02:00
9873f8779f Show online and maximum player count on Instances page on Web 2024-04-12 16:29:50 +02:00
31e101b21e Retrieve both online and maximum player count from Minecraft servers 2024-04-12 13:30:48 +02:00
398bb14742 Refactor online player count detection in Agent 2024-04-12 05:28:15 +02:00
4e89d7b12f Fix instance stop timeout not killing the process
Caused by 72ddaf91.
2024-04-12 05:28:15 +02:00
3b313bf0a8 Disable sync chunk writes to avoid lag during backups 2024-04-12 05:28:15 +02:00
21c90cb7c2 Fix null in Web login message reply 2024-04-08 00:03:41 +02:00
7b68b5d50d Implement backend logic for per-agent user access control 2024-04-07 23:53:27 +02:00
2d70d2b7d1 Update dotnet-ef to 8.0.3 2024-04-07 23:20:14 +02:00
8c623171f3 Add missing ARIA role to error messages on Web 2024-04-07 21:38:02 +02:00
62f8c685f0 Update Home page on Web 2024-04-07 21:21:05 +02:00
4a110db078 Fix label and spacing around instance command input field on Web 2024-04-07 17:08:10 +02:00
f683a1f700 Invalidate Web authorization when a user is updated/deleted 2024-04-07 17:05:24 +02:00
3ffb37529c Add user permission checking to Controller 2024-04-06 23:26:04 +02:00
4c3b81c54a Fix not re-focusing instance command input after sending a command on Web 2024-04-05 13:37:49 +02:00
8e2b019aa1 Fix exception due to disposed CancellationTokenSource in Minecraft server downloader 2024-04-05 13:34:42 +02:00
9a2c13c1e0 Add support for backups on older Minecraft versions 2024-04-05 12:56:32 +02:00
991b32032c Add "Backing Up" instance status 2024-04-05 11:37:55 +02:00
875fd9a766 Add timeouts while waiting for server output during backups 2024-04-05 02:41:46 +02:00
f7f08ec55c Fix backups not working with Forge log format 2024-04-04 20:44:17 +02:00
1b12fd9c3b Guard loading instances from database from exceptions 2024-04-04 20:37:37 +02:00
424dccb14e Migrate InstanceActionResult to Result type 2024-04-01 11:22:45 +02:00
d03f532996 Make Result type serializable 2024-03-31 18:24:22 +02:00
c99f5bc6bf Remove login session management logic from Web 2024-03-31 12:07:33 +02:00
d591318340 Fix missing audit logs for user login/logout 2024-03-30 17:18:27 +01:00
c7b57fac97 Fix Rider inspections 2024-03-30 17:08:57 +01:00
137a2a53c3 Replace remaining uses of TaskManager 2024-03-30 09:25:08 +01:00
94148add2d Force invariant culture for all applications 2024-03-30 08:23:39 +01:00
c1cbb848a9 Increase Agent buffer size for instance logs 2024-03-30 07:50:29 +01:00
9fcc3c53fc Catch "The pipe is being closed" Windows exception when stopping instance 2024-03-30 07:50:29 +01:00
72ddaf91ad Implement actors in Agent via Akka.NET 2024-03-30 07:50:29 +01:00
02828a91c6 Migrate event log to Akka.NET 2024-03-23 00:21:39 +01:00
7cdb0a1910 Migrate RPC message handling to Akka.NET 2024-03-23 00:21:39 +01:00
0d039b69de Implement actors in Controller via Akka.NET 2024-03-11 00:01:54 +01:00
8911a2257a Fix typo in instance event type 2024-01-25 15:25:22 +01:00
a1dec2f117 Update and fix Dockerfile 2024-01-25 14:55:30 +01:00
de72e77c7b Update dependencies 2023-12-27 22:45:12 +01:00
9c06178a63 Fix double padding in table cells with both clickable rows and custom classes 2023-12-27 22:45:12 +01:00
e981425ce5 Make table rows on the Instances page on Web clickable 2023-12-20 07:51:55 +01:00
1ded0e50b2 Redesign Web navigation 2023-12-20 06:11:24 +01:00
e679b17e3b Redesign Web tables 2023-12-20 06:11:24 +01:00
578ec2d11c Clean up Web project 2023-12-18 06:04:12 +01:00
2471dc04f1 Improve handling of missing registration message 2023-12-17 18:38:23 +01:00
5bc474d455 Define message groups and use queues to send and process messages within each group sequentially 2023-12-17 07:41:58 +01:00
3cab9a0152 Move Controller RPC code into the RPC utils project 2023-12-17 07:41:57 +01:00
8b0452ead6 Move logging to a utils project 2023-12-17 06:49:18 +01:00
96456f90bc Reimplement Web service 2023-12-05 14:27:55 +01:00
e69b0245aa Refactor methods for sending messages and waiting for replies 2023-12-02 06:30:06 +01:00
cd332a6571 Fix Controller handling messages from unauthorized Agents 2023-10-23 14:03:15 +02:00
2a9bb9e6ac Extract classes for client-to-server RPC connection 2023-10-23 14:03:13 +02:00
55b853d227 Fully separate Controller and Web into their own services - Controller compiling and setup 2023-10-23 14:02:58 +02:00
627e7436fd Remove unnecessary trailing spaces 2023-10-23 01:16:44 +02:00
339b958e45 Move methods that convert enums and discriminated unions to sentences 2023-10-21 17:53:12 +02:00
3a17eee8d0 Update namespaces in Controller and Web projects 2023-10-09 17:15:24 +02:00
2c623cbd9a Split Phantom Server projects into separate Controller and Web folders 2023-10-09 17:15:24 +02:00
346eab0b1b Replace ASP.NET Identity with a custom solution 2023-10-09 14:08:11 +02:00
956f1e779b Enable new .NET 8 artifacts output folder 2023-10-08 14:20:25 +02:00
2e8238a3bc Update dependencies 2023-10-07 17:50:26 +02:00
346ca5de75 Remove dependency on coverlet.collector 2023-10-07 16:40:45 +02:00
3e93ad9217 Fix wrong zstd parameters causing source files to not be removed in newer versions 2023-06-16 15:37:55 +02:00
07c31ac70b Update Docker images to multi-arch & reduce build size 2023-04-07 01:33:14 +02:00
c89e30cd3f Update to .NET 8 2023-04-07 01:33:13 +02:00
0091fa11fb Merge several Utils projects 2023-04-07 01:33:13 +02:00
20ff2871fa Switch to Eclipse Temurin JRE in Docker images 2023-04-05 19:43:02 +02:00
f211708509 Fix not internally updating selected Minecraft version when switching version types 2023-04-04 19:52:51 +02:00
ad7964677e Fix not flushing logs when calling Environment.Exit 2023-04-02 09:17:39 +02:00
626f141a2c Enable version tolerant mode for all MemoryPack structures 2023-04-01 08:27:09 +02:00
46dba1a4fa Revert "Add JVM argument to disable signal handling to prevent instant termination of Minecraft processes"
This reverts commit e699513036.
2023-03-30 04:24:02 +02:00
476de00cfd Delegate JVM argument validation to Server only 2023-03-30 04:00:22 +02:00
2180e0c067 Fix exception while shutting down instance procedures 2023-03-28 03:45:00 +02:00
a9646c5bbc Convert backup procedure into an instance procedure 2023-03-28 03:30:37 +02:00
01d6648b1f Rework Agent instance state management into a procedure-based system 2023-03-27 06:12:35 +02:00
def6c41a77 Remove Phantom.Common.Minecraft project 2023-03-07 12:53:42 +01:00
3f976295bd Make writing server.properties file asynchronous 2023-03-07 12:53:42 +01:00
9d9734d1fd Fix audit log event when editing user roles 2023-03-05 07:49:56 +01:00
267c5ad921 Reduce InstanceProcess output ring buffer capacity 2023-03-05 07:38:07 +01:00
f5b40a92e2 Reduce the minimum number of thread pool threads in Agent 2023-03-05 07:29:45 +01:00
2c413160f6 Update all NuGet packages 2023-03-05 07:29:45 +01:00
0af14c3262 Fix code issues and inefficiencies 2023-03-05 07:29:45 +01:00
0ab165fd21 Optimize sending instance logs to wait for output instead of constantly looping 2023-03-05 04:17:14 +01:00
6f11f65d91 Add environment variable to set max concurrent backup compression tasks 2023-03-05 01:24:39 +01:00
648 changed files with 16779 additions and 8212 deletions

View File

@@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "7.0.0-rc.1.22426.7",
"version": "9.0.9",
"commands": [
"dotnet-ef"
]
],
"rollForward": false
}
}
}

1
.gitignore vendored
View File

@@ -192,7 +192,6 @@ ClientBin/
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs

View File

@@ -1,28 +1,30 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 1" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 1" />
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,28 +1,30 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 2" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 2" />
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,28 +1,30 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 3" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" />
<env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compile Bootstrap" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/Server/Phantom.Server.Web.Bootstrap/package.json" />
<package-json value="$PROJECT_DIR$/Web/Phantom.Web.Bootstrap/package.json" />
<command value="run" />
<scripts>
<script value="compile" />
@@ -9,4 +9,4 @@
<envs />
<method v="2" />
</configuration>
</component>
</component>

View File

@@ -1,9 +1,10 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server + Agent x3" type="CompoundRunConfigurationType">
<configuration default="false" name="Controller + Web + Agent x3" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Agent 2" type="DotNetProject" />
<toRun name="Agent 3" type="DotNetProject" />
<toRun name="Server" type="DotNetProject" />
<toRun name="Controller" type="DotNetProject" />
<toRun name="Web" type="DotNetProject" />
<method v="2" />
</configuration>
</component>
</component>

View File

@@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller + Web + Agent" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Controller" type="DotNetProject" />
<toRun name="Web" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,30 +1,34 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/bin/Debug/net7.0/Phantom.Server.exe" />
<configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="AGENT_RPC_SERVER_HOST" value="localhost" />
<env name="PG_DATABASE" value="postgres" />
<env name="PG_HOST" value="localhost" />
<env name="PG_PASS" value="development" />
<env name="PG_PORT" value="9402" />
<env name="PG_PORT" value="9403" />
<env name="PG_USER" value="postgres" />
<env name="RPC_SERVER_HOST" value="localhost" />
<env name="WEB_SERVER_HOST" value="localhost" />
<env name="WEB_RPC_SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/Phantom.Server.csproj" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Controller/Phantom.Controller/Phantom.Controller.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>
</component>

View File

@@ -1,7 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server + Agent" type="CompoundRunConfigurationType">
<toRun name="Agent 1" type="DotNetProject" />
<toRun name="Server" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

29
.run/Web.run.xml Normal file
View File

@@ -0,0 +1,29 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="WEB_KEY" value="G9WXPDGCGHJD9W9XBPMNYWN6YTK7NKRWHT29P2XKNDCBWKHWXP2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs>
<option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net9.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

1
.workdir/Controller/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@


View File

@@ -0,0 +1,2 @@
F<EFBFBD>bq_<EFBFBD>,K<>
<EFBFBD>AU<EFBFBD>|WU<1A>un<75>Ɯ=<3D>

Binary file not shown.

View File

@@ -0,0 +1,2 @@
q<EFBFBD><EFBFBD>h4<EFBFBD><EFBFBD>H<EFBFBD><18>7<EFBFBD><37><EFBFBD><EFBFBD>H`<60><>W
<EFBFBD>4u`G

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
<EFBFBD>Z<EFBFBD>t<>MPI<49>GMZ<4D><5A><EFBFBD><EFBFBD>kN<6B>VF1X<><58>p

View File

@@ -5,4 +5,4 @@ if [%1]==[] (
exit
)
dotnet ef migrations add %~1 --project Server/Phantom.Server.Database.Postgres
dotnet ef migrations add %~1 --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres

View File

@@ -3,4 +3,4 @@ if [ -z "$1" ]; then
exit 1
fi
dotnet ef migrations add "$1" --project Server/Phantom.Server.Database.Postgres
dotnet ef migrations add "$1" --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres

View File

@@ -0,0 +1,289 @@
using System.Collections.Immutable;
using NUnit.Framework;
using Phantom.Agent.Minecraft.Java;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Tests.Java;
[TestFixture]
public sealed class JavaPropertiesStreamTests {
public sealed class Reader {
private static async Task<ImmutableArray<KeyValuePair<string, string>>> Parse(string contents) {
using var stream = new MemoryStream(JavaPropertiesStream.Encoding.GetBytes(contents));
using var properties = new JavaPropertiesStream.Reader(stream);
return await properties.ReadProperties(CancellationToken.None).ToImmutableArrayAsync();
}
private static ImmutableArray<KeyValuePair<string, string>> KeyValue(string key, string value) {
return [new KeyValuePair<string, string>(key, value)];
}
[TestCase("")]
[TestCase("\n")]
public async Task EmptyLinesAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("# Comment")]
[TestCase("! Comment")]
[TestCase("# Comment\n! Comment")]
public async Task CommentsAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("key=value")]
[TestCase("key= value")]
[TestCase("key =value")]
[TestCase("key = value")]
[TestCase("key:value")]
[TestCase("key: value")]
[TestCase("key :value")]
[TestCase("key : value")]
[TestCase("key value")]
[TestCase("key\tvalue")]
[TestCase("key\fvalue")]
[TestCase("key \t\fvalue")]
public async Task SimpleKeyValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "value")));
}
[TestCase("key")]
[TestCase(" key")]
[TestCase(" key ")]
[TestCase("key=")]
[TestCase("key:")]
public async Task KeyWithoutValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "")));
}
[TestCase(@"\#key=value", "#key")]
[TestCase(@"\!key=value", "!key")]
public async Task KeyBeginsWithEscapedComment(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"\=key=value", "=key")]
[TestCase(@"\:key=value", ":key")]
[TestCase(@"\ key=value", " key")]
[TestCase("\\\tkey=value", "\tkey")]
[TestCase("\\\fkey=value", "\fkey")]
public async Task KeyBeginsWithEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"start\=end=value", "start=end")]
[TestCase(@"start\:end:value", "start:end")]
[TestCase(@"start\ end value", "start end")]
[TestCase(@"start\ \:\=end = value", "start :=end")]
[TestCase("start\\ \\\t\\\fend = value", "start \t\fend")]
public async Task KeyContainsEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"key = \ value", " value")]
[TestCase("key = \\\tvalue", "\tvalue")]
[TestCase("key = \\\fvalue", "\fvalue")]
[TestCase("key=\\ \\\t\\\fvalue", " \t\fvalue")]
public async Task ValueBeginsWithEscapedWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase(@"key = value\", "value")]
public async Task ValueEndsWithTrailingBackslash(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=\\\0", "\0")]
[TestCase(@"key=\\", "\\")]
[TestCase(@"key=\t", "\t")]
[TestCase(@"key=\n", "\n")]
[TestCase(@"key=\r", "\r")]
[TestCase(@"key=\f", "\f")]
[TestCase(@"key=\u3053\u3093\u306b\u3061\u306f", "こんにちは")]
[TestCase(@"key=\u3053\u3093\u306B\u3061\u306F", "こんにちは")]
[TestCase("key=\\\0\\\\\\t\\n\\r\\f\\u3053", "\0\\\t\n\r\fこ")]
public async Task ValueContainsEscapedSpecialCharacters(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\nsecond", "first\nsecond")]
[TestCase("key=first\\\n second", "first\nsecond")]
[TestCase("key=first\\\n#second", "first\n#second")]
[TestCase("key=first\\\n!second", "first\n!second")]
public async Task ValueContainsNewLine(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\n \\ second", "first\n second")]
[TestCase("key=first\\\n \\\tsecond", "first\n\tsecond")]
[TestCase("key=first\\\n \\\fsecond", "first\n\fsecond")]
[TestCase("key=first\\\n \t\f\\ second", "first\n second")]
public async Task ValueContainsNewLineWithEscapedLeadingWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[Test]
public async Task ExampleFile() {
// From Wikipedia: https://en.wikipedia.org/wiki/.properties
const string ExampleFile = """
# You are reading a comment in ".properties" file.
! The exclamation mark ('!') can also be used for comments.
# Comments are ignored.
# Blank lines are also ignored.
# Lines with "properties" contain a key and a value separated by a delimiting character.
# There are 3 delimiting characters: equal ('='), colon (':') and whitespace (' ', '\t' and '\f').
website = https://en.wikipedia.org/
language : English
topic .properties files
# A word on a line will just create a key with no value.
empty
# Whitespace that appears between the key, the delimiter and the value is ignored.
# This means that the following are equivalent (other than for readability).
hello=hello
hello = hello
# To start the value with whitespace, escape it with a backslash ('\').
whitespaceStart = \ <-This space is not ignored.
# Keys with the same name will be overwritten by the key that is the furthest in a file.
# For example the final value for "duplicateKey" will be "second".
duplicateKey = first
duplicateKey = second
# To use the delimiter characters inside a key, you need to escape them with a ('\').
# However, there is no need to do this in the value.
delimiterCharacters\:\=\ = This is the value for the key "delimiterCharacters\:\=\ "
# Adding a backslash ('\') at the end of a line means that the value continues on the next line.
multiline = This line \
continues
# If you want your value to include a backslash ('\'), it should be escaped by another backslash ('\').
path = c:\\wiki\\templates
# This means that if the number of backslashes ('\') at the end of the line is even, the next line is not included in the value.
# In the following example, the value for "evenKey" is "This is on one line\".
evenKey = This is on one line\\
# This line is a normal comment and is not included in the value for "evenKey".
# If the number of backslash ('\') is odd, then the next line is included in the value.
# In the following example, the value for "oddKey" is "This is line one and\# This is line two".
oddKey = This is line one and\\\
# This is line two
# Whitespace characters at the beginning of a line is removed.
# Make sure to add the spaces you need before the backslash ('\') on the first line.
# If you add them at the beginning of the next line, they will be removed.
# In the following example, the value for "welcome" is "Welcome to Wikipedia!".
welcome = Welcome to \
Wikipedia!
# If you need to add newlines and carriage returns, they need to be escaped using ('\n') and ('\r') respectively.
# You can also optionally escape tabs with ('\t') for readability purposes.
valueWithEscapes = This is a newline\n and a carriage return\r and a tab\t.
# You can also use Unicode escape characters (maximum of four hexadecimal digits).
# In the following example, the value for "encodedHelloInJapanese" is "こんにちは".
encodedHelloInJapanese = \u3053\u3093\u306b\u3061\u306f
""";
ImmutableArray<KeyValuePair<string, string>> result = [
new ("website", "https://en.wikipedia.org/"),
new ("language", "English"),
new ("topic", ".properties files"),
new ("empty", ""),
new ("hello", "hello"),
new ("hello", "hello"),
new ("whitespaceStart", @" <-This space is not ignored."),
new ("duplicateKey", "first"),
new ("duplicateKey", "second"),
new ("delimiterCharacters:= ", @"This is the value for the key ""delimiterCharacters:= """),
new ("multiline", "This line \ncontinues"),
new ("path", @"c:\wiki\templates"),
new ("evenKey", @"This is on one line\"),
new ("oddKey", "This is line one and\\\n# This is line two"),
new ("welcome", "Welcome to \nWikipedia!"),
new ("valueWithEscapes", "This is a newline\n and a carriage return\r and a tab\t."),
new ("encodedHelloInJapanese", "こんにちは"),
];
Assert.That(await Parse(ExampleFile), Is.EquivalentTo(result));
}
}
public sealed class Writer {
private static async Task<string> Write(Func<JavaPropertiesStream.Writer, Task> write) {
using var stream = new MemoryStream();
await using (var writer = new JavaPropertiesStream.Writer(stream)) {
await write(writer);
}
return JavaPropertiesStream.Encoding.GetString(stream.ToArray());
}
[TestCase("one line comment", "# one line comment\n")]
[TestCase("こんにちは", "# \\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("first line\nsecond line\r\nthird line", "# first line\n# second line\n# third line\n")]
public async Task Comment(string comment, string contents) {
Assert.That(await Write(writer => writer.WriteComment(comment, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "value", "key=value\n")]
[TestCase("key", "", "key=\n")]
[TestCase("", "value", "=value\n")]
public async Task SimpleKeyValue(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("#key", "value", "\\#key=value\n")]
[TestCase("!key", "value", "\\!key=value\n")]
public async Task KeyBeginsWithEscapedComment(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("=key", "value", "\\=key=value\n")]
[TestCase(":key", "value", "\\:key=value\n")]
[TestCase(" key", "value", "\\ key=value\n")]
[TestCase("\tkey", "value", "\\tkey=value\n")]
[TestCase("\fkey", "value", "\\fkey=value\n")]
public async Task KeyBeginsWithEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("start=end", "value", "start\\=end=value\n")]
[TestCase("start:end", "value", "start\\:end=value\n")]
[TestCase("start end", "value", "start\\ end=value\n")]
[TestCase("start :=end", "value", "start\\ \\:\\=end=value\n")]
[TestCase("start \t\fend", "value", "start\\ \\t\\fend=value\n")]
public async Task KeyContainsEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("\\", "value", "\\\\=value\n")]
[TestCase("\t", "value", "\\t=value\n")]
[TestCase("\n", "value", "\\n=value\n")]
[TestCase("\r", "value", "\\r=value\n")]
[TestCase("\f", "value", "\\f=value\n")]
[TestCase("こんにちは", "value", "\\u3053\\u3093\\u306B\\u3061\\u306F=value\n")]
[TestCase("\\\t\n\r\fこ", "value", "\\\\\\t\\n\\r\\f\\u3053=value\n")]
[TestCase("first-line\nsecond-line\r\nthird-line", "value", "first-line\\nsecond-line\\r\\nthird-line=value\n")]
public async Task KeyContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "\\", "key=\\\\\n")]
[TestCase("key", "\t", "key=\\t\n")]
[TestCase("key", "\n", "key=\\n\n")]
[TestCase("key", "\r", "key=\\r\n")]
[TestCase("key", "\f", "key=\\f\n")]
[TestCase("key", "こんにちは", "key=\\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("key", "\\\t\n\r\fこ", "key=\\\\\\t\\n\\r\\f\\u3053\n")]
[TestCase("key", "first line\nsecond line\r\nthird line", "key=first line\\nsecond line\\r\\nthird line\n")]
public async Task ValueContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[Test]
public async Task ExampleFile() {
string contents = await Write(static async writer => {
await writer.WriteComment("Comment", CancellationToken.None);
await writer.WriteProperty("key", "value", CancellationToken.None);
await writer.WriteProperty("multiline", "first line\nsecond line", CancellationToken.None);
});
Assert.That(contents, Is.EqualTo("# Comment\nkey=value\nmultiline=first line\\nsecond line\n"));
}
}
}

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -15,11 +14,10 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
namespace Phantom.Agent.Minecraft.Command;
namespace Phantom.Agent.Minecraft.Command;
public static class MinecraftCommand {
public const string SaveOn = "save-on";
@@ -8,7 +8,7 @@ public static class MinecraftCommand {
public static string Say(string message) {
return "say " + message;
}
public static string SaveAll(bool flush) {
return flush ? "save-all flush" : "save-all";
}

View File

@@ -1,31 +1,32 @@
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
using Phantom.Utils.Processes;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Minecraft.Instance;
namespace Phantom.Agent.Minecraft.Instance;
public sealed class InstanceProcess : IDisposable {
public InstanceProperties InstanceProperties { get; }
public CancellableSemaphore BackupSemaphore { get; } = new (1, 1);
private readonly RingBuffer<string> outputBuffer = new (10000);
private readonly RingBuffer<string> outputBuffer = new (100);
private event EventHandler<string>? OutputEvent;
public event EventHandler? Ended;
public bool HasEnded { get; private set; }
private readonly Process process;
private readonly TaskCompletionSource processExited = AsyncTasks.CreateCompletionSource();
internal InstanceProcess(InstanceProperties instanceProperties, Process process) {
this.InstanceProperties = instanceProperties;
this.process = process;
this.process.Exited += ProcessOnExited;
this.process.OutputReceived += ProcessOutputReceived;
}
public async Task SendCommand(string command, CancellationToken cancellationToken) {
await process.StandardInput.WriteLineAsync(command.AsMemory(), cancellationToken);
}
public void AddOutputListener(EventHandler<string> listener, uint maxLinesToReadFromHistory = uint.MaxValue) {
OutputEvent += listener;
@@ -33,35 +34,33 @@ public sealed class InstanceProcess : IDisposable {
listener(this, line);
}
}
public void RemoveOutputListener(EventHandler<string> listener) {
OutputEvent -= listener;
}
private void ProcessOutputReceived(object? sender, Process.Output output) {
outputBuffer.Add(output.Line);
OutputEvent?.Invoke(this, output.Line);
}
private void ProcessOnExited(object? sender, EventArgs e) {
OutputEvent = null;
HasEnded = true;
Ended?.Invoke(this, EventArgs.Empty);
processExited.SetResult();
}
public void Kill() {
process.Kill(true);
}
public async Task WaitForExit(CancellationToken cancellationToken) {
if (!HasEnded) {
await process.WaitForExitAsync(cancellationToken);
}
public async Task WaitForExit(TimeSpan timeout) {
await processExited.Task.WaitAsync(timeout);
}
public void Dispose() {
process.Dispose();
BackupSemaphore.Dispose();
OutputEvent = null;
Ended = null;
}

View File

@@ -0,0 +1,52 @@
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor {
private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) {
overriddenProperties[key] = value;
}
public async Task EditOrCreate(string filePath, string comment, CancellationToken cancellationToken) {
if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp";
await Edit(filePath, tmpFilePath, comment, cancellationToken);
File.Move(tmpFilePath, filePath, overwrite: true);
}
else {
await Create(filePath, comment, cancellationToken);
}
}
private async Task Create(string targetFilePath, string comment, CancellationToken cancellationToken) {
await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await targetWriter.WriteComment(comment, cancellationToken);
foreach ((string key, string value) in overriddenProperties) {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
private async Task Edit(string sourceFilePath, string targetFilePath, string comment, CancellationToken cancellationToken) {
using var sourceReader = new JavaPropertiesStream.Reader(sourceFilePath);
await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await targetWriter.WriteComment(comment, cancellationToken);
var remainingOverriddenPropertyKeys = new HashSet<string>(overriddenProperties.Keys);
await foreach ((string key, string value) in sourceReader.ReadProperties(cancellationToken)) {
if (remainingOverriddenPropertyKeys.Remove(key)) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
}
else {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
foreach (string key in remainingOverriddenPropertyKeys) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
}
}
}

View File

@@ -0,0 +1,284 @@
using System.Buffers;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Java;
static class JavaPropertiesStream {
internal static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private static FileStreamOptions CreateFileStreamOptions(FileMode mode, FileAccess access) {
return new FileStreamOptions {
Mode = mode,
Access = access,
Share = FileShare.Read,
Options = FileOptions.SequentialScan,
};
}
internal sealed class Reader : IDisposable {
private static readonly SearchValues<char> LineStartWhitespace = SearchValues.Create(' ', '\t', '\f');
private static readonly SearchValues<char> KeyValueDelimiter = SearchValues.Create('=', ':', ' ', '\t', '\f');
private static readonly SearchValues<char> Backslash = SearchValues.Create('\\');
private readonly StreamReader reader;
public Reader(Stream stream) {
this.reader = new StreamReader(stream, Encoding, leaveOpen: false);
}
public Reader(string path) {
this.reader = new StreamReader(path, Encoding, detectEncodingFromByteOrderMarks: false, CreateFileStreamOptions(FileMode.Open, FileAccess.Read));
}
public async IAsyncEnumerable<KeyValuePair<string, string>> ReadProperties([EnumeratorCancellation] CancellationToken cancellationToken) {
await foreach (string line in ReadLogicalLines(cancellationToken)) {
yield return ParseLine(line.AsSpan());
}
}
private async IAsyncEnumerable<string> ReadLogicalLines([EnumeratorCancellation] CancellationToken cancellationToken) {
StringBuilder nextLogicalLine = new StringBuilder();
while (await reader.ReadLineAsync(cancellationToken) is {} line) {
var span = line.AsSpan();
int startIndex = span.IndexOfAnyExcept(LineStartWhitespace);
if (startIndex == -1) {
continue;
}
if (nextLogicalLine.Length == 0 && (span[0] == '#' || span[0] == '!')) {
continue;
}
span = span[startIndex..];
if (IsEndEscaped(span)) {
nextLogicalLine.Append(span[..^1]);
nextLogicalLine.Append('\n');
}
else {
nextLogicalLine.Append(span);
yield return nextLogicalLine.ToString();
nextLogicalLine.Clear();
}
}
if (nextLogicalLine.Length > 0) {
yield return nextLogicalLine.ToString(startIndex: 0, nextLogicalLine.Length - 1); // Remove trailing new line.
}
}
private static KeyValuePair<string, string> ParseLine(ReadOnlySpan<char> line) {
int delimiterIndex = -1;
foreach (int candidateIndex in line.IndicesOf(KeyValueDelimiter)) {
if (candidateIndex == 0 || !IsEndEscaped(line[..candidateIndex])) {
delimiterIndex = candidateIndex;
break;
}
}
if (delimiterIndex == -1) {
return new KeyValuePair<string, string>(line.ToString(), string.Empty);
}
string key = ReadPropertyComponent(line[..delimiterIndex]);
line = line[(delimiterIndex + 1)..];
int valueStartIndex = line.IndexOfAnyExcept(KeyValueDelimiter);
string value = valueStartIndex == -1 ? string.Empty : ReadPropertyComponent(line[valueStartIndex..]);
return new KeyValuePair<string, string>(key, value);
}
private static string ReadPropertyComponent(ReadOnlySpan<char> component) {
StringBuilder builder = new StringBuilder();
int nextStartIndex = 0;
foreach (int backslashIndex in component.IndicesOf(Backslash)) {
if (backslashIndex == component.Length - 1) {
break;
}
if (backslashIndex < nextStartIndex) {
continue;
}
builder.Append(component[nextStartIndex..backslashIndex]);
int escapedIndex = backslashIndex + 1;
int escapedLength = 1;
char c = component[escapedIndex];
switch (c) {
case 't':
builder.Append('\t');
break;
case 'n':
builder.Append('\n');
break;
case 'r':
builder.Append('\r');
break;
case 'f':
builder.Append('\f');
break;
case 'u':
escapedLength += 4;
int hexRangeStart = escapedIndex + 1;
int hexRangeEnd = hexRangeStart + 4;
if (hexRangeEnd - 1 < component.Length) {
var hexString = component[hexRangeStart..hexRangeEnd];
int hexValue = int.Parse(hexString, NumberStyles.HexNumber);
builder.Append((char) hexValue);
}
else {
throw new FormatException("Malformed \\uxxxx encoding.");
}
break;
default:
builder.Append(c);
break;
}
nextStartIndex = escapedIndex + escapedLength;
}
builder.Append(component[nextStartIndex..]);
return builder.ToString();
}
private static bool IsEndEscaped(ReadOnlySpan<char> span) {
if (span.EndsWith('\\')) {
int trailingBackslashCount = span.Length - span.TrimEnd('\\').Length;
return trailingBackslashCount % 2 == 1;
}
else {
return false;
}
}
public void Dispose() {
reader.Dispose();
}
}
internal sealed class Writer : IAsyncDisposable {
private const string CommentStart = "# ";
private readonly StreamWriter writer;
private readonly Memory<char> oneCharBuffer = new char[1];
public Writer(Stream stream) {
this.writer = new StreamWriter(stream, Encoding, leaveOpen: false);
}
public Writer(string path) {
this.writer = new StreamWriter(path, Encoding, CreateFileStreamOptions(FileMode.Create, FileAccess.Write));
}
public async Task WriteComment(string comment, CancellationToken cancellationToken) {
await Write(CommentStart, cancellationToken);
for (int index = 0; index < comment.Length; index++) {
char c = comment[index];
switch (c) {
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\n':
case '\r':
await Write(c: '\n', cancellationToken);
await Write(CommentStart, cancellationToken);
if (index < comment.Length - 1 && comment[index + 1] == '\n') {
index++;
}
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
await Write(c: '\n', cancellationToken);
}
public async Task WriteProperty(string key, string value, CancellationToken cancellationToken) {
await WritePropertyComponent(key, escapeSpaces: true, cancellationToken);
await Write(c: '=', cancellationToken);
await WritePropertyComponent(value, escapeSpaces: false, cancellationToken);
await Write(c: '\n', cancellationToken);
}
private async Task WritePropertyComponent(string component, bool escapeSpaces, CancellationToken cancellationToken) {
for (int index = 0; index < component.Length; index++) {
char c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await Write(c: '\\', cancellationToken);
await Write(c, cancellationToken);
break;
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\t':
await Write("\\t", cancellationToken);
break;
case '\n':
await Write("\\n", cancellationToken);
break;
case '\r':
await Write("\\r", cancellationToken);
break;
case '\f':
await Write("\\f", cancellationToken);
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
}
private Task Write(char c, CancellationToken cancellationToken) {
oneCharBuffer.Span[0] = c;
return writer.WriteAsync(oneCharBuffer, cancellationToken);
}
private Task Write(string value, CancellationToken cancellationToken) {
return writer.WriteAsync(value.AsMemory(), cancellationToken);
}
public async ValueTask DisposeAsync() {
await writer.DisposeAsync();
}
}
}

View File

@@ -1,42 +1,47 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
using Phantom.Common.Data.Java;
using Phantom.Common.Logging;
using Phantom.Utils.Collections;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeDiscovery {
private static readonly ILogger Logger = PhantomLogger.Create(nameof(JavaRuntimeDiscovery));
public static string? GetSystemSearchPath() {
const string LinuxJavaPath = "/usr/lib/jvm";
if (OperatingSystem.IsLinux() && Directory.Exists(LinuxJavaPath)) {
return LinuxJavaPath;
}
return null;
}
public static IAsyncEnumerable<JavaRuntimeExecutable> Scan(string folderPath) {
return new JavaRuntimeDiscovery().ScanInternal(folderPath);
public static async Task<JavaRuntimeRepository> Scan(string folderPath, CancellationToken cancellationToken) {
var runtimes = await new JavaRuntimeDiscovery().ScanInternal(folderPath, cancellationToken).ToImmutableArrayAsync(cancellationToken);
return new JavaRuntimeRepository(runtimes);
}
private readonly Dictionary<string, int> duplicateDisplayNames = new ();
private async IAsyncEnumerable<JavaRuntimeExecutable> ScanInternal(string folderPath) {
private async IAsyncEnumerable<JavaRuntimeExecutable> ScanInternal(string folderPath, [EnumeratorCancellation] CancellationToken cancellationToken) {
Logger.Information("Starting Java runtime scan in: {FolderPath}", folderPath);
string javaExecutableName = OperatingSystem.IsWindows() ? "java.exe" : "java";
foreach (var binFolderPath in Directory.EnumerateDirectories(Paths.ExpandTilde(folderPath), "bin", new EnumerationOptions {
MatchType = MatchType.Simple,
RecurseSubdirectories = true,
ReturnSpecialDirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System,
}).Order()) {
cancellationToken.ThrowIfCancellationRequested();
var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName));
FileAttributes javaExecutableAttributes;
@@ -45,16 +50,16 @@ public sealed class JavaRuntimeDiscovery {
} catch (Exception) {
continue;
}
if (javaExecutableAttributes.HasFlag(FileAttributes.ReparsePoint)) {
continue;
}
Logger.Information("Found candidate Java executable: {JavaExecutablePath}", javaExecutablePath);
JavaRuntime? foundRuntime;
try {
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath);
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath, cancellationToken);
} catch (OperationCanceledException) {
Logger.Error("Java process did not exit in time.");
continue;
@@ -62,62 +67,58 @@ public sealed class JavaRuntimeDiscovery {
Logger.Error(e, "Caught exception while reading Java version information.");
continue;
}
if (foundRuntime == null) {
Logger.Error("Java executable did not output version information.");
continue;
}
Logger.Information("Found Java {DisplayName} at: {Path}", foundRuntime.DisplayName, javaExecutablePath);
yield return new JavaRuntimeExecutable(javaExecutablePath, foundRuntime);
}
}
private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath) {
private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath, CancellationToken cancellationToken) {
var startInfo = new ProcessStartInfo {
FileName = javaExecutablePath,
WorkingDirectory = Path.GetDirectoryName(javaExecutablePath),
Arguments = "-XshowSettings:properties -version",
RedirectStandardInput = false,
RedirectStandardOutput = true,
RedirectStandardOutput = false,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = false
};
var process = new Process { StartInfo = startInfo };
var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
process.Start();
JavaRuntimeBuilder runtimeBuilder = new ();
while (await process.StandardError.ReadLineAsync(cancellationTokenSource.Token) is {} line) {
ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder);
JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames);
if (runtime != null) {
return runtime;
}
using var timeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var combinedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken);
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
JavaRuntimeBuilder runtimeBuilder = new ();
while (await process.StandardError.ReadLineAsync(combinedCancellationTokenSource.Token) is {} line) {
ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder);
JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames);
if (runtime != null) {
return runtime;
}
await process.WaitForExitAsync(cancellationTokenSource.Token);
return null;
} finally {
process.Dispose();
cancellationTokenSource.Dispose();
}
await process.WaitForExitAsync(combinedCancellationTokenSource.Token);
return null;
}
private static void ExtractJavaVersionPropertiesFromLine(ReadOnlySpan<char> line, JavaRuntimeBuilder runtimeBuilder) {
line = line.TrimStart();
int separatorIndex = line.IndexOf('=');
if (separatorIndex == -1) {
return;
}
var propertyName = line[..separatorIndex].TrimEnd();
if (propertyName.Equals("java.specification.version", StringComparison.Ordinal)) {
runtimeBuilder.MainVersion = ExtractValue(line, separatorIndex);
@@ -129,16 +130,16 @@ public sealed class JavaRuntimeDiscovery {
runtimeBuilder.Vendor = ExtractValue(line, separatorIndex);
}
}
private static string ExtractValue(ReadOnlySpan<char> line, int separatorIndex) {
return line[(separatorIndex + 1)..].Trim().ToString();
}
private sealed class JavaRuntimeBuilder {
public string? MainVersion { get; set; } = null;
public string? FullVersion { get; set; } = null;
public string? Vendor { get; set; } = null;
public JavaRuntime? TryBuild(Dictionary<string, int> duplicateDisplayNames) {
if (MainVersion == null || FullVersion == null || Vendor == null) {
return null;

View File

@@ -1,13 +1,5 @@
using Phantom.Common.Data.Java;
namespace Phantom.Agent.Minecraft.Java;
namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeExecutable {
internal string ExecutablePath { get; }
internal JavaRuntime Runtime { get; }
internal JavaRuntimeExecutable(string executablePath, JavaRuntime runtime) {
this.ExecutablePath = executablePath;
this.Runtime = runtime;
}
}
sealed record JavaRuntimeExecutable(string ExecutablePath, JavaRuntime Runtime);

View File

@@ -6,43 +6,29 @@ using Phantom.Utils.Cryptography;
namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeRepository {
private readonly Dictionary<string, Guid> guidsByPath = new ();
private readonly Dictionary<Guid, JavaRuntimeExecutable> runtimesByGuid = new ();
private readonly ReaderWriterLockSlim rwLock = new (LockRecursionPolicy.NoRecursion);
private readonly ImmutableDictionary<Guid, JavaRuntimeExecutable> runtimesByGuid;
internal JavaRuntimeRepository(ImmutableArray<JavaRuntimeExecutable> runtimes) {
var runtimesByGuidBuilder = ImmutableDictionary.CreateBuilder<Guid, JavaRuntimeExecutable>();
foreach (JavaRuntimeExecutable runtime in runtimes) {
runtimesByGuidBuilder.Add(GenerateStableGuid(runtime.ExecutablePath), runtime);
}
runtimesByGuid = runtimesByGuidBuilder.ToImmutable();
}
public ImmutableArray<TaggedJavaRuntime> All {
get {
rwLock.EnterReadLock();
try {
return runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime)).OrderBy(static taggedRuntime => taggedRuntime.Runtime).ToImmutableArray();
} finally {
rwLock.ExitReadLock();
}
}
[SuppressMessage("ReSharper", "UseCollectionExpression")]
get => runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime))
.OrderBy(static taggedRuntime => taggedRuntime.Runtime)
.ToImmutableArray();
}
public void Include(JavaRuntimeExecutable runtime) {
rwLock.EnterWriteLock();
try {
if (!guidsByPath.TryGetValue(runtime.ExecutablePath, out var guid)) {
guidsByPath[runtime.ExecutablePath] = guid = GenerateStableGuid(runtime.ExecutablePath);
}
runtimesByGuid[guid] = runtime;
} finally {
rwLock.ExitWriteLock();
}
internal bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
return runtimesByGuid.TryGetValue(guid, out runtime);
}
public bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
rwLock.EnterReadLock();
try {
return runtimesByGuid.TryGetValue(guid, out runtime);
} finally {
rwLock.ExitReadLock();
}
}
private static Guid GenerateStableGuid(string executablePath) {
Random rand = new Random(StableHashCode.ForString(executablePath));
Span<byte> bytes = stackalloc byte[16];

View File

@@ -1,31 +1,25 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Collections.ObjectModel;
namespace Phantom.Agent.Minecraft.Java;
sealed class JvmArgumentBuilder {
private readonly JvmProperties basicProperties;
private readonly List<string> customArguments = new ();
public JvmArgumentBuilder(JvmProperties basicProperties, ImmutableArray<string> customArguments) {
this.basicProperties = basicProperties;
foreach (var jvmArgument in customArguments) {
this.customArguments.Add(jvmArgument);
}
sealed class JvmArgumentBuilder(JvmProperties basicProperties) {
private readonly List<string> customArguments = [];
public void Add(string argument) {
customArguments.Add(argument);
}
public void AddProperty(string key, string value) {
customArguments.Add("-D" + key + "=\"" + value + "\""); // TODO test quoting?
}
public void Build(Collection<string> target) {
foreach (var property in customArguments) {
target.Add(property);
}
// In case of duplicate JVM arguments, typically the last one wins.
target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M");
target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M");
target.Add("-Xrs");
}
}

View File

@@ -1,37 +1,31 @@
using System.Text;
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Minecraft;
using Phantom.Utils.Runtime;
using Phantom.Utils.Processes;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
public abstract class BaseLauncher : IServerLauncher {
private readonly InstanceProperties instanceProperties;
protected string MinecraftVersion => instanceProperties.ServerVersion;
private protected BaseLauncher(InstanceProperties instanceProperties) {
this.instanceProperties = instanceProperties;
}
public async Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
if (!services.JavaRuntimeRepository.TryGetByGuid(instanceProperties.JavaRuntimeGuid, out var javaRuntimeExecutable)) {
return new LaunchResult.InvalidJavaRuntime();
}
if (JvmArgumentsHelper.Validate(instanceProperties.JvmArguments) != null) {
return new LaunchResult.InvalidJvmArguments();
}
var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.LaunchProperties.ServerDownloadInfo, MinecraftVersion, downloadProgressEventHandler, cancellationToken);
if (vanillaServerJarPath == null) {
return new LaunchResult.CouldNotDownloadMinecraftServer();
}
ServerJarInfo? serverJar;
try {
serverJar = await PrepareServerJar(logger, vanillaServerJarPath, cancellationToken);
@@ -41,15 +35,15 @@ public abstract class BaseLauncher : IServerLauncher {
logger.Error(e, "Caught exception while preparing the server jar.");
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
if (!File.Exists(serverJar.FilePath)) {
logger.Error("Missing prepared server or launcher jar: {FilePath}", serverJar.FilePath);
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
await UpdateServerProperties(instanceProperties, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
@@ -59,26 +53,18 @@ public abstract class BaseLauncher : IServerLauncher {
FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder,
RedirectInput = true,
UseShellExecute = false
UseShellExecute = false,
};
var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties, instanceProperties.JvmArguments);
CustomizeJvmArguments(jvmArguments);
var processArguments = processConfigurator.ArgumentList;
jvmArguments.Build(processArguments);
foreach (var extraArgument in serverJar.ExtraArgs) {
processArguments.Add(extraArgument);
}
PrepareJvmArguments(serverJar).Build(processArguments);
processArguments.Add("-jar");
processArguments.Add(serverJar.FilePath);
processArguments.Add("nogui");
var process = processConfigurator.CreateProcess();
var instanceProcess = new InstanceProcess(instanceProperties, process);
try {
process.Start();
} catch (Exception launchException) {
@@ -89,40 +75,42 @@ public abstract class BaseLauncher : IServerLauncher {
} catch (Exception killException) {
logger.Error(killException, "Caught exception trying to kill the server process after a failed launch.");
}
return new LaunchResult.CouldNotStartMinecraftServer();
}
return new LaunchResult.Success(instanceProcess);
}
private JvmArgumentBuilder PrepareJvmArguments(ServerJarInfo serverJar) {
var builder = new JvmArgumentBuilder(instanceProperties.JvmProperties);
foreach (string argument in instanceProperties.JvmArguments) {
builder.Add(argument);
}
foreach (var argument in serverJar.ExtraArgs) {
builder.Add(argument);
}
CustomizeJvmArguments(builder);
return builder;
}
private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
return Task.FromResult(new ServerJarInfo(serverJarPath));
}
private static async Task AcceptEula(InstanceProperties instanceProperties) {
var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt");
await File.WriteAllLinesAsync(eulaFilePath, new [] { "# EULA", "eula=true" }, Encoding.UTF8);
await File.WriteAllLinesAsync(eulaFilePath, ["# EULA", "eula=true"], Encoding.UTF8);
}
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {
var serverPropertiesFilePath = Path.Combine(instanceProperties.InstanceFolder, "server.properties");
var serverPropertiesData = new JavaProperties();
await using var fileStream = new FileStream(serverPropertiesFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
try {
serverPropertiesData.Load(fileStream);
} catch (ParseException e) {
throw new Exception("Could not parse server.properties file: " + serverPropertiesFilePath, e);
}
instanceProperties.ServerProperties.SetTo(serverPropertiesData);
fileStream.Seek(0L, SeekOrigin.Begin);
fileStream.SetLength(0L);
serverPropertiesData.Store(fileStream, true);
private static async Task UpdateServerProperties(InstanceProperties instanceProperties, CancellationToken cancellationToken) {
var serverPropertiesEditor = new JavaPropertiesFileEditor();
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor);
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"), comment: "server.properties", cancellationToken);
}
}

View File

@@ -4,13 +4,11 @@ namespace Phantom.Agent.Minecraft.Launcher;
public abstract record LaunchResult {
private LaunchResult() {}
public sealed record Success(InstanceProcess Process) : LaunchResult;
public sealed record InvalidJavaRuntime : LaunchResult;
public sealed record InvalidJvmArguments : LaunchResult;
public sealed record CouldNotDownloadMinecraftServer : LaunchResult;
public sealed record CouldNotPrepareMinecraftServerLauncher : LaunchResult;

View File

@@ -1,6 +1,6 @@
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
namespace Phantom.Agent.Minecraft.Launcher;
namespace Phantom.Agent.Minecraft.Launcher;
public sealed record LaunchServices(MinecraftServerExecutables ServerExecutables, JavaRuntimeRepository JavaRuntimeRepository);

View File

@@ -1,9 +1,8 @@
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class FabricLauncher : BaseLauncher {
public FabricLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
@@ -19,10 +18,10 @@ public sealed class FabricLauncher : BaseLauncher {
if (!File.Exists(launcherJarPath)) {
await DownloadLauncher(logger, launcherJarPath, cancellationToken);
}
return new ServerJarInfo(launcherJarPath, ImmutableArray.Create("-Dfabric.installer.server.gameJar=" + Paths.NormalizeSlashes(serverJarPath)));
return new ServerJarInfo(launcherJarPath, ["-Dfabric.installer.server.gameJar=" + Paths.NormalizeSlashes(serverJarPath)]);
}
private async Task DownloadLauncher(ILogger logger, string targetFilePath, CancellationToken cancellationToken) {
// TODO customizable loader version, probably with a dedicated temporary folder
string installerUrl = $"https://meta.fabricmc.net/v2/versions/loader/{MinecraftVersion}/stable/stable/server/jar";
@@ -42,7 +41,7 @@ public sealed class FabricLauncher : BaseLauncher {
throw;
}
}
private static void TryDeleteLauncherAfterFailure(ILogger logger, string filePath) {
if (File.Exists(filePath)) {
try {

View File

@@ -1,7 +1,7 @@
using Phantom.Agent.Minecraft.Server;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class InvalidLauncher : IServerLauncher {
public static InvalidLauncher Instance { get; } = new ();

View File

@@ -1,6 +1,6 @@
using Phantom.Agent.Minecraft.Instance;
namespace Phantom.Agent.Minecraft.Launcher.Types;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class VanillaLauncher : BaseLauncher {
public VanillaLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}

View File

@@ -1,23 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Kajabity.Tools.Java" />
<InternalsVisibleTo Include="Phantom.Agent.Minecraft.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Logging\Phantom.Utils.Logging.csproj" />
</ItemGroup>
</Project>

View File

@@ -14,8 +14,9 @@ static class MinecraftServerProperties {
protected override ushort Read(string value) => ushort.Parse(value);
protected override string Write(ushort value) => value.ToString();
}
public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
public static readonly MinecraftServerProperty<bool> SyncChunkWrites = new Boolean("sync-chunk-writes");
}

View File

@@ -1,18 +1,18 @@
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Java;
namespace Phantom.Agent.Minecraft.Properties;
namespace Phantom.Agent.Minecraft.Properties;
abstract class MinecraftServerProperty<T> {
private readonly string key;
protected MinecraftServerProperty(string key) {
this.key = key;
}
protected abstract T Read(string value);
protected abstract string Write(T value);
public void Set(JavaProperties properties, T value) {
properties.SetProperty(key, Write(value));
public void Set(JavaPropertiesFileEditor properties, T value) {
properties.Set(key, Write(value));
}
}

View File

@@ -1,15 +1,17 @@
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Java;
namespace Phantom.Agent.Minecraft.Properties;
public sealed record ServerProperties(
ushort ServerPort,
ushort RconPort,
bool EnableRcon = true
bool EnableRcon = true,
bool SyncChunkWrites = false
) {
internal void SetTo(JavaProperties properties) {
internal void SetTo(JavaPropertiesFileEditor properties) {
MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
MinecraftServerProperties.RconPort.Set(properties, RconPort);
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
MinecraftServerProperties.SyncChunkWrites.Set(properties, SyncChunkWrites);
}
}

View File

@@ -1,4 +1,4 @@
namespace Phantom.Agent.Minecraft.Server;
namespace Phantom.Agent.Minecraft.Server;
public sealed class DownloadProgressEventArgs : EventArgs {
public ulong DownloadedBytes { get; }

View File

@@ -1,3 +1,3 @@
namespace Phantom.Agent.Minecraft.Server;
namespace Phantom.Agent.Minecraft.Server;
sealed record MinecraftServerExecutableDownloadListener(EventHandler<DownloadProgressEventArgs> DownloadProgressEventHandler, CancellationToken CancellationToken);

View File

@@ -1,8 +1,8 @@
using System.Security.Cryptography;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Runtime;
using Serilog;
@@ -10,81 +10,105 @@ namespace Phantom.Agent.Minecraft.Server;
sealed class MinecraftServerExecutableDownloader {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>();
public Task<string?> Task { get; }
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
public event EventHandler? Completed;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0;
private readonly List<CancellationTokenRegistration> listenerCancellationRegistrations = [];
private int listenerCount = 0;
public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) {
Register(listener);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath, new DownloadProgressCallback(this), cancellationTokenSource.Token);
Task.ContinueWith(OnCompleted, TaskScheduler.Default);
}
public void Register(MinecraftServerExecutableDownloadListener listener) {
++listeners;
Logger.Debug("Registered download listener, current listener count: {Listeners}", listeners);
int newListenerCount;
DownloadProgress += listener.DownloadProgressEventHandler;
listener.CancellationToken.Register(Unregister, listener);
lock (this) {
newListenerCount = ++listenerCount;
DownloadProgress += listener.DownloadProgressEventHandler;
listenerCancellationRegistrations.Add(listener.CancellationToken.Register(Unregister, listener));
}
Logger.Debug("Registered download listener, current listener count: {Listeners}", newListenerCount);
}
private void Unregister(object? listenerObject) {
MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler;
if (--listeners <= 0) {
int newListenerCount;
lock (this) {
MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler;
newListenerCount = --listenerCount;
if (newListenerCount <= 0) {
cancellationTokenSource.Cancel();
}
}
if (newListenerCount <= 0) {
Logger.Debug("Unregistered last download listener, cancelling download.");
cancellationTokenSource.Cancel();
}
else {
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", listeners);
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", newListenerCount);
}
}
private void ReportDownloadProgress(DownloadProgressEventArgs args) {
DownloadProgress?.Invoke(this, args);
}
private void OnCompleted(Task task) {
Logger.Debug("Download task completed.");
Completed?.Invoke(this, EventArgs.Empty);
Completed = null;
DownloadProgress = null;
lock (this) {
Completed?.Invoke(this, EventArgs.Empty);
Completed = null;
DownloadProgress = null;
foreach (var registration in listenerCancellationRegistrations) {
registration.Dispose();
}
listenerCancellationRegistrations.Clear();
cancellationTokenSource.Dispose();
}
}
private sealed class DownloadProgressCallback {
private readonly MinecraftServerExecutableDownloader downloader;
public DownloadProgressCallback(MinecraftServerExecutableDownloader downloader) {
this.downloader = downloader;
}
public void ReportProgress(ulong downloadedBytes, ulong totalBytes) {
downloader.ReportDownloadProgress(new DownloadProgressEventArgs(downloadedBytes, totalBytes));
}
}
private async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath) {
private static async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token;
try {
Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1));
try {
using var http = new HttpClient();
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), fileDownloadInfo, tmpFilePath, cancellationToken);
await FetchServerExecutableFile(http, progressCallback, fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath);
throw;
}
File.Move(tmpFilePath, filePath, true);
File.Move(tmpFilePath, filePath, overwrite: true);
Logger.Information("Server version {Version} downloaded.", minecraftVersion);
return filePath;
} catch (OperationCanceledException) {
Logger.Information("Download for server version {Version} was cancelled.", minecraftVersion);
@@ -94,21 +118,19 @@ sealed class MinecraftServerExecutableDownloader {
} catch (Exception e) {
Logger.Error(e, "An unexpected error occurred.");
return null;
} finally {
cancellationTokenSource.Dispose();
}
}
private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, FileDownloadInfo fileDownloadInfo, string filePath, CancellationToken cancellationToken) {
Sha1String downloadedFileHash;
try {
var response = await http.GetAsync(fileDownloadInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var streamCopier = new MinecraftServerDownloadStreamCopier(progressCallback, fileDownloadInfo.Size.Bytes);
downloadedFileHash = await streamCopier.Copy(responseStream, fileStream, cancellationToken);
} catch (OperationCanceledException) {
@@ -117,13 +139,13 @@ sealed class MinecraftServerExecutableDownloader {
Logger.Error(e, "Unable to download server executable.");
throw StopProcedureException.Instance;
}
if (!downloadedFileHash.Equals(fileDownloadInfo.Hash)) {
Logger.Error("Downloaded server executable has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", fileDownloadInfo.Hash, downloadedFileHash);
throw StopProcedureException.Instance;
}
}
private static void TryDeleteExecutableAfterFailure(string filePath) {
if (File.Exists(filePath)) {
try {
@@ -133,33 +155,33 @@ sealed class MinecraftServerExecutableDownloader {
}
}
}
private sealed class MinecraftServerDownloadStreamCopier : IDisposable {
private readonly StreamCopier streamCopier = new ();
private readonly IncrementalHash sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
private readonly DownloadProgressCallback progressCallback;
private readonly ulong totalBytes;
private ulong readBytes;
public MinecraftServerDownloadStreamCopier(DownloadProgressCallback progressCallback, ulong totalBytes) {
this.progressCallback = progressCallback;
this.totalBytes = totalBytes;
this.streamCopier.BufferReady += OnBufferReady;
}
private void OnBufferReady(object? sender, StreamCopier.BufferEventArgs args) {
sha1.AppendData(args.Buffer.Span);
readBytes += (uint) args.Buffer.Length;
progressCallback.ReportProgress(readBytes, totalBytes);
}
public async Task<Sha1String> Copy(Stream source, Stream destination, CancellationToken cancellationToken) {
await streamCopier.Copy(source, destination, cancellationToken);
return Sha1String.FromBytes(sha1.GetHashAndReset());
}
public void Dispose() {
sha1.Dispose();
streamCopier.Dispose();

View File

@@ -1,47 +1,47 @@
using System.Text.RegularExpressions;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
public sealed partial class MinecraftServerExecutables {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex VersionFolderSanitizeRegex();
private static partial Regex SanitizePathRegex();
private readonly string basePath;
private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new ();
public MinecraftServerExecutables(string basePath) {
this.basePath = basePath;
}
internal async Task<string?> DownloadAndGetPath(FileDownloadInfo? fileDownloadInfo, string minecraftVersion, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex().Replace(minecraftVersion, "_"));
string serverExecutableFolderPath = Path.Combine(basePath, SanitizePathRegex().IsMatch(minecraftVersion) ? SanitizePathRegex().Replace(minecraftVersion, "_") : minecraftVersion);
string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar");
if (File.Exists(serverExecutableFilePath)) {
return serverExecutableFilePath;
}
if (fileDownloadInfo == null) {
Logger.Error("Unable to download server executable for version {Version} because no download info was provided.", minecraftVersion);
return null;
}
try {
Directories.Create(serverExecutableFolderPath, Chmod.URWX_GRX);
} catch (Exception e) {
Logger.Error(e, "Unable to create folder for server executable: {ServerExecutableFolderPath}", serverExecutableFolderPath);
return null;
}
MinecraftServerExecutableDownloader? downloader;
MinecraftServerExecutableDownloadListener listener = new (progressEventHandler, cancellationToken);
lock (this) {
if (runningDownloadersByVersion.TryGetValue(minecraftVersion, out downloader)) {
Logger.Information("A download for server version {Version} is already running, waiting for it to finish...", minecraftVersion);
@@ -54,11 +54,11 @@ public sealed partial class MinecraftServerExecutables {
runningDownloadersByVersion.Remove(minecraftVersion);
}
};
runningDownloadersByVersion[minecraftVersion] = downloader;
}
}
return await downloader.Task.WaitAsync(cancellationToken);
}
}

View File

@@ -3,28 +3,12 @@ using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Phantom.Common.Logging;
using Serilog;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Server;
namespace Phantom.Agent.Minecraft.Server;
public sealed class ServerStatusProtocol {
private readonly ILogger logger;
public ServerStatusProtocol(string loggerName) {
this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
}
public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
try {
return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Caught exception while checking if players are online.");
return null;
}
}
private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
public static class ServerStatusProtocol {
public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream();
@@ -32,25 +16,23 @@ public sealed class ServerStatusProtocol {
// https://wiki.vg/Server_List_Ping
tcpStream.WriteByte(0xFE);
await tcpStream.FlushAsync(cancellationToken);
short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken);
short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
}
private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
try {
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
await tcpStream.ReadExactlyAsync(headerBuffer, offset: 0, count: 3, cancellationToken);
if (headerBuffer[0] != 0xFF) {
logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]);
return null;
throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]);
}
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
if (messageLength <= 0) {
logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength);
return null;
throw new ProtocolException("Unexpected message length in response from server: " + messageLength);
}
return messageLength;
@@ -58,36 +40,55 @@ public sealed class ServerStatusProtocol {
ArrayPool<byte>.Shared.Return(headerBuffer);
}
}
private async Task<int?> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
logger.Error("Could not find message separators in response from server.");
return null;
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer[(separator1 + 1)..(separator2 - 1)]);
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
return null;
}
logger.Debug("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
return onlinePlayerCount;
await tcpStream.ReadExactlyAsync(messageBuffer, offset: 0, messageLength, cancellationToken);
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(start: 0, messageLength));
} finally {
ArrayPool<byte>.Shared.Return(messageBuffer);
}
}
/// <summary>
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
/// </summary>
private static readonly byte[] Separator = [0x00, 0xA7];
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
int lastSeparator = messageBuffer.LastIndexOf(Separator);
int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
if (lastSeparator == -1 || middleSeparator == -1) {
throw new ProtocolException("Could not find message separators in response from server.");
}
var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
// Player counts are integers, whose maximum string length is 10 characters.
Span<char> integerStringBuffer = stackalloc char[10];
return new InstancePlayerCounts(
DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
);
}
private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
}
if (!int.TryParse(tempCharBuffer, out int playerCount)) {
throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
}
return playerCount;
}
public sealed class ProtocolException : Exception {
internal ProtocolException(string message) : base(message) {}
}
}

View File

@@ -1,40 +0,0 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Serilog;
namespace Phantom.Agent.Rpc;
sealed class KeepAliveLoop {
private static readonly ILogger Logger = PhantomLogger.Create<KeepAliveLoop>();
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(10);
private readonly RpcServerConnection connection;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public KeepAliveLoop(RpcServerConnection connection) {
this.connection = connection;
Task.Run(Run);
}
private async Task Run() {
var cancellationToken = cancellationTokenSource.Token;
Logger.Information("Started keep-alive loop.");
try {
while (true) {
await Task.Delay(KeepAliveInterval, cancellationToken);
await connection.Send(new AgentIsAliveMessage());
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
cancellationTokenSource.Dispose();
Logger.Information("Stopped keep-alive loop.");
}
}
public void Cancel() {
cancellationTokenSource.Cancel();
}
}

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,107 +0,0 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Runtime;
using Serilog;
using Serilog.Events;
namespace Phantom.Agent.Rpc;
public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
var socket = new ClientSocket();
var options = socket.Options;
options.CurveServerCertificate = config.ServerCertificate;
options.CurveCertificate = new NetMQCertificate();
options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray();
await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch();
}
private readonly RpcConfiguration config;
private readonly Guid agentGuid;
private readonly Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory;
private readonly SemaphoreSlim disconnectSemaphore;
private readonly CancellationToken receiveCancellationToken;
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
this.config = config;
this.agentGuid = agentGuid;
this.messageListenerFactory = messageListenerFactory;
this.disconnectSemaphore = disconnectSemaphore;
this.receiveCancellationToken = receiveCancellationToken;
}
protected override void Connect(ClientSocket socket) {
var logger = config.RuntimeLogger;
var url = config.TcpUrl;
logger.Information("Starting ZeroMQ client and connecting to {Url}...", url);
socket.Connect(url);
logger.Information("ZeroMQ client ready.");
}
protected override void Run(ClientSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) {
var connection = new RpcServerConnection(socket, replyTracker);
ServerMessaging.SetCurrentConnection(connection);
var logger = config.RuntimeLogger;
var handler = new MessageToAgentHandler(messageListenerFactory(connection), logger, taskManager, receiveCancellationToken);
var keepAliveLoop = new KeepAliveLoop(connection);
try {
while (!receiveCancellationToken.IsCancellationRequested) {
var data = socket.Receive(receiveCancellationToken);
LogMessageType(logger, data);
if (data.Length > 0) {
MessageRegistries.ToAgent.Handle(data, handler);
}
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
logger.Debug("ZeroMQ client stopped receiving messages.");
disconnectSemaphore.Wait(CancellationToken.None);
keepAliveLoop.Cancel();
}
}
private static void LogMessageType(ILogger logger, ReadOnlyMemory<byte> data) {
if (!logger.IsEnabled(LogEventLevel.Verbose)) {
return;
}
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length);
}
else {
logger.Verbose("Received {Bytes} B message from server.", data.Length);
}
}
protected override async Task Disconnect() {
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
if (finishedTask == unregisterTimeoutTask) {
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the server.");
}
}
private sealed class MessageToAgentHandler : MessageHandler<IMessageToAgentListener> {
public MessageToAgentHandler(IMessageToAgentListener listener, ILogger logger, TaskManager taskManager, CancellationToken cancellationToken) : base(listener, logger, taskManager, cancellationToken) {}
protected override Task SendReply(uint sequenceId, byte[] serializedReply) {
return ServerMessaging.Send(new ReplyMessage(sequenceId, serializedReply));
}
}
}

View File

@@ -1,41 +0,0 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Rpc;
public sealed class RpcServerConnection {
private readonly ClientSocket socket;
private readonly MessageReplyTracker replyTracker;
internal RpcServerConnection(ClientSocket socket, MessageReplyTracker replyTracker) {
this.socket = socket;
this.replyTracker = replyTracker;
}
internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
var bytes = MessageRegistries.ToServer.Write(message).ToArray();
if (bytes.Length > 0) {
await socket.SendAsync(bytes);
}
}
internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
var sequenceId = replyTracker.RegisterReply();
var bytes = MessageRegistries.ToServer.Write<TMessage, TReply>(sequenceId, message).ToArray();
if (bytes.Length == 0) {
replyTracker.ForgetReply(sequenceId);
return null;
}
await socket.SendAsync(bytes);
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken);
}
public void Receive(ReplyMessage message) {
replyTracker.ReceiveReply(message.SequenceId, message.SerializedReply);
}
}

View File

@@ -1,34 +0,0 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages;
using Serilog;
namespace Phantom.Agent.Rpc;
public static class ServerMessaging {
private static readonly ILogger Logger = PhantomLogger.Create(nameof(ServerMessaging));
private static RpcServerConnection? CurrentConnection { get; set; }
private static RpcServerConnection CurrentConnectionOrThrow => CurrentConnection ?? throw new InvalidOperationException("Server connection not ready.");
private static readonly object SetCurrentConnectionLock = new ();
internal static void SetCurrentConnection(RpcServerConnection connection) {
lock (SetCurrentConnectionLock) {
if (CurrentConnection != null) {
throw new InvalidOperationException("Server connection can only be set once.");
}
CurrentConnection = connection;
}
Logger.Information("Server connection ready.");
}
public static Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
return CurrentConnectionOrThrow.Send(message);
}
public static Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
return CurrentConnectionOrThrow.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken);
}
}

View File

@@ -1,21 +1,21 @@
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services;
public sealed class AgentFolders {
private static readonly ILogger Logger = PhantomLogger.Create<AgentFolders>();
public string DataFolderPath { get; }
public string InstancesFolderPath { get; }
public string BackupsFolderPath { get; }
public string TemporaryFolderPath { get; }
public string ServerExecutableFolderPath { get; }
public string JavaSearchFolderPath { get; }
public AgentFolders(string dataFolderPath, string temporaryFolderPath, string javaSearchFolderPath) {
this.DataFolderPath = Path.GetFullPath(dataFolderPath);
this.InstancesFolderPath = Path.Combine(DataFolderPath, "instances");
@@ -26,7 +26,7 @@ public sealed class AgentFolders {
this.JavaSearchFolderPath = javaSearchFolderPath;
}
public bool TryCreate() {
return TryCreateFolder(DataFolderPath) &&
TryCreateFolder(InstancesFolderPath) &&
@@ -34,12 +34,12 @@ public sealed class AgentFolders {
TryCreateFolder(TemporaryFolderPath) &&
TryCreateFolder(ServerExecutableFolderPath);
}
private static bool TryCreateFolder(string folderPath) {
if (Directory.Exists(folderPath)) {
return true;
}
try {
Directories.Create(folderPath, Chmod.URWX_GRX);
return true;

View File

@@ -0,0 +1,85 @@
using System.Collections.Immutable;
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
using Serilog;
namespace Phantom.Agent.Services;
public sealed class AgentRegistrationHandler {
private readonly ILogger logger = PhantomLogger.Create<AgentRegistrationHandler>();
private readonly ManualResetEventSlim newSessionEvent = new ();
private ImmutableArray<ConfigureInstanceMessage> lastConfigureInstanceMessages;
internal void OnRegistrationComplete(ImmutableArray<ConfigureInstanceMessage> configureInstanceMessages) {
ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, configureInstanceMessages);
}
internal void OnNewSession() {
newSessionEvent.Set();
}
public async Task<bool> Start(AgentServices agentServices, CancellationToken cancellationToken) {
var configureInstanceMessages = ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, value: default);
if (configureInstanceMessages.IsDefault) {
logger.Fatal("Handshake failed.");
return false;
}
foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid);
return false;
}
}
agentServices.InstanceTicketManager.RefreshAgentStatus();
_ = HandleNewSessionRegistrations(agentServices, cancellationToken);
return true;
}
private async Task HandleNewSessionRegistrations(AgentServices agentServices, CancellationToken cancellationToken) {
while (cancellationToken.Check()) {
await newSessionEvent.WaitHandle.WaitOneAsync(cancellationToken);
newSessionEvent.Reset();
try {
await HandleNewSessionRegistration(agentServices, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Could not configure instances after re-registration.");
}
}
}
private async Task HandleNewSessionRegistration(AgentServices agentServices, CancellationToken cancellationToken) {
var configureInstanceMessages = ImmutableInterlocked.InterlockedExchange(ref lastConfigureInstanceMessages, value: default);
if (configureInstanceMessages.IsDefaultOrEmpty) {
return;
}
foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Error("Unable to configure instance \"{Name}\" (GUID {Guid}).", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid);
}
}
agentServices.InstanceTicketManager.RefreshAgentStatus();
}
private static InstanceManagerActor.ConfigureInstanceCommand GetCommand(ConfigureInstanceMessage configureInstanceMessage) {
return new InstanceManagerActor.ConfigureInstanceCommand(
configureInstanceMessage.InstanceGuid,
configureInstanceMessage.Configuration,
configureInstanceMessage.LaunchProperties,
configureInstanceMessage.LaunchNow,
AlwaysReportStatus: true
);
}
}

View File

@@ -0,0 +1,3 @@
namespace Phantom.Agent.Services;
public readonly record struct AgentServiceConfiguration(int MaxConcurrentCompressionTasks);

View File

@@ -1,9 +1,11 @@
using Phantom.Agent.Minecraft.Java;
using Akka.Actor;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Phantom.Utils.Actor;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services;
@@ -11,34 +13,38 @@ namespace Phantom.Agent.Services;
public sealed class AgentServices {
private static readonly ILogger Logger = PhantomLogger.Create<AgentServices>();
private AgentFolders AgentFolders { get; }
private TaskManager TaskManager { get; }
public ActorSystem ActorSystem { get; }
private AgentState AgentState { get; }
private BackupManager BackupManager { get; }
internal JavaRuntimeRepository JavaRuntimeRepository { get; }
internal InstanceSessionManager InstanceSessionManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders) {
this.AgentFolders = agentFolders;
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>());
this.BackupManager = new BackupManager(agentFolders);
this.JavaRuntimeRepository = new JavaRuntimeRepository();
this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository, TaskManager, BackupManager);
internal InstanceTicketManager InstanceTicketManager { get; }
internal ActorRef<InstanceManagerActor.ICommand> InstanceManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration, ControllerConnection controllerConnection, JavaRuntimeRepository javaRuntimeRepository) {
this.ActorSystem = ActorSystemFactory.Create("Agent");
this.AgentState = new AgentState();
this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks);
this.JavaRuntimeRepository = javaRuntimeRepository;
this.InstanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection);
var instanceManagerInit = new InstanceManagerActor.Init(controllerConnection, agentFolders, AgentState, JavaRuntimeRepository, InstanceTicketManager, BackupManager);
this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager");
}
public async Task Initialize() {
await foreach (var runtime in JavaRuntimeDiscovery.Scan(AgentFolders.JavaSearchFolderPath)) {
JavaRuntimeRepository.Include(runtime);
}
}
public async Task Shutdown() {
Logger.Information("Stopping services...");
await InstanceSessionManager.StopAll();
InstanceSessionManager.Dispose();
await InstanceManager.Stop(new InstanceManagerActor.ShutdownCommand());
await InstanceTicketManager.Shutdown();
await TaskManager.Stop();
BackupManager.Dispose();
await ActorSystem.Terminate();
ActorSystem.Dispose();
Logger.Information("Services stopped.");
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using Phantom.Agent.Services.Instances;
using Phantom.Utils.Actor.Event;
namespace Phantom.Agent.Services;
sealed class AgentState {
private readonly ObservableState<ImmutableDictionary<Guid, Instance>> instancesByGuid = new (ImmutableDictionary<Guid, Instance>.Empty);
public ImmutableDictionary<Guid, Instance> InstancesByGuid => instancesByGuid.State;
public void UpdateInstance(Instance instance) {
instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
}
}

View File

@@ -3,11 +3,11 @@ using System.Diagnostics.CodeAnalysis;
using System.Formats.Tar;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Backups;
namespace Phantom.Agent.Services.Backups;
sealed class BackupArchiver {
private readonly string destinationBasePath;
@@ -23,7 +23,7 @@ sealed class BackupArchiver {
this.instanceProperties = instanceProperties;
this.cancellationToken = cancellationToken;
}
private bool IsFolderSkipped(ImmutableList<string> relativePath) {
return relativePath is ["cache" or "crash-reports" or "debug" or "libraries" or "logs" or "mods" or "versions"];
}
@@ -35,7 +35,7 @@ sealed class BackupArchiver {
if (relativePath.Count == 2 && name == "session.lock") {
return true;
}
var extension = Path.GetExtension(name);
if (extension is ".jar" or ".zip") {
return true;
@@ -43,7 +43,7 @@ sealed class BackupArchiver {
return false;
}
public async Task<string?> ArchiveWorld(BackupCreationResult.Builder resultBuilder) {
string guid = instanceProperties.InstanceGuid.ToString();
string currentDateTime = DateTime.Now.ToString("yyyyMMdd-HHmmss");
@@ -63,7 +63,7 @@ sealed class BackupArchiver {
logger.Error(e, "Could not create backup folder: {Folder}", backupFolderPath);
return null;
}
string temporaryFolderPath = Path.Combine(temporaryBasePath, guid + "_" + currentDateTime);
if (!await CopyWorldAndCreateTarArchive(temporaryFolderPath, backupFilePath, resultBuilder)) {
return null;
@@ -72,19 +72,19 @@ sealed class BackupArchiver {
logger.Debug("Created world backup: {FilePath}", backupFilePath);
return backupFilePath;
}
private async Task<bool> CopyWorldAndCreateTarArchive(string temporaryFolderPath, string backupFilePath, BackupCreationResult.Builder resultBuilder) {
try {
if (!await CopyWorldToTemporaryFolder(temporaryFolderPath)) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder;
return false;
}
if (!await CreateTarArchive(temporaryFolderPath, backupFilePath)) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCreateWorldArchive;
return false;
}
return true;
} finally {
try {
@@ -95,7 +95,7 @@ sealed class BackupArchiver {
}
}
}
private async Task<bool> CopyWorldToTemporaryFolder(string temporaryFolderPath) {
try {
await CopyDirectory(new DirectoryInfo(instanceProperties.InstanceFolder), temporaryFolderPath, ImmutableList<string>.Empty);
@@ -105,10 +105,10 @@ sealed class BackupArchiver {
return false;
}
}
private async Task<bool> CreateTarArchive(string sourceFolderPath, string backupFilePath) {
try {
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, false, cancellationToken);
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, includeBaseDirectory: false, cancellationToken);
return true;
} catch (Exception e) {
logger.Error(e, "Could not create archive.");
@@ -116,7 +116,7 @@ sealed class BackupArchiver {
return false;
}
}
private void DeleteBrokenArchiveFile(string filePath) {
if (File.Exists(filePath)) {
try {
@@ -126,38 +126,38 @@ sealed class BackupArchiver {
}
}
}
private async Task CopyDirectory(DirectoryInfo sourceFolder, string destinationFolderPath, ImmutableList<string> relativePath) {
cancellationToken.ThrowIfCancellationRequested();
bool needsToCreateFolder = true;
foreach (FileInfo file in sourceFolder.EnumerateFiles()) {
var filePath = relativePath.Add(file.Name);
if (IsFileSkipped(filePath)) {
logger.Debug("Skipping file: {File}", string.Join('/', filePath));
logger.Debug("Skipping file: {File}", string.Join(separator: '/', filePath));
continue;
}
if (needsToCreateFolder) {
needsToCreateFolder = false;
Directories.Create(destinationFolderPath, Chmod.URWX);
}
await CopyFileWithRetries(file, destinationFolderPath);
}
foreach (DirectoryInfo directory in sourceFolder.EnumerateDirectories()) {
var folderPath = relativePath.Add(directory.Name);
if (IsFolderSkipped(folderPath)) {
logger.Debug("Skipping folder: {Folder}", string.Join('/', folderPath));
logger.Debug("Skipping folder: {Folder}", string.Join(separator: '/', folderPath));
continue;
}
await CopyDirectory(directory, Path.Join(destinationFolderPath, directory.Name), folderPath);
}
}
private async Task CopyFileWithRetries(FileInfo sourceFile, string destinationFolderPath) {
var destinationFilePath = Path.Combine(destinationFolderPath, sourceFile.Name);
@@ -172,7 +172,7 @@ sealed class BackupArchiver {
}
else {
logger.Warning("Failed copying file {File}, retrying...", sourceFile.FullName);
await Task.Delay(200, cancellationToken);
await Task.Delay(millisecondsDelay: 200, cancellationToken);
}
}
}

View File

@@ -1,8 +1,8 @@
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Phantom.Utils.Logging;
using Phantom.Utils.Processes;
using Serilog;
namespace Phantom.Agent.Services.Backups;
namespace Phantom.Agent.Services.Backups;
static class BackupCompressor {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(BackupCompressor));
@@ -19,27 +19,27 @@ static class BackupCompressor {
}
var destinationFilePath = sourceFilePath + ".zst";
if (!await TryCompressFile(sourceFilePath, destinationFilePath, cancellationToken)) {
try {
File.Delete(destinationFilePath);
} catch (Exception e) {
Logger.Error(e, "Could not delete compresed archive after unsuccessful compression: {Path}", destinationFilePath);
}
return null;
}
return destinationFilePath;
}
private static async Task<bool> TryCompressFile(string sourceFilePath, string destinationFilePath, CancellationToken cancellationToken) {
var workingDirectory = Path.GetDirectoryName(sourceFilePath);
if (string.IsNullOrEmpty(workingDirectory)) {
Logger.Error("Invalid destination path: {Path}", destinationFilePath);
return false;
}
var launcher = new ProcessConfigurator {
FileName = "zstd",
WorkingDirectory = workingDirectory,
@@ -47,21 +47,19 @@ static class BackupCompressor {
Quality,
Memory,
Threads,
"-c",
"--rm",
"--no-progress",
"-c",
"-o", destinationFilePath,
"--", sourceFilePath
}
"--", sourceFilePath,
},
};
static void OnZstdOutput(object? sender, Process.Output output) {
if (!string.IsNullOrWhiteSpace(output.Line)) {
ZstdLogger.Debug("[Output] {Line}", output.Line);
}
}
var process = new OneShotProcess(ZstdLogger, launcher);
process.OutputReceived += OnZstdOutput;
return await process.Run(cancellationToken);

View File

@@ -1,54 +1,44 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed class BackupManager {
sealed class BackupManager : IDisposable {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
public BackupManager(AgentFolders agentFolders) {
private readonly SemaphoreSlim compressionSemaphore;
public BackupManager(AgentFolders agentFolders, int maxConcurrentCompressionTasks) {
this.destinationBasePath = agentFolders.BackupsFolderPath;
this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups");
this.compressionSemaphore = new SemaphoreSlim(maxConcurrentCompressionTasks, maxConcurrentCompressionTasks);
}
public async Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
try {
if (!await process.BackupSemaphore.Wait(TimeSpan.FromSeconds(1), cancellationToken)) {
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
} catch (ObjectDisposedException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
} catch (OperationCanceledException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
}
try {
return await new BackupCreator(destinationBasePath, temporaryBasePath, loggerName, process, cancellationToken).CreateBackup();
} finally {
process.BackupSemaphore.Release();
}
public Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
return new BackupCreator(this, loggerName, process, cancellationToken).CreateBackup();
}
public void Dispose() {
compressionSemaphore.Dispose();
}
private sealed class BackupCreator {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly BackupManager manager;
private readonly string loggerName;
private readonly ILogger logger;
private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken;
public BackupCreator(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.destinationBasePath = destinationBasePath;
this.temporaryBasePath = temporaryBasePath;
public BackupCreator(BackupManager manager, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.manager = manager;
this.loggerName = loggerName;
this.logger = PhantomLogger.Create<BackupManager>(loggerName);
this.process = process;
this.cancellationToken = cancellationToken;
}
public async Task<BackupCreationResult> CreateBackup() {
logger.Information("Backup started.");
@@ -67,16 +57,20 @@ sealed class BackupManager {
LogBackupResult(result);
return result;
}
private async Task<string?> CreateWorldArchive(BackupServerCommandDispatcher dispatcher, BackupCreationResult.Builder resultBuilder) {
try {
await dispatcher.DisableAutomaticSaving();
await dispatcher.SaveAllChunks();
return await new BackupArchiver(destinationBasePath, temporaryBasePath, loggerName, process.InstanceProperties, cancellationToken).ArchiveWorld(resultBuilder);
return await new BackupArchiver(manager.destinationBasePath, manager.temporaryBasePath, loggerName, process.InstanceProperties, cancellationToken).ArchiveWorld(resultBuilder);
} catch (OperationCanceledException) {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled.");
return null;
} catch (TimeoutException) {
resultBuilder.Kind = BackupCreationResultKind.BackupTimedOut;
logger.Warning("Backup creation timed out.");
return null;
} catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.UnknownError;
logger.Error(e, "Caught exception while creating an instance backup.");
@@ -85,24 +79,37 @@ sealed class BackupManager {
try {
await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) {
// ignore
// Ignore.
} catch (TimeoutException) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Warning("Timed out waiting for automatic saving to be re-enabled.");
} catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
}
}
}
private async Task CompressWorldArchive(string filePath, BackupCreationResult.Builder resultBuilder) {
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
if (compressedFilePath == null) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
if (!await manager.compressionSemaphore.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken)) {
logger.Information("Too many compression tasks running, waiting for one of them to complete...");
await manager.compressionSemaphore.WaitAsync(cancellationToken);
}
logger.Information("Compressing backup...");
try {
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
if (compressedFilePath == null) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
}
} finally {
manager.compressionSemaphore.Release();
}
}
private void LogBackupResult(BackupCreationResult result) {
if (result.Kind != BackupCreationResultKind.Success) {
logger.Warning("Backup failed: {Reason}", result.Kind.ToSentence());
logger.Warning("Backup failed: {Reason}", DescribeResult(result.Kind));
return;
}
@@ -114,5 +121,20 @@ sealed class BackupManager {
logger.Information("Backup finished successfully.");
}
}
private static string DescribeResult(BackupCreationResultKind kind) {
return kind switch {
BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupTimedOut => "Backup timed out.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
_ => "Unknown error.",
};
}
}
}

View File

@@ -1,8 +1,8 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Instances.State;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Backups;
@@ -11,28 +11,26 @@ sealed class BackupScheduler : CancellableBackgroundTask {
private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(2);
private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30);
private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5);
private readonly string loggerName;
private readonly BackupManager backupManager;
private readonly InstanceProcess process;
private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol;
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceProcess process, int serverPort, string loggerName) : base(PhantomLogger.Create<BackupScheduler>(loggerName), taskManager, "Backup scheduler for " + loggerName) {
this.loggerName = loggerName;
this.backupManager = backupManager;
this.process = process;
this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(loggerName);
private readonly BackupManager backupManager;
private readonly InstanceContext context;
private readonly SemaphoreSlim backupSemaphore = new (initialCount: 1, maxCount: 1);
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
private readonly InstancePlayerCountTracker playerCountTracker;
public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(InstanceContext context, InstancePlayerCountTracker playerCountTracker) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
this.backupManager = context.Services.BackupManager;
this.context = context;
this.playerCountTracker = playerCountTracker;
Start();
}
protected override async Task RunTask() {
await Task.Delay(InitialDelay, CancellationToken);
Logger.Information("Starting a new backup after server launched.");
while (!CancellationToken.IsCancellationRequested) {
var result = await CreateBackup();
BackupCompleted?.Invoke(this, result);
@@ -48,49 +46,40 @@ sealed class BackupScheduler : CancellableBackgroundTask {
}
}
}
private async Task<BackupCreationResult> CreateBackup() {
return await backupManager.CreateBackup(loggerName, process, CancellationToken.None);
}
private async Task WaitForOnlinePlayers() {
bool needsToLogOfflinePlayersMessage = true;
if (!await backupSemaphore.WaitAsync(TimeSpan.FromSeconds(1))) {
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
try {
while (!CancellationToken.IsCancellationRequested) {
serverOutputWhileWaitingForOnlinePlayers.Reset();
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
if (onlinePlayerCount == null) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
break;
}
if (onlinePlayerCount > 0) {
Logger.Information("Players are online, starting a new backup.");
break;
}
if (needsToLogOfflinePlayersMessage) {
needsToLogOfflinePlayersMessage = false;
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
}
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
Logger.Debug("Waiting for server output before checking for online players again...");
await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
}
context.ActorCancellationToken.ThrowIfCancellationRequested();
return await context.Actor.Request(new InstanceActor.BackupInstanceCommand(backupManager), context.ActorCancellationToken);
} catch (OperationCanceledException) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
} finally {
process.RemoveOutputListener(ServerOutputListener);
backupSemaphore.Release();
}
}
private void ServerOutputListener(object? sender, string line) {
if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
serverOutputWhileWaitingForOnlinePlayers.Set();
Logger.Debug("Detected server output, signalling to check for online players again.");
private async Task WaitForOnlinePlayers() {
var task = playerCountTracker.WaitForOnlinePlayers(CancellationToken);
if (!task.IsCompleted) {
Logger.Information("Waiting for someone to join before starting a new backup.");
}
try {
await task;
Logger.Information("Players are online, starting a new backup.");
} catch (OperationCanceledException) {
throw;
} catch (Exception) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
}
}
protected override void Dispose() {
backupSemaphore.Dispose();
serverOutputWhileWaitingForOnlinePlayers.Dispose();
}
}

View File

@@ -1,77 +1,95 @@
using System.Text.RegularExpressions;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed partial class BackupServerCommandDispatcher : IDisposable {
[GeneratedRegex(@"^\[(?:.*?)\] \[Server thread/INFO\]: (.*?)$", RegexOptions.NonBacktracking)]
[GeneratedRegex(@"^(?:(?:\[.*?\] \[Server thread/INFO\].*?:)|(?:[\d-]+? [\d:]+? \[INFO\])) (.*?)$", RegexOptions.NonBacktracking)]
private static partial Regex ServerThreadInfoRegex();
private static readonly ImmutableHashSet<string> AutomaticSavingDisabledMessages = ImmutableHashSet.Create(
"Automatic saving is now disabled",
"Turned off world auto-saving",
"CONSOLE: Disabling level saving.."
);
private static readonly ImmutableHashSet<string> SavedTheGameMessages = ImmutableHashSet.Create(
"Saved the game",
"Saved the world",
"CONSOLE: Save complete."
);
private static readonly ImmutableHashSet<string> AutomaticSavingEnabledMessages = ImmutableHashSet.Create(
"Automatic saving is now enabled",
"Turned on world auto-saving",
"CONSOLE: Enabling level saving.."
);
private readonly ILogger logger;
private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken;
private readonly TaskCompletionSource automaticSavingDisabled = Tasks.CreateCompletionSource();
private readonly TaskCompletionSource savedTheGame = Tasks.CreateCompletionSource();
private readonly TaskCompletionSource automaticSavingEnabled = Tasks.CreateCompletionSource();
private readonly TaskCompletionSource automaticSavingDisabled = AsyncTasks.CreateCompletionSource();
private readonly TaskCompletionSource savedTheGame = AsyncTasks.CreateCompletionSource();
private readonly TaskCompletionSource automaticSavingEnabled = AsyncTasks.CreateCompletionSource();
public BackupServerCommandDispatcher(ILogger logger, InstanceProcess process, CancellationToken cancellationToken) {
this.logger = logger;
this.process = process;
this.cancellationToken = cancellationToken;
this.process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
}
void IDisposable.Dispose() {
process.RemoveOutputListener(OnOutput);
}
public async Task DisableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOff, cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
}
public async Task SaveAllChunks() {
// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
await savedTheGame.Task.WaitAsync(cancellationToken);
await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
}
public async Task EnableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOn, cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
}
private void OnOutput(object? sender, string? line) {
if (line == null) {
return;
}
var match = ServerThreadInfoRegex().Match(line);
if (!match.Success) {
return;
}
string info = match.Groups[1].Value;
if (!automaticSavingDisabled.Task.IsCompleted) {
if (info == "Automatic saving is now disabled") {
if (AutomaticSavingDisabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is disabled.");
automaticSavingDisabled.SetResult();
}
}
else if (!savedTheGame.Task.IsCompleted) {
if (info == "Saved the game") {
if (SavedTheGameMessages.Contains(info)) {
logger.Debug("Detected that the game is saved.");
savedTheGame.SetResult();
}
}
else if (!automaticSavingEnabled.Task.IsCompleted) {
if (info == "Automatic saving is now enabled") {
if (AutomaticSavingEnabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is enabled.");
automaticSavingEnabled.SetResult();
}

View File

@@ -1,209 +1,5 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Serilog;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances;
sealed class Instance : IDisposable {
private static uint loggerSequenceId = 0;
private static string GetLoggerName(Guid guid) {
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref loggerSequenceId);
}
private InstanceServices Services { get; }
public InstanceConfiguration Configuration { get; private set; }
private IServerLauncher Launcher { get; set; }
private readonly string shortName;
private readonly ILogger logger;
private IInstanceStatus currentStatus;
private int statusUpdateCounter;
private IInstanceState currentState;
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
public bool IsRunning => currentState is not InstanceNotRunningState;
public event EventHandler? IsRunningChanged;
public Instance(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
this.shortName = GetLoggerName(configuration.InstanceGuid);
this.logger = PhantomLogger.Create<Instance>(shortName);
this.Services = services;
this.Configuration = configuration;
this.Launcher = launcher;
this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.NotRunning;
}
private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter);
Services.TaskManager.Run(taskName, async () => {
if (myStatusUpdateCounter == statusUpdateCounter) {
await getUpdateTask();
}
});
}
public void ReportLastStatus() {
TryUpdateStatus("Report last status of instance " + shortName, async () => {
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
});
}
private void ReportAndSetStatus(IInstanceStatus status) {
TryUpdateStatus("Report status of instance " + shortName + " as " + status.GetType().Name, async () => {
currentStatus = status;
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
});
}
private void ReportEvent(IInstanceEvent instanceEvent) {
var message = new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, Configuration.InstanceGuid, instanceEvent);
Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message));
}
private void TransitionState(IInstanceState newState) {
if (currentState == newState) {
return;
}
if (currentState is IDisposable disposable) {
disposable.Dispose();
}
logger.Debug("Transitioning instance state to: {NewState}", newState.GetType().Name);
var wasRunning = IsRunning;
currentState = newState;
currentState.Initialize();
if (IsRunning != wasRunning) {
IsRunningChanged?.Invoke(this, EventArgs.Empty);
}
}
private T TransitionStateAndReturn<T>((IInstanceState State, T Result) newStateAndResult) {
TransitionState(newStateAndResult.State);
return newStateAndResult.Result;
}
public async Task Reconfigure(InstanceConfiguration configuration, IServerLauncher launcher, CancellationToken cancellationToken) {
await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
try {
Configuration = configuration;
Launcher = launcher;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task<LaunchInstanceResult> Launch(CancellationToken shutdownCancellationToken) {
await stateTransitioningActionSemaphore.WaitAsync(shutdownCancellationToken);
try {
return TransitionStateAndReturn(currentState.Launch(new InstanceContextImpl(this, shutdownCancellationToken)));
} catch (Exception e) {
logger.Error(e, "Caught exception while launching instance.");
return LaunchInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy) {
await stateTransitioningActionSemaphore.WaitAsync();
try {
return TransitionStateAndReturn(currentState.Stop(stopStrategy));
} catch (Exception e) {
logger.Error(e, "Caught exception while stopping instance.");
return StopInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task StopAndWait(TimeSpan waitTime) {
await Stop(MinecraftStopStrategy.Instant);
using var waitTokenSource = new CancellationTokenSource(waitTime);
var waitToken = waitTokenSource.Token;
while (currentState is not InstanceNotRunningState) {
await Task.Delay(TimeSpan.FromMilliseconds(250), waitToken);
}
}
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return await currentState.SendCommand(command, cancellationToken);
}
private sealed class InstanceContextImpl : InstanceContext {
private readonly Instance instance;
private readonly CancellationToken shutdownCancellationToken;
public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Services, instance.Configuration, instance.Launcher) {
this.instance = instance;
this.shutdownCancellationToken = shutdownCancellationToken;
}
public override ILogger Logger => instance.logger;
public override string ShortName => instance.shortName;
public override void SetStatus(IInstanceStatus newStatus) {
instance.ReportAndSetStatus(newStatus);
}
public override void ReportEvent(IInstanceEvent instanceEvent) {
instance.ReportEvent(instanceEvent);
}
public override void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus) {
instance.stateTransitioningActionSemaphore.Wait(CancellationToken.None);
try {
var (state, status) = newStateAndStatus();
if (!instance.IsRunning) {
// Only InstanceSessionManager is allowed to transition an instance out of a non-running state.
instance.logger.Debug("Cancelled state transition to {State} because instance is not running.", state.GetType().Name);
return;
}
if (state is not InstanceNotRunningState && shutdownCancellationToken.IsCancellationRequested) {
instance.logger.Debug("Cancelled state transition to {State} due to Agent shutdown.", state.GetType().Name);
return;
}
if (status != null) {
SetStatus(status);
}
instance.TransitionState(state);
} catch (Exception e) {
instance.logger.Error(e, "Caught exception during state transition.");
} finally {
instance.stateTransitioningActionSemaphore.Release();
}
}
}
public void Dispose() {
stateTransitioningActionSemaphore.Dispose();
if (currentState is IDisposable disposable) {
disposable.Dispose();
}
}
}
sealed record Instance(Guid InstanceGuid, IInstanceStatus Status);

View File

@@ -0,0 +1,174 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.State;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor;
using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Logging;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
public readonly record struct Init(AgentState AgentState, Guid InstanceGuid, string ShortName, InstanceServices InstanceServices, InstanceTicketManager InstanceTicketManager, CancellationToken ShutdownCancellationToken);
public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
}
private readonly AgentState agentState;
private readonly CancellationToken shutdownCancellationToken;
private readonly Guid instanceGuid;
private readonly InstanceTicketManager instanceTicketManager;
private readonly InstanceContext context;
private readonly ControllerSendQueue<ReportInstanceStatusMessage> reportStatusQueue;
private readonly ControllerSendQueue<ReportInstanceEventMessage> reportEventsQueue;
private readonly CancellationTokenSource actorCancellationTokenSource = new ();
private IInstanceStatus currentStatus = InstanceStatus.NotRunning;
private InstanceRunningState? runningState = null;
private InstanceActor(Init init) {
InstanceServices services = init.InstanceServices;
this.agentState = init.AgentState;
this.instanceGuid = init.InstanceGuid;
this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = init.ShutdownCancellationToken;
this.reportStatusQueue = new ControllerSendQueue<ReportInstanceStatusMessage>(services.ControllerConnection, init.ShortName + "-Status", capacity: 1, singleWriter: true);
this.reportEventsQueue = new ControllerSendQueue<ReportInstanceEventMessage>(services.ControllerConnection, init.ShortName + "-Events", capacity: 1000, singleWriter: true);
var logger = PhantomLogger.Create<InstanceActor>(init.ShortName);
this.context = new InstanceContext(instanceGuid, init.ShortName, logger, services, reportEventsQueue, SelfTyped, actorCancellationTokenSource.Token);
Receive<ReportInstanceStatusCommand>(ReportInstanceStatus);
ReceiveAsync<LaunchInstanceCommand>(LaunchInstance);
ReceiveAsync<StopInstanceCommand>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, SendCommandToInstanceResult>(SendCommandToInstance);
ReceiveAsyncAndReply<BackupInstanceCommand, BackupCreationResult>(BackupInstance);
Receive<HandleProcessEndedCommand>(HandleProcessEnded);
ReceiveAsync<ShutdownCommand>(Shutdown);
}
private void SetAndReportStatus(IInstanceStatus status) {
currentStatus = status;
ReportCurrentStatus();
}
private void ReportCurrentStatus() {
agentState.UpdateInstance(new Instance(instanceGuid, currentStatus));
reportStatusQueue.Enqueue(new ReportInstanceStatusMessage(instanceGuid, currentStatus));
}
private void TransitionState(InstanceRunningState? newState) {
if (runningState == newState) {
return;
}
runningState?.Dispose();
runningState = newState;
runningState?.Initialize();
}
public interface ICommand;
public sealed record ReportInstanceStatusCommand : ICommand;
public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting) : ICommand;
public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy) : ICommand;
public sealed record SendCommandToInstanceCommand(string Command) : ICommand, ICanReply<SendCommandToInstanceResult>;
public sealed record BackupInstanceCommand(BackupManager BackupManager) : ICommand, ICanReply<BackupCreationResult>;
public sealed record HandleProcessEndedCommand(IInstanceStatus Status) : ICommand, IJumpAhead;
public sealed record ShutdownCommand : ICommand;
private void ReportInstanceStatus(ReportInstanceStatusCommand command) {
ReportCurrentStatus();
}
private async Task LaunchInstance(LaunchInstanceCommand command) {
if (command.IsRestarting || runningState is null) {
SetAndReportStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching);
var newState = await InstanceLaunchProcedure.Run(context, command.Configuration, command.Launcher, instanceTicketManager, command.Ticket, SetAndReportStatus, shutdownCancellationToken);
if (newState is null) {
instanceTicketManager.Release(command.Ticket);
}
TransitionState(newState);
}
}
private async Task StopInstance(StopInstanceCommand command) {
if (runningState is null) {
return;
}
IInstanceStatus oldStatus = currentStatus;
SetAndReportStatus(InstanceStatus.Stopping);
if (await InstanceStopProcedure.Run(context, command.StopStrategy, runningState, SetAndReportStatus, shutdownCancellationToken)) {
instanceTicketManager.Release(runningState.Ticket);
TransitionState(null);
}
else {
SetAndReportStatus(oldStatus);
}
}
private async Task<SendCommandToInstanceResult> SendCommandToInstance(SendCommandToInstanceCommand command) {
if (runningState is null) {
return SendCommandToInstanceResult.InstanceNotRunning;
}
else {
return await runningState.SendCommand(command.Command, shutdownCancellationToken);
}
}
private async Task<BackupCreationResult> BackupInstance(BackupInstanceCommand command) {
if (runningState is null || runningState.Process.HasEnded) {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
}
else {
SetAndReportStatus(InstanceStatus.BackingUp);
try {
return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken);
} finally {
SetAndReportStatus(InstanceStatus.Running);
}
}
}
private void HandleProcessEnded(HandleProcessEndedCommand command) {
if (runningState is { Process.HasEnded: true }) {
SetAndReportStatus(command.Status);
context.ReportEvent(InstanceEvent.Stopped);
instanceTicketManager.Release(runningState.Ticket);
TransitionState(null);
}
}
private async Task Shutdown(ShutdownCommand command) {
await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant));
await actorCancellationTokenSource.CancelAsync();
await Task.WhenAll(
reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5)),
reportEventsQueue.Shutdown(TimeSpan.FromSeconds(5))
);
Context.Stop(Self);
}
}

View File

@@ -1,35 +1,21 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Instances.States;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor;
using Serilog;
namespace Phantom.Agent.Services.Instances;
abstract class InstanceContext {
public InstanceServices Services { get; }
public InstanceConfiguration Configuration { get; }
public IServerLauncher Launcher { get; }
public abstract ILogger Logger { get; }
public abstract string ShortName { get; }
protected InstanceContext(InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
Services = services;
Configuration = configuration;
Launcher = launcher;
}
public abstract void SetStatus(IInstanceStatus newStatus);
public void SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason reason) {
SetStatus(InstanceStatus.Failed(reason));
ReportEvent(new InstanceLaunchFailedEvent(reason));
}
public abstract void ReportEvent(IInstanceEvent instanceEvent);
public abstract void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus);
public void TransitionState(IInstanceState newState, IInstanceStatus? newStatus = null) {
TransitionState(() => (newState, newStatus));
sealed record InstanceContext(
Guid InstanceGuid,
string ShortName,
ILogger Logger,
InstanceServices Services,
ControllerSendQueue<ReportInstanceEventMessage> ReportEventQueue,
ActorRef<InstanceActor.ICommand> Actor,
CancellationToken ActorCancellationToken
) {
public void ReportEvent(IInstanceEvent instanceEvent) {
ReportEventQueue.Enqueue(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent));
}
}

View File

@@ -1,71 +0,0 @@
using System.Collections.Immutable;
using Phantom.Agent.Rpc;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly Guid instanceGuid;
private readonly SemaphoreSlim semaphore = new (1, 1);
private readonly RingBuffer<string> buffer = new (1000);
public InstanceLogSender(TaskManager taskManager, Guid instanceGuid, string loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName), taskManager, "Instance log sender for " + loggerName) {
this.instanceGuid = instanceGuid;
}
protected override async Task RunTask() {
try {
while (!CancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, CancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
await SendOutputToServer(DequeueWithoutSemaphore());
}
private async Task SendOutputToServer(ImmutableArray<string> lines) {
if (!lines.IsEmpty) {
await ServerMessaging.Send(new InstanceOutputMessage(instanceGuid, lines));
}
}
private ImmutableArray<string> DequeueWithoutSemaphore() {
ImmutableArray<string> lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
buffer.Clear();
return lines;
}
private async Task<ImmutableArray<string>> DequeueOrThrow() {
await semaphore.WaitAsync(CancellationToken);
try {
return DequeueWithoutSemaphore();
} finally {
semaphore.Release();
}
}
public void Enqueue(string line) {
try {
semaphore.Wait(CancellationToken);
} catch (Exception) {
return;
}
try {
buffer.Add(line);
} finally {
semaphore.Release();
}
}
}

View File

@@ -0,0 +1,204 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand> {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceManagerActor>();
public readonly record struct Init(ControllerConnection ControllerConnection, AgentFolders AgentFolders, AgentState AgentState, JavaRuntimeRepository JavaRuntimeRepository, InstanceTicketManager InstanceTicketManager, BackupManager BackupManager);
public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new InstanceManagerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
}
private readonly AgentState agentState;
private readonly string basePath;
private readonly InstanceServices instanceServices;
private readonly InstanceTicketManager instanceTicketManager;
private readonly Dictionary<Guid, InstanceInfo> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken;
private uint instanceLoggerSequenceId = 0;
private InstanceManagerActor(Init init) {
this.agentState = init.AgentState;
this.basePath = init.AgentFolders.InstancesFolderPath;
this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var minecraftServerExecutables = new MinecraftServerExecutables(init.AgentFolders.ServerExecutableFolderPath);
var launchServices = new LaunchServices(minecraftServerExecutables, init.JavaRuntimeRepository);
this.instanceServices = new InstanceServices(init.ControllerConnection, init.BackupManager, launchServices);
ReceiveAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendCommandToInstance);
ReceiveAsync<ShutdownCommand>(Shutdown);
}
private sealed record InstanceInfo(ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher);
public interface ICommand;
public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record StopInstanceCommand(Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
public sealed record ShutdownCommand : ICommand;
private Result<ConfigureInstanceResult, InstanceActionFailure> ConfigureInstance(ConfigureInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
var configuration = command.Configuration;
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
Directories.Create(instanceFolder, Chmod.URWX_GRX);
var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
var jvmProperties = new JvmProperties(
InitialHeapMegabytes: heapMegabytes / 2,
MaximumHeapMegabytes: heapMegabytes
);
var properties = new InstanceProperties(
instanceGuid,
configuration.JavaRuntimeGuid,
jvmProperties,
configuration.JvmArguments,
instanceFolder,
configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort),
command.LaunchProperties
);
IServerLauncher launcher = configuration.MinecraftServerKind switch {
MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties),
_ => InvalidLauncher.Instance,
};
if (instances.TryGetValue(instanceGuid, out var instance)) {
instances[instanceGuid] = instance with {
Configuration = configuration,
Launcher = launcher,
};
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
if (command.AlwaysReportStatus) {
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
}
}
else {
var instanceLoggerName = PhantomLogger.ShortenGuid(instanceGuid) + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
var instanceInit = new InstanceActor.Init(agentState, instanceGuid, instanceLoggerName, instanceServices, instanceTicketManager, shutdownCancellationToken);
instances[instanceGuid] = instance = new InstanceInfo(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), configuration, launcher);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid);
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
}
if (command.LaunchNow) {
LaunchInstance(new LaunchInstanceCommand(instanceGuid));
}
return ConfigureInstanceResult.Success;
}
private Result<LaunchInstanceResult, InstanceActionFailure> LaunchInstance(LaunchInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration);
if (!ticket) {
return ticket.Error;
}
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status;
if (status.IsRunning()) {
return LaunchInstanceResult.InstanceAlreadyRunning;
}
else if (status.IsLaunching()) {
return LaunchInstanceResult.InstanceAlreadyLaunching;
}
}
instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false));
return LaunchInstanceResult.LaunchInitiated;
}
private Result<StopInstanceResult, InstanceActionFailure> StopInstance(StopInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status;
if (status.IsStopping()) {
return StopInstanceResult.InstanceAlreadyStopping;
}
else if (!status.CanStop()) {
return StopInstanceResult.InstanceAlreadyStopped;
}
}
instanceInfo.Actor.Tell(new InstanceActor.StopInstanceCommand(command.StopStrategy));
return StopInstanceResult.StopInitiated;
}
private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendCommandToInstance(SendCommandToInstanceCommand command) {
var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionFailure.InstanceDoesNotExist;
}
try {
return await instanceInfo.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command.Command), shutdownCancellationToken);
} catch (OperationCanceledException) {
return InstanceActionFailure.AgentShuttingDown;
}
}
private async Task Shutdown(ShutdownCommand command) {
Logger.Information("Stopping all instances...");
await shutdownCancellationTokenSource.CancelAsync();
await Task.WhenAll(instances.Values.Select(static instance => instance.Actor.Stop(new InstanceActor.ShutdownCommand())));
instances.Clear();
shutdownCancellationTokenSource.Dispose();
Logger.Information("All instances stopped.");
Context.Stop(Self);
}
}

View File

@@ -1,7 +1,7 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Utils.Runtime;
using Phantom.Agent.Services.Rpc;
namespace Phantom.Agent.Services.Instances;
namespace Phantom.Agent.Services.Instances;
sealed record InstanceServices(TaskManager TaskManager, PortManager PortManager, BackupManager BackupManager, LaunchServices LaunchServices);
sealed record InstanceServices(ControllerConnection ControllerConnection, BackupManager BackupManager, LaunchServices LaunchServices);

View File

@@ -1,204 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data;
using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.IO;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceSessionManager : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>();
private readonly AgentInfo agentInfo;
private readonly string basePath;
private readonly InstanceServices instanceServices;
private readonly Dictionary<Guid, Instance> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1);
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) {
this.agentInfo = agentInfo;
this.basePath = agentFolders.InstancesFolderPath;
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository);
var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices);
}
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
try {
return await func();
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.AgentShuttingDown);
}
}
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<Instance, Task<T>> func) {
return AcquireSemaphoreAndRun(async () => {
if (instances.TryGetValue(instanceGuid, out var instance)) {
return InstanceActionResult.Concrete(await func(instance));
}
else {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.InstanceDoesNotExist);
}
});
}
public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) {
return await AcquireSemaphoreAndRun(async () => {
var instanceGuid = configuration.InstanceGuid;
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
Directories.Create(instanceFolder, Chmod.URWX_GRX);
var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
var jvmProperties = new JvmProperties(
InitialHeapMegabytes: heapMegabytes / 2,
MaximumHeapMegabytes: heapMegabytes
);
var properties = new InstanceProperties(
instanceGuid,
configuration.JavaRuntimeGuid,
jvmProperties,
configuration.JvmArguments,
instanceFolder,
configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort),
launchProperties
);
IServerLauncher launcher = configuration.MinecraftServerKind switch {
MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties),
_ => InvalidLauncher.Instance
};
if (instances.TryGetValue(instanceGuid, out var instance)) {
await instance.Reconfigure(configuration, launcher, shutdownCancellationToken);
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
if (alwaysReportStatus) {
instance.ReportLastStatus();
}
}
else {
instances[instanceGuid] = instance = new Instance(instanceServices, configuration, launcher);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
instance.ReportLastStatus();
instance.IsRunningChanged += OnInstanceIsRunningChanged;
}
if (launchNow) {
await LaunchInternal(instance);
}
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success);
});
}
private ImmutableArray<Instance> GetRunningInstancesInternal() {
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
}
private void OnInstanceIsRunningChanged(object? sender, EventArgs e) {
instanceServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus);
}
public async Task RefreshAgentStatus() {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
try {
var runningInstances = GetRunningInstancesInternal();
var runningInstanceCount = runningInstances.Length;
var runningInstanceMemory = runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
await ServerMessaging.Send(new ReportAgentStatusMessage(runningInstanceCount, runningInstanceMemory));
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) {
// ignore
}
}
public Task<InstanceActionResult<LaunchInstanceResult>> Launch(Guid instanceGuid) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, LaunchInternal);
}
private async Task<LaunchInstanceResult> LaunchInternal(Instance instance) {
var runningInstances = GetRunningInstancesInternal();
if (runningInstances.Length + 1 > agentInfo.MaxInstances) {
return LaunchInstanceResult.InstanceLimitExceeded;
}
var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
if (availableMemory < instance.Configuration.MemoryAllocation) {
return LaunchInstanceResult.MemoryLimitExceeded;
}
return await instance.Launch(shutdownCancellationToken);
}
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy));
}
public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError);
}
public async Task StopAll() {
shutdownCancellationTokenSource.Cancel();
Logger.Information("Stopping all instances...");
await semaphore.WaitAsync(CancellationToken.None);
try {
await Task.WhenAll(instances.Values.Select(static instance => instance.StopAndWait(TimeSpan.FromSeconds(30))));
DisposeAllInstances();
} finally {
semaphore.Release();
}
}
public void Dispose() {
DisposeAllInstances();
shutdownCancellationTokenSource.Dispose();
semaphore.Dispose();
}
private void DisposeAllInstances() {
foreach (var (_, instance) in instances) {
instance.Dispose();
}
instances.Clear();
}
}

View File

@@ -0,0 +1,97 @@
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data;
using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection controllerConnection) {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceTicketManager>();
private readonly ControllerSendQueue<ReportAgentStatusMessage> reportStatusQueue = new (controllerConnection, nameof(InstanceTicketManager), capacity: 1, singleWriter: true);
private readonly HashSet<Guid> activeTicketGuids = [];
private readonly HashSet<ushort> usedPorts = [];
private RamAllocationUnits usedMemory = new ();
public Result<Ticket, LaunchInstanceResult> Reserve(InstanceConfiguration configuration) {
var memoryAllocation = configuration.MemoryAllocation;
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!agentInfo.AllowedServerPorts.Contains(serverPort)) {
return LaunchInstanceResult.ServerPortNotAllowed;
}
if (!agentInfo.AllowedRconPorts.Contains(rconPort)) {
return LaunchInstanceResult.RconPortNotAllowed;
}
lock (this) {
if (activeTicketGuids.Count + 1 > agentInfo.MaxInstances) {
return LaunchInstanceResult.InstanceLimitExceeded;
}
if (usedMemory + memoryAllocation > agentInfo.MaxMemory) {
return LaunchInstanceResult.MemoryLimitExceeded;
}
if (usedPorts.Contains(serverPort)) {
return LaunchInstanceResult.ServerPortAlreadyInUse;
}
if (usedPorts.Contains(rconPort)) {
return LaunchInstanceResult.RconPortAlreadyInUse;
}
var ticket = new Ticket(Guid.NewGuid(), memoryAllocation, serverPort, rconPort);
activeTicketGuids.Add(ticket.TicketGuid);
usedMemory += memoryAllocation;
usedPorts.Add(serverPort);
usedPorts.Add(rconPort);
RefreshAgentStatus();
Logger.Debug("Reserved ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes);
return ticket;
}
}
public bool IsValid(Ticket ticket) {
lock (this) {
return activeTicketGuids.Contains(ticket.TicketGuid);
}
}
public void Release(Ticket ticket) {
lock (this) {
if (!activeTicketGuids.Remove(ticket.TicketGuid)) {
return;
}
usedMemory -= ticket.MemoryAllocation;
usedPorts.Remove(ticket.ServerPort);
usedPorts.Remove(ticket.RconPort);
RefreshAgentStatus();
Logger.Debug("Released ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes);
}
}
public void RefreshAgentStatus() {
lock (this) {
reportStatusQueue.Enqueue(new ReportAgentStatusMessage(activeTicketGuids.Count, usedMemory));
}
}
public async Task Shutdown() {
await reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5));
}
public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort);
}

View File

@@ -1,58 +0,0 @@
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances;
sealed class PortManager {
private readonly AllowedPorts allowedServerPorts;
private readonly AllowedPorts allowedRconPorts;
private readonly HashSet<ushort> usedPorts = new ();
public PortManager(AllowedPorts allowedServerPorts, AllowedPorts allowedRconPorts) {
this.allowedServerPorts = allowedServerPorts;
this.allowedRconPorts = allowedRconPorts;
}
public Result Reserve(InstanceConfiguration configuration) {
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!allowedServerPorts.Contains(serverPort)) {
return Result.ServerPortNotAllowed;
}
if (!allowedRconPorts.Contains(rconPort)) {
return Result.RconPortNotAllowed;
}
lock (usedPorts) {
if (usedPorts.Contains(serverPort)) {
return Result.ServerPortAlreadyInUse;
}
if (usedPorts.Contains(rconPort)) {
return Result.RconPortAlreadyInUse;
}
usedPorts.Add(serverPort);
usedPorts.Add(rconPort);
}
return Result.Success;
}
public void Release(InstanceConfiguration configuration) {
lock (usedPorts) {
usedPorts.Remove(configuration.ServerPort);
usedPorts.Remove(configuration.RconPort);
}
}
public enum Result {
Success,
ServerPortNotAllowed,
ServerPortAlreadyInUse,
RconPortNotAllowed,
RconPortAlreadyInUse
}
}

View File

@@ -1,28 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
namespace Phantom.Agent.Services.Instances.Sessions;
sealed class InstanceSession : IDisposable {
private readonly InstanceProcess process;
private readonly InstanceContext context;
private readonly InstanceLogSender logSender;
public InstanceSession(InstanceProcess process, InstanceContext context) {
this.process = process;
this.context = context;
this.logSender = new InstanceLogSender(context.Services.TaskManager, context.Configuration.InstanceGuid, context.ShortName);
this.process.AddOutputListener(SessionOutput);
}
private void SessionOutput(object? sender, string line) {
context.Logger.Debug("[Server] {Line}", line);
logSender.Enqueue(line);
}
public void Dispose() {
logSender.Stop();
process.Dispose();
context.Services.PortManager.Release(context.Configuration);
}
}

View File

@@ -0,0 +1,86 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.State;
static class InstanceLaunchProcedure {
public static async Task<InstanceRunningState?> Run(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager ticketManager, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
context.Logger.Information("Session starting...");
Result<InstanceProcess, InstanceLaunchFailReason> result;
if (ticketManager.IsValid(ticket)) {
try {
result = await LaunchInstance(context, launcher, reportStatus, cancellationToken);
} catch (OperationCanceledException) {
reportStatus(InstanceStatus.NotRunning);
return null;
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while launching instance.");
result = InstanceLaunchFailReason.UnknownError;
}
}
else {
context.Logger.Error("Attempted to launch instance with an invalid ticket!");
result = InstanceLaunchFailReason.UnknownError;
}
if (result) {
reportStatus(InstanceStatus.Running);
context.ReportEvent(InstanceEvent.LaunchSucceeded);
return new InstanceRunningState(context, configuration, launcher, ticket, result.Value, cancellationToken);
}
else {
reportStatus(InstanceStatus.Failed(result.Error));
context.ReportEvent(new InstanceLaunchFailedEvent(result.Error));
return null;
}
}
private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstance(InstanceContext context, IServerLauncher launcher, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
cancellationToken.ThrowIfCancellationRequested();
byte lastDownloadProgress = byte.MaxValue;
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, val2: 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
reportStatus(InstanceStatus.Downloading(progress));
}
}
switch (await launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken)) {
case LaunchResult.Success launchSuccess:
return launchSuccess.Process;
case LaunchResult.InvalidJavaRuntime:
context.Logger.Error("Session failed to launch, invalid Java runtime.");
return InstanceLaunchFailReason.JavaRuntimeNotFound;
case LaunchResult.CouldNotDownloadMinecraftServer:
context.Logger.Error("Session failed to launch, could not download Minecraft server.");
return InstanceLaunchFailReason.CouldNotDownloadMinecraftServer;
case LaunchResult.CouldNotPrepareMinecraftServerLauncher:
context.Logger.Error("Session failed to launch, could not prepare Minecraft server launcher.");
return InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher;
case LaunchResult.CouldNotConfigureMinecraftServer:
context.Logger.Error("Session failed to launch, could not configure Minecraft server.");
return InstanceLaunchFailReason.CouldNotConfigureMinecraftServer;
case LaunchResult.CouldNotStartMinecraftServer:
context.Logger.Error("Session failed to launch, could not start Minecraft server.");
return InstanceLaunchFailReason.CouldNotStartMinecraftServer;
default:
context.Logger.Error("Session failed to launch.");
return InstanceLaunchFailReason.UnknownError;
}
}
}

View File

@@ -0,0 +1,100 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Channels;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly BoundedChannelOptions BufferOptions = new (capacity: 200) {
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.DropNewest,
};
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly Channel<string> outputChannel;
private int droppedLinesSinceLastSend;
public InstanceLogSender(ControllerConnection controllerConnection, Guid instanceGuid, string loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName)) {
this.controllerConnection = controllerConnection;
this.instanceGuid = instanceGuid;
this.outputChannel = Channel.CreateBounded<string>(BufferOptions, OnLineDropped);
Start();
}
protected override async Task RunTask() {
var lineReader = outputChannel.Reader;
var lineBuilder = ImmutableArray.CreateBuilder<string>();
using var sendOutputCancellationTokenSource = new CancellationTokenSource();
await using var sendOutputCancellationRegistration = CancellationToken.Register([SuppressMessage("ReSharper", "AccessToDisposedClosure")]() => {
sendOutputCancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(10));
});
var sendOutputCancellationToken = sendOutputCancellationTokenSource.Token;
try {
while (await lineReader.WaitToReadAsync(CancellationToken)) {
await Task.Delay(SendDelay, CancellationToken);
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
try {
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
} catch (OperationCanceledException) {
// Ignore.
}
}
private ImmutableArray<string> ReadLinesFromChannel(ChannelReader<string> reader, ImmutableArray<string>.Builder builder) {
builder.Clear();
while (reader.TryRead(out string? line)) {
builder.Add(line);
}
int droppedLines = Interlocked.Exchange(ref droppedLinesSinceLastSend, value: 0);
if (droppedLines > 0) {
builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow.");
}
return builder.ToImmutable();
}
private ValueTask SendOutputToServer(ImmutableArray<string> lines, CancellationToken cancellationToken) {
if (lines.IsEmpty) {
return ValueTask.CompletedTask;
}
else {
return controllerConnection.Send(new InstanceOutputMessage(instanceGuid, lines), cancellationToken);
}
}
private void OnLineDropped(string line) {
Logger.Warning("Buffer is full, dropped line: {Line}", line);
Interlocked.Increment(ref droppedLinesSinceLastSend);
}
public void Enqueue(string line) {
outputChannel.Writer.TryWrite(line);
}
protected override void Dispose() {
if (!outputChannel.Writer.TryComplete()) {
Logger.Error("Could not mark channel as completed.");
}
}
}

View File

@@ -0,0 +1,150 @@
using System.Net.Sockets;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly ushort serverPort;
private readonly InstanceProcess process;
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new ();
private bool WaitingForFirstDetection => !firstDetection.Task.IsCompleted;
private InstancePlayerCounts? playerCounts;
private event EventHandler<int?>? OnlinePlayerCountChanged;
private bool isDisposed = false;
public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) {
this.controllerConnection = context.Services.ControllerConnection;
this.instanceGuid = context.InstanceGuid;
this.process = process;
this.serverPort = serverPort;
Start();
}
protected override async Task RunTask() {
// Give the server time to start accepting connections.
await Task.Delay(TimeSpan.FromSeconds(5), CancellationToken);
serverOutputEvent.Set();
process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
while (CancellationToken.Check()) {
serverOutputEvent.Reset();
InstancePlayerCounts? latestPlayerCounts = await TryGetPlayerCounts();
UpdatePlayerCounts(latestPlayerCounts);
if (latestPlayerCounts == null) {
await Task.Delay(WaitingForFirstDetection ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(10), CancellationToken);
}
else {
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
}
}
}
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try {
return await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
} catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error("{Message}", e.Message);
return null;
} catch (SocketException e) {
bool waitingForServerStart = e.SocketErrorCode == SocketError.ConnectionRefused && WaitingForFirstDetection;
if (!waitingForServerStart) {
Logger.Warning("Could not check online player count. Socket error {ErrorCode} ({ErrorCodeName}), reason: {ErrorMessage}", e.ErrorCode, e.SocketErrorCode, e.Message);
}
return null;
} catch (Exception e) {
Logger.Error(e, "Caught exception while checking online player count.");
return null;
}
}
private void UpdatePlayerCounts(InstancePlayerCounts? newPlayerCounts) {
if (newPlayerCounts is {} value) {
Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", value.Online, value.Maximum);
firstDetection.TrySetResult();
}
EventHandler<int?>? onlinePlayerCountChanged;
lock (this) {
if (playerCounts == newPlayerCounts) {
return;
}
playerCounts = newPlayerCounts;
onlinePlayerCountChanged = OnlinePlayerCountChanged;
}
onlinePlayerCountChanged?.Invoke(this, newPlayerCounts?.Online);
if (!controllerConnection.TrySend(new ReportInstancePlayerCountsMessage(instanceGuid, newPlayerCounts))) {
Logger.Warning("Could not report online player count to Controller.");
}
}
public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
await firstDetection.Task.WaitAsync(cancellationToken);
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) {
if (playerCounts is { Online: > 0 }) {
return;
}
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
void OnOnlinePlayerCountChanged(object? sender, int? newPlayerCount) {
if (newPlayerCount == null) {
onlinePlayersDetected.TrySetException(new InvalidOperationException());
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
else if (newPlayerCount > 0) {
onlinePlayersDetected.TrySetResult();
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
}
}
await onlinePlayersDetected.Task;
}
private void OnOutput(object? sender, string? line) {
lock (this) {
if (!isDisposed) {
serverOutputEvent.Set();
}
}
}
protected override void Dispose() {
lock (this) {
isDisposed = true;
playerCounts = null;
}
process.RemoveOutputListener(OnOutput);
serverOutputEvent.Dispose();
}
}

View File

@@ -0,0 +1,123 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstanceRunningState : IDisposable {
public InstanceTicketManager.Ticket Ticket { get; }
public InstanceProcess Process { get; }
internal bool IsStopping { get; set; }
private readonly InstanceContext context;
private readonly InstanceConfiguration configuration;
private readonly IServerLauncher launcher;
private readonly CancellationToken cancellationToken;
private readonly InstanceLogSender logSender;
private readonly InstancePlayerCountTracker playerCountTracker;
private readonly BackupScheduler backupScheduler;
private bool isDisposed;
public InstanceRunningState(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process, CancellationToken cancellationToken) {
this.context = context;
this.configuration = configuration;
this.launcher = launcher;
this.Ticket = ticket;
this.Process = process;
this.cancellationToken = cancellationToken;
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort);
this.backupScheduler = new BackupScheduler(context, playerCountTracker);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
}
public void Initialize() {
Process.Ended += ProcessEnded;
if (Process.HasEnded) {
if (TryDispose()) {
context.Logger.Warning("Session ended immediately after it was started.");
context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
}
}
else {
context.Logger.Information("Session started.");
Process.AddOutputListener(SessionOutput);
}
}
private void SessionOutput(object? sender, string line) {
context.Logger.Debug("[Server] {Line}", line);
logSender.Enqueue(line);
}
private void ProcessEnded(object? sender, EventArgs e) {
if (!TryDispose()) {
return;
}
if (cancellationToken.IsCancellationRequested) {
return;
}
if (IsStopping) {
context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.NotRunning));
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed);
context.Actor.Tell(new InstanceActor.LaunchInstanceCommand(configuration, launcher, Ticket, IsRestarting: true));
}
}
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
}
public async Task<SendCommandToInstanceResult> SendCommand(string command, CancellationToken cancellationToken) {
try {
context.Logger.Information("Sending command: {Command}", command);
await Process.SendCommand(command, cancellationToken);
return SendCommandToInstanceResult.Success;
} catch (OperationCanceledException) {
return SendCommandToInstanceResult.UnknownError;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending command.");
return SendCommandToInstanceResult.UnknownError;
}
}
public void OnStopInitiated() {
backupScheduler.Stop();
playerCountTracker.Stop();
}
private bool TryDispose() {
lock (this) {
if (isDisposed) {
return false;
}
isDisposed = true;
}
OnStopInitiated();
logSender.Stop();
Process.Dispose();
return true;
}
public void Dispose() {
TryDispose();
}
}

View File

@@ -0,0 +1,99 @@
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Agent.Services.Instances.State;
static class InstanceStopProcedure {
private static readonly ushort[] Stops = [60, 30, 10, 5, 4, 3, 2, 1, 0];
public static async Task<bool> Run(InstanceContext context, MinecraftStopStrategy stopStrategy, InstanceRunningState runningState, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
var process = runningState.Process;
runningState.IsStopping = true;
var seconds = stopStrategy.Seconds;
if (seconds > 0) {
try {
await CountDownWithAnnouncements(context, process, seconds, cancellationToken);
} catch (OperationCanceledException) {
runningState.IsStopping = false;
return false;
}
}
try {
// Too late to cancel the stop procedure now.
runningState.OnStopInitiated();
if (!process.HasEnded) {
context.Logger.Information("Session stopping now.");
await DoStop(context, process);
}
} finally {
context.Logger.Information("Session stopped.");
reportStatus(InstanceStatus.NotRunning);
context.ReportEvent(InstanceEvent.Stopped);
}
return true;
}
private static async Task CountDownWithAnnouncements(InstanceContext context, InstanceProcess process, ushort seconds, CancellationToken cancellationToken) {
context.Logger.Information("Session stopping in {Seconds} seconds.", seconds);
foreach (var stop in Stops) {
// TODO change to event-based cancellation
if (process.HasEnded) {
return;
}
if (seconds > stop) {
await process.SendCommand(GetCountDownAnnouncementCommand(seconds), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
}
private static string GetCountDownAnnouncementCommand(ushort seconds) {
return MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds."));
}
private static async Task DoStop(InstanceContext context, InstanceProcess process) {
context.Logger.Information("Sending stop command...");
await TrySendStopCommand(context, process);
context.Logger.Information("Waiting for session to end...");
await WaitForSessionToEnd(context, process);
}
private static async Task TrySendStopCommand(InstanceContext context, InstanceProcess process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await process.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// Ignore.
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && process.HasEnded) {
// Ignore.
} catch (IOException e) when (e.HResult == -2147024664 /* The pipe is being closed */) {
// Ignore.
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
try {
await process.WaitForExit(TimeSpan.FromSeconds(55));
} catch (TimeoutException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
process.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
}

View File

@@ -1,11 +0,0 @@
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
interface IInstanceState {
void Initialize();
(IInstanceState, LaunchInstanceResult) Launch(InstanceContext context);
(IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy);
Task<bool> SendCommand(string command, CancellationToken cancellationToken);
}

View File

@@ -1,127 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances.Sessions;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceLaunchingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private byte lastDownloadProgress = byte.MaxValue;
public InstanceLaunchingState(InstanceContext context) {
this.context = context;
}
public void Initialize() {
context.Logger.Information("Session starting...");
var launchTask = context.Services.TaskManager.Run("Launch procedure for instance " + context.ShortName, DoLaunch);
launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default);
}
private async Task<InstanceProcess> DoLaunch() {
var cancellationToken = cancellationTokenSource.Token;
cancellationToken.ThrowIfCancellationRequested();
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.SetStatus(InstanceStatus.Downloading(progress));
}
}
var launchResult = await context.Launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
}
else if (launchResult is LaunchResult.InvalidJvmArguments) {
throw new LaunchFailureException(InstanceLaunchFailReason.InvalidJvmArguments, "Session failed to launch, invalid JVM arguments.");
}
else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher.");
}
else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotStartMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server.");
}
if (launchResult is not LaunchResult.Success launchSuccess) {
throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.");
}
context.SetStatus(InstanceStatus.Launching);
return launchSuccess.Process;
}
private void OnLaunchSuccess(Task<InstanceProcess> task) {
context.TransitionState(() => {
context.ReportEvent(InstanceEvent.LaunchSucceded);
var process = task.Result;
var session = new InstanceSession(process, context);
if (cancellationTokenSource.IsCancellationRequested) {
return (new InstanceStoppingState(context, process, session), InstanceStatus.Stopping);
}
else {
return (new InstanceRunningState(context, process, session), null);
}
});
}
private void OnLaunchFailure(Task task) {
if (task.IsFaulted) {
if (task.Exception is { InnerException: LaunchFailureException e }) {
context.Logger.Error(e.LogMessage);
context.SetLaunchFailedStatusAndReportEvent(e.Reason);
}
else {
context.Logger.Error(task.Exception, "Caught exception while launching instance.");
context.SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError);
}
}
context.Services.PortManager.Release(context.Configuration);
context.TransitionState(new InstanceNotRunningState());
}
private sealed class LaunchFailureException : Exception {
public InstanceLaunchFailReason Reason { get; }
public string LogMessage { get; }
public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) {
this.Reason = reason;
this.LogMessage = logMessage;
}
}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceAlreadyLaunching);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
cancellationTokenSource.Cancel();
return (this, StopInstanceResult.StopInitiated);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
cancellationTokenSource.Dispose();
}
}

View File

@@ -1,35 +0,0 @@
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceNotRunningState : IInstanceState {
public void Initialize() {}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(context.Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,
PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse,
_ => null
};
if (failReason is {} reason) {
context.SetLaunchFailedStatusAndReportEvent(reason);
return (this, LaunchInstanceResult.LaunchInitiated);
}
context.SetStatus(InstanceStatus.Launching);
return (new InstanceLaunchingState(context), LaunchInstanceResult.LaunchInitiated);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopped);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
}

View File

@@ -1,178 +0,0 @@
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.Sessions;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceRunningState : IInstanceState {
private readonly InstanceContext context;
private readonly InstanceProcess process;
private readonly BackupScheduler backupScheduler;
private readonly RunningSessionDisposer runningSessionDisposer;
private readonly CancellationTokenSource delayedStopCancellationTokenSource = new ();
private bool stateOwnsDelayedStopCancellationTokenSource = true;
private bool isStopping;
public InstanceRunningState(InstanceContext context, InstanceProcess process, InstanceSession session) {
this.context = context;
this.process = process;
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context.Configuration.ServerPort, context.ShortName);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
this.runningSessionDisposer = new RunningSessionDisposer(this, session);
}
public void Initialize() {
process.Ended += ProcessEnded;
if (process.HasEnded) {
if (runningSessionDisposer.Dispose()) {
context.Logger.Warning("Session ended immediately after it was started.");
context.ReportEvent(InstanceEvent.Stopped);
context.Services.TaskManager.Run("Transition state of instance " + context.ShortName + " to not running", () => context.TransitionState(new InstanceNotRunningState(), InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
}
}
else {
context.SetStatus(InstanceStatus.Running);
context.Logger.Information("Session started.");
}
}
private void ProcessEnded(object? sender, EventArgs e) {
if (!runningSessionDisposer.Dispose()) {
return;
}
if (isStopping) {
context.Logger.Information("Session ended.");
context.ReportEvent(InstanceEvent.Stopped);
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed);
context.TransitionState(new InstanceLaunchingState(context), InstanceStatus.Restarting);
}
}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceAlreadyRunning);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
if (stopStrategy == MinecraftStopStrategy.Instant) {
CancelDelayedStop();
return (PrepareStoppedState(), StopInstanceResult.StopInitiated);
}
if (isStopping) {
// TODO change delay or something
return (this, StopInstanceResult.InstanceAlreadyStopping);
}
isStopping = true;
context.Services.TaskManager.Run("Delayed stop timer for instance " + context.ShortName, () => StopLater(stopStrategy.Seconds));
return (this, StopInstanceResult.StopInitiated);
}
private IInstanceState PrepareStoppedState() {
process.Ended -= ProcessEnded;
backupScheduler.Stop();
return new InstanceStoppingState(context, process, runningSessionDisposer);
}
private void CancelDelayedStop() {
try {
delayedStopCancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// ignore
}
}
private async Task StopLater(int seconds) {
var cancellationToken = delayedStopCancellationTokenSource.Token;
try {
stateOwnsDelayedStopCancellationTokenSource = false;
int[] stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 };
foreach (var stop in stops) {
if (seconds > stop) {
await SendCommand(MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds.")), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
} catch (OperationCanceledException) {
context.Logger.Debug("Cancelled delayed stop.");
return;
} catch (ObjectDisposedException) {
return;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception during delayed stop.");
return;
} finally {
delayedStopCancellationTokenSource.Dispose();
}
context.TransitionState(PrepareStoppedState());
}
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
try {
context.Logger.Information("Sending command: {Command}", command);
await process.SendCommand(command, cancellationToken);
return true;
} catch (OperationCanceledException) {
return false;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending command.");
return false;
}
}
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings));
}
private sealed class RunningSessionDisposer : IDisposable {
private readonly InstanceRunningState state;
private readonly InstanceSession session;
private bool isDisposed;
public RunningSessionDisposer(InstanceRunningState state, InstanceSession session) {
this.state = state;
this.session = session;
}
public bool Dispose() {
lock (this) {
if (isDisposed) {
return false;
}
isDisposed = true;
}
if (state.stateOwnsDelayedStopCancellationTokenSource) {
state.delayedStopCancellationTokenSource.Dispose();
}
else {
state.CancelDelayedStop();
}
session.Dispose();
return true;
}
void IDisposable.Dispose() {
Dispose();
}
}
}

View File

@@ -1,89 +0,0 @@
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceStoppingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly InstanceProcess process;
private readonly IDisposable sessionDisposer;
public InstanceStoppingState(InstanceContext context, InstanceProcess process, IDisposable sessionDisposer) {
this.context = context;
this.process = process;
this.sessionDisposer = sessionDisposer;
}
public void Initialize() {
context.Logger.Information("Session stopping.");
context.SetStatus(InstanceStatus.Stopping);
context.Services.TaskManager.Run("Stop procedure for instance " + context.ShortName, DoStop);
}
private async Task DoStop() {
try {
// Do not release the semaphore after this point.
if (!await process.BackupSemaphore.CancelAndWait(TimeSpan.FromSeconds(1))) {
context.Logger.Information("Waiting for backup to finish...");
await process.BackupSemaphore.CancelAndWait(Timeout.InfiniteTimeSpan);
}
context.Logger.Information("Sending stop command...");
await DoSendStopCommand();
context.Logger.Information("Waiting for session to end...");
await DoWaitForSessionToEnd();
} finally {
context.Logger.Information("Session stopped.");
context.ReportEvent(InstanceEvent.Stopped);
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
}
private async Task DoSendStopCommand() {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await process.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// ignore
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && process.HasEnded) {
// ignore
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private async Task DoWaitForSessionToEnd() {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
try {
await process.WaitForExit(timeout.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
process.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceIsStopping);
}
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopping); // TODO maybe provide a way to kill?
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
sessionDisposer.Dispose();
}
}

View File

@@ -1,15 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerConnection(MessageSender<IMessageToController> sender) {
internal bool TrySend<TMessage>(TMessage message) where TMessage : IMessageToController {
return sender.TrySend(message);
}
internal ValueTask Send<TMessage>(TMessage message, CancellationToken cancellationToken) where TMessage : IMessageToController {
return sender.Send(message, cancellationToken);
}
internal Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TMessage : IMessageToController, ICanReply<TReply> {
return sender.Send<TMessage, TReply>(message, waitForReplyTime, cancellationToken);
}
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Immutable;
using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime;
using Phantom.Utils.Rpc.Runtime.Client;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerHandshake(AgentRegistration registration, AgentRegistrationHandler registrationHandler) : IRpcClientHandshake {
private const int MaxInstances = 100_000;
private const int MaxMessageBytes = 1024 * 1024 * 8;
private readonly ILogger logger = PhantomLogger.Create<ControllerHandshake>();
public async Task Perform(RpcStream stream, CancellationToken cancellationToken) {
logger.Information("Registering with the controller...");
ReadOnlyMemory<byte> serializedRegistration = MessageSerialization.Serialize(registration);
await stream.WriteSignedInt(serializedRegistration.Length, cancellationToken);
await stream.WriteBytes(serializedRegistration, cancellationToken);
await stream.Flush(cancellationToken);
if (await stream.ReadByte(cancellationToken) == 0) {
return;
}
uint configureInstanceMessageCount = await stream.ReadUnsignedInt(cancellationToken);
if (configureInstanceMessageCount > MaxInstances) {
throw new InvalidOperationException("Trying to configure too many instances (" + configureInstanceMessageCount + " > " + MaxInstances + ").");
}
var configureInstanceMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
for (int index = 0; index < configureInstanceMessageCount; index++) {
int serializedMessageLength = await stream.ReadSignedInt(cancellationToken);
if (serializedMessageLength is < 0 or > MaxMessageBytes) {
throw new InvalidOperationException("Message must be between 0 and " + MaxMessageBytes + " bytes.");
}
var serializedMessage = await stream.ReadBytes(serializedMessageLength, cancellationToken);
configureInstanceMessages.Add(MessageSerialization.Deserialize<ConfigureInstanceMessage>(serializedMessage));
}
registrationHandler.OnRegistrationComplete(configureInstanceMessages.ToImmutable());
logger.Information("Registration complete.");
}
}

View File

@@ -0,0 +1,43 @@
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Actor;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent> {
public readonly record struct Init(AgentServices Agent);
public static Props<IMessageToAgent> Factory(Init init) {
return Props<IMessageToAgent>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
}
private readonly AgentServices agent;
private ControllerMessageHandlerActor(Init init) {
this.agent = init.Agent;
ReceiveAndReplyLater<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(HandleConfigureInstance);
ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance);
ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance);
}
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, AlwaysReportStatus: false));
}
private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.LaunchInstanceCommand(message.InstanceGuid));
}
private async Task<Result<StopInstanceResult, InstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.StopInstanceCommand(message.InstanceGuid, message.StopStrategy));
}
private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command));
}
}

View File

@@ -0,0 +1,11 @@
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerMessageReceiver(ActorRef<IMessageToAgent> actor, AgentRegistrationHandler agentRegistrationHandler) : IMessageReceiver<IMessageToAgent>.Actor(actor) {
public override void OnSessionRestarted() {
agentRegistrationHandler.OnNewSession();
}
}

View File

@@ -0,0 +1,53 @@
using System.Threading.Channels;
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
sealed class ControllerSendQueue<TMessage> where TMessage : IMessageToController {
private readonly ILogger logger;
private readonly Channel<TMessage> channel;
private readonly Task sendTask;
private readonly CancellationTokenSource shutdownTokenSource = new ();
public ControllerSendQueue(ControllerConnection controllerConnection, string loggerName, int capacity, bool singleWriter) {
this.logger = PhantomLogger.Create<ControllerSendQueue<TMessage>>(loggerName);
this.channel = Channel.CreateBounded<TMessage>(new BoundedChannelOptions(capacity) {
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = singleWriter,
});
this.sendTask = Send(controllerConnection, shutdownTokenSource.Token);
}
private async Task Send(ControllerConnection controllerConnection, CancellationToken cancellationToken) {
await foreach (var message in channel.Reader.ReadAllAsync(cancellationToken)) {
await controllerConnection.Send(message, cancellationToken);
}
}
public void Enqueue(TMessage message) {
channel.Writer.TryWrite(message);
}
public async Task Shutdown(TimeSpan gracefulTimeout) {
channel.Writer.TryComplete();
try {
await sendTask.WaitAsync(gracefulTimeout);
} catch (TimeoutException) {
logger.Warning("Timed out waiting for queue to finish processing.");
} catch (Exception) {
// Ignore.
}
await shutdownTokenSource.CancelAsync();
await sendTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
shutdownTokenSource.Dispose();
}
}

View File

@@ -1,86 +0,0 @@
using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Rpc.Message;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
public sealed class MessageListener : IMessageToAgentListener {
private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>();
private readonly RpcServerConnection connection;
private readonly AgentServices agent;
private readonly CancellationTokenSource shutdownTokenSource;
public MessageListener(RpcServerConnection connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
this.connection = connection;
this.agent = agent;
this.shutdownTokenSource = shutdownTokenSource;
}
public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful.");
void ShutdownAfterConfigurationFailed(InstanceConfiguration configuration) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, configuration.InstanceGuid);
shutdownTokenSource.Cancel();
}
foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) {
var result = await HandleConfigureInstance(configureInstanceMessage, alwaysReportStatus: true);
if (!result.Is(ConfigureInstanceResult.Success)) {
ShutdownAfterConfigurationFailed(configureInstanceMessage.Configuration);
return NoReply.Instance;
}
}
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
await agent.InstanceSessionManager.RefreshAgentStatus();
return NoReply.Instance;
}
public Task<NoReply> HandleRegisterAgentFailure(RegisterAgentFailureMessage message) {
string errorMessage = message.FailureKind switch {
RegisterAgentFailure.ConnectionAlreadyHasAnAgent => "This connection already has an associated agent.",
RegisterAgentFailure.InvalidToken => "Invalid token.",
_ => "Unknown error " + (byte) message.FailureKind + "."
};
Logger.Fatal("Agent authentication failed: {Error}", errorMessage);
Environment.Exit(1);
return Task.FromResult(NoReply.Instance);
}
private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) {
return agent.InstanceSessionManager.Configure(message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus);
}
public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await HandleConfigureInstance(message, alwaysReportStatus: false);
}
public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceSessionManager.Launch(message.InstanceGuid);
}
public async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceSessionManager.Stop(message.InstanceGuid, message.StopStrategy);
}
public async Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceSessionManager.SendCommand(message.InstanceGuid, message.Command);
}
public Task<NoReply> HandleReply(ReplyMessage message) {
connection.Receive(message);
return Task.FromResult(NoReply.Instance);
}
}

View File

@@ -1,16 +1,16 @@
using NetMQ;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using System.Text;
using Phantom.Common.Data;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent;
static class AgentKey {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey));
public static Task<(NetMQCertificate, AgentAuthToken)?> Load(string? agentKeyToken, string? agentKeyFilePath) {
public static Task<ConnectionKey?> Load(string? agentKeyToken, string? agentKeyFilePath) {
if (agentKeyFilePath != null) {
return LoadFromFile(agentKeyFilePath);
}
@@ -21,27 +21,28 @@ static class AgentKey {
throw new InvalidOperationException();
}
}
private static async Task<(NetMQCertificate, AgentAuthToken)?> LoadFromFile(string agentKeyFilePath) {
private static async Task<ConnectionKey?> LoadFromFile(string agentKeyFilePath) {
if (!File.Exists(agentKeyFilePath)) {
Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath);
return null;
}
try {
Files.RequireMaximumFileSize(agentKeyFilePath, 64);
return LoadFromBytes(await File.ReadAllBytesAsync(agentKeyFilePath));
Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 64);
string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]);
} catch (IOException e) {
Logger.Fatal("Error loading agent key from file: {AgentKeyFilePath}", agentKeyFilePath);
Logger.Fatal(e.Message);
Logger.Fatal("{Message}", e.Message);
return null;
} catch (Exception) {
Logger.Fatal("File does not contain a valid agent key: {AgentKeyFilePath}", agentKeyFilePath);
return null;
}
}
private static (NetMQCertificate, AgentAuthToken)? LoadFromToken(string agentKey) {
private static ConnectionKey? LoadFromToken(string agentKey) {
try {
return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey));
} catch (Exception) {
@@ -49,12 +50,10 @@ static class AgentKey {
return null;
}
}
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
var serverCertificate = NetMQCertificate.FromPublicKey(publicKey);
private static ConnectionKey? LoadFromBytes(byte[] agentKey) {
var connectionKey = ConnectionKey.FromBytes(agentKey);
Logger.Information("Loaded agent key.");
return (serverCertificate, agentToken);
return connectionKey;
}
}

View File

@@ -1,44 +0,0 @@
using System.Text;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent;
static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile));
private const string GuidFileName = "agent.guid";
public static async Task<Guid?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, GuidFileName);
if (File.Exists(filePath)) {
try {
var guid = await LoadGuidFromFile(filePath);
Logger.Information("Loaded existing agent GUID file.");
return guid;
} catch (Exception e) {
Logger.Fatal("Error reading agent GUID file: {Message}", e.Message);
return null;
}
}
Logger.Information("Creating agent GUID file: {FilePath}", filePath);
try {
var guid = Guid.NewGuid();
await File.WriteAllTextAsync(filePath, guid.ToString(), Encoding.ASCII);
return guid;
} catch (Exception e) {
Logger.Fatal("Error creating agent GUID file: {Message}", e.Message);
return null;
}
}
private static async Task<Guid> LoadGuidFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, 128);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return Guid.Parse(contents.Trim());
}
}

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -12,8 +11,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Services\Phantom.Agent.Services.csproj" />
</ItemGroup>

View File

@@ -1,77 +1,102 @@
using System.Reflection;
using Phantom.Agent;
using Phantom.Agent.Rpc;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Services;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Rpc;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Utils.Actor;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime.Client;
using Phantom.Utils.Runtime;
using Phantom.Utils.Threading;
const int ProtocolVersion = 1;
var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
ProgramCulture.UseInvariantCulture();
ThreadPool.SetMinThreads(workerThreads: 2, completionPortThreads: 1);
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent...");
});
try {
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) {
Environment.Exit(1);
return 1;
}
var folders = new AgentFolders("./data", "./temp", javaSearchPath);
if (!folders.TryCreate()) {
Environment.Exit(1);
return 1;
}
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath);
if (agentGuid == null) {
Environment.Exit(1);
var agentInfo = new AgentInfo(ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken);
var agentRegistrationHandler = new AgentRegistrationHandler();
var controllerHandshake = new ControllerHandshake(new AgentRegistration(agentInfo, javaRuntimeRepository.All), agentRegistrationHandler);
var rpcClientConnectionParameters = new RpcClientConnectionParameters(
Host: controllerHost,
Port: controllerPort,
DistinguishedName: "phantom-controller",
CertificateThumbprint: agentKey.Value.CertificateThumbprint,
AuthToken: agentKey.Value.AuthToken,
Handshake: controllerHandshake,
MessageQueueCapacity: 250,
FrameQueueCapacity: 500,
MaxConcurrentlyHandledMessages: 50
);
using var rpcClient = await RpcClient<IMessageToController, IMessageToAgent>.Connect("Controller", rpcClientConnectionParameters, AgentMessageRegistries.Registries, shutdownCancellationToken);
if (rpcClient == null) {
PhantomLogger.Root.Fatal("Could not connect to Phantom Controller, shutting down.");
return 1;
}
var (serverCertificate, agentToken) = agentKey.Value;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var agentServices = new AgentServices(agentInfo, folders);
MessageListener MessageListenerFactory(RpcServerConnection connection) {
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);
}
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
await agentServices.Initialize();
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate);
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
try {
await rpcTask.WaitAsync(shutdownCancellationToken);
} finally {
shutdownCancellationTokenSource.Cancel();
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks), new ControllerConnection(rpcClient.MessageSender), javaRuntimeRepository);
var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(agentServices);
var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler");
rpcClient.StartListening(new ControllerMessageReceiver(rpcMessageHandlerActor, agentRegistrationHandler));
if (await agentRegistrationHandler.Start(agentServices, shutdownCancellationToken)) {
PhantomLogger.Root.Information("Phantom Panel agent is ready.");
await shutdownCancellationToken.WaitHandle.WaitOneAsync();
}
await agentServices.Shutdown();
rpcDisconnectSemaphore.Release();
await rpcTask;
rpcDisconnectSemaphore.Dispose();
} finally {
await rpcClient.Shutdown();
}
return 0;
} catch (OperationCanceledException) {
// Ignore.
return 0;
} catch (StopProcedureException) {
return 1;
} catch (Exception e) {
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
return 1;
} finally {
shutdownCancellationTokenSource.Dispose();
PhantomLogger.Root.Information("Bye!");
PhantomLogger.Dispose();
}

View File

@@ -1,51 +1,50 @@
using Phantom.Agent.Minecraft.Java;
using Phantom.Common.Data;
using Phantom.Common.Logging;
using Phantom.Utils.Logging;
using Phantom.Utils.Runtime;
namespace Phantom.Agent;
sealed record Variables(
string ServerHost,
ushort ServerPort,
string ControllerHost,
ushort ControllerPort,
string JavaSearchPath,
string? AgentKeyToken,
string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances,
RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts
AllowedPorts AllowedRconPorts,
ushort MaxConcurrentBackupCompressionTasks
) {
private static Variables LoadOrThrow() {
var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require;
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
return new Variables(
EnvironmentVariables.GetString("SERVER_HOST").Require,
EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401),
EnvironmentVariables.GetString("CONTROLLER_HOST").Require,
EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401),
javaSearchPath,
agentKeyToken,
agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require,
(ushort) EnvironmentVariables.GetInteger("MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS", min: 1, max: 10000).WithDefault(1)
);
}
private static string GetDefaultJavaSearchPath() {
return JavaRuntimeDiscovery.GetSystemSearchPath() ?? throw new Exception("Could not automatically determine the path to Java installations on this system. Please set the JAVA_SEARCH_PATH environment variable to the folder containing Java installations.");
}
public static Variables LoadOrExit() {
public static Variables LoadOrStop() {
try {
return LoadOrThrow();
} catch (Exception e) {
PhantomLogger.Root.Fatal(e.Message);
Environment.Exit(1);
throw;
throw StopProcedureException.Instance;
}
}
}

View File

@@ -1,6 +1,6 @@
using NUnit.Framework;
namespace Phantom.Common.Data.Tests;
namespace Phantom.Common.Data.Tests;
[TestFixture]
public sealed class AllowedPortsTests {

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
@@ -15,7 +14,6 @@
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>

View File

@@ -8,7 +8,7 @@ public sealed class RamAllocationUnitsTests {
private Action CallFromMegabytes(int value) {
return () => RamAllocationUnits.FromMegabytes(value);
}
[TestCase(1)]
[TestCase(-1)]
[TestCase(255)]
@@ -24,13 +24,13 @@ public sealed class RamAllocationUnitsTests {
public void LessThan256MegabytesThrows(int value) {
Assert.That(CallFromMegabytes(value), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must be at least 0 MB."));
}
[TestCase(16777216)]
[TestCase(int.MaxValue - 255)]
public void MoreThan16TerabytesThrows(int value) {
Assert.That(CallFromMegabytes(value), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must be at most " + (256 * 65535) + " MB."));
}
[TestCase(0)]
[TestCase(256)]
[TestCase(512)]
@@ -41,17 +41,17 @@ public sealed class RamAllocationUnitsTests {
Assert.That(RamAllocationUnits.FromMegabytes(value).InMegabytes, Is.EqualTo(value));
}
}
public sealed class FromString {
private Action CallFromString(string definition) {
return () => RamAllocationUnits.FromString(definition);
}
[Test]
public void EmptyThrows() {
Assert.That(CallFromString(""), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must not be empty."));
}
[Test]
public void MissingUnitThrows() {
Assert.That(CallFromString("256"), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must end with "));
@@ -66,23 +66,23 @@ public sealed class RamAllocationUnitsTests {
public void UnparseableValueThrows() {
Assert.That(CallFromString("123A5M"), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must begin with a number."));
}
[TestCase("0m", 0)]
[TestCase("256m", 256)]
[TestCase("256M", 256)]
[TestCase("512M", 512)]
[TestCase("65536M", 65536)]
[TestCase("0m", arg2: 0)]
[TestCase("256m", arg2: 256)]
[TestCase("256M", arg2: 256)]
[TestCase("512M", arg2: 512)]
[TestCase("65536M", arg2: 65536)]
[TestCase("16776960M", 16777216 - 256)]
public void ValidDefinitionInMegabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
}
[TestCase("0g", 0)]
[TestCase("1g", 1024)]
[TestCase("1G", 1024)]
[TestCase("8G", 8192)]
[TestCase("64G", 65536)]
[TestCase("16383G", 16776192)]
[TestCase("0g", arg2: 0)]
[TestCase("1g", arg2: 1024)]
[TestCase("1G", arg2: 1024)]
[TestCase("8G", arg2: 8192)]
[TestCase("64G", arg2: 65536)]
[TestCase("16383G", arg2: 16776192)]
public void ValidDefinitionInGigabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Agent(
[property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] string Name,
[property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey,
[property: MemoryPackOrder(3)] AgentConfiguration Configuration,
[property: MemoryPackOrder(4)] AgentStats? Stats,
[property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus
) {
[MemoryPackIgnore]
public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory;
}

View File

@@ -0,0 +1,18 @@
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentConfiguration(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -0,0 +1,27 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(AgentIsOffline))]
[MemoryPackUnion(tag: 1, typeof(AgentIsDisconnected))]
[MemoryPackUnion(tag: 2, typeof(AgentIsOnline))]
public partial interface IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsOffline : IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsDisconnected([property: MemoryPackOrder(0)] DateTimeOffset LastPingTime) : IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsOnline : IAgentConnectionStatus;
public static class AgentConnectionStatus {
public static readonly IAgentConnectionStatus Offline = new AgentIsOffline();
public static readonly IAgentConnectionStatus Online = new AgentIsOnline();
public static IAgentConnectionStatus Disconnected(DateTimeOffset lastPingTime) {
return new AgentIsDisconnected(lastPingTime);
}
}

View File

@@ -1,4 +1,4 @@
namespace Phantom.Server.Database.Enums;
namespace Phantom.Common.Data.Web.AuditLog;
public enum AuditLogEventType {
AdministratorUserCreated,
@@ -6,13 +6,14 @@ public enum AuditLogEventType {
UserLoggedIn,
UserLoggedOut,
UserCreated,
UserPasswordChanged,
UserRolesChanged,
UserDeleted,
InstanceCreated,
InstanceEdited,
InstanceLaunched,
InstanceStopped,
InstanceCommandExecuted
InstanceCommandExecuted,
}
public static class AuditLogEventTypeExtensions {
@@ -22,15 +23,16 @@ public static class AuditLogEventTypeExtensions {
{ AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User },
{ AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User },
{ AuditLogEventType.UserCreated, AuditLogSubjectType.User },
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceStopped, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceCommandExecuted, AuditLogSubjectType.Instance }
{ AuditLogEventType.InstanceCommandExecuted, AuditLogSubjectType.Instance },
};
static AuditLogEventTypeExtensions() {
foreach (var eventType in Enum.GetValues<AuditLogEventType>()) {
if (!SubjectTypes.ContainsKey(eventType)) {
@@ -38,8 +40,8 @@ public static class AuditLogEventTypeExtensions {
}
}
}
internal static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) {
public static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) {
return SubjectTypes[type];
}
}

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