1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2026-02-20 13:46:36 +01:00

23 Commits

Author SHA1 Message Date
1983748036 WIP 2026-02-20 07:22:02 +01:00
03ba1407e8 Introduce instance launch recipes 2026-02-19 15:20:11 +01:00
ea66f9f056 Prevent Ctrl-C from killing Agent's child processes on Windows 2025-12-30 04:02:46 +01:00
9a69a6b2bb Update to .NET 10 and C# 14 2025-12-29 23:26:51 +01:00
27e70d47c3 Rework Agent configuration and authorization 2025-12-28 22:22:55 +01:00
68e0801e4f Fix logging templates 2025-12-28 04:02:38 +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
459 changed files with 9566 additions and 5027 deletions

View File

@@ -3,10 +3,11 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "8.0.3", "version": "10.0.1",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ],
"rollForward": false
} }
} }
} }

1
.gitignore vendored
View File

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

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 1" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" /> <env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" /> <env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,15 +13,18 @@
<env name="MAX_INSTANCES" value="3" /> <env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" /> <env name="MAX_MEMORY" value="12G" />
</envs> </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="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <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_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 2" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" /> <env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" /> <env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,15 +13,18 @@
<env name="MAX_INSTANCES" value="5" /> <env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" /> <env name="MAX_MEMORY" value="10G" />
</envs> </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="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <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_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" /> <env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" /> <env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,15 +13,18 @@
<env name="MAX_INSTANCES" value="1" /> <env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" /> <env name="MAX_MEMORY" value="2560M" />
</envs> </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="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <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_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -14,14 +14,19 @@
<env name="WEB_RPC_SERVER_HOST" value="localhost" /> <env name="WEB_RPC_SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <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="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<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_PATH" value="$PROJECT_DIR$/Controller/Phantom.Controller/Phantom.Controller.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -7,18 +7,21 @@
<envs> <envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" /> <env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
<env name="WEB_KEY" value="BMNHM9RRPMCBBY29D9XHS6KBKZSRY7F5XFN27YZX96XXWJC2NM2D6YRHM9PZN9JGQGCSJ6FMB2GGZ" /> <env name="WEB_KEY" value="G9WXPDGCGHJD9W9XBPMNYWN6YTK7NKRWHT29P2XKNDCBWKHWXP2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" />
<env name="WEB_SERVER_HOST" value="localhost" /> <env name="WEB_SERVER_HOST" value="localhost" />
</envs> </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="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <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_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net9.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

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

@@ -0,0 +1,2 @@
qミモh4ぴHヲ、7胥<37>H`├W
ウ4u`G

View File

@@ -1,2 +0,0 @@
±™h?־<>ֹBx
<02>

Binary file not shown.

View File

@@ -1 +0,0 @@
./gϋΏNρ°t<C2B0>$Ν!Β(ƒρ#η~ΖΞ}<14><:

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

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,17 +1,6 @@
using System.Collections.Immutable; namespace Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Instance;
public sealed record InstanceProperties( public sealed record InstanceProperties(
Guid InstanceGuid, Guid InstanceGuid,
Guid JavaRuntimeGuid, string InstanceFolder
JvmProperties JvmProperties,
ImmutableArray<string> JvmArguments,
string InstanceFolder,
string ServerVersion,
ServerProperties ServerProperties,
InstanceLaunchProperties LaunchProperties
); );

View File

@@ -1,92 +1,58 @@
using System.Text; namespace Phantom.Agent.Minecraft.Java;
using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor { sealed class JavaPropertiesFileEditor {
private static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private readonly Dictionary<string, string> overriddenProperties = new (); private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) { public void Set(string key, string value) {
overriddenProperties[key] = value; overriddenProperties[key] = value;
} }
public async Task EditOrCreate(string filePath) { public void SetAll(IDictionary<string, string> values) {
foreach ((string key, string value) in values) {
Set(key, value);
}
}
public async Task EditOrCreate(string filePath, string comment, CancellationToken cancellationToken) {
if (File.Exists(filePath)) { if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp"; string tmpFilePath = filePath + ".tmp";
File.Copy(filePath, tmpFilePath, overwrite: true); await Edit(filePath, tmpFilePath, comment, cancellationToken);
await EditFromCopyOrCreate(filePath, tmpFilePath);
File.Move(tmpFilePath, filePath, overwrite: true); File.Move(tmpFilePath, filePath, overwrite: true);
} }
else { else {
await EditFromCopyOrCreate(null, filePath); await Create(filePath, comment, cancellationToken);
} }
} }
private async Task EditFromCopyOrCreate(string? sourceFilePath, string targetFilePath) { private async Task Create(string targetFilePath, string comment, CancellationToken cancellationToken) {
var properties = new JavaProperties(); await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
if (sourceFilePath != null) { await targetWriter.WriteComment(comment, cancellationToken);
// TODO replace with custom async parser
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
properties.Load(sourceStream, Encoding);
}
foreach (var (key, value) in overriddenProperties) { foreach ((string key, string value) in overriddenProperties) {
properties[key] = value; await targetWriter.WriteProperty(key, value, cancellationToken);
}
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var targetWriter = new StreamWriter(targetStream, Encoding);
await targetWriter.WriteLineAsync("# Properties");
foreach (var (key, value) in properties) {
await WriteProperty(targetWriter, key, value);
} }
} }
private static async Task WriteProperty(StreamWriter writer, string key, string value) { private async Task Edit(string sourceFilePath, string targetFilePath, string comment, CancellationToken cancellationToken) {
await WritePropertyComponent(writer, key, escapeSpaces: true); using var sourceReader = new JavaPropertiesStream.Reader(sourceFilePath);
await writer.WriteAsync('='); await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await WritePropertyComponent(writer, value, escapeSpaces: false);
await writer.WriteLineAsync(); 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);
}
} }
private static async Task WritePropertyComponent(TextWriter writer, string component, bool escapeSpaces) { foreach (string key in remainingOverriddenPropertyKeys) {
for (int index = 0; index < component.Length; index++) { await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
var c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await writer.WriteAsync('\\');
await writer.WriteAsync(c);
break;
case var _ when c > 31 && c < 127:
await writer.WriteAsync(c);
break;
case '\t':
await writer.WriteAsync("\\t");
break;
case '\n':
await writer.WriteAsync("\\n");
break;
case '\r':
await writer.WriteAsync("\\r");
break;
case '\f':
await writer.WriteAsync("\\f");
break;
default:
await writer.WriteAsync("\\u");
await writer.WriteAsync(((int) c).ToString("X4"));
break;
}
} }
} }
} }

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,5 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.CompilerServices;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Utils.Collections;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Serilog; using Serilog;
@@ -19,13 +21,14 @@ public sealed class JavaRuntimeDiscovery {
return null; return null;
} }
public static IAsyncEnumerable<JavaRuntimeExecutable> Scan(string folderPath) { public static async Task<JavaRuntimeRepository> Scan(string folderPath, CancellationToken cancellationToken) {
return new JavaRuntimeDiscovery().ScanInternal(folderPath); var runtimes = await new JavaRuntimeDiscovery().ScanInternal(folderPath, cancellationToken).ToImmutableArrayAsync(cancellationToken);
return new JavaRuntimeRepository(runtimes);
} }
private readonly Dictionary<string, int> duplicateDisplayNames = new (); 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); Logger.Information("Starting Java runtime scan in: {FolderPath}", folderPath);
string javaExecutableName = OperatingSystem.IsWindows() ? "java.exe" : "java"; string javaExecutableName = OperatingSystem.IsWindows() ? "java.exe" : "java";
@@ -35,8 +38,10 @@ public sealed class JavaRuntimeDiscovery {
RecurseSubdirectories = true, RecurseSubdirectories = true,
ReturnSpecialDirectories = false, ReturnSpecialDirectories = false,
IgnoreInaccessible = true, IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System,
}).Order()) { }).Order()) {
cancellationToken.ThrowIfCancellationRequested();
var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName)); var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName));
FileAttributes javaExecutableAttributes; FileAttributes javaExecutableAttributes;
@@ -54,7 +59,7 @@ public sealed class JavaRuntimeDiscovery {
JavaRuntime? foundRuntime; JavaRuntime? foundRuntime;
try { try {
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath); foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath, cancellationToken);
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
Logger.Error("Java process did not exit in time."); Logger.Error("Java process did not exit in time.");
continue; continue;
@@ -73,7 +78,7 @@ public sealed class JavaRuntimeDiscovery {
} }
} }
private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath) { private async Task<JavaRuntime?> TryReadJavaRuntimeInformationFromProcess(string javaExecutablePath, CancellationToken cancellationToken) {
var startInfo = new ProcessStartInfo { var startInfo = new ProcessStartInfo {
FileName = javaExecutablePath, FileName = javaExecutablePath,
WorkingDirectory = Path.GetDirectoryName(javaExecutablePath), WorkingDirectory = Path.GetDirectoryName(javaExecutablePath),
@@ -81,18 +86,19 @@ public sealed class JavaRuntimeDiscovery {
RedirectStandardInput = false, RedirectStandardInput = false,
RedirectStandardOutput = false, RedirectStandardOutput = false,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false UseShellExecute = false,
}; };
var process = new Process { StartInfo = startInfo }; using var timeoutCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var combinedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, cancellationToken);
try { using var process = new Process();
process.StartInfo = startInfo;
process.Start(); process.Start();
JavaRuntimeBuilder runtimeBuilder = new (); JavaRuntimeBuilder runtimeBuilder = new ();
while (await process.StandardError.ReadLineAsync(cancellationTokenSource.Token) is {} line) { while (await process.StandardError.ReadLineAsync(combinedCancellationTokenSource.Token) is {} line) {
ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder); ExtractJavaVersionPropertiesFromLine(line, runtimeBuilder);
JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames); JavaRuntime? runtime = runtimeBuilder.TryBuild(duplicateDisplayNames);
@@ -101,12 +107,8 @@ public sealed class JavaRuntimeDiscovery {
} }
} }
await process.WaitForExitAsync(cancellationTokenSource.Token); await process.WaitForExitAsync(combinedCancellationTokenSource.Token);
return null; return null;
} finally {
process.Dispose();
cancellationTokenSource.Dispose();
}
} }
private static void ExtractJavaVersionPropertiesFromLine(ReadOnlySpan<char> line, JavaRuntimeBuilder runtimeBuilder) { private static void ExtractJavaVersionPropertiesFromLine(ReadOnlySpan<char> line, JavaRuntimeBuilder runtimeBuilder) {

View File

@@ -2,12 +2,4 @@
namespace Phantom.Agent.Minecraft.Java; namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeExecutable { public sealed record JavaRuntimeExecutable(string ExecutablePath, JavaRuntime Runtime);
internal string ExecutablePath { get; }
internal JavaRuntime Runtime { get; }
internal JavaRuntimeExecutable(string executablePath, JavaRuntime runtime) {
this.ExecutablePath = executablePath;
this.Runtime = runtime;
}
}

View File

@@ -6,41 +6,27 @@ using Phantom.Utils.Cryptography;
namespace Phantom.Agent.Minecraft.Java; namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeRepository { public sealed class JavaRuntimeRepository {
private readonly Dictionary<string, Guid> guidsByPath = new (); private readonly ImmutableDictionary<Guid, JavaRuntimeExecutable> runtimesByGuid;
private readonly Dictionary<Guid, JavaRuntimeExecutable> runtimesByGuid = new ();
private readonly ReaderWriterLockSlim rwLock = new (LockRecursionPolicy.NoRecursion); 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 { public ImmutableArray<TaggedJavaRuntime> All {
get { [SuppressMessage("ReSharper", "UseCollectionExpression")]
rwLock.EnterReadLock(); get => runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime))
try { .OrderBy(static taggedRuntime => taggedRuntime.Runtime)
return runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime)).OrderBy(static taggedRuntime => taggedRuntime.Runtime).ToImmutableArray(); .ToImmutableArray();
} finally {
rwLock.ExitReadLock();
}
}
}
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();
}
} }
public bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) { public bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
rwLock.EnterReadLock();
try {
return runtimesByGuid.TryGetValue(guid, out runtime); return runtimesByGuid.TryGetValue(guid, out runtime);
} finally {
rwLock.ExitReadLock();
}
} }
private static Guid GenerateStableGuid(string executablePath) { private static Guid GenerateStableGuid(string executablePath) {

View File

@@ -2,13 +2,8 @@
namespace Phantom.Agent.Minecraft.Java; namespace Phantom.Agent.Minecraft.Java;
sealed class JvmArgumentBuilder { sealed class JvmArgumentBuilder(JvmProperties basicProperties) {
private readonly JvmProperties basicProperties; private readonly List<string> customArguments = [];
private readonly List<string> customArguments = new ();
public JvmArgumentBuilder(JvmProperties basicProperties) {
this.basicProperties = basicProperties;
}
public void Add(string argument) { public void Add(string argument) {
customArguments.Add(argument); customArguments.Add(argument);

View File

@@ -1,116 +0,0 @@
using System.Text;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
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();
}
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);
} catch (OperationCanceledException) {
throw;
} catch (Exception e) {
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);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
}
var processConfigurator = new ProcessConfigurator {
FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder,
RedirectInput = true,
UseShellExecute = false
};
var processArguments = processConfigurator.ArgumentList;
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) {
logger.Error(launchException, "Caught exception launching the server process.");
try {
process.Kill();
} 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);
}
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {
var serverPropertiesEditor = new JavaPropertiesFileEditor();
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor);
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"));
}
}

View File

@@ -1,8 +0,0 @@
using Phantom.Agent.Minecraft.Server;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
public interface IServerLauncher {
Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Processes;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
public sealed class InstanceLauncher(
FileDownloadManager downloadManager,
IInstancePathResolver pathResolver,
IInstanceValueResolver valueResolver,
InstanceProperties instanceProperties,
InstanceLaunchRecipe launchRecipe
) {
public async Task<LaunchResult> Launch(ILogger logger, Action<IInstanceStatus?> reportStatus, CancellationToken cancellationToken) {
string? executablePath = launchRecipe.Executable.Resolve(pathResolver);
if (executablePath == null) {
logger.Error("Could not resolve server executable: {Path}", launchRecipe.Executable);
return new LaunchResult.CouldNotFindServerExecutable();
}
var stepVisitor = new StepExecutor(downloadManager, pathResolver, reportStatus, cancellationToken);
var steps = launchRecipe.Preparation;
for (int stepIndex = 0; stepIndex < steps.Length; stepIndex++) {
var step = steps[stepIndex];
try {
await step.Run(stepVisitor);
} catch (Exception e) {
logger.Error(e, "Failed preparation step {StepIndex} out of {StepCount}: {Step}", stepIndex, steps.Length, step);
return new LaunchResult.CouldNotPrepareServerInstance();
}
}
var processConfigurator = new ProcessConfigurator {
FileName = executablePath,
WorkingDirectory = instanceProperties.InstanceFolder,
RedirectInput = true,
UseShellExecute = false,
};
var processArguments = processConfigurator.ArgumentList;
foreach (var value in launchRecipe.Arguments) {
if (value.Resolve(valueResolver) is {} resolved) {
processArguments.Add(resolved);
}
else {
logger.Error("Could not resolve server executable argument: {Value}", value);
return new LaunchResult.CouldNotPrepareServerInstance();
}
}
var process = processConfigurator.CreateProcess();
var instanceProcess = new InstanceProcess(instanceProperties, process);
try {
process.Start();
} catch (Exception launchException) {
logger.Error(launchException, "Caught exception launching the server process.");
try {
process.Kill();
} catch (Exception killException) {
logger.Error(killException, "Caught exception trying to kill the server process after a failed launch.");
}
return new LaunchResult.CouldNotStartServerExecutable();
}
return new LaunchResult.Success(instanceProcess);
}
private sealed class StepExecutor(FileDownloadManager downloadManager, IInstancePathResolver pathResolver, Action<IInstanceStatus?> reportStatus, CancellationToken cancellationToken) : IInstanceLaunchStepExecutor {
public async Task DownloadFile(FileDownloadInfo downloadInfo, IInstancePath path) {
string? filePath = path.Resolve(pathResolver);
if (filePath == null) {
throw new FileNotFoundException("Could not resolve path");
}
byte? lastDownloadProgress = null;
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte? progress = args.TotalBytes is not {} totalBytes ? null : (byte) Math.Min(args.DownloadedBytes * 100 / totalBytes, val2: 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
reportStatus(InstanceStatus.Downloading(progress));
}
}
if (await downloadManager.DownloadAndGetPath(downloadInfo, filePath, OnDownloadProgress, cancellationToken) == null) {
throw new FileNotFoundException("Could not download file");
}
reportStatus(null);
}
public async Task EditPropertiesFile(InstancePath.Local path, string comment, ImmutableDictionary<string, string> newValues) {
string? filePath = path.Resolve(pathResolver);
if (filePath == null) {
throw new FileNotFoundException("Could not resolve path");
}
var editor = new JavaPropertiesFileEditor();
editor.SetAll(newValues);
await editor.EditOrCreate(filePath, comment, cancellationToken);
}
}
}

View File

@@ -7,13 +7,9 @@ public abstract record LaunchResult {
public sealed record Success(InstanceProcess Process) : LaunchResult; public sealed record Success(InstanceProcess Process) : LaunchResult;
public sealed record InvalidJavaRuntime : LaunchResult; public sealed record CouldNotPrepareServerInstance : LaunchResult;
public sealed record CouldNotDownloadMinecraftServer : LaunchResult; public sealed record CouldNotFindServerExecutable : LaunchResult;
public sealed record CouldNotPrepareMinecraftServerLauncher : LaunchResult; public sealed record CouldNotStartServerExecutable : LaunchResult;
public sealed record CouldNotConfigureMinecraftServer : LaunchResult;
public sealed record CouldNotStartMinecraftServer : LaunchResult;
} }

View File

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

View File

@@ -1,55 +0,0 @@
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class FabricLauncher : BaseLauncher {
public FabricLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
private protected override async Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
var serverJarParentFolderPath = Directory.GetParent(serverJarPath);
if (serverJarParentFolderPath == null) {
throw new ArgumentException("Could not get parent folder from: " + serverJarPath, nameof(serverJarPath));
}
var launcherJarPath = Path.Combine(serverJarParentFolderPath.FullName, "fabric.jar");
if (!File.Exists(launcherJarPath)) {
await DownloadLauncher(logger, launcherJarPath, cancellationToken);
}
return new ServerJarInfo(launcherJarPath, ImmutableArray.Create("-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";
logger.Information("Downloading Fabric launcher from: {Url}", installerUrl);
using var http = new HttpClient();
var response = await http.GetAsync(installerUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
try {
await using var fileStream = new FileStream(targetFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await responseStream.CopyToAsync(fileStream, cancellationToken);
} catch (Exception) {
TryDeleteLauncherAfterFailure(logger, targetFilePath);
throw;
}
}
private static void TryDeleteLauncherAfterFailure(ILogger logger, string filePath) {
if (File.Exists(filePath)) {
try {
File.Delete(filePath);
} catch (Exception e) {
logger.Warning(e, "Could not clean up partially downloaded Fabric launcher: {FilePath}", filePath);
}
}
}
}

View File

@@ -1,14 +0,0 @@
using Phantom.Agent.Minecraft.Server;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class InvalidLauncher : IServerLauncher {
public static InvalidLauncher Instance { get; } = new ();
private InvalidLauncher() {}
public Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
return Task.FromResult<LaunchResult>(new LaunchResult.CouldNotPrepareMinecraftServerLauncher());
}
}

View File

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

View File

@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Kajabity.Tools.Java" /> <InternalsVisibleTo Include="Phantom.Agent.Minecraft.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,22 +0,0 @@
namespace Phantom.Agent.Minecraft.Properties;
static class MinecraftServerProperties {
private sealed class Boolean : MinecraftServerProperty<bool> {
public Boolean(string key) : base(key) {}
protected override bool Read(string value) => value.Equals("true", StringComparison.OrdinalIgnoreCase);
protected override string Write(bool value) => value ? "true" : "false";
}
private sealed class UnsignedShort : MinecraftServerProperty<ushort> {
public UnsignedShort(string key) : base(key) {}
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 +0,0 @@
using Phantom.Agent.Minecraft.Java;
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(JavaPropertiesFileEditor properties, T value) {
properties.Set(key, Write(value));
}
}

View File

@@ -1,17 +0,0 @@
using Phantom.Agent.Minecraft.Java;
namespace Phantom.Agent.Minecraft.Properties;
public sealed record ServerProperties(
ushort ServerPort,
ushort RconPort,
bool EnableRcon = true,
bool SyncChunkWrites = false
) {
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

@@ -2,9 +2,9 @@
public sealed class DownloadProgressEventArgs : EventArgs { public sealed class DownloadProgressEventArgs : EventArgs {
public ulong DownloadedBytes { get; } public ulong DownloadedBytes { get; }
public ulong TotalBytes { get; } public ulong? TotalBytes { get; }
internal DownloadProgressEventArgs(ulong downloadedBytes, ulong totalBytes) { internal DownloadProgressEventArgs(ulong downloadedBytes, ulong? totalBytes) {
DownloadedBytes = downloadedBytes; DownloadedBytes = downloadedBytes;
TotalBytes = totalBytes; TotalBytes = totalBytes;
} }

View File

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

View File

@@ -0,0 +1,52 @@
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
public sealed class FileDownloadManager {
private static readonly ILogger Logger = PhantomLogger.Create<FileDownloadManager>();
private readonly Dictionary<string, FileDownloader> runningDownloadersByPath = new ();
internal async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string filePath, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
var fileInfo = new FileInfo(filePath);
if (fileInfo.Exists) {
return filePath;
}
filePath = fileInfo.FullName;
if (Directory.GetParent(filePath) is {} parentPath) {
try {
Directories.Create(parentPath.FullName, Chmod.URWX_GRX);
} catch (Exception e) {
Logger.Error(e, "Unable to create folder: {FolderName}", parentPath.FullName);
return null;
}
}
FileDownloader? downloader;
FileDownloadListener listener = new (progressEventHandler, cancellationToken);
lock (this) {
if (runningDownloadersByPath.TryGetValue(filePath, out downloader)) {
Logger.Information("A download for {Path} is already running, waiting for it to finish...", filePath);
downloader.Register(listener);
}
else {
downloader = new FileDownloader(fileDownloadInfo, filePath, listener);
downloader.Completed += (_, _) => {
lock (this) {
runningDownloadersByPath.Remove(filePath);
}
};
runningDownloadersByPath[filePath] = downloader;
}
}
return await downloader.Task.WaitAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,202 @@
using System.Security.Cryptography;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Net;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
sealed class FileDownloader {
private static readonly ILogger Logger = PhantomLogger.Create<FileDownloader>();
public Task<string?> Task { get; }
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
public event EventHandler? Completed;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private readonly List<CancellationTokenRegistration> listenerCancellationRegistrations = [];
private int listenerCount = 0;
public FileDownloader(FileDownloadInfo fileDownloadInfo, string filePath, FileDownloadListener listener) {
Register(listener);
Task = DownloadFileAndGetFinalPath(fileDownloadInfo, filePath, new DownloadProgressCallback(this), cancellationTokenSource.Token);
Task.ContinueWith(OnCompleted, TaskScheduler.Default);
}
public void Register(FileDownloadListener listener) {
int newListenerCount;
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) {
int newListenerCount;
lock (this) {
FileDownloadListener listener = (FileDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler;
newListenerCount = --listenerCount;
if (newListenerCount <= 0) {
cancellationTokenSource.Cancel();
}
}
if (newListenerCount <= 0) {
Logger.Debug("Unregistered last download listener, cancelling download.");
}
else {
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.");
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(FileDownloader downloader) {
public void ReportProgress(ulong downloadedBytes, ulong? totalBytes) {
downloader.ReportDownloadProgress(new DownloadProgressEventArgs(downloadedBytes, totalBytes));
}
}
private static async Task<string?> DownloadFileAndGetFinalPath(FileDownloadInfo fileDownloadInfo, string filePath, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string tmpFilePath = filePath + ".tmp";
try {
await DownloadFile(tmpFilePath, fileDownloadInfo, progressCallback, cancellationToken);
MoveDownloadedFile(filePath, tmpFilePath);
} catch (Exception) {
TryDeletePartiallyDownloadedFile(tmpFilePath);
throw;
}
return filePath;
}
private static async Task DownloadFile(string filePath, FileDownloadInfo fileDownloadInfo, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string downloadUrl = fileDownloadInfo.DownloadUrl;
DownloadResult result;
try {
var httpClient = new HttpClient();
var response = await httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
FileSize? fileSize = response.Headers.ContentLength;
ulong? fileSizeBytes = fileSize?.Bytes;
Logger.Information("Downloading {Url} ({Size})...", downloadUrl, FormatFileSize(fileSize));
progressCallback.ReportProgress(downloadedBytes: 0, fileSizeBytes);
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 DownloadStreamCopier(progressCallback, fileSizeBytes);
result = await streamCopier.Copy(responseStream, fileStream, cancellationToken);
} catch (OperationCanceledException) {
Logger.Information("File download was cancelled: {Url}", downloadUrl);
throw;
} catch (Exception e) {
Logger.Error(e, "Could not download file: {Url}", downloadUrl);
throw StopProcedureException.Instance;
}
if (fileDownloadInfo.Hash is {} expectedHash && !result.Hash.Equals(expectedHash)) {
Logger.Error("Downloaded file from {Url} has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", downloadUrl, expectedHash, result.Hash);
throw StopProcedureException.Instance;
}
Logger.Information("Finished downloading {Url} ({Size}).", downloadUrl, FormatFileSize(result.Size));
}
private static string FormatFileSize(FileSize? fileSize) {
return fileSize?.ToHumanReadable(decimalPlaces: 1) ?? "unknown size";
}
private static void MoveDownloadedFile(string filePath, string tmpFilePath) {
try {
File.Move(tmpFilePath, filePath, overwrite: true);
} catch (Exception e) {
Logger.Error(e, "Could not move downloaded file from {SourcePath} to {TargetPath}", tmpFilePath, filePath);
throw StopProcedureException.Instance;
}
}
private static void TryDeletePartiallyDownloadedFile(string filePath) {
if (!File.Exists(filePath)) {
return;
}
try {
File.Delete(filePath);
} catch (Exception e) {
Logger.Warning(e, "Could not clean up partially downloaded file: {FilePath}", filePath);
}
}
private sealed class DownloadStreamCopier : 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 DownloadStreamCopier(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<DownloadResult> Copy(Stream source, Stream destination, CancellationToken cancellationToken) {
await streamCopier.Copy(source, destination, cancellationToken);
FileSize size = new FileSize(readBytes);
Sha1String hash = Sha1String.FromBytes(sha1.GetHashAndReset());
return new DownloadResult(size, hash);
}
public void Dispose() {
sha1.Dispose();
streamCopier.Dispose();
}
}
private readonly record struct DownloadResult(FileSize Size, Sha1String Hash);
}

View File

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

View File

@@ -1,190 +0,0 @@
using System.Security.Cryptography;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Runtime;
using Serilog;
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 readonly List<CancellationTokenRegistration> listenerCancellationRegistrations = new ();
private int listenerCount = 0;
public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) {
Register(listener);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath, new DownloadProgressCallback(this), cancellationTokenSource.Token);
Task.ContinueWith(OnCompleted, TaskScheduler.Default);
}
public void Register(MinecraftServerExecutableDownloadListener listener) {
int newListenerCount;
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) {
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.");
}
else {
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.");
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 static async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string tmpFilePath = filePath + ".tmp";
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, progressCallback, fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath);
throw;
}
File.Move(tmpFilePath, filePath, true);
Logger.Information("Server version {Version} downloaded.", minecraftVersion);
return filePath;
} catch (OperationCanceledException) {
Logger.Information("Download for server version {Version} was cancelled.", minecraftVersion);
throw;
} catch (StopProcedureException) {
return null;
} catch (Exception e) {
Logger.Error(e, "An unexpected error occurred.");
return null;
}
}
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) {
throw;
} catch (Exception e) {
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 {
File.Delete(filePath);
} catch (Exception e) {
Logger.Warning(e, "Could not clean up partially downloaded server executable: {FilePath}", filePath);
}
}
}
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,64 +0,0 @@
using System.Text.RegularExpressions;
using Phantom.Common.Data.Minecraft;
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 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, 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);
downloader.Register(listener);
}
else {
downloader = new MinecraftServerExecutableDownloader(fileDownloadInfo, minecraftVersion, serverExecutableFilePath, listener);
downloader.Completed += (_, _) => {
lock (this) {
runningDownloadersByVersion.Remove(minecraftVersion);
}
};
runningDownloadersByVersion[minecraftVersion] = downloader;
}
}
return await downloader.Task.WaitAsync(cancellationToken);
}
}

View File

@@ -3,11 +3,12 @@ using System.Buffers.Binary;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
public static class ServerStatusProtocol { public static class ServerStatusProtocol {
public static async Task<int> GetOnlinePlayerCount(ushort serverPort, CancellationToken cancellationToken) { public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient(); using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken); await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream(); var tcpStream = tcpClient.GetStream();
@@ -17,13 +18,13 @@ public static class ServerStatusProtocol {
await tcpStream.FlushAsync(cancellationToken); await tcpStream.FlushAsync(cancellationToken);
short messageLength = await ReadStreamHeader(tcpStream, cancellationToken); short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return await ReadOnlinePlayerCount(tcpStream, messageLength * 2, cancellationToken); return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
} }
private static 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); var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
try { try {
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken); await tcpStream.ReadExactlyAsync(headerBuffer, offset: 0, count: 3, cancellationToken);
if (headerBuffer[0] != 0xFF) { if (headerBuffer[0] != 0xFF) {
throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]); throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]);
@@ -40,35 +41,53 @@ public static class ServerStatusProtocol {
} }
} }
private static 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); var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try { try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken); await tcpStream.ReadExactlyAsync(messageBuffer, offset: 0, messageLength, cancellationToken);
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(start: 0, messageLength));
// 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)) {
throw new ProtocolException("Could not find message separators in response from server.");
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
throw new ProtocolException("Could not parse online player count in response from server: " + onlinePlayerCountStr);
}
return onlinePlayerCount;
} finally { } finally {
ArrayPool<byte>.Shared.Return(messageBuffer); 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 { public sealed class ProtocolException : Exception {
internal ProtocolException(string message) : base(message) {} internal ProtocolException(string message) : base(message) {}
} }

View File

@@ -1,21 +0,0 @@
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime;
using Serilog;
namespace Phantom.Agent.Rpc;
public sealed class ControllerConnection {
private static readonly ILogger Logger = PhantomLogger.Create(nameof(ControllerConnection));
private readonly RpcConnectionToServer<IMessageToController> connection;
public ControllerConnection(RpcConnectionToServer<IMessageToController> connection) {
this.connection = connection;
Logger.Information("Connection ready.");
}
public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController {
return connection.Send(message);
}
}

View File

@@ -1,44 +0,0 @@
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime;
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 RpcConnectionToServer<IMessageToController> connection;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public KeepAliveLoop(RpcConnectionToServer<IMessageToController> connection) {
this.connection = connection;
Task.Run(Run);
}
private async Task Run() {
var cancellationToken = cancellationTokenSource.Token;
try {
await connection.IsReady.WaitAsync(cancellationToken);
Logger.Information("Started keep-alive loop.");
while (true) {
await Task.Delay(KeepAliveInterval, cancellationToken);
await connection.Send(new AgentIsAliveMessage()).WaitAsync(cancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
cancellationTokenSource.Dispose();
Logger.Information("Stopped keep-alive loop.");
}
}
public void Cancel() {
cancellationTokenSource.Cancel();
}
}

View File

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

View File

@@ -1,37 +0,0 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.BiDirectional;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Runtime;
using Phantom.Utils.Rpc.Sockets;
using Serilog;
namespace Phantom.Agent.Rpc;
public sealed class RpcClientRuntime : RpcClientRuntime<IMessageToAgent, IMessageToController, ReplyMessage> {
public static Task Launch(RpcClientSocket<IMessageToAgent, IMessageToController, ReplyMessage> socket, ActorRef<IMessageToAgent> handlerActorRef, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
return new RpcClientRuntime(socket, handlerActorRef, disconnectSemaphore, receiveCancellationToken).Launch();
}
private RpcClientRuntime(RpcClientSocket<IMessageToAgent, IMessageToController, ReplyMessage> socket, ActorRef<IMessageToAgent> handlerActor, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(socket, handlerActor, disconnectSemaphore, receiveCancellationToken) {}
protected override async Task RunWithConnection(ClientSocket socket, RpcConnectionToServer<IMessageToController> connection) {
var keepAliveLoop = new KeepAliveLoop(connection);
try {
await base.RunWithConnection(socket, connection);
} finally {
keepAliveLoop.Cancel();
}
}
protected override async Task SendDisconnectMessage(ClientSocket socket, ILogger logger) {
var unregisterMessageBytes = AgentMessageRegistries.ToController.Write(new UnregisterAgentMessage()).ToArray();
try {
await socket.SendAsync(unregisterMessageBytes).AsTask().WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
} catch (TimeoutException) {
logger.Error("Timed out communicating agent shutdown with the controller.");
}
}
}

View File

@@ -7,12 +7,12 @@ namespace Phantom.Agent.Services;
public sealed class AgentFolders { public sealed class AgentFolders {
private static readonly ILogger Logger = PhantomLogger.Create<AgentFolders>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentFolders>();
public string DataFolderPath { get; } internal string DataFolderPath { get; }
public string InstancesFolderPath { get; } internal string InstancesFolderPath { get; }
public string BackupsFolderPath { get; } internal string BackupsFolderPath { get; }
public string TemporaryFolderPath { get; } internal string TemporaryFolderPath { get; }
public string ServerExecutableFolderPath { get; } internal string ServerExecutableFolderPath { get; }
public string JavaSearchFolderPath { get; } public string JavaSearchFolderPath { get; }

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.Info.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.Info.InstanceName, configureInstanceMessage.InstanceGuid);
}
}
agentServices.InstanceTicketManager.RefreshAgentStatus();
}
private static InstanceManagerActor.ConfigureInstanceCommand GetCommand(ConfigureInstanceMessage configureInstanceMessage) {
return new InstanceManagerActor.ConfigureInstanceCommand(
configureInstanceMessage.InstanceGuid,
configureInstanceMessage.Info,
configureInstanceMessage.LaunchRecipe,
configureInstanceMessage.LaunchNow,
AlwaysReportStatus: true
);
}
}

View File

@@ -1,8 +1,8 @@
using Akka.Actor; using Akka.Actor;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances; using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
@@ -15,7 +15,6 @@ public sealed class AgentServices {
public ActorSystem ActorSystem { get; } public ActorSystem ActorSystem { get; }
private AgentFolders AgentFolders { get; }
private AgentState AgentState { get; } private AgentState AgentState { get; }
private BackupManager BackupManager { get; } private BackupManager BackupManager { get; }
@@ -23,30 +22,24 @@ public sealed class AgentServices {
internal InstanceTicketManager InstanceTicketManager { get; } internal InstanceTicketManager InstanceTicketManager { get; }
internal ActorRef<InstanceManagerActor.ICommand> InstanceManager { get; } internal ActorRef<InstanceManagerActor.ICommand> InstanceManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration, ControllerConnection controllerConnection) { public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration, ControllerConnection controllerConnection, JavaRuntimeRepository javaRuntimeRepository) {
this.ActorSystem = ActorSystemFactory.Create("Agent"); this.ActorSystem = ActorSystemFactory.Create("Agent");
this.AgentFolders = agentFolders;
this.AgentState = new AgentState(); this.AgentState = new AgentState();
this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks); this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks);
this.JavaRuntimeRepository = new JavaRuntimeRepository(); this.JavaRuntimeRepository = javaRuntimeRepository;
this.InstanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection); this.InstanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection);
var instanceManagerInit = new InstanceManagerActor.Init(controllerConnection, agentFolders, AgentState, JavaRuntimeRepository, InstanceTicketManager, BackupManager); var instanceManagerInit = new InstanceManagerActor.Init(controllerConnection, agentFolders, AgentState, JavaRuntimeRepository, InstanceTicketManager, BackupManager);
this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager"); 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() { public async Task Shutdown() {
Logger.Information("Stopping services..."); Logger.Information("Stopping services...");
await InstanceManager.Stop(new InstanceManagerActor.ShutdownCommand()); await InstanceManager.Stop(new InstanceManagerActor.ShutdownCommand());
await InstanceTicketManager.Shutdown();
BackupManager.Dispose(); BackupManager.Dispose();

View File

@@ -108,7 +108,7 @@ sealed class BackupArchiver {
private async Task<bool> CreateTarArchive(string sourceFolderPath, string backupFilePath) { private async Task<bool> CreateTarArchive(string sourceFolderPath, string backupFilePath) {
try { try {
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, false, cancellationToken); await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, includeBaseDirectory: false, cancellationToken);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Could not create archive."); logger.Error(e, "Could not create archive.");
@@ -135,7 +135,7 @@ sealed class BackupArchiver {
foreach (FileInfo file in sourceFolder.EnumerateFiles()) { foreach (FileInfo file in sourceFolder.EnumerateFiles()) {
var filePath = relativePath.Add(file.Name); var filePath = relativePath.Add(file.Name);
if (IsFileSkipped(filePath)) { if (IsFileSkipped(filePath)) {
logger.Debug("Skipping file: {File}", string.Join('/', filePath)); logger.Debug("Skipping file: {File}", string.Join(separator: '/', filePath));
continue; continue;
} }
@@ -150,7 +150,7 @@ sealed class BackupArchiver {
foreach (DirectoryInfo directory in sourceFolder.EnumerateDirectories()) { foreach (DirectoryInfo directory in sourceFolder.EnumerateDirectories()) {
var folderPath = relativePath.Add(directory.Name); var folderPath = relativePath.Add(directory.Name);
if (IsFolderSkipped(folderPath)) { if (IsFolderSkipped(folderPath)) {
logger.Debug("Skipping folder: {Folder}", string.Join('/', folderPath)); logger.Debug("Skipping folder: {Folder}", string.Join(separator: '/', folderPath));
continue; continue;
} }
@@ -172,7 +172,7 @@ sealed class BackupArchiver {
} }
else { else {
logger.Warning("Failed copying file {File}, retrying...", sourceFile.FullName); logger.Warning("Failed copying file {File}, retrying...", sourceFile.FullName);
await Task.Delay(200, cancellationToken); await Task.Delay(millisecondsDelay: 200, cancellationToken);
} }
} }
} }

View File

@@ -50,8 +50,8 @@ static class BackupCompressor {
"--rm", "--rm",
"--no-progress", "--no-progress",
"-o", destinationFilePath, "-o", destinationFilePath,
"--", sourceFilePath "--", sourceFilePath,
} },
}; };
static void OnZstdOutput(object? sender, Process.Output output) { static void OnZstdOutput(object? sender, Process.Output output) {

View File

@@ -133,7 +133,7 @@ sealed class BackupManager : IDisposable {
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.", BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.", BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.", BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
_ => "Unknown error." _ => "Unknown error.",
}; };
} }
} }

View File

@@ -14,7 +14,7 @@ sealed class BackupScheduler : CancellableBackgroundTask {
private readonly BackupManager backupManager; private readonly BackupManager backupManager;
private readonly InstanceContext context; private readonly InstanceContext context;
private readonly SemaphoreSlim backupSemaphore = new (1, 1); private readonly SemaphoreSlim backupSemaphore = new (initialCount: 1, maxCount: 1);
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new (); private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
private readonly InstancePlayerCountTracker playerCountTracker; private readonly InstancePlayerCountTracker playerCountTracker;

View File

@@ -1,6 +1,7 @@
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.State; using Phantom.Agent.Services.Instances.State;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
@@ -23,24 +24,30 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private readonly CancellationToken shutdownCancellationToken; private readonly CancellationToken shutdownCancellationToken;
private readonly Guid instanceGuid; private readonly Guid instanceGuid;
private readonly InstanceServices instanceServices;
private readonly InstanceTicketManager instanceTicketManager; private readonly InstanceTicketManager instanceTicketManager;
private readonly InstanceContext context; private readonly InstanceContext context;
private readonly ControllerSendQueue<ReportInstanceStatusMessage> reportStatusQueue;
private readonly ControllerSendQueue<ReportInstanceEventMessage> reportEventsQueue;
private readonly CancellationTokenSource actorCancellationTokenSource = new (); private readonly CancellationTokenSource actorCancellationTokenSource = new ();
private IInstanceStatus currentStatus = InstanceStatus.NotRunning; private IInstanceStatus currentStatus = InstanceStatus.NotRunning;
private InstanceRunningState? runningState = null; private InstanceRunningState? runningState = null;
private InstanceActor(Init init) { private InstanceActor(Init init) {
InstanceServices services = init.InstanceServices;
this.agentState = init.AgentState; this.agentState = init.AgentState;
this.instanceGuid = init.InstanceGuid; this.instanceGuid = init.InstanceGuid;
this.instanceServices = init.InstanceServices;
this.instanceTicketManager = init.InstanceTicketManager; this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = init.ShutdownCancellationToken; 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); var logger = PhantomLogger.Create<InstanceActor>(init.ShortName);
this.context = new InstanceContext(instanceGuid, init.ShortName, logger, instanceServices, SelfTyped, actorCancellationTokenSource.Token); this.context = new InstanceContext(instanceGuid, init.ShortName, logger, services, reportEventsQueue, SelfTyped, actorCancellationTokenSource.Token);
Receive<ReportInstanceStatusCommand>(ReportInstanceStatus); Receive<ReportInstanceStatusCommand>(ReportInstanceStatus);
ReceiveAsync<LaunchInstanceCommand>(LaunchInstance); ReceiveAsync<LaunchInstanceCommand>(LaunchInstance);
@@ -58,7 +65,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private void ReportCurrentStatus() { private void ReportCurrentStatus() {
agentState.UpdateInstance(new Instance(instanceGuid, currentStatus)); agentState.UpdateInstance(new Instance(instanceGuid, currentStatus));
instanceServices.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, currentStatus)); reportStatusQueue.Enqueue(new ReportInstanceStatusMessage(instanceGuid, currentStatus));
} }
private void TransitionState(InstanceRunningState? newState) { private void TransitionState(InstanceRunningState? newState) {
@@ -71,11 +78,11 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
runningState?.Initialize(); runningState?.Initialize();
} }
public interface ICommand {} public interface ICommand;
public sealed record ReportInstanceStatusCommand : ICommand; public sealed record ReportInstanceStatusCommand : ICommand;
public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting) : ICommand; public sealed record LaunchInstanceCommand(InstanceInfo Info, InstanceLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting) : ICommand;
public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy) : ICommand; public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy) : ICommand;
@@ -93,9 +100,14 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private async Task LaunchInstance(LaunchInstanceCommand command) { private async Task LaunchInstance(LaunchInstanceCommand command) {
if (command.IsRestarting || runningState is null) { if (command.IsRestarting || runningState is null) {
SetAndReportStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching); var defaultLaunchStatus = command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching;
SetAndReportStatus(defaultLaunchStatus);
var newState = await InstanceLaunchProcedure.Run(context, command.Configuration, command.Launcher, instanceTicketManager, command.Ticket, SetAndReportStatus, shutdownCancellationToken); void UpdateStatus(IInstanceStatus? newStatus) {
SetAndReportStatus(newStatus ?? defaultLaunchStatus);
}
var newState = await InstanceLaunchProcedure.Run(context, command.Info, command.Launcher, instanceTicketManager, command.Ticket, UpdateStatus, shutdownCancellationToken);
if (newState is null) { if (newState is null) {
instanceTicketManager.Release(command.Ticket); instanceTicketManager.Release(command.Ticket);
} }
@@ -156,6 +168,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private async Task Shutdown(ShutdownCommand command) { private async Task Shutdown(ShutdownCommand command) {
await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant)); await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant));
await actorCancellationTokenSource.CancelAsync(); await actorCancellationTokenSource.CancelAsync();
await Task.WhenAll(
reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5)),
reportEventsQueue.Shutdown(TimeSpan.FromSeconds(5))
);
Context.Stop(Self); Context.Stop(Self);
} }
} }

View File

@@ -1,12 +1,21 @@
using Phantom.Common.Data.Instance; using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed record InstanceContext(Guid InstanceGuid, string ShortName, ILogger Logger, InstanceServices Services, ActorRef<InstanceActor.ICommand> Actor, CancellationToken ActorCancellationToken) { 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) { public void ReportEvent(IInstanceEvent instanceEvent) {
Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent)); ReportEventQueue.Enqueue(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent));
} }
} }

View File

@@ -1,13 +1,12 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -27,11 +26,11 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
} }
private readonly AgentState agentState; private readonly AgentState agentState;
private readonly string basePath; private readonly AgentFolders agentFolders;
private readonly InstanceServices instanceServices; private readonly InstanceServices instanceServices;
private readonly InstanceTicketManager instanceTicketManager; private readonly InstanceTicketManager instanceTicketManager;
private readonly Dictionary<Guid, InstanceInfo> instances = new (); private readonly Dictionary<Guid, Instance> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken; private readonly CancellationToken shutdownCancellationToken;
@@ -40,15 +39,12 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
private InstanceManagerActor(Init init) { private InstanceManagerActor(Init init) {
this.agentState = init.AgentState; this.agentState = init.AgentState;
this.basePath = init.AgentFolders.InstancesFolderPath; this.agentFolders = init.AgentFolders;
this.instanceServices = new InstanceServices(init.ControllerConnection, init.BackupManager, new FileDownloadManager(), init.JavaRuntimeRepository);
this.instanceTicketManager = init.InstanceTicketManager; this.instanceTicketManager = init.InstanceTicketManager;
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; 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<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); ReceiveAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); ReceiveAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
@@ -56,16 +52,11 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
ReceiveAsync<ShutdownCommand>(Shutdown); ReceiveAsync<ShutdownCommand>(Shutdown);
} }
private string GetInstanceLoggerName(Guid guid) { private sealed record Instance(ActorRef<InstanceActor.ICommand> Actor, InstanceInfo Info, InstanceProperties Properties, InstanceLaunchRecipe? LaunchRecipe);
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
}
private sealed record InstanceInfo(ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher); public interface ICommand;
public interface ICommand {} public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceInfo Info, InstanceLaunchRecipe? LaunchRecipe, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
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 LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
@@ -77,55 +68,40 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
private Result<ConfigureInstanceResult, InstanceActionFailure> ConfigureInstance(ConfigureInstanceCommand command) { private Result<ConfigureInstanceResult, InstanceActionFailure> ConfigureInstance(ConfigureInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
var configuration = command.Configuration; var instanceInfo = command.Info;
var launchRecipe = command.LaunchRecipe;
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)) { if (instances.TryGetValue(instanceGuid, out var instance)) {
instances[instanceGuid] = instance with { instances[instanceGuid] = instance with {
Configuration = configuration, Info = instanceInfo,
Launcher = launcher LaunchRecipe = launchRecipe,
}; };
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", instanceInfo.InstanceName, instanceGuid);
if (command.AlwaysReportStatus) { if (command.AlwaysReportStatus) {
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
} }
} }
else { else {
var instanceInit = new InstanceActor.Init(agentState, instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, instanceTicketManager, shutdownCancellationToken); var instanceLoggerName = PhantomLogger.ShortenGuid(instanceGuid) + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
instances[instanceGuid] = instance = new InstanceInfo(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), configuration, launcher); var instanceProperties = new InstanceProperties(instanceGuid, Path.Combine(agentFolders.InstancesFolderPath, instanceGuid.ToString()));
var instanceInit = new InstanceActor.Init(agentState, instanceGuid, instanceLoggerName, instanceServices, instanceTicketManager, shutdownCancellationToken);
instances[instanceGuid] = instance = new Instance(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), instanceInfo, instanceProperties, launchRecipe);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", instanceInfo.InstanceName, instanceGuid);
instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand());
} }
string instanceFolder = instance.Properties.InstanceFolder;
try {
Directories.Create(instanceFolder, Chmod.URWX_GRX);
} catch (Exception e) {
Logger.Error(e, "Could not create instance folder: {Path}", instanceFolder);
return ConfigureInstanceResult.CouldNotCreateInstanceFolder;
}
if (command.LaunchNow) { if (command.LaunchNow) {
LaunchInstance(new LaunchInstanceCommand(instanceGuid)); LaunchInstance(new LaunchInstanceCommand(instanceGuid));
} }
@@ -135,17 +111,21 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
private Result<LaunchInstanceResult, InstanceActionFailure> LaunchInstance(LaunchInstanceCommand command) { private Result<LaunchInstanceResult, InstanceActionFailure> LaunchInstance(LaunchInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { if (!instances.TryGetValue(instanceGuid, out var instance)) {
return InstanceActionFailure.InstanceDoesNotExist; return InstanceActionFailure.InstanceDoesNotExist;
} }
var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration); if (instance.LaunchRecipe is not {} launchRecipe) {
return LaunchInstanceResult.InvalidConfiguration;
}
var ticket = instanceTicketManager.Reserve(instance.Info);
if (!ticket) { if (!ticket) {
return ticket.Error; return ticket.Error;
} }
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) { if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var agentInstance)) {
var status = instance.Status; var status = agentInstance.Status;
if (status.IsRunning()) { if (status.IsRunning()) {
return LaunchInstanceResult.InstanceAlreadyRunning; return LaunchInstanceResult.InstanceAlreadyRunning;
} }
@@ -154,7 +134,12 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
} }
} }
instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false)); var pathResolver = new InstancePathResolver(agentFolders, instanceServices.JavaRuntimeRepository, instance.Properties);
var valueResolver = new InstanceValueResolver(pathResolver);
var launcher = new InstanceLauncher(instanceServices.DownloadManager, pathResolver, valueResolver, instance.Properties, launchRecipe);
instance.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instance.Info, launcher, ticket.Value, IsRestarting: false));
return LaunchInstanceResult.LaunchInitiated; return LaunchInstanceResult.LaunchInitiated;
} }

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances;
sealed class InstancePathResolver(AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, InstanceProperties instanceProperties) : IInstancePathResolver {
public string Global(ImmutableArray<string> path) {
return CombinePath(agentFolders.ServerExecutableFolderPath, path);
}
public string Local(ImmutableArray<string> path) {
return CombinePath(instanceProperties.InstanceFolder, path);
}
public string? Runtime(Guid guid) {
if (javaRuntimeRepository.TryGetByGuid(guid, out var runtime)) {
return runtime.ExecutablePath;
}
else {
return null;
}
}
private string CombinePath(string basePath, ImmutableArray<string> additionalParts) {
// TODO validation
return Path.Combine([basePath, ..additionalParts]);
}
}

View File

@@ -1,7 +1,13 @@
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Rpc; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed record InstanceServices(ControllerConnection ControllerConnection, BackupManager BackupManager, LaunchServices LaunchServices); sealed record InstanceServices(
ControllerConnection ControllerConnection,
BackupManager BackupManager,
FileDownloadManager DownloadManager,
JavaRuntimeRepository JavaRuntimeRepository
);

View File

@@ -1,41 +1,33 @@
using Phantom.Agent.Rpc; using System.Collections.Immutable;
using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class InstanceTicketManager { sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection controllerConnection) {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceTicketManager>(); private static readonly ILogger Logger = PhantomLogger.Create<InstanceTicketManager>();
private readonly AgentInfo agentInfo; private readonly ControllerSendQueue<ReportAgentStatusMessage> reportStatusQueue = new (controllerConnection, nameof(InstanceTicketManager), capacity: 1, singleWriter: true);
private readonly ControllerConnection controllerConnection;
private readonly HashSet<Guid> activeTicketGuids = new (); private readonly HashSet<Guid> activeTicketGuids = [];
private readonly HashSet<ushort> usedPorts = new (); private readonly HashSet<ushort> usedPorts = [];
private RamAllocationUnits usedMemory = new (); private RamAllocationUnits usedMemory = new ();
public InstanceTicketManager(AgentInfo agentInfo, ControllerConnection controllerConnection) { public Result<Ticket, LaunchInstanceResult> Reserve(InstanceInfo info) {
this.agentInfo = agentInfo; var memoryAllocation = info.MemoryAllocation;
this.controllerConnection = controllerConnection;
}
public Result<Ticket, LaunchInstanceResult> Reserve(InstanceConfiguration configuration) { if (!agentInfo.AllowedServerPorts.Contains(info.ServerPort)) {
var memoryAllocation = configuration.MemoryAllocation;
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!agentInfo.AllowedServerPorts.Contains(serverPort)) {
return LaunchInstanceResult.ServerPortNotAllowed; return LaunchInstanceResult.ServerPortNotAllowed;
} }
if (!agentInfo.AllowedRconPorts.Contains(rconPort)) { if (info.AdditionalPorts.Any(port => !agentInfo.AllowedAdditionalPorts.Contains(port))) {
return LaunchInstanceResult.RconPortNotAllowed; return LaunchInstanceResult.AdditionalPortNotAllowed;
} }
lock (this) { lock (this) {
@@ -47,23 +39,23 @@ sealed class InstanceTicketManager {
return LaunchInstanceResult.MemoryLimitExceeded; return LaunchInstanceResult.MemoryLimitExceeded;
} }
if (usedPorts.Contains(serverPort)) { if (usedPorts.Contains(info.ServerPort)) {
return LaunchInstanceResult.ServerPortAlreadyInUse; return LaunchInstanceResult.ServerPortAlreadyInUse;
} }
if (usedPorts.Contains(rconPort)) { if (info.AdditionalPorts.Any(port => usedPorts.Contains(port))) {
return LaunchInstanceResult.RconPortAlreadyInUse; return LaunchInstanceResult.AdditionalPortAlreadyInUse;
} }
var ticket = new Ticket(Guid.NewGuid(), memoryAllocation, serverPort, rconPort); var ticket = new Ticket(Guid.NewGuid(), memoryAllocation, info.ServerPort, info.AdditionalPorts);
activeTicketGuids.Add(ticket.TicketGuid); activeTicketGuids.Add(ticket.TicketGuid);
usedMemory += memoryAllocation; usedMemory += memoryAllocation;
usedPorts.Add(serverPort); usedPorts.Add(ticket.ServerPort);
usedPorts.Add(rconPort); usedPorts.UnionWith(ticket.AdditionalPorts);
RefreshAgentStatus(); 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); Logger.Debug("Reserved ticket {TicketGuid} (server port {ServerPort}, additional ports [{AdditionalPorts}], memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, string.Join(", ", ticket.AdditionalPorts), ticket.MemoryAllocation.InMegabytes);
return ticket; return ticket;
} }
@@ -83,18 +75,22 @@ sealed class InstanceTicketManager {
usedMemory -= ticket.MemoryAllocation; usedMemory -= ticket.MemoryAllocation;
usedPorts.Remove(ticket.ServerPort); usedPorts.Remove(ticket.ServerPort);
usedPorts.Remove(ticket.RconPort); usedPorts.ExceptWith(ticket.AdditionalPorts);
RefreshAgentStatus(); 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); Logger.Debug("Released ticket {TicketGuid} (server port {ServerPort}, additional ports [{AdditionalPorts}], memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, string.Join(", ", ticket.AdditionalPorts), ticket.MemoryAllocation.InMegabytes);
} }
} }
public void RefreshAgentStatus() { public void RefreshAgentStatus() {
lock (this) { lock (this) {
controllerConnection.Send(new ReportAgentStatusMessage(activeTicketGuids.Count, usedMemory)); reportStatusQueue.Enqueue(new ReportAgentStatusMessage(activeTicketGuids.Count, usedMemory));
} }
} }
public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort); public async Task Shutdown() {
await reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5));
}
public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ImmutableSortedSet<ushort> AdditionalPorts);
} }

View File

@@ -0,0 +1,13 @@
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceValueResolver(IInstancePathResolver pathResolver) : IInstanceValueResolver {
public string? Path(IInstancePath value) {
return value.Resolve(pathResolver);
}
public string? Variable(InstanceVariable value) {
return null;
}
}

View File

@@ -1,13 +1,13 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
static class InstanceLaunchProcedure { 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) { public static async Task<InstanceRunningState?> Run(InstanceContext context, InstanceInfo info, InstanceLauncher launcher, InstanceTicketManager ticketManager, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus?> reportStatus, CancellationToken cancellationToken) {
context.Logger.Information("Session starting..."); context.Logger.Information("Session starting...");
Result<InstanceProcess, InstanceLaunchFailReason> result; Result<InstanceProcess, InstanceLaunchFailReason> result;
@@ -31,7 +31,7 @@ static class InstanceLaunchProcedure {
if (result) { if (result) {
reportStatus(InstanceStatus.Running); reportStatus(InstanceStatus.Running);
context.ReportEvent(InstanceEvent.LaunchSucceeded); context.ReportEvent(InstanceEvent.LaunchSucceeded);
return new InstanceRunningState(context, configuration, launcher, ticket, result.Value, cancellationToken); return new InstanceRunningState(context, info, launcher, ticket, result.Value, cancellationToken);
} }
else { else {
reportStatus(InstanceStatus.Failed(result.Error)); reportStatus(InstanceStatus.Failed(result.Error));
@@ -40,43 +40,24 @@ static class InstanceLaunchProcedure {
} }
} }
private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstance(InstanceContext context, IServerLauncher launcher, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstance(InstanceContext context, InstanceLauncher launcher, Action<IInstanceStatus?> reportStatus, CancellationToken cancellationToken) {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
byte lastDownloadProgress = byte.MaxValue; switch (await launcher.Launch(context.Logger, reportStatus, cancellationToken)) {
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 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: case LaunchResult.Success launchSuccess:
return launchSuccess.Process; return launchSuccess.Process;
case LaunchResult.InvalidJavaRuntime: case LaunchResult.CouldNotPrepareServerInstance:
context.Logger.Error("Session failed to launch, invalid Java runtime."); context.Logger.Error("Session failed to launch, could not prepare server instance.");
return InstanceLaunchFailReason.JavaRuntimeNotFound; return InstanceLaunchFailReason.CouldNotPrepareServerInstance;
case LaunchResult.CouldNotDownloadMinecraftServer: case LaunchResult.CouldNotFindServerExecutable:
context.Logger.Error("Session failed to launch, could not download Minecraft server."); context.Logger.Error("Session failed to launch, could not find server executable.");
return InstanceLaunchFailReason.CouldNotDownloadMinecraftServer; return InstanceLaunchFailReason.CouldNotFindServerExecutable;
case LaunchResult.CouldNotPrepareMinecraftServerLauncher: case LaunchResult.CouldNotStartServerExecutable:
context.Logger.Error("Session failed to launch, could not prepare Minecraft server launcher."); context.Logger.Error("Session failed to launch, could not start server executable.");
return InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher; return InstanceLaunchFailReason.CouldNotStartServerExecutable;
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: default:
context.Logger.Error("Session failed to launch."); context.Logger.Error("Session failed to launch.");

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Channels; using System.Threading.Channels;
using Phantom.Agent.Rpc; using Phantom.Agent.Services.Rpc;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
@@ -8,10 +9,10 @@ using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
sealed class InstanceLogSender : CancellableBackgroundTask { sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly BoundedChannelOptions BufferOptions = new (capacity: 100) { private static readonly BoundedChannelOptions BufferOptions = new (capacity: 200) {
SingleReader = true, SingleReader = true,
SingleWriter = true, SingleWriter = true,
FullMode = BoundedChannelFullMode.DropNewest FullMode = BoundedChannelFullMode.DropNewest,
}; };
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200); private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
@@ -33,17 +34,29 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
var lineReader = outputChannel.Reader; var lineReader = outputChannel.Reader;
var lineBuilder = ImmutableArray.CreateBuilder<string>(); 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 { try {
while (await lineReader.WaitToReadAsync(CancellationToken)) { while (await lineReader.WaitToReadAsync(CancellationToken)) {
await Task.Delay(SendDelay, CancellationToken); await Task.Delay(SendDelay, CancellationToken);
SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder)); await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
} }
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
// Ignore. // Ignore.
} }
// Flush remaining lines. // Flush remaining lines.
SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder)); try {
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder), sendOutputCancellationToken);
} catch (OperationCanceledException) {
// Ignore.
}
} }
private ImmutableArray<string> ReadLinesFromChannel(ChannelReader<string> reader, ImmutableArray<string>.Builder builder) { private ImmutableArray<string> ReadLinesFromChannel(ChannelReader<string> reader, ImmutableArray<string>.Builder builder) {
@@ -53,7 +66,7 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
builder.Add(line); builder.Add(line);
} }
int droppedLines = Interlocked.Exchange(ref droppedLinesSinceLastSend, 0); int droppedLines = Interlocked.Exchange(ref droppedLinesSinceLastSend, value: 0);
if (droppedLines > 0) { if (droppedLines > 0) {
builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow."); builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow.");
} }
@@ -61,9 +74,12 @@ sealed class InstanceLogSender : CancellableBackgroundTask {
return builder.ToImmutable(); return builder.ToImmutable();
} }
private void SendOutputToServer(ImmutableArray<string> lines) { private ValueTask SendOutputToServer(ImmutableArray<string> lines, CancellationToken cancellationToken) {
if (!lines.IsEmpty) { if (lines.IsEmpty) {
controllerConnection.Send(new InstanceOutputMessage(instanceGuid, lines)); return ValueTask.CompletedTask;
}
else {
return controllerConnection.Send(new InstanceOutputMessage(instanceGuid, lines), cancellationToken);
} }
} }

View File

@@ -1,5 +1,9 @@
using Phantom.Agent.Minecraft.Instance; using System.Net.Sockets;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server; 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.Logging;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using Phantom.Utils.Threading; using Phantom.Utils.Threading;
@@ -7,40 +11,24 @@ using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask { sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly InstanceProcess process; private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly ushort serverPort; private readonly ushort serverPort;
private readonly InstanceProcess process;
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource(); private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new (); private readonly ManualResetEventSlim serverOutputEvent = new ();
private int? onlinePlayerCount; private bool WaitingForFirstDetection => !firstDetection.Task.IsCompleted;
public int? OnlinePlayerCount {
get {
lock (this) {
return onlinePlayerCount;
}
}
private set {
EventHandler<int?>? onlinePlayerCountChanged;
lock (this) {
if (onlinePlayerCount == value) {
return;
}
onlinePlayerCount = value;
onlinePlayerCountChanged = OnlinePlayerCountChanged;
}
onlinePlayerCountChanged?.Invoke(this, value);
}
}
private InstancePlayerCounts? playerCounts;
private event EventHandler<int?>? OnlinePlayerCountChanged; private event EventHandler<int?>? OnlinePlayerCountChanged;
private bool isDisposed = false; private bool isDisposed = false;
public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) { 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.process = process;
this.serverPort = serverPort; this.serverPort = serverPort;
Start(); Start();
@@ -48,33 +36,40 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
protected override async Task RunTask() { protected override async Task RunTask() {
// Give the server time to start accepting connections. // Give the server time to start accepting connections.
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken); await Task.Delay(TimeSpan.FromSeconds(5), CancellationToken);
serverOutputEvent.Set(); serverOutputEvent.Set();
process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0); process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
while (!CancellationToken.IsCancellationRequested) { while (CancellationToken.Check()) {
serverOutputEvent.Reset(); serverOutputEvent.Reset();
OnlinePlayerCount = await TryGetOnlinePlayerCount(); InstancePlayerCounts? latestPlayerCounts = await TryGetPlayerCounts();
UpdatePlayerCounts(latestPlayerCounts);
if (!firstDetection.Task.IsCompleted) { if (latestPlayerCounts == null) {
firstDetection.SetResult(); await Task.Delay(WaitingForFirstDetection ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(10), CancellationToken);
} }
else {
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken); await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken); await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken); await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
} }
} }
}
private async Task<int?> TryGetOnlinePlayerCount() { private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try { try {
int newOnlinePlayerCount = await ServerStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken); return await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} online player(s).", newOnlinePlayerCount);
return newOnlinePlayerCount;
} catch (ServerStatusProtocol.ProtocolException e) { } catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error(e.Message); 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; return null;
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Caught exception while checking online player count."); Logger.Error(e, "Caught exception while checking online player count.");
@@ -82,18 +77,41 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
} }
} }
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) { public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
await firstDetection.Task.WaitAsync(cancellationToken); await firstDetection.Task.WaitAsync(cancellationToken);
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource(); var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) { lock (this) {
if (onlinePlayerCount == null) { if (playerCounts is { Online: > 0 }) {
throw new InvalidOperationException();
}
else if (onlinePlayerCount > 0) {
return; return;
} }
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged; OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
@@ -123,7 +141,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
protected override void Dispose() { protected override void Dispose() {
lock (this) { lock (this) {
isDisposed = true; isDisposed = true;
onlinePlayerCount = null; playerCounts = null;
} }
process.RemoveOutputListener(OnOutput); process.RemoveOutputListener(OnOutput);

View File

@@ -3,6 +3,7 @@ using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
@@ -14,8 +15,8 @@ sealed class InstanceRunningState : IDisposable {
internal bool IsStopping { get; set; } internal bool IsStopping { get; set; }
private readonly InstanceContext context; private readonly InstanceContext context;
private readonly InstanceConfiguration configuration; private readonly InstanceInfo info;
private readonly IServerLauncher launcher; private readonly InstanceLauncher launcher;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly InstanceLogSender logSender; private readonly InstanceLogSender logSender;
@@ -24,16 +25,16 @@ sealed class InstanceRunningState : IDisposable {
private bool isDisposed; private bool isDisposed;
public InstanceRunningState(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process, CancellationToken cancellationToken) { public InstanceRunningState(InstanceContext context, InstanceInfo info, InstanceLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process, CancellationToken cancellationToken) {
this.context = context; this.context = context;
this.configuration = configuration; this.info = info;
this.launcher = launcher; this.launcher = launcher;
this.Ticket = ticket; this.Ticket = ticket;
this.Process = process; this.Process = process;
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName); this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort); this.playerCountTracker = new InstancePlayerCountTracker(context, process, info.ServerPort);
this.backupScheduler = new BackupScheduler(context, playerCountTracker); this.backupScheduler = new BackupScheduler(context, playerCountTracker);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
@@ -74,7 +75,7 @@ sealed class InstanceRunningState : IDisposable {
else { else {
context.Logger.Information("Session ended unexpectedly, restarting..."); context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed); context.ReportEvent(InstanceEvent.Crashed);
context.Actor.Tell(new InstanceActor.LaunchInstanceCommand(configuration, launcher, Ticket, IsRestarting: true)); context.Actor.Tell(new InstanceActor.LaunchInstanceCommand(info, launcher, Ticket, IsRestarting: true));
} }
} }

View File

@@ -7,7 +7,7 @@ using Phantom.Common.Data.Minecraft;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;
static class InstanceStopProcedure { static class InstanceStopProcedure {
private static readonly ushort[] Stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 }; 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) { public static async Task<bool> Run(InstanceContext context, MinecraftStopStrategy stopStrategy, InstanceRunningState runningState, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) {
var process = runningState.Process; var process = runningState.Process;

View File

@@ -8,7 +8,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.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.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" />
</ItemGroup> </ItemGroup>
</Project> </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

@@ -1,86 +1,32 @@
using Phantom.Agent.Services.Instances; using Phantom.Agent.Services.Instances;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.BiDirectional;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Rpc; namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent> { public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent> {
private static ILogger Logger { get; } = PhantomLogger.Create<ControllerMessageHandlerActor>(); public readonly record struct Init(AgentServices Agent);
public readonly record struct Init(RpcConnectionToServer<IMessageToController> Connection, AgentServices Agent, CancellationTokenSource ShutdownTokenSource);
public static Props<IMessageToAgent> Factory(Init init) { public static Props<IMessageToAgent> Factory(Init init) {
return Props<IMessageToAgent>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume }); return Props<IMessageToAgent>.Create(() => new ControllerMessageHandlerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
} }
private readonly RpcConnectionToServer<IMessageToController> connection;
private readonly AgentServices agent; private readonly AgentServices agent;
private readonly CancellationTokenSource shutdownTokenSource;
private ControllerMessageHandlerActor(Init init) { private ControllerMessageHandlerActor(Init init) {
this.connection = init.Connection;
this.agent = init.Agent; this.agent = init.Agent;
this.shutdownTokenSource = init.ShutdownTokenSource;
ReceiveAsync<RegisterAgentSuccessMessage>(HandleRegisterAgentSuccess);
Receive<RegisterAgentFailureMessage>(HandleRegisterAgentFailure);
ReceiveAndReplyLater<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(HandleConfigureInstance); ReceiveAndReplyLater<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(HandleConfigureInstance);
ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance); ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance);
ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance); ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance); ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance);
Receive<ReplyMessage>(HandleReply);
}
private async Task HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful.");
void ShutdownAfterConfigurationFailed(Guid instanceGuid, InstanceConfiguration configuration) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, instanceGuid);
shutdownTokenSource.Cancel();
}
foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) {
var result = await HandleConfigureInstance(configureInstanceMessage, alwaysReportStatus: true);
if (!result.Is(ConfigureInstanceResult.Success)) {
ShutdownAfterConfigurationFailed(configureInstanceMessage.InstanceGuid, configureInstanceMessage.Configuration);
return;
}
}
connection.SetIsReady();
await connection.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
agent.InstanceTicketManager.RefreshAgentStatus();
}
private void 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);
PhantomLogger.Dispose();
Environment.Exit(1);
}
private Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) {
return agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus));
} }
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) { private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await HandleConfigureInstance(message, alwaysReportStatus: false); return await agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Info, message.LaunchRecipe, message.LaunchNow, AlwaysReportStatus: false));
} }
private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) { private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
@@ -94,8 +40,4 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent
private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command)); return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command));
} }
private void HandleReply(ReplyMessage message) {
connection.Receive(message);
}
} }

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,4 +1,4 @@
using NetMQ; using System.Text;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Utils.IO; using Phantom.Utils.IO;
@@ -10,7 +10,7 @@ namespace Phantom.Agent;
static class AgentKey { static class AgentKey {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey)); private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey));
public static Task<(NetMQCertificate, AuthToken)?> Load(string? agentKeyToken, string? agentKeyFilePath) { public static Task<ConnectionKey?> Load(string? agentKeyToken, string? agentKeyFilePath) {
if (agentKeyFilePath != null) { if (agentKeyFilePath != null) {
return LoadFromFile(agentKeyFilePath); return LoadFromFile(agentKeyFilePath);
} }
@@ -22,18 +22,19 @@ static class AgentKey {
} }
} }
private static async Task<(NetMQCertificate, AuthToken)?> LoadFromFile(string agentKeyFilePath) { private static async Task<ConnectionKey?> LoadFromFile(string agentKeyFilePath) {
if (!File.Exists(agentKeyFilePath)) { if (!File.Exists(agentKeyFilePath)) {
Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath); Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath);
return null; return null;
} }
try { try {
Files.RequireMaximumFileSize(agentKeyFilePath, 64); Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 128);
return LoadFromBytes(await File.ReadAllBytesAsync(agentKeyFilePath)); string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]);
} catch (IOException e) { } catch (IOException e) {
Logger.Fatal("Error loading agent key from file: {AgentKeyFilePath}", agentKeyFilePath); Logger.Fatal("Error loading agent key from file: {AgentKeyFilePath}", agentKeyFilePath);
Logger.Fatal(e.Message); Logger.Fatal("{Message}", e.Message);
return null; return null;
} catch (Exception) { } catch (Exception) {
Logger.Fatal("File does not contain a valid agent key: {AgentKeyFilePath}", agentKeyFilePath); Logger.Fatal("File does not contain a valid agent key: {AgentKeyFilePath}", agentKeyFilePath);
@@ -41,7 +42,7 @@ static class AgentKey {
} }
} }
private static (NetMQCertificate, AuthToken)? LoadFromToken(string agentKey) { private static ConnectionKey? LoadFromToken(string agentKey) {
try { try {
return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey)); return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey));
} catch (Exception) { } catch (Exception) {
@@ -50,11 +51,9 @@ static class AgentKey {
} }
} }
private static (NetMQCertificate, AuthToken)? LoadFromBytes(byte[] agentKey) { private static ConnectionKey? LoadFromBytes(byte[] agentKey) {
var (publicKey, agentToken) = ConnectionCommonKey.FromBytes(agentKey); var connectionKey = ConnectionKey.FromBytes(agentKey);
var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey);
Logger.Information("Loaded agent key."); Logger.Information("Loaded agent key.");
return (controllerCertificate, agentToken); return connectionKey;
} }
} }

View File

@@ -1,44 +0,0 @@
using System.Text;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
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,17 +1,16 @@
using System.Reflection; using System.Reflection;
using NetMQ;
using Phantom.Agent; using Phantom.Agent;
using Phantom.Agent.Rpc; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Services; using Phantom.Agent.Services;
using Phantom.Agent.Services.Rpc; using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc; using Phantom.Utils.Rpc.Runtime.Client;
using Phantom.Utils.Rpc.Sockets;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Phantom.Utils.Threading;
const int ProtocolVersion = 1; const int ProtocolVersion = 1;
@@ -31,7 +30,7 @@ try {
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion); PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) { if (agentKey == null) {
@@ -43,38 +42,48 @@ try {
return 1; return 1;
} }
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath); var agentInfo = new AgentInfo(ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
if (agentGuid == null) { 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; return 1;
} }
var (controllerCertificate, agentToken) = agentKey.Value; try {
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
var rpcConfiguration = new RpcConfiguration("Agent", controllerHost, controllerPort, controllerCertificate); var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks), new ControllerConnection(rpcClient.MessageSender), javaRuntimeRepository);
var rpcSocket = RpcClientSocket.Connect(rpcConfiguration, AgentMessageRegistries.Definitions, new RegisterAgentMessage(agentToken, agentInfo));
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks), new ControllerConnection(rpcSocket.Connection)); var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(agentServices);
await agentServices.Initialize();
var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(rpcSocket.Connection, agentServices, shutdownCancellationTokenSource);
var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler"); var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler");
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1); rpcClient.StartListening(new ControllerMessageReceiver(rpcMessageHandlerActor, agentRegistrationHandler));
var rpcTask = RpcClientRuntime.Launch(rpcSocket, rpcMessageHandlerActor, rpcDisconnectSemaphore, shutdownCancellationToken);
try { if (await agentRegistrationHandler.Start(agentServices, shutdownCancellationToken)) {
await rpcTask.WaitAsync(shutdownCancellationToken); PhantomLogger.Root.Information("Phantom Panel agent is ready.");
} finally { await shutdownCancellationToken.WaitHandle.WaitOneAsync();
shutdownCancellationTokenSource.Cancel(); }
await agentServices.Shutdown(); await agentServices.Shutdown();
} finally {
rpcDisconnectSemaphore.Release(); await rpcClient.Shutdown();
await rpcTask;
rpcDisconnectSemaphore.Dispose();
NetMQConfig.Cleanup();
} }
return 0; return 0;

View File

@@ -11,11 +11,10 @@ sealed record Variables(
string JavaSearchPath, string JavaSearchPath,
string? AgentKeyToken, string? AgentKeyToken,
string? AgentKeyFilePath, string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances, ushort MaxInstances,
RamAllocationUnits MaxMemory, RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts, AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts, AllowedPorts AllowedAdditionalPorts,
ushort MaxConcurrentBackupCompressionTasks ushort MaxConcurrentBackupCompressionTasks
) { ) {
private static Variables LoadOrThrow() { private static Variables LoadOrThrow() {
@@ -28,11 +27,10 @@ sealed record Variables(
javaSearchPath, javaSearchPath,
agentKeyToken, agentKeyToken,
agentKeyFilePath, agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require, (ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require, EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_ADDITIONAL_PORTS").MapParse(AllowedPorts.FromString).Require,
(ushort) EnvironmentVariables.GetInteger("MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS", min: 1, max: 10000).WithDefault(1) (ushort) EnvironmentVariables.GetInteger("MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS", min: 1, max: 10000).WithDefault(1)
); );
} }
@@ -45,7 +43,7 @@ sealed record Variables(
try { try {
return LoadOrThrow(); return LoadOrThrow();
} catch (Exception e) { } catch (Exception e) {
PhantomLogger.Root.Fatal(e.Message); PhantomLogger.Root.Fatal("{}", e.Message);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
} }

View File

@@ -67,22 +67,22 @@ public sealed class RamAllocationUnitsTests {
Assert.That(CallFromString("123A5M"), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must begin with a number.")); Assert.That(CallFromString("123A5M"), Throws.Exception.TypeOf<ArgumentOutOfRangeException>().With.Message.StartsWith("Must begin with a number."));
} }
[TestCase("0m", 0)] [TestCase("0m", arg2: 0)]
[TestCase("256m", 256)] [TestCase("256m", arg2: 256)]
[TestCase("256M", 256)] [TestCase("256M", arg2: 256)]
[TestCase("512M", 512)] [TestCase("512M", arg2: 512)]
[TestCase("65536M", 65536)] [TestCase("65536M", arg2: 65536)]
[TestCase("16776960M", 16777216 - 256)] [TestCase("16776960M", 16777216 - 256)]
public void ValidDefinitionInMegabytesIsParsedCorrectly(string definition, int megabytes) { public void ValidDefinitionInMegabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes)); Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
} }
[TestCase("0g", 0)] [TestCase("0g", arg2: 0)]
[TestCase("1g", 1024)] [TestCase("1g", arg2: 1024)]
[TestCase("1G", 1024)] [TestCase("1G", arg2: 1024)]
[TestCase("8G", 8192)] [TestCase("8G", arg2: 8192)]
[TestCase("64G", 65536)] [TestCase("64G", arg2: 65536)]
[TestCase("16383G", 16776192)] [TestCase("16383G", arg2: 16776192)]
public void ValidDefinitionInGigabytesIsParsedCorrectly(string definition, int megabytes) { public void ValidDefinitionInGigabytesIsParsedCorrectly(string definition, int megabytes) {
Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes)); Assert.That(RamAllocationUnits.FromString(definition).InMegabytes, Is.EqualTo(megabytes));
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct AgentVersionInfo(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion
);

View File

@@ -0,0 +1,17 @@
namespace Phantom.Common.Data.Web.Agent;
public enum CreateOrUpdateAgentResult : byte {
UnknownError,
Success,
AgentNameMustNotBeEmpty,
}
public static class CreateOrUpdateAgentResultExtensions {
public static string ToSentence(this CreateOrUpdateAgentResult reason) {
return reason switch {
CreateOrUpdateAgentResult.Success => "Success.",
CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty => "Agent name must not be empty.",
_ => "Unknown error.",
};
}
}

View File

@@ -3,10 +3,10 @@
namespace Phantom.Common.Data.Web.Agent; namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(AgentIsOffline))] [MemoryPackUnion(tag: 0, typeof(AgentIsOffline))]
[MemoryPackUnion(1, typeof(AgentIsDisconnected))] [MemoryPackUnion(tag: 1, typeof(AgentIsDisconnected))]
[MemoryPackUnion(2, typeof(AgentIsOnline))] [MemoryPackUnion(tag: 2, typeof(AgentIsOnline))]
public partial interface IAgentConnectionStatus {} public partial interface IAgentConnectionStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentIsOffline : IAgentConnectionStatus; public sealed partial record AgentIsOffline : IAgentConnectionStatus;

View File

@@ -9,11 +9,13 @@ public enum AuditLogEventType {
UserPasswordChanged, UserPasswordChanged,
UserRolesChanged, UserRolesChanged,
UserDeleted, UserDeleted,
AgentCreated,
AgentEdited,
InstanceCreated, InstanceCreated,
InstanceEdited, InstanceEdited,
InstanceLaunched, InstanceLaunched,
InstanceStopped, InstanceStopped,
InstanceCommandExecuted InstanceCommandExecuted,
} }
public static class AuditLogEventTypeExtensions { public static class AuditLogEventTypeExtensions {
@@ -26,11 +28,13 @@ public static class AuditLogEventTypeExtensions {
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User }, { AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
{ AuditLogEventType.AgentCreated, AuditLogSubjectType.Agent },
{ AuditLogEventType.AgentEdited, AuditLogSubjectType.Agent },
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceStopped, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceStopped, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceCommandExecuted, AuditLogSubjectType.Instance } { AuditLogEventType.InstanceCommandExecuted, AuditLogSubjectType.Instance },
}; };
static AuditLogEventTypeExtensions() { static AuditLogEventTypeExtensions() {

View File

@@ -2,5 +2,6 @@
public enum AuditLogSubjectType { public enum AuditLogSubjectType {
User, User,
Instance Agent,
Instance,
} }

View File

@@ -7,7 +7,7 @@ public enum EventLogEventType {
InstanceStopped, InstanceStopped,
InstanceBackupSucceeded, InstanceBackupSucceeded,
InstanceBackupSucceededWithWarnings, InstanceBackupSucceededWithWarnings,
InstanceBackupFailed InstanceBackupFailed,
} }
public static class EventLogEventTypeExtensions { public static class EventLogEventTypeExtensions {
@@ -18,7 +18,7 @@ public static class EventLogEventTypeExtensions {
{ EventLogEventType.InstanceStopped, EventLogSubjectType.Instance }, { EventLogEventType.InstanceStopped, EventLogSubjectType.Instance },
{ EventLogEventType.InstanceBackupSucceeded, EventLogSubjectType.Instance }, { EventLogEventType.InstanceBackupSucceeded, EventLogSubjectType.Instance },
{ EventLogEventType.InstanceBackupSucceededWithWarnings, EventLogSubjectType.Instance }, { EventLogEventType.InstanceBackupSucceededWithWarnings, EventLogSubjectType.Instance },
{ EventLogEventType.InstanceBackupFailed, EventLogSubjectType.Instance } { EventLogEventType.InstanceBackupFailed, EventLogSubjectType.Instance },
}; };
static EventLogEventTypeExtensions() { static EventLogEventTypeExtensions() {

View File

@@ -1,5 +1,5 @@
namespace Phantom.Common.Data.Web.EventLog; namespace Phantom.Common.Data.Web.EventLog;
public enum EventLogSubjectType { public enum EventLogSubjectType {
Instance Instance,
} }

View File

@@ -6,7 +6,7 @@ public enum CreateOrUpdateInstanceResult : byte {
InstanceNameMustNotBeEmpty, InstanceNameMustNotBeEmpty,
InstanceMemoryMustNotBeZero, InstanceMemoryMustNotBeZero,
MinecraftVersionDownloadInfoNotFound, MinecraftVersionDownloadInfoNotFound,
AgentNotFound AgentNotFound,
} }
public static class CreateOrUpdateInstanceResultExtensions { public static class CreateOrUpdateInstanceResultExtensions {
@@ -17,7 +17,7 @@ public static class CreateOrUpdateInstanceResultExtensions {
CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.", CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.", CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.",
CreateOrUpdateInstanceResult.AgentNotFound => "Agent not found.", CreateOrUpdateInstanceResult.AgentNotFound => "Agent not found.",
_ => "Unknown error." _ => "Unknown error.",
}; };
} }
} }

View File

@@ -8,9 +8,10 @@ public sealed partial record Instance(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] IInstanceStatus Status, [property: MemoryPackOrder(2)] IInstanceStatus Status,
[property: MemoryPackOrder(3)] bool LaunchAutomatically [property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts,
[property: MemoryPackOrder(4)] bool LaunchAutomatically
) { ) {
public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) { public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically); return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically);
} }
} }

View File

@@ -4,11 +4,11 @@ namespace Phantom.Common.Data.Web.Minecraft;
public static class JvmArgumentsHelper { public static class JvmArgumentsHelper {
public static ImmutableArray<string> Split(string arguments) { public static ImmutableArray<string> Split(string arguments) {
return arguments.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToImmutableArray(); return [..arguments.Split(separator: '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)];
} }
public static string Join(ImmutableArray<string> arguments) { public static string Join(ImmutableArray<string> arguments) {
return string.Join('\n', arguments); return string.Join(separator: '\n', arguments);
} }
public static ValidationError? Validate(string arguments) { public static ValidationError? Validate(string arguments) {
@@ -35,6 +35,6 @@ public static class JvmArgumentsHelper {
public enum ValidationError { public enum ValidationError {
InvalidFormat, InvalidFormat,
XmxNotAllowed, XmxNotAllowed,
XmsNotAllowed XmsNotAllowed,
} }
} }

View File

@@ -4,5 +4,5 @@ public enum AddRoleError : byte {
NameIsEmpty, NameIsEmpty,
NameIsTooLong, NameIsTooLong,
NameAlreadyExists, NameAlreadyExists,
UnknownError UnknownError,
} }

View File

@@ -1,19 +1,16 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.AddUserErrors;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(NameIsInvalid))] [MemoryPackUnion(tag: 0, typeof(NameIsInvalid))]
[MemoryPackUnion(1, typeof(PasswordIsInvalid))] [MemoryPackUnion(tag: 1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(2, typeof(NameAlreadyExists))] [MemoryPackUnion(tag: 2, typeof(NameAlreadyExists))]
[MemoryPackUnion(3, typeof(UnknownError))] [MemoryPackUnion(tag: 3, typeof(UnknownError))]
public abstract partial record AddUserError { public abstract partial record AddUserError {
internal AddUserError() {} private AddUserError() {}
}
}
namespace Phantom.Common.Data.Web.Users.AddUserErrors {
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError; public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError;

View File

@@ -1,19 +1,16 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(Success))] [MemoryPackUnion(tag: 0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))] [MemoryPackUnion(tag: 1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UpdatingFailed))] [MemoryPackUnion(tag: 2, typeof(UpdatingFailed))]
[MemoryPackUnion(3, typeof(AddingToRoleFailed))] [MemoryPackUnion(tag: 3, typeof(AddingToRoleFailed))]
[MemoryPackUnion(4, typeof(UnknownError))] [MemoryPackUnion(tag: 4, typeof(UnknownError))]
public abstract partial record CreateOrUpdateAdministratorUserResult { public abstract partial record CreateOrUpdateAdministratorUserResult {
internal CreateOrUpdateAdministratorUserResult() {} private CreateOrUpdateAdministratorUserResult() {}
}
}
namespace Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults {
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult; public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult;

View File

@@ -1,17 +1,14 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.CreateUserResults;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(Success))] [MemoryPackUnion(tag: 0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))] [MemoryPackUnion(tag: 1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UnknownError))] [MemoryPackUnion(tag: 2, typeof(UnknownError))]
public abstract partial record CreateUserResult { public abstract partial record CreateUserResult {
internal CreateUserResult() {} private CreateUserResult() {}
}
}
namespace Phantom.Common.Data.Web.Users.CreateUserResults {
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateUserResult; public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateUserResult;

View File

@@ -3,5 +3,5 @@
public enum DeleteUserResult : byte { public enum DeleteUserResult : byte {
Deleted, Deleted,
NotFound, NotFound,
Failed Failed,
} }

View File

@@ -1,18 +1,15 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.PasswordRequirementViolations;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(TooShort))] [MemoryPackUnion(tag: 0, typeof(TooShort))]
[MemoryPackUnion(1, typeof(MustContainLowercaseLetter))] [MemoryPackUnion(tag: 1, typeof(MustContainLowercaseLetter))]
[MemoryPackUnion(2, typeof(MustContainUppercaseLetter))] [MemoryPackUnion(tag: 2, typeof(MustContainUppercaseLetter))]
[MemoryPackUnion(3, typeof(MustContainDigit))] [MemoryPackUnion(tag: 3, typeof(MustContainDigit))]
public abstract partial record PasswordRequirementViolation { public abstract partial record PasswordRequirementViolation {
internal PasswordRequirementViolation() {} private PasswordRequirementViolation() {}
}
}
namespace Phantom.Common.Data.Web.Users.PasswordRequirementViolations {
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation; public sealed partial record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation;

View File

@@ -1,7 +1,7 @@
namespace Phantom.Common.Data.Web.Users; namespace Phantom.Common.Data.Web.Users;
public sealed record Permission(string Id, Permission? Parent) { public sealed record Permission(string Id, Permission? Parent) {
private static readonly List<Permission> AllPermissions = new (); private static readonly List<Permission> AllPermissions = [];
public static IEnumerable<Permission> All => AllPermissions; public static IEnumerable<Permission> All => AllPermissions;
private static Permission Register(string id, Permission? parent = null) { private static Permission Register(string id, Permission? parent = null) {

View File

@@ -1,18 +1,15 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.SetUserPasswordErrors;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(UserNotFound))] [MemoryPackUnion(tag: 0, typeof(UserNotFound))]
[MemoryPackUnion(1, typeof(PasswordIsInvalid))] [MemoryPackUnion(tag: 1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(2, typeof(UnknownError))] [MemoryPackUnion(tag: 2, typeof(UnknownError))]
public abstract partial record SetUserPasswordError { public abstract partial record SetUserPasswordError {
internal SetUserPasswordError() {} private SetUserPasswordError() {}
}
}
namespace Phantom.Common.Data.Web.Users.SetUserPasswordErrors {
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UserNotFound : SetUserPasswordError; public sealed partial record UserNotFound : SetUserPasswordError;

View File

@@ -1,5 +1,5 @@
namespace Phantom.Common.Data.Web.Users; namespace Phantom.Common.Data.Web.Users;
public enum UserActionFailure { public enum UserActionFailure {
NotAuthorized NotAuthorized,
} }

View File

@@ -4,22 +4,22 @@ using Phantom.Common.Data.Replies;
namespace Phantom.Common.Data.Web.Users; namespace Phantom.Common.Data.Web.Users;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(OfUserActionFailure))] [MemoryPackUnion(tag: 0, typeof(User))]
[MemoryPackUnion(1, typeof(OfInstanceActionFailure))] [MemoryPackUnion(tag: 1, typeof(Instance))]
public abstract partial record UserInstanceActionFailure { public abstract partial record UserInstanceActionFailure {
internal UserInstanceActionFailure() {} private UserInstanceActionFailure() {}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record User([property: MemoryPackOrder(0)] UserActionFailure Failure) : UserInstanceActionFailure;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Instance([property: MemoryPackOrder(0)] InstanceActionFailure Failure) : UserInstanceActionFailure;
public static implicit operator UserInstanceActionFailure(UserActionFailure failure) { public static implicit operator UserInstanceActionFailure(UserActionFailure failure) {
return new OfUserActionFailure(failure); return new User(failure);
} }
public static implicit operator UserInstanceActionFailure(InstanceActionFailure failure) { public static implicit operator UserInstanceActionFailure(InstanceActionFailure failure) {
return new OfInstanceActionFailure(failure); return new Instance(failure);
} }
} }
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record OfUserActionFailure([property: MemoryPackOrder(0)] UserActionFailure Failure) : UserInstanceActionFailure;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record OfInstanceActionFailure([property: MemoryPackOrder(0)] InstanceActionFailure Failure) : UserInstanceActionFailure;

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