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

18 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
304 changed files with 6101 additions and 2982 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
} }
} }
} }

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="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <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,14 +13,12 @@
<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="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <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="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" 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" />

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="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <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,14 +13,12 @@
<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="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <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="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" 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" />

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="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <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,14 +13,12 @@
<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="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <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="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" 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" />

View File

@@ -7,17 +7,15 @@
<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="T5Y722D2GZBXT2H27QS95P2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" /> <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="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <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="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" 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" />

View File

@@ -1 +0,0 @@
<EFBFBD>H嶗鰂g<EFBFBD>

View File

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

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);
private static async Task WritePropertyComponent(TextWriter writer, string component, bool escapeSpaces) {
for (int index = 0; index < component.Length; index++) { await foreach ((string key, string value) in sourceReader.ReadProperties(cancellationToken)) {
var c = component[index]; if (remainingOverriddenPropertyKeys.Remove(key)) {
switch (c) { await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
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;
} }
else {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
foreach (string key in remainingOverriddenPropertyKeys) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
} }
} }
} }

View File

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

View File

@@ -38,7 +38,7 @@ 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(); cancellationToken.ThrowIfCancellationRequested();

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

@@ -19,14 +19,13 @@ public sealed class JavaRuntimeRepository {
} }
public ImmutableArray<TaggedJavaRuntime> All { public ImmutableArray<TaggedJavaRuntime> All {
get { [SuppressMessage("ReSharper", "UseCollectionExpression")]
return runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime)) get => runtimesByGuid.Select(static kvp => new TaggedJavaRuntime(kvp.Key, kvp.Value.Runtime))
.OrderBy(static taggedRuntime => taggedRuntime.Runtime) .OrderBy(static taggedRuntime => taggedRuntime.Runtime)
.ToImmutableArray(); .ToImmutableArray();
}
} }
internal bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) { public bool TryGetByGuid(Guid guid, [MaybeNullWhen(false)] out JavaRuntimeExecutable runtime) {
return runtimesByGuid.TryGetValue(guid, out runtime); return runtimesByGuid.TryGetValue(guid, out runtime);
} }

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

@@ -24,7 +24,7 @@ public static class ServerStatusProtocol {
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]);
@@ -44,8 +44,8 @@ public static class ServerStatusProtocol {
private static async Task<InstancePlayerCounts> ReadPlayerCounts(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(0, messageLength)); return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(start: 0, messageLength));
} finally { } finally {
ArrayPool<byte>.Shared.Return(messageBuffer); ArrayPool<byte>.Shared.Return(messageBuffer);
} }
@@ -54,7 +54,7 @@ public static class ServerStatusProtocol {
/// <summary> /// <summary>
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE. /// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
/// </summary> /// </summary>
private static readonly byte[] Separator = { 0x00, 0xA7 }; private static readonly byte[] Separator = [0x00, 0xA7];
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) { private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
int lastSeparator = messageBuffer.LastIndexOf(Separator); int lastSeparator = messageBuffer.LastIndexOf(Separator);

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,12 +1,9 @@
using System.Collections.Immutable; using Akka.Actor;
using Akka.Actor;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
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.Agent.Services.Rpc;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Serilog; using Serilog;
@@ -38,33 +35,6 @@ public sealed class AgentServices {
this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager"); this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager");
} }
public async Task<bool> Register(ControllerHandshake handshake, CancellationToken cancellationToken) {
ImmutableArray<ConfigureInstanceMessage> configureInstanceMessages = handshake.Response;
if (configureInstanceMessages.IsDefault) {
Logger.Fatal("Handshake failed.");
return false;
}
foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceCommand = new InstanceManagerActor.ConfigureInstanceCommand(
configureInstanceMessage.InstanceGuid,
configureInstanceMessage.Configuration,
configureInstanceMessage.LaunchProperties,
configureInstanceMessage.LaunchNow,
AlwaysReportStatus: true
);
var configureInstanceResult = await InstanceManager.Request(configureInstanceCommand, cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid);
return false;
}
}
InstanceTicketManager.RefreshAgentStatus();
return true;
}
public async Task Shutdown() { public async Task Shutdown() {
Logger.Information("Stopping services..."); Logger.Information("Stopping services...");

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

@@ -82,7 +82,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.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;
@@ -100,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);
} }

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.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc; 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,11 +52,11 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
ReceiveAsync<ShutdownCommand>(Shutdown); ReceiveAsync<ShutdownCommand>(Shutdown);
} }
private sealed record InstanceInfo(ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher); private sealed record Instance(ActorRef<InstanceActor.ICommand> Actor, InstanceInfo Info, InstanceProperties Properties, InstanceLaunchRecipe? LaunchRecipe);
public interface ICommand {} public interface ICommand;
public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>; public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceInfo Info, InstanceLaunchRecipe? LaunchRecipe, 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>>;
@@ -72,41 +68,16 @@ 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());
@@ -114,14 +85,23 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
} }
else { else {
var instanceLoggerName = PhantomLogger.ShortenGuid(instanceGuid) + "/" + Interlocked.Increment(ref instanceLoggerSequenceId); var instanceLoggerName = PhantomLogger.ShortenGuid(instanceGuid) + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
var instanceProperties = new InstanceProperties(instanceGuid, Path.Combine(agentFolders.InstancesFolderPath, instanceGuid.ToString()));
var instanceInit = new InstanceActor.Init(agentState, instanceGuid, instanceLoggerName, instanceServices, instanceTicketManager, shutdownCancellationToken); var instanceInit = new InstanceActor.Init(agentState, instanceGuid, instanceLoggerName, instanceServices, instanceTicketManager, shutdownCancellationToken);
instances[instanceGuid] = instance = new InstanceInfo(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), configuration, launcher); 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));
} }
@@ -131,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;
} }
@@ -150,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.Minecraft.Server;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Rpc; 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,4 +1,5 @@
using Phantom.Agent.Services.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;
@@ -18,17 +19,15 @@ sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection con
private readonly HashSet<ushort> usedPorts = []; private readonly HashSet<ushort> usedPorts = [];
private RamAllocationUnits usedMemory = new (); private RamAllocationUnits usedMemory = new ();
public Result<Ticket, LaunchInstanceResult> Reserve(InstanceConfiguration configuration) { public Result<Ticket, LaunchInstanceResult> Reserve(InstanceInfo info) {
var memoryAllocation = configuration.MemoryAllocation; var memoryAllocation = info.MemoryAllocation;
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!agentInfo.AllowedServerPorts.Contains(serverPort)) { if (!agentInfo.AllowedServerPorts.Contains(info.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) {
@@ -40,23 +39,23 @@ sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection con
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;
} }
@@ -76,10 +75,10 @@ sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection con
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);
} }
} }
@@ -93,5 +92,5 @@ sealed class InstanceTicketManager(AgentInfo agentInfo, ControllerConnection con
await reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5)); await reportStatusQueue.Shutdown(TimeSpan.FromSeconds(5));
} }
public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort); 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,4 +1,5 @@
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.Agent.Services.Rpc;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
@@ -18,6 +19,8 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource(); private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new (); private readonly ManualResetEventSlim serverOutputEvent = new ();
private bool WaitingForFirstDetection => !firstDetection.Task.IsCompleted;
private InstancePlayerCounts? playerCounts; private InstancePlayerCounts? playerCounts;
private event EventHandler<int?>? OnlinePlayerCountChanged; private event EventHandler<int?>? OnlinePlayerCountChanged;
@@ -33,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();
await UpdatePlayerCounts(await TryGetPlayerCounts()); 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 serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
} }
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
} }
} }
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() { private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try { try {
var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken); return await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum);
return result;
} catch (ServerStatusProtocol.ProtocolException e) { } catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error("{Message}", 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.");
@@ -67,19 +77,27 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
} }
} }
private async Task UpdatePlayerCounts(InstancePlayerCounts? value) { 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; EventHandler<int?>? onlinePlayerCountChanged;
lock (this) { lock (this) {
if (playerCounts == value) { if (playerCounts == newPlayerCounts) {
return; return;
} }
playerCounts = value; playerCounts = newPlayerCounts;
onlinePlayerCountChanged = OnlinePlayerCountChanged; onlinePlayerCountChanged = OnlinePlayerCountChanged;
} }
onlinePlayerCountChanged?.Invoke(this, value?.Online); onlinePlayerCountChanged?.Invoke(this, newPlayerCounts?.Online);
await controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value), CancellationToken);
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) {

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

@@ -2,46 +2,32 @@
using Phantom.Common.Messages.Agent.Handshake; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;
using Phantom.Utils.Rpc.Runtime.Client; using Phantom.Utils.Rpc.Runtime.Client;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Rpc; namespace Phantom.Agent.Services.Rpc;
public sealed class ControllerHandshake(AgentRegistration registration) : IRpcClientHandshake { public sealed class ControllerHandshake(AgentRegistration registration, AgentRegistrationHandler registrationHandler) : IRpcClientHandshake {
private const int MaxInstances = 100_000; private const int MaxInstances = 100_000;
private const int MaxMessageBytes = 1024 * 1024 * 8; private const int MaxMessageBytes = 1024 * 1024 * 8;
private readonly ILogger logger = PhantomLogger.Create<ControllerHandshake>(); private readonly ILogger logger = PhantomLogger.Create<ControllerHandshake>();
private readonly Lock responseLock = new (); public async Task Perform(RpcStream stream, CancellationToken cancellationToken) {
private ImmutableArray<ConfigureInstanceMessage> response;
internal ImmutableArray<ConfigureInstanceMessage> Response {
get {
lock (responseLock) {
return response;
}
}
private set {
lock (responseLock) {
response = value;
}
}
}
public async Task Perform(Stream stream, CancellationToken cancellationToken) {
logger.Information("Registering with the controller..."); logger.Information("Registering with the controller...");
ReadOnlyMemory<byte> serializedRegistration = RpcSerialization.Serialize(registration); ReadOnlyMemory<byte> serializedRegistration = MessageSerialization.Serialize(registration);
await RpcSerialization.WriteSignedInt(serializedRegistration.Length, stream, cancellationToken); await stream.WriteSignedInt(serializedRegistration.Length, cancellationToken);
await stream.WriteAsync(serializedRegistration, cancellationToken); await stream.WriteBytes(serializedRegistration, cancellationToken);
await stream.Flush(cancellationToken);
if (await RpcSerialization.ReadByte(stream, cancellationToken) == 0) { if (await stream.ReadByte(cancellationToken) == 0) {
return; return;
} }
uint configureInstanceMessageCount = await RpcSerialization.ReadUnsignedInt(stream, cancellationToken); uint configureInstanceMessageCount = await stream.ReadUnsignedInt(cancellationToken);
if (configureInstanceMessageCount > MaxInstances) { if (configureInstanceMessageCount > MaxInstances) {
throw new InvalidOperationException("Trying to configure too many instances (" + configureInstanceMessageCount + " > " + MaxInstances + ")."); throw new InvalidOperationException("Trying to configure too many instances (" + configureInstanceMessageCount + " > " + MaxInstances + ").");
} }
@@ -49,17 +35,16 @@ public sealed class ControllerHandshake(AgentRegistration registration) : IRpcCl
var configureInstanceMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>(); var configureInstanceMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
for (int index = 0; index < configureInstanceMessageCount; index++) { for (int index = 0; index < configureInstanceMessageCount; index++) {
int serializedMessageLength = await RpcSerialization.ReadSignedInt(stream, cancellationToken); int serializedMessageLength = await stream.ReadSignedInt(cancellationToken);
if (serializedMessageLength is < 0 or > MaxMessageBytes) { if (serializedMessageLength is < 0 or > MaxMessageBytes) {
throw new InvalidOperationException("Message must be between 0 and " + MaxMessageBytes + " bytes."); throw new InvalidOperationException("Message must be between 0 and " + MaxMessageBytes + " bytes.");
} }
var serializedMessage = await RpcSerialization.ReadBytes(serializedMessageLength, stream, cancellationToken); var serializedMessage = await stream.ReadBytes(serializedMessageLength, cancellationToken);
configureInstanceMessages.Add(RpcSerialization.Deserialize<ConfigureInstanceMessage>(serializedMessage)); configureInstanceMessages.Add(MessageSerialization.Deserialize<ConfigureInstanceMessage>(serializedMessage));
} }
// TODO handle response, only if new session registrationHandler.OnRegistrationComplete(configureInstanceMessages.ToImmutable());
Response = configureInstanceMessages.ToImmutable();
logger.Information("Registration complete."); logger.Information("Registration complete.");
} }
} }

View File

@@ -26,7 +26,7 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent
} }
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) { private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, AlwaysReportStatus: false)); 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) {

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

@@ -29,7 +29,7 @@ static class AgentKey {
} }
try { try {
Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 64); Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 128);
string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8); string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]); return LoadFromToken(lines[0]);
} catch (IOException e) { } catch (IOException e) {

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

@@ -6,11 +6,8 @@ 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.Handshake; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime;
using Phantom.Utils.Rpc.Runtime.Client; using Phantom.Utils.Rpc.Runtime.Client;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Phantom.Utils.Threading; using Phantom.Utils.Threading;
@@ -33,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) {
@@ -45,14 +42,11 @@ 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) {
return 1;
}
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken); var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken);
var controllerHandshake = new ControllerHandshake(new AgentRegistration(agentInfo, javaRuntimeRepository.All));
var agentRegistrationHandler = new AgentRegistrationHandler();
var controllerHandshake = new ControllerHandshake(new AgentRegistration(agentInfo, javaRuntimeRepository.All), agentRegistrationHandler);
var rpcClientConnectionParameters = new RpcClientConnectionParameters( var rpcClientConnectionParameters = new RpcClientConnectionParameters(
Host: controllerHost, Host: controllerHost,
@@ -61,21 +55,17 @@ try {
CertificateThumbprint: agentKey.Value.CertificateThumbprint, CertificateThumbprint: agentKey.Value.CertificateThumbprint,
AuthToken: agentKey.Value.AuthToken, AuthToken: agentKey.Value.AuthToken,
Handshake: controllerHandshake, Handshake: controllerHandshake,
CommonParameters: new RpcCommonConnectionParameters( MessageQueueCapacity: 250,
MessageQueueCapacity: 250, FrameQueueCapacity: 500,
FrameQueueCapacity: 500, MaxConcurrentlyHandledMessages: 50
MaxConcurrentlyHandledMessages: 50,
PingInterval: TimeSpan.FromSeconds(10)
)
); );
using var rpcClient = await RpcClient<IMessageToController, IMessageToAgent>.Connect("Controller", rpcClientConnectionParameters, AgentMessageRegistries.Definitions, shutdownCancellationToken); using var rpcClient = await RpcClient<IMessageToController, IMessageToAgent>.Connect("Controller", rpcClientConnectionParameters, AgentMessageRegistries.Registries, shutdownCancellationToken);
if (rpcClient == null) { if (rpcClient == null) {
PhantomLogger.Root.Fatal("Could not connect to Phantom Controller, shutting down."); PhantomLogger.Root.Fatal("Could not connect to Phantom Controller, shutting down.");
return 1; return 1;
} }
Task? rpcClientListener = null;
try { try {
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
@@ -84,30 +74,16 @@ try {
var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(agentServices); var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(agentServices);
var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler"); var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler");
rpcClientListener = rpcClient.Listen(new IMessageReceiver<IMessageToAgent>.Actor(rpcMessageHandlerActor)); rpcClient.StartListening(new ControllerMessageReceiver(rpcMessageHandlerActor, agentRegistrationHandler));
if (await agentServices.Register(controllerHandshake, shutdownCancellationToken)) { if (await agentRegistrationHandler.Start(agentServices, shutdownCancellationToken)) {
PhantomLogger.Root.Information("Phantom Panel agent is ready."); PhantomLogger.Root.Information("Phantom Panel agent is ready.");
await shutdownCancellationToken.WaitHandle.WaitOneAsync(); await shutdownCancellationToken.WaitHandle.WaitOneAsync();
} }
await agentServices.Shutdown(); await agentServices.Shutdown();
} finally { } finally {
PhantomLogger.Root.Information("Unregistering agent..."); await rpcClient.Shutdown();
try {
using var unregisterCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await rpcClient.MessageSender.Send(new UnregisterAgentMessage(), unregisterCancellationTokenSource.Token);
} catch (OperationCanceledException) {
PhantomLogger.Root.Warning("Could not unregister agent after shutdown.");
} catch (Exception e) {
PhantomLogger.Root.Warning(e, "Could not unregister agent during shutdown.");
} finally {
await rpcClient.Shutdown();
if (rpcClientListener != null) {
await rpcClientListener;
}
}
} }
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

@@ -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]
[MemoryPackUnion(0, typeof(NameIsInvalid))]
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(2, typeof(NameAlreadyExists))]
[MemoryPackUnion(3, typeof(UnknownError))]
public abstract partial record AddUserError {
internal AddUserError() {}
}
}
namespace Phantom.Common.Data.Web.Users.AddUserErrors { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(NameIsInvalid))]
[MemoryPackUnion(tag: 1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(tag: 2, typeof(NameAlreadyExists))]
[MemoryPackUnion(tag: 3, typeof(UnknownError))]
public abstract partial record AddUserError {
private AddUserError() {}
[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]
[MemoryPackUnion(0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UpdatingFailed))]
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
[MemoryPackUnion(4, typeof(UnknownError))]
public abstract partial record CreateOrUpdateAdministratorUserResult {
internal CreateOrUpdateAdministratorUserResult() {}
}
}
namespace Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(Success))]
[MemoryPackUnion(tag: 1, typeof(CreationFailed))]
[MemoryPackUnion(tag: 2, typeof(UpdatingFailed))]
[MemoryPackUnion(tag: 3, typeof(AddingToRoleFailed))]
[MemoryPackUnion(tag: 4, typeof(UnknownError))]
public abstract partial record CreateOrUpdateAdministratorUserResult {
private CreateOrUpdateAdministratorUserResult() {}
[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]
[MemoryPackUnion(0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UnknownError))]
public abstract partial record CreateUserResult {
internal CreateUserResult() {}
}
}
namespace Phantom.Common.Data.Web.Users.CreateUserResults { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(Success))]
[MemoryPackUnion(tag: 1, typeof(CreationFailed))]
[MemoryPackUnion(tag: 2, typeof(UnknownError))]
public abstract partial record CreateUserResult {
private CreateUserResult() {}
[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]
[MemoryPackUnion(0, typeof(TooShort))]
[MemoryPackUnion(1, typeof(MustContainLowercaseLetter))]
[MemoryPackUnion(2, typeof(MustContainUppercaseLetter))]
[MemoryPackUnion(3, typeof(MustContainDigit))]
public abstract partial record PasswordRequirementViolation {
internal PasswordRequirementViolation() {}
}
}
namespace Phantom.Common.Data.Web.Users.PasswordRequirementViolations { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(TooShort))]
[MemoryPackUnion(tag: 1, typeof(MustContainLowercaseLetter))]
[MemoryPackUnion(tag: 2, typeof(MustContainUppercaseLetter))]
[MemoryPackUnion(tag: 3, typeof(MustContainDigit))]
public abstract partial record PasswordRequirementViolation {
private PasswordRequirementViolation() {}
[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]
[MemoryPackUnion(0, typeof(UserNotFound))]
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(2, typeof(UnknownError))]
public abstract partial record SetUserPasswordError {
internal SetUserPasswordError() {}
}
}
namespace Phantom.Common.Data.Web.Users.SetUserPasswordErrors { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(UserNotFound))]
[MemoryPackUnion(tag: 1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(tag: 2, typeof(UnknownError))]
public abstract partial record SetUserPasswordError {
private SetUserPasswordError() {}
[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;

View File

@@ -1,16 +1,13 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Web.Users.UsernameRequirementViolations;
namespace Phantom.Common.Data.Web.Users { namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(IsEmpty))]
[MemoryPackUnion(1, typeof(TooLong))]
public abstract partial record UsernameRequirementViolation {
internal UsernameRequirementViolation() {}
}
}
namespace Phantom.Common.Data.Web.Users.UsernameRequirementViolations { [MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(IsEmpty))]
[MemoryPackUnion(tag: 1, typeof(TooLong))]
public abstract partial record UsernameRequirementViolation {
private UsernameRequirementViolation() {}
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record IsEmpty : UsernameRequirementViolation; public sealed partial record IsEmpty : UsernameRequirementViolation;

View File

@@ -4,12 +4,10 @@ namespace Phantom.Common.Data.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentInfo( public sealed partial record AgentInfo(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string AgentName, [property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort ProtocolVersion, [property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] string BuildVersion, [property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] ushort MaxInstances, [property: MemoryPackOrder(4)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory, [property: MemoryPackOrder(5)] AllowedPorts AllowedAdditionalPorts
[property: MemoryPackOrder(6)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(7)] AllowedPorts AllowedRconPorts
); );

View File

@@ -35,7 +35,7 @@ public sealed partial class AllowedPorts {
} }
private static AllowedPorts FromString(ReadOnlySpan<char> definitions) { private static AllowedPorts FromString(ReadOnlySpan<char> definitions) {
List<PortRange> parsedDefinitions = new (); List<PortRange> parsedDefinitions = [];
while (!definitions.IsEmpty) { while (!definitions.IsEmpty) {
int separatorIndex = definitions.IndexOf(','); int separatorIndex = definitions.IndexOf(',');
@@ -49,7 +49,7 @@ public sealed partial class AllowedPorts {
} }
} }
return new AllowedPorts(parsedDefinitions.ToImmutableArray()); return new AllowedPorts([..parsedDefinitions]);
} }
public static AllowedPorts FromString(string definitions) { public static AllowedPorts FromString(string definitions) {

View File

@@ -10,7 +10,7 @@ public enum BackupCreationResultKind : byte {
BackupFileAlreadyExists = 6, BackupFileAlreadyExists = 6,
CouldNotCreateBackupFolder = 7, CouldNotCreateBackupFolder = 7,
CouldNotCopyWorldToTemporaryFolder = 8, CouldNotCopyWorldToTemporaryFolder = 8,
CouldNotCreateWorldArchive = 9 CouldNotCreateWorldArchive = 9,
} }
public static class BackupCreationResultSummaryExtensions { public static class BackupCreationResultSummaryExtensions {

View File

@@ -7,7 +7,7 @@ public enum BackupCreationWarnings : byte {
None = 0, None = 0,
CouldNotDeleteTemporaryFolder = 1 << 0, CouldNotDeleteTemporaryFolder = 1 << 0,
CouldNotCompressWorldArchive = 1 << 1, CouldNotCompressWorldArchive = 1 << 1,
CouldNotRestoreAutomaticSaving = 1 << 2 CouldNotRestoreAutomaticSaving = 1 << 2,
} }
public static class BackupCreationWarningsExtensions { public static class BackupCreationWarningsExtensions {

View File

@@ -1,20 +1,21 @@
using Phantom.Utils.Rpc.Handshake; using System.Collections.Immutable;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Common.Data; namespace Phantom.Common.Data;
public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) { public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) {
private const byte TokenLength = AuthToken.Length; private const byte TokenLength = AuthToken.Length;
public byte[] ToBytes() { public ImmutableArray<byte> ToBytes() {
Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length]; Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length];
AuthToken.Bytes.CopyTo(result[..TokenLength]); AuthToken.ToBytes(result[..TokenLength]);
CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]); CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]);
return result.ToArray(); return [..result];
} }
public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) { public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) {
var authToken = new AuthToken([..data[..TokenLength]]); var authToken = AuthToken.FromBytes(data[..TokenLength]);
var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]); var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]);
return new ConnectionKey(certificateThumbprint, authToken); return new ConnectionKey(certificateThumbprint, authToken);
} }

View File

@@ -1,14 +1,15 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance.Launch;
namespace Phantom.Common.Data.Instance; namespace Phantom.Common.Data.Instance;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(InstanceLaunchSucceededEvent))] [MemoryPackUnion(tag: 0, typeof(InstanceLaunchSucceededEvent))]
[MemoryPackUnion(1, typeof(InstanceLaunchFailedEvent))] [MemoryPackUnion(tag: 1, typeof(InstanceLaunchFailedEvent))]
[MemoryPackUnion(2, typeof(InstanceCrashedEvent))] [MemoryPackUnion(tag: 2, typeof(InstanceCrashedEvent))]
[MemoryPackUnion(3, typeof(InstanceStoppedEvent))] [MemoryPackUnion(tag: 3, typeof(InstanceStoppedEvent))]
[MemoryPackUnion(4, typeof(InstanceBackupCompletedEvent))] [MemoryPackUnion(tag: 4, typeof(InstanceBackupCompletedEvent))]
public partial interface IInstanceEvent { public partial interface IInstanceEvent {
void Accept(IInstanceEventVisitor visitor); void Accept(IInstanceEventVisitor visitor);
} }

View File

@@ -0,0 +1,41 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(InstancePath.Global))]
[MemoryPackUnion(tag: 1, typeof(InstancePath.Local))]
[MemoryPackUnion(tag: 2, typeof(InstancePath.Runtime))]
public partial interface IInstancePath {
string? Resolve(IInstancePathResolver resolver);
}
public static partial class InstancePath {
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Global(
[property: MemoryPackOrder(0)] ImmutableArray<string> Path
) : IInstancePath {
public string? Resolve(IInstancePathResolver resolver) {
return resolver.Global(Path);
}
}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Local(
[property: MemoryPackOrder(0)] ImmutableArray<string> Path
) : IInstancePath {
public string? Resolve(IInstancePathResolver resolver) {
return resolver.Local(Path);
}
}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Runtime(
[property: MemoryPackOrder(0)] Guid Guid
) : IInstancePath {
public string? Resolve(IInstancePathResolver resolver) {
return resolver.Runtime(Guid);
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Immutable;
namespace Phantom.Common.Data.Instance;
public interface IInstancePathResolver {
string? Global(ImmutableArray<string> path);
string? Local(ImmutableArray<string> path);
string? Runtime(Guid guid);
}

View File

@@ -1,19 +1,20 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Instance.Launch;
namespace Phantom.Common.Data.Instance; namespace Phantom.Common.Data.Instance;
[MemoryPackable] [MemoryPackable]
[MemoryPackUnion(0, typeof(InstanceIsOffline))] [MemoryPackUnion(tag: 0, typeof(InstanceIsOffline))]
[MemoryPackUnion(1, typeof(InstanceIsInvalid))] [MemoryPackUnion(tag: 1, typeof(InstanceIsInvalid))]
[MemoryPackUnion(2, typeof(InstanceIsNotRunning))] [MemoryPackUnion(tag: 2, typeof(InstanceIsNotRunning))]
[MemoryPackUnion(3, typeof(InstanceIsDownloading))] [MemoryPackUnion(tag: 3, typeof(InstanceIsDownloading))]
[MemoryPackUnion(4, typeof(InstanceIsLaunching))] [MemoryPackUnion(tag: 4, typeof(InstanceIsLaunching))]
[MemoryPackUnion(5, typeof(InstanceIsRunning))] [MemoryPackUnion(tag: 5, typeof(InstanceIsRunning))]
[MemoryPackUnion(6, typeof(InstanceIsBackingUp))] [MemoryPackUnion(tag: 6, typeof(InstanceIsBackingUp))]
[MemoryPackUnion(7, typeof(InstanceIsRestarting))] [MemoryPackUnion(tag: 7, typeof(InstanceIsRestarting))]
[MemoryPackUnion(8, typeof(InstanceIsStopping))] [MemoryPackUnion(tag: 8, typeof(InstanceIsStopping))]
[MemoryPackUnion(9, typeof(InstanceIsFailed))] [MemoryPackUnion(tag: 9, typeof(InstanceIsFailed))]
public partial interface IInstanceStatus {} public partial interface IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsOffline : IInstanceStatus; public sealed partial record InstanceIsOffline : IInstanceStatus;
@@ -25,7 +26,7 @@ public sealed partial record InstanceIsInvalid([property: MemoryPackOrder(0)] st
public sealed partial record InstanceIsNotRunning : IInstanceStatus; public sealed partial record InstanceIsNotRunning : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsDownloading([property: MemoryPackOrder(0)] byte Progress) : IInstanceStatus; public sealed partial record InstanceIsDownloading([property: MemoryPackOrder(0)] byte? Progress) : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsLaunching : IInstanceStatus; public sealed partial record InstanceIsLaunching : IInstanceStatus;
@@ -55,7 +56,7 @@ public static class InstanceStatus {
public static readonly IInstanceStatus Stopping = new InstanceIsStopping(); public static readonly IInstanceStatus Stopping = new InstanceIsStopping();
public static IInstanceStatus Invalid(string reason) => new InstanceIsInvalid(reason); public static IInstanceStatus Invalid(string reason) => new InstanceIsInvalid(reason);
public static IInstanceStatus Downloading(byte progress) => new InstanceIsDownloading(progress); public static IInstanceStatus Downloading(byte? progress) => new InstanceIsDownloading(progress);
public static IInstanceStatus Failed(InstanceLaunchFailReason reason) => new InstanceIsFailed(reason); public static IInstanceStatus Failed(InstanceLaunchFailReason reason) => new InstanceIsFailed(reason);
public static bool IsLaunching(this IInstanceStatus status) { public static bool IsLaunching(this IInstanceStatus status) {

View File

@@ -0,0 +1,57 @@
using System.Collections.Immutable;
using System.Text;
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(InstanceValues.Concatenation))]
[MemoryPackUnion(tag: 1, typeof(InstanceValues.Text))]
[MemoryPackUnion(tag: 2, typeof(InstanceValues.Path))]
[MemoryPackUnion(tag: 3, typeof(InstanceValues.Variable))]
public partial interface IInstanceValue {
string? Resolve(IInstanceValueResolver resolver);
}
public static partial class InstanceValues {
[MemoryPackable]
public sealed partial record Concatenation(ImmutableArray<IInstanceValue> Values) : IInstanceValue {
public string? Resolve(IInstanceValueResolver resolver) {
var result = new StringBuilder();
foreach (IInstanceValue value in Values) {
if (value.Resolve(resolver) is {} resolved) {
result.Append(resolved);
}
else {
return null;
}
}
return result.ToString();
}
}
[MemoryPackable]
public sealed partial record Text(string Value) : IInstanceValue {
public string Resolve(IInstanceValueResolver resolver) {
return Value;
}
}
[MemoryPackable]
public sealed partial record Path(IInstancePath Value) : IInstanceValue {
public string? Resolve(IInstanceValueResolver resolver) {
return resolver.Path(Value);
}
}
[MemoryPackable]
public sealed partial record Variable(InstanceVariable Value) : IInstanceValue {
public string? Resolve(IInstanceValueResolver resolver) {
return resolver.Variable(Value);
}
}
}
public enum InstanceVariable {}

View File

@@ -0,0 +1,6 @@
namespace Phantom.Common.Data.Instance;
public interface IInstanceValueResolver {
string? Path(IInstancePath value);
string? Variable(InstanceVariable value);
}

View File

@@ -15,4 +15,7 @@ public sealed partial record InstanceConfiguration(
[property: MemoryPackOrder(6)] RamAllocationUnits MemoryAllocation, [property: MemoryPackOrder(6)] RamAllocationUnits MemoryAllocation,
[property: MemoryPackOrder(7)] Guid JavaRuntimeGuid, [property: MemoryPackOrder(7)] Guid JavaRuntimeGuid,
[property: MemoryPackOrder(8)] ImmutableArray<string> JvmArguments [property: MemoryPackOrder(8)] ImmutableArray<string> JvmArguments
); ) {
[MemoryPackIgnore]
public InstanceInfo AsInfo => new (InstanceName, ServerPort, [RconPort], MemoryAllocation);
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceInfo(
[property: MemoryPackOrder(0)] string InstanceName,
[property: MemoryPackOrder(1)] ushort ServerPort,
[property: MemoryPackOrder(2)] ImmutableSortedSet<ushort> AdditionalPorts,
[property: MemoryPackOrder(3)] RamAllocationUnits MemoryAllocation
);

View File

@@ -1,10 +0,0 @@
namespace Phantom.Common.Data.Instance;
public enum InstanceLaunchFailReason : byte {
UnknownError = 0,
JavaRuntimeNotFound = 5,
CouldNotDownloadMinecraftServer = 6,
CouldNotConfigureMinecraftServer = 7,
CouldNotPrepareMinecraftServerLauncher = 8,
CouldNotStartMinecraftServer = 9
}

View File

@@ -1,9 +0,0 @@
using MemoryPack;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceLaunchProperties(
[property: MemoryPackOrder(0)] FileDownloadInfo? ServerDownloadInfo
);

View File

@@ -3,7 +3,7 @@
namespace Phantom.Common.Data.Instance; namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct InstancePlayerCounts( public sealed partial record InstancePlayerCounts(
[property: MemoryPackOrder(0)] int Online, [property: MemoryPackOrder(0)] int Online,
[property: MemoryPackOrder(1)] int Maximum [property: MemoryPackOrder(1)] int Maximum
); );

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