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

5 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
155 changed files with 3496 additions and 1469 deletions

View File

@@ -3,11 +3,11 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "9.0.9", "version": "10.0.1",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
], ],
"rollForward": false "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

@@ -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

@@ -7,6 +7,12 @@ sealed class JavaPropertiesFileEditor {
overriddenProperties[key] = value; overriddenProperties[key] = value;
} }
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) { 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";

View File

@@ -2,4 +2,4 @@
namespace Phantom.Agent.Minecraft.Java; namespace Phantom.Agent.Minecraft.Java;
sealed record JavaRuntimeExecutable(string ExecutablePath, JavaRuntime Runtime); public sealed record JavaRuntimeExecutable(string ExecutablePath, JavaRuntime Runtime);

View File

@@ -25,7 +25,7 @@ public sealed class JavaRuntimeRepository {
.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

@@ -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, cancellationToken);
} 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, ["# EULA", "eula=true"], Encoding.UTF8);
}
private static async Task UpdateServerProperties(InstanceProperties instanceProperties, CancellationToken cancellationToken) {
var serverPropertiesEditor = new JavaPropertiesFileEditor();
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor);
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"), comment: "server.properties", cancellationToken);
}
}

View File

@@ -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,54 +0,0 @@
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, ["-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

@@ -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 = [];
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, overwrite: 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

@@ -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

@@ -33,7 +33,7 @@ public sealed class AgentRegistrationHandler {
foreach (var configureInstanceMessage in configureInstanceMessages) { foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken); var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) { if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid); logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configureInstanceMessage.Info.InstanceName, configureInstanceMessage.InstanceGuid);
return false; return false;
} }
} }
@@ -66,7 +66,7 @@ public sealed class AgentRegistrationHandler {
foreach (var configureInstanceMessage in configureInstanceMessages) { foreach (var configureInstanceMessage in configureInstanceMessages) {
var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken); var configureInstanceResult = await agentServices.InstanceManager.Request(GetCommand(configureInstanceMessage), cancellationToken);
if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) { if (!configureInstanceResult.Is(ConfigureInstanceResult.Success)) {
logger.Error("Unable to configure instance \"{Name}\" (GUID {Guid}).", configureInstanceMessage.Configuration.InstanceName, configureInstanceMessage.InstanceGuid); logger.Error("Unable to configure instance \"{Name}\" (GUID {Guid}).", configureInstanceMessage.Info.InstanceName, configureInstanceMessage.InstanceGuid);
} }
} }
@@ -76,8 +76,8 @@ public sealed class AgentRegistrationHandler {
private static InstanceManagerActor.ConfigureInstanceCommand GetCommand(ConfigureInstanceMessage configureInstanceMessage) { private static InstanceManagerActor.ConfigureInstanceCommand GetCommand(ConfigureInstanceMessage configureInstanceMessage) {
return new InstanceManagerActor.ConfigureInstanceCommand( return new InstanceManagerActor.ConfigureInstanceCommand(
configureInstanceMessage.InstanceGuid, configureInstanceMessage.InstanceGuid,
configureInstanceMessage.Configuration, configureInstanceMessage.Info,
configureInstanceMessage.LaunchProperties, configureInstanceMessage.LaunchRecipe,
configureInstanceMessage.LaunchNow, configureInstanceMessage.LaunchNow,
AlwaysReportStatus: true AlwaysReportStatus: true
); );

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, val2: 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
reportStatus(InstanceStatus.Downloading(progress));
}
}
switch (await launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken)) {
case LaunchResult.Success launchSuccess: 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

@@ -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

@@ -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

@@ -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, maximumBytes: 128);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return Guid.Parse(contents.Trim());
}
}

View File

@@ -30,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) {
@@ -42,12 +42,7 @@ 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 agentRegistrationHandler = new AgentRegistrationHandler(); var agentRegistrationHandler = new AgentRegistrationHandler();

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)
); );
} }

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

@@ -9,6 +9,8 @@ public enum AuditLogEventType {
UserPasswordChanged, UserPasswordChanged,
UserRolesChanged, UserRolesChanged,
UserDeleted, UserDeleted,
AgentCreated,
AgentEdited,
InstanceCreated, InstanceCreated,
InstanceEdited, InstanceEdited,
InstanceLaunched, InstanceLaunched,
@@ -26,6 +28,8 @@ 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 },

View File

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

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

@@ -1,20 +1,21 @@
using Phantom.Utils.Rpc; 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,5 +1,6 @@
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;

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,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Instance.Launch;
namespace Phantom.Common.Data.Instance; namespace Phantom.Common.Data.Instance;
@@ -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

@@ -0,0 +1,35 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Common.Data.Instance.Launch;
[MemoryPackable]
[MemoryPackUnion(tag: 0, typeof(InstanceLaunchStep.DownloadFile))]
[MemoryPackUnion(tag: 1, typeof(InstanceLaunchStep.EditPropertiesFile))]
public partial interface IInstanceLaunchStep {
Task Run(IInstanceLaunchStepExecutor executor);
}
public static partial class InstanceLaunchStep {
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record DownloadFile(
[property: MemoryPackOrder(0)] FileDownloadInfo DownloadInfo,
[property: MemoryPackOrder(1)] IInstancePath Path
) : IInstanceLaunchStep {
public Task Run(IInstanceLaunchStepExecutor executor) {
return executor.DownloadFile(DownloadInfo, Path);
}
}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record EditPropertiesFile(
[property: MemoryPackOrder(0)] InstancePath.Local Path,
[property: MemoryPackOrder(1)] string Comment,
[property: MemoryPackOrder(2)] ImmutableDictionary<string, string> NewValues
) : IInstanceLaunchStep {
public Task Run(IInstanceLaunchStepExecutor executor) {
return executor.EditPropertiesFile(Path, Comment, NewValues);
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Immutable;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Common.Data.Instance.Launch;
public interface IInstanceLaunchStepExecutor {
Task DownloadFile(FileDownloadInfo downloadInfo, IInstancePath path);
Task EditPropertiesFile(InstancePath.Local path, string comment, ImmutableDictionary<string, string> newValues);
}

View File

@@ -0,0 +1,8 @@
namespace Phantom.Common.Data.Instance.Launch;
public enum InstanceLaunchFailReason : byte {
UnknownError = 0,
CouldNotPrepareServerInstance = 1,
CouldNotFindServerExecutable = 2,
CouldNotStartServerExecutable = 3,
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Instance.Launch;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceLaunchRecipe(
[property: MemoryPackOrder(0)] ImmutableArray<IInstanceLaunchStep> Preparation,
[property: MemoryPackOrder(1)] IInstancePath Executable,
[property: MemoryPackOrder(2)] ImmutableArray<IInstanceValue> Arguments
);

View File

@@ -1,6 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
namespace Phantom.Common.Data.Minecraft; namespace Phantom.Common.Data.Minecraft;
@@ -11,20 +10,16 @@ public sealed partial class FileDownloadInfo {
[MemoryPackOrder(1)] [MemoryPackOrder(1)]
[MemoryPackInclude] [MemoryPackInclude]
private readonly string hash; private readonly string? hash;
[MemoryPackIgnore] [MemoryPackIgnore]
public Sha1String Hash => Sha1String.FromString(hash); public Sha1String? Hash => hash == null ? null : Sha1String.FromString(hash);
[MemoryPackOrder(2)] public FileDownloadInfo(string downloadUrl, Sha1String? hash = null) : this(downloadUrl, hash?.ToString()) {}
public FileSize Size { get; }
public FileDownloadInfo(string downloadUrl, Sha1String hash, FileSize size) : this(downloadUrl, hash.ToString(), size) {}
[MemoryPackConstructor] [MemoryPackConstructor]
private FileDownloadInfo(string downloadUrl, string hash, FileSize size) { private FileDownloadInfo(string downloadUrl, string? hash) {
this.DownloadUrl = downloadUrl; this.DownloadUrl = downloadUrl;
this.hash = hash; this.hash = hash;
this.Size = size;
} }
} }

View File

@@ -1,14 +1,16 @@
namespace Phantom.Common.Data.Replies; namespace Phantom.Common.Data.Replies;
public enum ConfigureInstanceResult : byte { public enum ConfigureInstanceResult : byte {
Success, Success = 0,
CouldNotCreateInstanceFolder = 1,
} }
public static class ConfigureInstanceResultExtensions { public static class ConfigureInstanceResultExtensions {
public static string ToSentence(this ConfigureInstanceResult reason) { public static string ToSentence(this ConfigureInstanceResult reason) {
return reason switch { return reason switch {
ConfigureInstanceResult.Success => "Success.", ConfigureInstanceResult.Success => "Success.",
_ => "Unknown error.", ConfigureInstanceResult.CouldNotCreateInstanceFolder => "Could not create instance folder.",
_ => "Unknown error.",
}; };
} }
} }

View File

@@ -1,13 +1,14 @@
namespace Phantom.Common.Data.Replies; namespace Phantom.Common.Data.Replies;
public enum LaunchInstanceResult : byte { public enum LaunchInstanceResult : byte {
LaunchInitiated = 1, LaunchInitiated = 1,
InstanceAlreadyLaunching = 2, InstanceAlreadyLaunching = 2,
InstanceAlreadyRunning = 3, InstanceAlreadyRunning = 3,
InstanceLimitExceeded = 4, InstanceLimitExceeded = 4,
MemoryLimitExceeded = 5, MemoryLimitExceeded = 5,
ServerPortNotAllowed = 6, ServerPortNotAllowed = 6,
ServerPortAlreadyInUse = 7, ServerPortAlreadyInUse = 7,
RconPortNotAllowed = 8, AdditionalPortNotAllowed = 8,
RconPortAlreadyInUse = 9, AdditionalPortAlreadyInUse = 9,
InvalidConfiguration = 10,
} }

View File

@@ -18,10 +18,10 @@ public static class AgentMessageRegistries {
ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(); ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>();
ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(); ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>();
ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<InstanceOutputMessage>();
ToController.Add<ReportAgentStatusMessage>(); ToController.Add<ReportAgentStatusMessage>();
ToController.Add<ReportInstanceEventMessage>(); ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<ReportInstancePlayerCountsMessage>(); ToController.Add<ReportInstancePlayerCountsMessage>();
ToController.Add<ReportInstanceEventMessage>();
ToController.Add<InstanceOutputMessage>();
} }
} }

View File

@@ -1,6 +1,7 @@
using MemoryPack; using MemoryPack;
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.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -9,7 +10,7 @@ namespace Phantom.Common.Messages.Agent.ToAgent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record ConfigureInstanceMessage( public sealed partial record ConfigureInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(1)] InstanceInfo Info,
[property: MemoryPackOrder(2)] InstanceLaunchProperties LaunchProperties, [property: MemoryPackOrder(2)] InstanceLaunchRecipe? LaunchRecipe,
[property: MemoryPackOrder(3)] bool LaunchNow = false [property: MemoryPackOrder(3)] bool LaunchNow = false
) : IMessageToAgent, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>; ) : IMessageToAgent, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateOrUpdateAgentMessage(
[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] AgentConfiguration Configuration
) : IMessageToController, ICanReply<Result<CreateOrUpdateAgentResult, UserActionFailure>>;

View File

@@ -3,6 +3,7 @@ using Phantom.Common.Data;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -25,17 +26,18 @@ public static class WebMessageRegistries {
ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(); ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>();
ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(); ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>();
ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(); ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(); ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>();
ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(); ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>();
ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(); ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>();
ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(); ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(); ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>();
ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(); ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>();
ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(); ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>();
ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(); ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>();
ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(); ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(); ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>();
ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(); ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>();

View File

@@ -0,0 +1,357 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251225053921_AgentAuthSecret")]
partial class AgentAuthSecret
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<int>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<int>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentAuthSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "AuthSecret",
schema: "agents",
table: "Agents",
type: "bytea",
maxLength: 12,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthSecret",
schema: "agents",
table: "Agents");
}
}
}

View File

@@ -0,0 +1,356 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251228053557_AgentFieldNullability")]
partial class AgentFieldNullability
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.HasColumnType("text");
b.Property<int?>("MaxInstances")
.HasColumnType("integer");
b.Property<int?>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<int>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentFieldNullability : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "ProtocolVersion",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "MaxMemory",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "MaxInstances",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<string>(
name: "BuildVersion",
schema: "agents",
table: "Agents",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "ProtocolVersion",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "MaxMemory",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "MaxInstances",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "BuildVersion",
schema: "agents",
table: "Agents",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}

View File

@@ -0,0 +1,357 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251228211902_AgentAuthSecretNullability")]
partial class AgentAuthSecretNullability
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.HasColumnType("text");
b.Property<int?>("MaxInstances")
.HasColumnType("integer");
b.Property<int?>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<int>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentAuthSecretNullability : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<byte[]>(
name: "AuthSecret",
schema: "agents",
table: "Agents",
type: "bytea",
maxLength: 12,
nullable: false,
defaultValue: new byte[0],
oldClrType: typeof(byte[]),
oldType: "bytea",
oldMaxLength: 12,
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<byte[]>(
name: "AuthSecret",
schema: "agents",
table: "Agents",
type: "bytea",
maxLength: 12,
nullable: true,
oldClrType: typeof(byte[]),
oldType: "bytea",
oldMaxLength: 12);
}
}
}

View File

@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -29,21 +29,25 @@ namespace Phantom.Controller.Database.Postgres.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("BuildVersion") b.Property<byte[]>("AuthSecret")
.IsRequired() .IsRequired()
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("MaxInstances") b.Property<int?>("MaxInstances")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<ushort>("MaxMemory") b.Property<int?>("MaxMemory")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("ProtocolVersion") b.Property<int?>("ProtocolVersion")
.HasColumnType("integer"); .HasColumnType("integer");
b.HasKey("AgentGuid"); b.HasKey("AgentGuid");
@@ -142,7 +146,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
b.Property<bool>("LaunchAutomatically") b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation") b.Property<int>("MemoryAllocation")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("MinecraftServerKind") b.Property<string>("MinecraftServerKind")

View File

@@ -8,6 +8,7 @@ using Phantom.Common.Data.Web.EventLog;
using Phantom.Controller.Database.Converters; using Phantom.Controller.Database.Converters;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Factories; using Phantom.Controller.Database.Factories;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database; namespace Phantom.Controller.Database;
@@ -79,6 +80,8 @@ public class ApplicationDbContext : DbContext {
builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>(); builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>();
builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>(); builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>();
builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>(); builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>();
builder.Properties<AuthSecret>().HaveConversion<AuthSecretConverter>();
builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>(); builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>();
} }
} }

View File

@@ -0,0 +1,12 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class AuthSecretConverter() : ValueConverter<AuthSecret, byte[]>(
static units => units.Bytes.ToArray(),
static value => new AuthSecret(ImmutableArray.Create(value))
);

View File

@@ -5,9 +5,7 @@ using Phantom.Common.Data;
namespace Phantom.Controller.Database.Converters; namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class RamAllocationUnitsConverter : ValueConverter<RamAllocationUnits, ushort> { sealed class RamAllocationUnitsConverter() : ValueConverter<RamAllocationUnits, ushort>(
public RamAllocationUnitsConverter() : base( static units => units.RawValue,
static units => units.RawValue, static value => new RamAllocationUnits(value)
static value => new RamAllocationUnits(value) );
) {}
}

View File

@@ -2,6 +2,8 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Entities; namespace Phantom.Controller.Database.Entities;
@@ -12,14 +14,22 @@ public sealed class AgentEntity {
public Guid AgentGuid { get; init; } public Guid AgentGuid { get; init; }
public string Name { get; set; } public string Name { get; set; }
public ushort ProtocolVersion { get; set; } public ushort? ProtocolVersion { get; set; }
public string BuildVersion { get; set; } public string? BuildVersion { get; set; }
public ushort MaxInstances { get; set; } public ushort? MaxInstances { get; set; }
public RamAllocationUnits MaxMemory { get; set; } public RamAllocationUnits? MaxMemory { get; set; }
[MaxLength(AuthSecret.Length)]
public AuthSecret AuthSecret { get; set; }
public AgentConfiguration Configuration => new (Name);
public AgentVersionInfo? VersionInfo => ProtocolVersion is {} protocolVersion && BuildVersion is {} buildVersion ? new AgentVersionInfo(protocolVersion, buildVersion) : null;
public AgentRuntimeInfo RuntimeInfo => new (VersionInfo, MaxInstances, MaxMemory);
internal AgentEntity(Guid agentGuid) { internal AgentEntity(Guid agentGuid) {
AgentGuid = agentGuid; AgentGuid = agentGuid;
Name = null!; Name = null!;
BuildVersion = null!; BuildVersion = null!;
AuthSecret = null!;
} }
} }

View File

@@ -14,15 +14,21 @@ public abstract class AbstractUpsertHelper<T> where T : class {
private protected abstract T Construct(Guid guid); private protected abstract T Construct(Guid guid);
public T Fetch(Guid guid) { public T Fetch(Guid guid) {
return Fetch(guid, out _);
}
public T Fetch(Guid guid, out bool wasCreated) {
DbSet<T> set = Set; DbSet<T> set = Set;
T? entity = set.Find(guid); T? entity = set.Find(guid);
if (entity == null) { if (entity == null) {
entity = Construct(guid); entity = Construct(guid);
set.Add(entity); set.Add(entity);
wasCreated = true;
} }
else { else {
set.Update(entity); set.Update(entity);
wasCreated = false;
} }
return entity; return entity;

View File

@@ -11,7 +11,6 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="System.Linq.Async" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -61,6 +61,14 @@ sealed partial class AuditLogRepository {
}); });
} }
public void AgentCreated(Guid agentGuid) {
AddItem(AuditLogEventType.AgentCreated, agentGuid.ToString());
}
public void AgentEdited(Guid agentGuid) {
AddItem(AuditLogEventType.AgentEdited, agentGuid.ToString());
}
public void InstanceCreated(Guid instanceGuid) { public void InstanceCreated(Guid instanceGuid) {
AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
} }

View File

@@ -0,0 +1,77 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Collections;
namespace Phantom.Controller.Minecraft;
public sealed partial class MinecraftLaunchRecipes(MinecraftVersions minecraftVersions) {
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex SanitizePathRegex();
private static readonly ImmutableDictionary<string, string> Eula = ImmutableDictionary.From([
("eula", "true"),
]);
public async Task<InstanceLaunchRecipe?> Create(InstanceConfiguration configuration, CancellationToken cancellationToken) {
string minecraftVersion = configuration.MinecraftVersion;
string minecraftVersionPath = SanitizePathRegex().IsMatch(minecraftVersion) ? SanitizePathRegex().Replace(minecraftVersion, "_") : minecraftVersion;
uint maximumHeapSizeMegabvtes = configuration.MemoryAllocation.InMegabytes;
uint initialHeapSizeMegabytes = maximumHeapSizeMegabvtes / 2;
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(minecraftVersion, cancellationToken);
if (serverExecutableInfo == null) {
// TODO
return null;
}
var vanillaJarFilePath = new InstancePath.Global(["minecraft", "vanilla", minecraftVersionPath, "server.jar"]);
var steps = ImmutableArray.CreateBuilder<IInstanceLaunchStep>();
steps.Add(new InstanceLaunchStep.DownloadFile(serverExecutableInfo, vanillaJarFilePath));
steps.Add(new InstanceLaunchStep.EditPropertiesFile(new InstancePath.Local(["eula.txt"]), "EULA", Eula));
steps.Add(new InstanceLaunchStep.EditPropertiesFile(new InstancePath.Local(["server.properties"]), "Server Properties", ServerProperties(configuration)));
var serverExecutableFilePath = vanillaJarFilePath;
var additionalJvmArguments = new List<IInstanceValue>();
if (configuration.MinecraftServerKind == MinecraftServerKind.Fabric) {
// TODO customizable loader version
string fabricLauncherUrl = $"https://meta.fabricmc.net/v2/versions/loader/{minecraftVersion}/stable/stable/server/jar";
var fabricLauncherFilePath = new InstancePath.Global(["minecraft", "fabric", minecraftVersionPath, "loader.jar"]);
steps.Add(new InstanceLaunchStep.DownloadFile(new FileDownloadInfo(fabricLauncherUrl), fabricLauncherFilePath));
serverExecutableFilePath = fabricLauncherFilePath;
additionalJvmArguments.Add(new InstanceValues.Concatenation([
new InstanceValues.Text("-Dfabric.installer.server.gameJar="),
new InstanceValues.Path(vanillaJarFilePath),
]));
}
return new InstanceLaunchRecipe(steps.ToImmutable(), new InstancePath.Runtime(configuration.JavaRuntimeGuid), [
..configuration.JvmArguments.Select(static arg => new InstanceValues.Text(arg)),
..additionalJvmArguments,
new InstanceValues.Text("-Xms" + initialHeapSizeMegabytes + "M"),
new InstanceValues.Text("-Xmx" + maximumHeapSizeMegabvtes + "M"),
new InstanceValues.Text("-jar"),
new InstanceValues.Path(serverExecutableFilePath),
new InstanceValues.Text("-nogui"),
]);
}
private static ImmutableDictionary<string, string> ServerProperties(InstanceConfiguration configuration) {
return ImmutableDictionary.From([
("server-port", configuration.ServerPort.ToString()),
("rcon.port", configuration.RconPort.ToString()),
("enable-rcon", "true"),
("sync-chunk-writes", "false"),
]);
}
}

View File

@@ -3,7 +3,6 @@ using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Serilog; using Serilog;
@@ -127,15 +126,6 @@ sealed class MinecraftVersionApi : IDisposable {
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
JsonElement sizeElement = GetJsonPropertyOrThrow(serverElement, "size", JsonValueKind.Number, "downloads.server object in version metadata");
ulong size;
try {
size = sizeElement.GetUInt64();
} catch (FormatException) {
Logger.Error("The \"size\" key in downloads.server object in version metadata contains an invalid file size: {Size}", sizeElement);
throw StopProcedureException.Instance;
}
JsonElement sha1Element = GetJsonPropertyOrThrow(serverElement, "sha1", JsonValueKind.String, "downloads.server object in version metadata"); JsonElement sha1Element = GetJsonPropertyOrThrow(serverElement, "sha1", JsonValueKind.String, "downloads.server object in version metadata");
Sha1String hash; Sha1String hash;
try { try {
@@ -145,7 +135,7 @@ sealed class MinecraftVersionApi : IDisposable {
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
return new FileDownloadInfo(url, hash, new FileSize(size)); return new FileDownloadInfo(url, hash);
} }
private static JsonElement GetJsonPropertyOrThrow(JsonElement parentElement, string propertyKey, JsonValueKind expectedKind, string location) { private static JsonElement GetJsonPropertyOrThrow(JsonElement parentElement, string propertyKey, JsonValueKind expectedKind, string location) {

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Instance.Launch;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
@@ -21,6 +22,7 @@ using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Actor.Tasks; using Phantom.Utils.Actor.Tasks;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server; using Phantom.Utils.Rpc.Runtime.Server;
using Serilog; using Serilog;
@@ -32,7 +34,18 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12); private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12);
public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, IDbContextProvider DbProvider, CancellationToken CancellationToken); public readonly record struct Init(
Guid? LoggedInUserGuid,
Guid AgentGuid,
AgentConfiguration AgentConfiguration,
AuthSecret AuthSecret,
AgentRuntimeInfo AgentRuntimeInfo,
AgentConnectionKeys AgentConnectionKeys,
ControllerState ControllerState,
MinecraftLaunchRecipes LaunchRecipes,
IDbContextProvider DbProvider,
CancellationToken CancellationToken
);
public static Props<ICommand> Factory(Init init) { public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
@@ -40,17 +53,22 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
public ITimerScheduler Timers { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!;
private readonly AgentConnectionKeys agentConnectionKeys;
private readonly ControllerState controllerState; private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions; private readonly MinecraftLaunchRecipes launchRecipes;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly Guid agentGuid; private readonly Guid agentGuid;
private readonly AuthInfo authInfo;
private AgentConfiguration configuration; private AgentConfiguration configuration;
private AgentRuntimeInfo runtimeInfo;
private AgentStats? stats; private AgentStats? stats;
private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty; private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty;
private string AgentName => configuration.AgentName;
private readonly AgentConnection connection; private readonly AgentConnection connection;
private DateTimeOffset? lastPingTime; private DateTimeOffset? lastPingTime;
@@ -76,23 +94,33 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private readonly Dictionary<Guid, Instance> instanceDataByGuid = new (); private readonly Dictionary<Guid, Instance> instanceDataByGuid = new ();
private AgentActor(Init init) { private AgentActor(Init init) {
this.agentConnectionKeys = init.AgentConnectionKeys;
this.controllerState = init.ControllerState; this.controllerState = init.ControllerState;
this.minecraftVersions = init.MinecraftVersions; this.launchRecipes = init.LaunchRecipes;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.authInfo = new AuthInfo(this, init.AuthSecret);
this.configuration = init.AgentConfiguration; this.configuration = init.AgentConfiguration;
this.runtimeInfo = init.AgentRuntimeInfo;
this.connection = new AgentConnection(agentGuid, configuration.AgentName); this.connection = new AgentConnection(agentGuid, configuration.AgentName);
this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
if (init.LoggedInUserGuid is {} loggedInUserGuid) {
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(loggedInUserGuid, configuration));
}
NotifyAgentUpdated(); NotifyAgentUpdated();
ReceiveAsync<InitializeCommand>(Initialize); ReceiveAsync<InitializeCommand>(Initialize);
Receive<ConfigureAgentCommand>(ConfigureAgent);
ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register); ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register);
Receive<SetConnectionCommand>(SetConnection); Receive<SetConnectionCommand>(SetConnection);
Receive<UnregisterCommand>(Unregister); Receive<UnregisterCommand>(Unregister);
ReceiveAndReply<GetAuthSecretCommand, AuthSecret>(GetAuthSecret);
Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus); Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus);
Receive<NotifyIsAliveCommand>(NotifyIsAlive); Receive<NotifyIsAliveCommand>(NotifyIsAlive);
Receive<UpdateStatsCommand>(UpdateStats); Receive<UpdateStatsCommand>(UpdateStats);
@@ -106,7 +134,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
} }
private void NotifyAgentUpdated() { private void NotifyAgentUpdated() {
controllerState.UpdateAgent(new Agent(agentGuid, configuration, stats, ConnectionStatus)); controllerState.UpdateAgent(new Agent(agentGuid, configuration, authInfo.ConnectionKey, runtimeInfo, stats, ConnectionStatus));
} }
protected override void PreStart() { protected override void PreStart() {
@@ -163,23 +191,31 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>(); var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) { foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken); var launchRecipe = await CreateInstanceLaunchRecipe(instanceConfiguration);
configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically)); configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration.AsInfo, launchRecipe, launchAutomatically));
} }
return configurationMessages.ToImmutable(); return configurationMessages.ToImmutable();
} }
private async Task<InstanceLaunchRecipe?> CreateInstanceLaunchRecipe(InstanceConfiguration configuration) {
return await launchRecipes.Create(configuration, cancellationToken);
}
public interface ICommand; public interface ICommand;
private sealed record InitializeCommand : ICommand; private sealed record InitializeCommand : ICommand;
public sealed record RegisterCommand(AgentConfiguration Configuration, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>; public sealed record ConfigureAgentCommand(Guid LoggedInUserGuid, AgentConfiguration Configuration) : ICommand;
public sealed record RegisterCommand(AgentRuntimeInfo RuntimeInfo, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>;
public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand; public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand;
public sealed record UnregisterCommand : ICommand; public sealed record UnregisterCommand : ICommand;
public sealed record GetAuthSecretCommand : ICommand, ICanReply<AuthSecret>;
private sealed record RefreshConnectionStatusCommand : ICommand; private sealed record RefreshConnectionStatusCommand : ICommand;
public sealed record NotifyIsAliveCommand : ICommand; public sealed record NotifyIsAliveCommand : ICommand;
@@ -227,19 +263,24 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
} }
} }
private void ConfigureAgent(ConfigureAgentCommand message) {
configuration = message.Configuration;
NotifyAgentUpdated();
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(message.LoggedInUserGuid, configuration));
}
private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) { private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) {
var configurationMessages = await PrepareInitialConfigurationMessages(); var configurationMessages = await PrepareInitialConfigurationMessages();
configuration = command.Configuration; runtimeInfo = command.RuntimeInfo;
connection.SetAgentName(configuration.AgentName);
lastPingTime = DateTimeOffset.Now; lastPingTime = DateTimeOffset.Now;
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Information("Registered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(configuration)); databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentRuntimeInfoCommand(runtimeInfo));
javaRuntimes = command.JavaRuntimes; javaRuntimes = command.JavaRuntimes;
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
@@ -261,7 +302,11 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline)); TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline));
Logger.Information("Unregistered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Information("Unregistered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
}
private AuthSecret GetAuthSecret(GetAuthSecretCommand command) {
return authInfo.Secret;
} }
private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) { private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) {
@@ -269,7 +314,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = false; isOnline = false;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Lost connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -280,7 +325,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Restored connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Restored connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -300,13 +345,14 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero); return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero);
} }
return minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken) return launchRecipes.Create(instanceConfiguration, cancellationToken)
.ContinueOnActor(CreateOrUpdateInstance1, command) .ContinueOnActor(CreateOrUpdateInstance1, command)
.Unwrap(); .Unwrap();
} }
private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, CreateOrUpdateInstanceCommand command) { private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance1(InstanceLaunchRecipe? launchRecipe, CreateOrUpdateInstanceCommand command) {
if (serverExecutableInfo == null) { if (launchRecipe == null) {
// TODO
return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound); return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound);
} }
@@ -317,7 +363,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration)); instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration));
} }
var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.LoggedInUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance); var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.LoggedInUserGuid, command.InstanceGuid, instanceConfiguration, launchRecipe, isCreatingInstance);
return instanceActorRef.Request(configureInstanceCommand, cancellationToken) return instanceActorRef.Request(configureInstanceCommand, cancellationToken)
.ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand); .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand);
@@ -329,19 +375,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
var isCreating = command.IsCreatingInstance; var isCreating = command.IsCreatingInstance;
if (result.Is(ConfigureInstanceResult.Success)) { if (result.Is(ConfigureInstanceResult.Success)) {
string action = isCreating ? "Added" : "Edited"; string action = isCreating ? "Created" : "Edited";
string relation = isCreating ? "to agent" : "in agent"; Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\".", instanceName, instanceGuid, AgentName);
Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName);
return CreateOrUpdateInstanceResult.Success; return CreateOrUpdateInstanceResult.Success;
} }
else { else {
string action = isCreating ? "adding" : "editing"; string action = isCreating ? "creating" : "editing";
string relation = isCreating ? "to agent" : "in agent";
string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence); string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, AgentName, reason);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
return CreateOrUpdateInstanceResult.UnknownError; return CreateOrUpdateInstanceResult.UnknownError;
} }
@@ -370,4 +412,26 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private void ReceiveInstanceData(ReceiveInstanceDataCommand command) { private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
UpdateInstanceData(command.Instance); UpdateInstanceData(command.Instance);
} }
private sealed class AuthInfo {
private readonly AgentActor actor;
public AuthSecret Secret { get; private set; }
public ImmutableArray<byte> ConnectionKey { get; private set; }
public AuthInfo(AgentActor actor, AuthSecret authSecret) {
this.actor = actor;
this.Secret = authSecret;
this.ConnectionKey = CreateConnectionKey(authSecret);
}
public void UpdateSecret(AuthSecret newSecret) {
this.Secret = newSecret;
this.ConnectionKey = CreateConnectionKey(newSecret);
}
private ImmutableArray<byte> CreateConnectionKey(AuthSecret authSecret) {
return actor.agentConnectionKeys.Get(new AuthToken(actor.agentGuid, authSecret)).ToBytes();
}
}
} }

View File

@@ -0,0 +1,11 @@
using Phantom.Common.Data;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Controller.Services.Agents;
sealed class AgentConnectionKeys(RpcCertificateThumbprint certificateThumbprint) {
public ConnectionKey Get(AuthToken authToken) {
return new ConnectionKey(certificateThumbprint, authToken);
}
}

View File

@@ -1,8 +1,11 @@
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
@@ -22,62 +25,105 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private AgentConfiguration? configurationToStore; private StoreAgentRuntimeInfoCommand? storeRuntimeInfoCommand;
private bool hasScheduledFlush;
private AgentDatabaseStorageActor(Init init) { private AgentDatabaseStorageActor(Init init) {
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
Receive<StoreAgentConfigurationCommand>(StoreAgentConfiguration); Receive<StoreAgentRuntimeInfoCommand>(StoreAgentRuntimeInfo);
ReceiveAsync<FlushChangesCommand>(FlushChanges); ReceiveAsync<StoreAgentConfigurationCommand>(StoreAgentConfiguration);
ReceiveAsync<FlushAgentRuntimeInfoCommand>(FlushAgentRuntimeInfo);
}
private ValueTask<AgentEntity?> FindAgentEntity(ILazyDbContext db) {
return db.Ctx.Agents.FindAsync([agentGuid], cancellationToken);
} }
public interface ICommand; public interface ICommand;
public sealed record StoreAgentConfigurationCommand(AgentConfiguration Configuration) : ICommand; public sealed record StoreAgentConfigurationCommand(Guid AuditLogUserGuid, AgentConfiguration Configuration) : ICommand;
private sealed record FlushChangesCommand : ICommand; public sealed record StoreAgentRuntimeInfoCommand(AgentRuntimeInfo RuntimeInfo) : ICommand;
private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) { private sealed record FlushAgentRuntimeInfoCommand : ICommand;
configurationToStore = command.Configuration;
private async Task StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
await FlushAgentRuntimeInfo();
bool wasCreated;
await using (var db = dbProvider.Lazy()) {
var entity = db.Ctx.AgentUpsert.Fetch(agentGuid, out wasCreated);
if (wasCreated) {
entity.AuthSecret = AuthSecret.Generate();
}
entity.Name = command.Configuration.AgentName;
var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
if (wasCreated) {
auditLogWriter.AgentCreated(agentGuid);
}
else {
auditLogWriter.AgentEdited(agentGuid);
}
await db.Ctx.SaveChangesAsync(cancellationToken);
}
string action = wasCreated ? "Created" : "Edited";
Logger.Information(action + " agent \"{AgentName}\" (GUID {AgentGuid}) in database.", command.Configuration.AgentName, agentGuid);
}
private void StoreAgentRuntimeInfo(StoreAgentRuntimeInfoCommand command) {
storeRuntimeInfoCommand = command;
ScheduleFlush(TimeSpan.FromSeconds(2)); ScheduleFlush(TimeSpan.FromSeconds(2));
} }
private async Task FlushChanges(FlushChangesCommand command) { private void ScheduleFlush(TimeSpan delay) {
hasScheduledFlush = false; if (storeRuntimeInfoCommand != null) {
Timers.StartSingleTimer("FlushChanges", new FlushAgentRuntimeInfoCommand(), delay, Self);
if (configurationToStore == null) {
return;
} }
try {
await using var ctx = dbProvider.Eager();
var entity = ctx.AgentUpsert.Fetch(agentGuid);
entity.Name = configurationToStore.AgentName;
entity.ProtocolVersion = configurationToStore.ProtocolVersion;
entity.BuildVersion = configurationToStore.BuildVersion;
entity.MaxInstances = configurationToStore.MaxInstances;
entity.MaxMemory = configurationToStore.MaxMemory;
await ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
return;
}
Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
configurationToStore = null;
} }
private void ScheduleFlush(TimeSpan delay) { private Task FlushAgentRuntimeInfo(FlushAgentRuntimeInfoCommand command) {
if (!hasScheduledFlush) { return FlushAgentRuntimeInfo();
hasScheduledFlush = true; }
Timers.StartSingleTimer("FlushChanges", new FlushChangesCommand(), delay, Self);
private async Task FlushAgentRuntimeInfo() {
if (storeRuntimeInfoCommand == null) {
return;
} }
string agentName;
await using (var db = dbProvider.Lazy()) {
var entity = await FindAgentEntity(db);
if (entity == null) {
return;
}
agentName = entity.Name;
try {
entity.ProtocolVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.ProtocolVersion;
entity.BuildVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.BuildVersion;
entity.MaxInstances = storeRuntimeInfoCommand.RuntimeInfo.MaxInstances;
entity.MaxMemory = storeRuntimeInfoCommand.RuntimeInfo.MaxMemory;
await db.Ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not update agent \"{AgentName}\" (GUID {AgentGuid}) in database.", entity.Name, agentGuid);
return;
}
}
Logger.Information("Updated agent \"{AgentName}\" (GUID {AgentGuid}) in database.", agentName, agentGuid);
storeRuntimeInfoCommand = null;
} }
} }

View File

@@ -12,57 +12,57 @@ using Phantom.Controller.Minecraft;
using Phantom.Controller.Services.Users.Sessions; using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
sealed class AgentManager { sealed class AgentManager(
IActorRefFactory actorSystem,
AgentConnectionKeys agentConnectionKeys,
ControllerState controllerState,
MinecraftLaunchRecipes launchRecipes,
IDbContextProvider dbProvider,
CancellationToken cancellationToken
) {
private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>();
private readonly IActorRefFactory actorSystem;
private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions;
private readonly UserLoginManager userLoginManager;
private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken;
private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new (); private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new ();
private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
public AgentManager(IActorRefFactory actorSystem, ControllerState controllerState, MinecraftVersions minecraftVersions, UserLoginManager userLoginManager, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
this.actorSystem = actorSystem;
this.controllerState = controllerState;
this.minecraftVersions = minecraftVersions;
this.userLoginManager = userLoginManager;
this.dbProvider = dbProvider;
this.cancellationToken = cancellationToken;
this.addAgentActorFactory = CreateAgentActor;
}
private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, AgentConfiguration agentConfiguration) {
var init = new AgentActor.Init(agentGuid, agentConfiguration, controllerState, minecraftVersions, dbProvider, cancellationToken);
var name = "Agent:" + agentGuid;
return actorSystem.ActorOf(AgentActor.Factory(init), name);
}
public async Task Initialize() { public async Task Initialize() {
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var agentGuid = entity.AgentGuid; var agentGuid = entity.AgentGuid;
var agentConfiguration = new AgentConfiguration(entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, agentConfiguration))) { if (AddAgent(loggedInUserGuid: null, agentGuid, entity.Configuration, entity.AuthSecret, entity.RuntimeInfo)) {
Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", agentConfiguration.AgentName, agentGuid); Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", entity.Name, agentGuid);
} }
} }
} }
public async Task<ImmutableArray<ConfigureInstanceMessage>> RegisterAgent(AgentRegistration registration) { private bool AddAgent(Guid? loggedInUserGuid, Guid agentGuid, AgentConfiguration configuration, AuthSecret authSecret, AgentRuntimeInfo runtimeInfo) {
var agentConfiguration = AgentConfiguration.From(registration.AgentInfo); var init = new AgentActor.Init(loggedInUserGuid, agentGuid, configuration, authSecret, runtimeInfo, agentConnectionKeys, controllerState, launchRecipes, dbProvider, cancellationToken);
var agentActor = agentsByAgentGuid.GetOrAdd(registration.AgentInfo.AgentGuid, addAgentActorFactory, agentConfiguration); var name = "Agent:" + agentGuid;
return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, registration.JavaRuntimes), cancellationToken); return agentsByAgentGuid.TryAdd(agentGuid, actorSystem.ActorOf(AgentActor.Factory(init), name));
}
public async Task<ImmutableArray<ConfigureInstanceMessage>?> RegisterAgent(Guid agentGuid, AgentRegistration registration) {
if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) {
return null;
}
var runtimeInfo = AgentRuntimeInfo.From(registration.AgentInfo);
return await agentActor.Request(new AgentActor.RegisterCommand(runtimeInfo, registration.JavaRuntimes), cancellationToken);
}
public async Task<AuthSecret?> GetAgentAuthSecret(Guid agentGuid) {
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
return await agent.Request(new AgentActor.GetAuthSecretCommand(), cancellationToken);
}
else {
return null;
}
} }
public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) { public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) {
@@ -71,13 +71,31 @@ sealed class AgentManager {
return true; return true;
} }
else { else {
Logger.Warning("Could not deliver command {CommandType} to agent {AgentGuid}, agent not registered.", command.GetType().Name, agentGuid); Logger.Warning("Could not deliver command {CommandType} to unknown agent {AgentGuid}.", command.GetType().Name, agentGuid);
return false; return false;
} }
} }
public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Permission requiredPermission, ImmutableArray<byte> authToken, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> { public Result<CreateOrUpdateAgentResult, UserActionFailure> CreateOrUpdateAgent(LoggedInUser loggedInUser, Guid agentGuid, AgentConfiguration configuration) {
var loggedInUser = userLoginManager.GetLoggedInUser(authToken); if (!loggedInUser.CheckPermission(Permission.ManageAllAgents)) {
return UserActionFailure.NotAuthorized;
}
if (configuration.AgentName.Length == 0) {
return CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty;
}
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
agent.Tell(new AgentActor.ConfigureAgentCommand(loggedInUser.Guid!.Value, configuration));
}
else {
AddAgent(loggedInUser.Guid!.Value, agentGuid, configuration, AuthSecret.Generate(), new AgentRuntimeInfo());
}
return CreateOrUpdateAgentResult.Success;
}
public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(LoggedInUser loggedInUser, Permission requiredPermission, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) { if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) {
return (UserInstanceActionFailure) UserActionFailure.NotAuthorized; return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
} }

View File

@@ -8,8 +8,10 @@ using Phantom.Controller.Services.Rpc;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions; using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent, Phantom.Common.Data.Agent.AgentInfo>; using Phantom.Utils.Rpc.Runtime.Server;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb, Phantom.Utils.Rpc.Runtime.Server.RpcServerClientHandshake.NoValue>; using Phantom.Utils.Rpc.Runtime.Tls;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent>;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb>;
namespace Phantom.Controller.Services; namespace Phantom.Controller.Services;
@@ -18,6 +20,7 @@ public sealed class ControllerServices : IDisposable {
private ControllerState ControllerState { get; } private ControllerState ControllerState { get; }
private MinecraftVersions MinecraftVersions { get; } private MinecraftVersions MinecraftVersions { get; }
private MinecraftLaunchRecipes LaunchRecipes { get; }
private AuthenticatedUserCache AuthenticatedUserCache { get; } private AuthenticatedUserCache AuthenticatedUserCache { get; }
private UserManager UserManager { get; } private UserManager UserManager { get; }
@@ -32,14 +35,15 @@ public sealed class ControllerServices : IDisposable {
private AuditLogManager AuditLogManager { get; } private AuditLogManager AuditLogManager { get; }
private EventLogManager EventLogManager { get; } private EventLogManager EventLogManager { get; }
public IRpcServerClientAuthProvider AgentAuthProvider { get; }
public IRpcServerClientHandshake AgentHandshake { get; }
public IRpcAgentRegistrar AgentRegistrar { get; } public IRpcAgentRegistrar AgentRegistrar { get; }
public AgentClientHandshake AgentHandshake { get; }
public IRpcWebRegistrar WebRegistrar { get; } public IRpcWebRegistrar WebRegistrar { get; }
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public ControllerServices(IDbContextProvider dbProvider, CancellationToken shutdownCancellationToken) { public ControllerServices(IDbContextProvider dbProvider, RpcCertificateThumbprint agentCertificateThumbprint, CancellationToken shutdownCancellationToken) {
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.cancellationToken = shutdownCancellationToken; this.cancellationToken = shutdownCancellationToken;
@@ -47,6 +51,7 @@ public sealed class ControllerServices : IDisposable {
this.ControllerState = new ControllerState(); this.ControllerState = new ControllerState();
this.MinecraftVersions = new MinecraftVersions(); this.MinecraftVersions = new MinecraftVersions();
this.LaunchRecipes = new MinecraftLaunchRecipes(MinecraftVersions);
this.AuthenticatedUserCache = new AuthenticatedUserCache(); this.AuthenticatedUserCache = new AuthenticatedUserCache();
this.UserManager = new UserManager(AuthenticatedUserCache, ControllerState, dbProvider); this.UserManager = new UserManager(AuthenticatedUserCache, ControllerState, dbProvider);
@@ -55,14 +60,15 @@ public sealed class ControllerServices : IDisposable {
this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider); this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider);
this.PermissionManager = new PermissionManager(dbProvider); this.PermissionManager = new PermissionManager(dbProvider);
this.AgentManager = new AgentManager(ActorSystem, ControllerState, MinecraftVersions, UserLoginManager, dbProvider, cancellationToken); this.AgentManager = new AgentManager(ActorSystem, new AgentConnectionKeys(agentCertificateThumbprint), ControllerState, LaunchRecipes, dbProvider, cancellationToken);
this.InstanceLogManager = new InstanceLogManager(); this.InstanceLogManager = new InstanceLogManager();
this.AuditLogManager = new AuditLogManager(dbProvider); this.AuditLogManager = new AuditLogManager(dbProvider);
this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken); this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager); this.AgentAuthProvider = new AgentClientAuthProvider(AgentManager);
this.AgentHandshake = new AgentClientHandshake(AgentManager); this.AgentHandshake = new AgentClientHandshake(AgentManager);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager);
this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager); this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);
} }

View File

@@ -1,5 +1,6 @@
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.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -70,7 +71,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand; public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>; public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchRecipe LaunchRecipe, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>; public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
@@ -94,7 +95,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
} }
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> ConfigureInstance(ConfigureInstanceCommand command) { private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> ConfigureInstance(ConfigureInstanceCommand command) {
var message = new ConfigureInstanceMessage(command.InstanceGuid, command.Configuration, command.LaunchProperties); var message = new ConfigureInstanceMessage(command.InstanceGuid, command.Configuration.AsInfo, command.LaunchRecipe);
var result = await SendInstanceActionMessage<ConfigureInstanceMessage, ConfigureInstanceResult>(message); var result = await SendInstanceActionMessage<ConfigureInstanceMessage, ConfigureInstanceResult>(message);
if (result.Is(ConfigureInstanceResult.Success)) { if (result.Is(ConfigureInstanceResult.Success)) {

View File

@@ -0,0 +1,11 @@
using Phantom.Controller.Services.Agents;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientAuthProvider(AgentManager agentManager) : IRpcServerClientAuthProvider {
public Task<AuthSecret?> GetAuthSecret(Guid agentGuid) {
return agentManager.GetAgentAuthSecret(agentGuid);
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data.Agent;
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.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
@@ -10,7 +9,7 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo> { sealed class AgentClientHandshake : IRpcServerClientHandshake {
private const int MaxRegistrationBytes = 1024 * 1024 * 8; private const int MaxRegistrationBytes = 1024 * 1024 * 8;
private readonly AgentManager agentManager; private readonly AgentManager agentManager;
@@ -19,9 +18,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
this.agentManager = agentManager; this.agentManager = agentManager;
} }
public async Task<Either<AgentInfo, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) { public async Task Perform(bool isNewSession, RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
RegistrationResult registrationResult; RegistrationResult registrationResult;
switch (await RegisterAgent(stream, cancellationToken)) { switch (await RegisterAgent(stream, agentGuid, cancellationToken)) {
case Left<RegistrationResult, Exception>(var result): case Left<RegistrationResult, Exception>(var result):
await stream.WriteByte(value: 1, cancellationToken); await stream.WriteByte(value: 1, cancellationToken);
registrationResult = result; registrationResult = result;
@@ -29,11 +28,11 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
case Right<RegistrationResult, Exception>(var exception): case Right<RegistrationResult, Exception>(var exception):
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right(exception); throw exception;
default: default:
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right<Exception>(new InvalidOperationException("Invalid result type.")); throw new InvalidOperationException("Invalid result type.");
} }
if (isNewSession) { if (isNewSession) {
@@ -50,11 +49,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
} }
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
return Either.Left(registrationResult.AgentInfo);
} }
private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, CancellationToken cancellationToken) { private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken); int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken);
if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) { if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) {
return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes.")); return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes."));
@@ -69,9 +66,13 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e)); return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e));
} }
var configureInstanceMessages = await agentManager.RegisterAgent(registration); var configureInstanceMessages = await agentManager.RegisterAgent(agentGuid, registration);
return Either.Left(new RegistrationResult(registration.AgentInfo, configureInstanceMessages)); if (configureInstanceMessages == null) {
return Either.Right<Exception>(new InvalidOperationException("Could not register agent."));
}
return Either.Left(new RegistrationResult(configureInstanceMessages.Value));
} }
private readonly record struct RegistrationResult(AgentInfo AgentInfo, ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages); private readonly record struct RegistrationResult(ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages);
} }

View File

@@ -1,7 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
@@ -12,20 +10,29 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientRegistrar( sealed class AgentClientRegistrar : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent> {
IActorRefFactory actorSystem, private readonly IActorRefFactory actorSystem;
AgentManager agentManager, private readonly AgentManager agentManager;
InstanceLogManager instanceLogManager, private readonly InstanceLogManager instanceLogManager;
EventLogManager eventLogManager private readonly EventLogManager eventLogManager;
) : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent, AgentInfo> {
private readonly Func<Guid, Guid, Receiver> receiverFactory;
private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new (); private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new ();
[SuppressMessage("ReSharper", "LambdaShouldNotCaptureContext")] public AgentClientRegistrar(IActorRefFactory actorSystem, AgentManager agentManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager) {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection, AgentInfo handshakeResult) { this.actorSystem = actorSystem;
var agentGuid = handshakeResult.AgentGuid; this.agentManager = agentManager;
this.instanceLogManager = instanceLogManager;
this.eventLogManager = eventLogManager;
this.receiverFactory = CreateReceiver;
}
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection) {
Guid agentGuid = connection.ClientGuid;
agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection)); agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection));
var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionId, CreateReceiver, agentGuid); var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionGuid, receiverFactory, agentGuid);
if (receiver.AgentGuid != agentGuid) { if (receiver.AgentGuid != agentGuid) {
throw new InvalidOperationException("Cannot register two agents to the same session!"); throw new InvalidOperationException("Cannot register two agents to the same session!");
} }
@@ -33,8 +40,8 @@ sealed class AgentClientRegistrar(
return receiver; return receiver;
} }
private Receiver CreateReceiver(Guid sessionId, Guid agentGuid) { private Receiver CreateReceiver(Guid sessionGuid, Guid agentGuid) {
var name = "AgentClient-" + sessionId; var name = "AgentClient-" + sessionGuid;
var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager); var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager);
return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name)); return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name));
} }

View File

@@ -25,30 +25,30 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
this.instanceLogManager = init.InstanceLogManager; this.instanceLogManager = init.InstanceLogManager;
this.eventLogManager = init.EventLogManager; this.eventLogManager = init.EventLogManager;
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportAgentStatusMessage>(ReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); Receive<ReportInstanceStatusMessage>(ReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts); Receive<ReportInstancePlayerCountsMessage>(ReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<ReportInstanceEventMessage>(ReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<InstanceOutputMessage>(InstanceOutput);
} }
private void HandleReportAgentStatus(ReportAgentStatusMessage message) { private void ReportAgentStatus(ReportAgentStatusMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory));
} }
private void HandleReportInstanceStatus(ReportInstanceStatusMessage message) { private void ReportInstanceStatus(ReportInstanceStatusMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
} }
private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) { private void ReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
} }
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { private void ReportInstanceEvent(ReportInstanceEventMessage message) {
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid)); message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
} }
private void HandleInstanceOutput(InstanceOutputMessage message) { private void InstanceOutput(InstanceOutputMessage message) {
instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines); instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines);
} }
} }

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