1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2024-10-18 06:42:50 +02:00

Compare commits

..

5 Commits

33 changed files with 317 additions and 199 deletions

View File

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

View File

@ -11,6 +11,8 @@ namespace Phantom.Agent.Minecraft.Launcher;
public abstract class BaseLauncher { public abstract class BaseLauncher {
private readonly InstanceProperties instanceProperties; private readonly InstanceProperties instanceProperties;
protected string MinecraftVersion => instanceProperties.ServerVersion;
private protected BaseLauncher(InstanceProperties instanceProperties) { private protected BaseLauncher(InstanceProperties instanceProperties) {
this.instanceProperties = instanceProperties; this.instanceProperties = instanceProperties;
@ -25,11 +27,34 @@ public abstract class BaseLauncher {
return new LaunchResult.InvalidJvmArguments(); return new LaunchResult.InvalidJvmArguments();
} }
var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.ServerVersion, downloadProgressEventHandler, cancellationToken); var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.LaunchProperties.ServerDownloadInfo, MinecraftVersion, downloadProgressEventHandler, cancellationToken);
if (vanillaServerJarPath == null) { if (vanillaServerJarPath == null) {
return new LaunchResult.CouldNotDownloadMinecraftServer(); return new LaunchResult.CouldNotDownloadMinecraftServer();
} }
ServerJarInfo? serverJar;
try {
serverJar = await PrepareServerJar(logger, vanillaServerJarPath, instanceProperties.InstanceFolder, cancellationToken);
} catch (OperationCanceledException) {
throw;
} catch (Exception e) {
logger.Error(e, "Caught exception while preparing the server jar.");
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
if (!File.Exists(serverJar.FilePath)) {
logger.Error("Missing prepared server or launcher jar: {FilePath}", serverJar.FilePath);
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
}
var startInfo = new ProcessStartInfo { var startInfo = new ProcessStartInfo {
FileName = javaRuntimeExecutable.ExecutablePath, FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder, WorkingDirectory = instanceProperties.InstanceFolder,
@ -43,24 +68,20 @@ public abstract class BaseLauncher {
var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties, instanceProperties.JvmArguments); var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties, instanceProperties.JvmArguments);
CustomizeJvmArguments(jvmArguments); CustomizeJvmArguments(jvmArguments);
var serverJarPath = await PrepareServerJar(vanillaServerJarPath, instanceProperties.InstanceFolder, cancellationToken);
var processArguments = startInfo.ArgumentList; var processArguments = startInfo.ArgumentList;
jvmArguments.Build(processArguments); jvmArguments.Build(processArguments);
foreach (var extraArgument in serverJar.ExtraArgs) {
processArguments.Add(extraArgument);
}
processArguments.Add("-jar"); processArguments.Add("-jar");
processArguments.Add(serverJarPath); processArguments.Add(serverJar.FilePath);
processArguments.Add("nogui"); processArguments.Add("nogui");
var process = new Process { StartInfo = startInfo }; var process = new Process { StartInfo = startInfo };
var instanceProcess = new InstanceProcess(instanceProperties, process); var instanceProcess = new InstanceProcess(instanceProperties, process);
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
}
try { try {
process.Start(); process.Start();
process.BeginOutputReadLine(); process.BeginOutputReadLine();
@ -82,8 +103,8 @@ public abstract class BaseLauncher {
private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {} private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
private protected virtual Task<string> PrepareServerJar(string serverJarPath, string instanceFolderPath, CancellationToken cancellationToken) { private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, string instanceFolderPath, CancellationToken cancellationToken) {
return Task.FromResult(serverJarPath); return Task.FromResult(new ServerJarInfo(serverJarPath));
} }
private static async Task AcceptEula(InstanceProperties instanceProperties) { private static async Task AcceptEula(InstanceProperties instanceProperties) {

View File

@ -13,6 +13,8 @@ public abstract record LaunchResult {
public sealed record CouldNotDownloadMinecraftServer : LaunchResult; public sealed record CouldNotDownloadMinecraftServer : LaunchResult;
public sealed record CouldNotPrepareMinecraftServerLauncher : LaunchResult;
public sealed record CouldNotConfigureMinecraftServer : LaunchResult; public sealed record CouldNotConfigureMinecraftServer : LaunchResult;
public sealed record CouldNotStartMinecraftServer : LaunchResult; public sealed record CouldNotStartMinecraftServer : LaunchResult;

View File

@ -0,0 +1,7 @@
using System.Collections.Immutable;
namespace Phantom.Agent.Minecraft.Launcher;
sealed record ServerJarInfo(string FilePath, ImmutableArray<string> ExtraArgs) {
public ServerJarInfo(string filePath) : this(filePath, ImmutableArray<string>.Empty) {}
}

View File

@ -17,6 +17,7 @@
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,6 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Minecraft;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
@ -11,8 +11,6 @@ namespace Phantom.Agent.Minecraft.Server;
sealed class MinecraftServerExecutableDownloader { sealed class MinecraftServerExecutableDownloader {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>();
private readonly MinecraftVersions minecraftVersions;
public Task<string?> Task { get; } public Task<string?> Task { get; }
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress; public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
public event EventHandler? Completed; public event EventHandler? Completed;
@ -20,11 +18,9 @@ sealed class MinecraftServerExecutableDownloader {
private readonly CancellationTokenSource cancellationTokenSource = new (); private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0; private int listeners = 0;
public MinecraftServerExecutableDownloader(MinecraftVersions minecraftVersions, string version, string filePath, MinecraftServerExecutableDownloadListener listener) { public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) {
this.minecraftVersions = minecraftVersions;
Register(listener); Register(listener);
Task = DownloadAndGetPath(version, filePath); Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath);
Task.ContinueWith(OnCompleted, TaskScheduler.Default); Task.ContinueWith(OnCompleted, TaskScheduler.Default);
} }
@ -72,33 +68,26 @@ sealed class MinecraftServerExecutableDownloader {
} }
} }
private async Task<string?> DownloadAndGetPath(string version, string filePath) { private async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath) {
Logger.Information("Downloading server version {Version}...", version);
string tmpFilePath = filePath + ".tmp"; string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token; var cancellationToken = cancellationTokenSource.Token;
try { try {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(version, cancellationToken); Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1));
if (serverExecutableInfo == null) {
return null;
}
Logger.Information("Downloading server executable from: {Url} ({Size})", serverExecutableInfo.DownloadUrl, serverExecutableInfo.Size.ToHumanReadable(decimalPlaces: 1));
try { try {
using var http = new HttpClient(); using var http = new HttpClient();
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), serverExecutableInfo, tmpFilePath, cancellationToken); await FetchServerExecutableFile(http, new DownloadProgressCallback(this), fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) { } catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath); TryDeleteExecutableAfterFailure(tmpFilePath);
throw; throw;
} }
File.Move(tmpFilePath, filePath, true); File.Move(tmpFilePath, filePath, true);
Logger.Information("Server version {Version} downloaded.", version); Logger.Information("Server version {Version} downloaded.", minecraftVersion);
return filePath; return filePath;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
Logger.Information("Download for server version {Version} was cancelled.", version); Logger.Information("Download for server version {Version} was cancelled.", minecraftVersion);
throw; throw;
} catch (StopProcedureException) { } catch (StopProcedureException) {
return null; return null;
@ -110,17 +99,17 @@ sealed class MinecraftServerExecutableDownloader {
} }
} }
private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, MinecraftServerExecutableInfo info, string filePath, CancellationToken cancellationToken) { private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, FileDownloadInfo fileDownloadInfo, string filePath, CancellationToken cancellationToken) {
Sha1String downloadedFileHash; Sha1String downloadedFileHash;
try { try {
var response = await http.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); var response = await http.GetAsync(fileDownloadInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var streamCopier = new MinecraftServerDownloadStreamCopier(progressCallback, info.Size.Bytes); using var streamCopier = new MinecraftServerDownloadStreamCopier(progressCallback, fileDownloadInfo.Size.Bytes);
downloadedFileHash = await streamCopier.Copy(responseStream, fileStream, cancellationToken); downloadedFileHash = await streamCopier.Copy(responseStream, fileStream, cancellationToken);
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
throw; throw;
@ -129,8 +118,8 @@ sealed class MinecraftServerExecutableDownloader {
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
if (!downloadedFileHash.Equals(info.Hash)) { if (!downloadedFileHash.Equals(fileDownloadInfo.Hash)) {
Logger.Error("Downloaded server executable has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", info.Hash, downloadedFileHash); Logger.Error("Downloaded server executable has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", fileDownloadInfo.Hash, downloadedFileHash);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
} }

View File

@ -1,33 +1,37 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Minecraft;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Serilog; using Serilog;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
public sealed partial class MinecraftServerExecutables : IDisposable { public sealed partial class MinecraftServerExecutables {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)] [GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex VersionFolderSanitizeRegex(); private static partial Regex VersionFolderSanitizeRegex();
private readonly string basePath; private readonly string basePath;
private readonly MinecraftVersions minecraftVersions = new ();
private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new (); private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new ();
public MinecraftServerExecutables(string basePath) { public MinecraftServerExecutables(string basePath) {
this.basePath = basePath; this.basePath = basePath;
} }
internal async Task<string?> DownloadAndGetPath(string version, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) { internal async Task<string?> DownloadAndGetPath(FileDownloadInfo? fileDownloadInfo, string minecraftVersion, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex().Replace(version, "_")); string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex().Replace(minecraftVersion, "_"));
string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar"); string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar");
if (File.Exists(serverExecutableFilePath)) { if (File.Exists(serverExecutableFilePath)) {
return 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 { try {
Directories.Create(serverExecutableFolderPath, Chmod.URWX_GRX); Directories.Create(serverExecutableFolderPath, Chmod.URWX_GRX);
} catch (Exception e) { } catch (Exception e) {
@ -39,26 +43,22 @@ public sealed partial class MinecraftServerExecutables : IDisposable {
MinecraftServerExecutableDownloadListener listener = new (progressEventHandler, cancellationToken); MinecraftServerExecutableDownloadListener listener = new (progressEventHandler, cancellationToken);
lock (this) { lock (this) {
if (runningDownloadersByVersion.TryGetValue(version, out downloader)) { if (runningDownloadersByVersion.TryGetValue(minecraftVersion, out downloader)) {
Logger.Information("A download for server version {Version} is already running, waiting for it to finish...", version); Logger.Information("A download for server version {Version} is already running, waiting for it to finish...", minecraftVersion);
downloader.Register(listener); downloader.Register(listener);
} }
else { else {
downloader = new MinecraftServerExecutableDownloader(minecraftVersions, version, serverExecutableFilePath, listener); downloader = new MinecraftServerExecutableDownloader(fileDownloadInfo, minecraftVersion, serverExecutableFilePath, listener);
downloader.Completed += (_, _) => { downloader.Completed += (_, _) => {
lock (this) { lock (this) {
runningDownloadersByVersion.Remove(version); runningDownloadersByVersion.Remove(minecraftVersion);
} }
}; };
runningDownloadersByVersion[version] = downloader; runningDownloadersByVersion[minecraftVersion] = downloader;
} }
} }
return await downloader.Task.WaitAsync(cancellationToken); return await downloader.Task.WaitAsync(cancellationToken);
} }
public void Dispose() {
minecraftVersions.Dispose();
}
} }

View File

@ -1,15 +1,17 @@
using System.Diagnostics; using System.Diagnostics;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
static class BackupCompressor { static class BackupCompressor {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(BackupCompressor)); private static ILogger Logger { get; } = PhantomLogger.Create(nameof(BackupCompressor));
private static ILogger ZstdLogger { get; } = PhantomLogger.Create(nameof(BackupCompressor), "Zstd");
private const int Quality = 10; private const string Quality = "-10";
private const int Memory = 26; private const string Memory = "--long=26";
private const int Threads = 3; private const string Threads = "-T3";
public static async Task<string?> Compress(string sourceFilePath, CancellationToken cancellationToken) { public static async Task<string?> Compress(string sourceFilePath, CancellationToken cancellationToken) {
if (sourceFilePath.Contains('"')) { if (sourceFilePath.Contains('"')) {
@ -38,66 +40,31 @@ static class BackupCompressor {
Logger.Error("Invalid destination path: {Path}", destinationFilePath); Logger.Error("Invalid destination path: {Path}", destinationFilePath);
return false; return false;
} }
var startInfo = new ProcessStartInfo { var startInfo = new ProcessStartInfo {
FileName = "zstd", FileName = "zstd",
WorkingDirectory = workingDirectory, WorkingDirectory = workingDirectory,
Arguments = $"-{Quality} --long={Memory} -T{Threads} -c --rm --no-progress -c -o \"{destinationFilePath}\" -- \"{sourceFilePath}\"", ArgumentList = {
RedirectStandardOutput = true, Quality,
RedirectStandardError = true Memory,
Threads,
"-c",
"--rm",
"--no-progress",
"-c",
"-o", destinationFilePath,
"--", sourceFilePath
}
}; };
using var process = new Process { StartInfo = startInfo }; static void OnZstdOutput(object? sender, DataReceivedEventArgs e) {
process.OutputDataReceived += OnZstdProcessOutput; if (!string.IsNullOrWhiteSpace(e.Data)) {
process.ErrorDataReceived += OnZstdProcessOutput; ZstdLogger.Verbose("[Output] {Line}", e.Data);
}
try {
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
} catch (Exception e) {
Logger.Error(e, "Caught exception launching zstd process.");
} }
try { var process = new OneShotProcess(ZstdLogger, startInfo);
await process.WaitForExitAsync(cancellationToken); process.Output += OnZstdOutput;
} catch (OperationCanceledException) { return await process.Run(cancellationToken);
await TryKillProcess(process);
return false;
} catch (Exception e) {
Logger.Error(e, "Caught exception waiting for zstd process to exit.");
return false;
}
if (!process.HasExited) {
await TryKillProcess(process);
return false;
}
if (process.ExitCode != 0) {
Logger.Error("Zstd process exited with code {ExitCode}.", process.ExitCode);
return false;
}
return true;
}
private static void OnZstdProcessOutput(object sender, DataReceivedEventArgs e) {
if (!string.IsNullOrWhiteSpace(e.Data)) {
Logger.Verbose("[Zstd] {Line}", e.Data);
}
}
private static async Task TryKillProcess(Process process) {
CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try {
process.Kill();
await process.WaitForExitAsync(timeout.Token);
} catch (OperationCanceledException) {
Logger.Error("Timed out waiting for killed zstd process to exit.");
} catch (Exception e) {
Logger.Error(e, "Caught exception killing zstd process.");
}
} }
} }

View File

@ -71,7 +71,7 @@ sealed class InstanceSessionManager : IDisposable {
}); });
} }
public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration) { public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow) {
return await AcquireSemaphoreAndRun(async () => { return await AcquireSemaphoreAndRun(async () => {
var instanceGuid = configuration.InstanceGuid; var instanceGuid = configuration.InstanceGuid;
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString()); var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
@ -90,7 +90,8 @@ sealed class InstanceSessionManager : IDisposable {
configuration.JvmArguments, configuration.JvmArguments,
instanceFolder, instanceFolder,
configuration.MinecraftVersion, configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort) new ServerProperties(configuration.ServerPort, configuration.RconPort),
launchProperties
); );
BaseLauncher launcher = new VanillaLauncher(properties); BaseLauncher launcher = new VanillaLauncher(properties);
@ -105,7 +106,7 @@ sealed class InstanceSessionManager : IDisposable {
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
} }
if (configuration.LaunchAutomatically) { if (launchNow) {
await LaunchInternal(instance); await LaunchInternal(instance);
} }
@ -179,7 +180,6 @@ sealed class InstanceSessionManager : IDisposable {
public void Dispose() { public void Dispose() {
DisposeAllInstances(); DisposeAllInstances();
minecraftServerExecutables.Dispose();
shutdownCancellationTokenSource.Dispose(); shutdownCancellationTokenSource.Dispose();
semaphore.Dispose(); semaphore.Dispose();
} }

View File

@ -48,6 +48,9 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) { else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server."); throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
} }
else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher.");
}
else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) { else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server."); throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server.");
} }

View File

@ -1,4 +1,5 @@
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages; using Phantom.Common.Messages;
@ -26,12 +27,15 @@ public sealed class MessageListener : IMessageToAgentListener {
public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) { public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful."); Logger.Information("Agent authentication successful.");
foreach (var instanceInfo in message.InitialInstances) { void ShutdownAfterConfigurationFailed(InstanceConfiguration configuration) {
var result = await agent.InstanceSessionManager.Configure(instanceInfo); Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, configuration.InstanceGuid);
shutdownTokenSource.Cancel();
}
foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) {
var result = await HandleConfigureInstance(configureInstanceMessage);
if (!result.Is(ConfigureInstanceResult.Success)) { if (!result.Is(ConfigureInstanceResult.Success)) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", instanceInfo.InstanceName, instanceInfo.InstanceGuid); ShutdownAfterConfigurationFailed(configureInstanceMessage.Configuration);
shutdownTokenSource.Cancel();
return NoReply.Instance; return NoReply.Instance;
} }
} }
@ -56,7 +60,7 @@ public sealed class MessageListener : IMessageToAgentListener {
} }
public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) { public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await agent.InstanceSessionManager.Configure(message.Configuration); return await agent.InstanceSessionManager.Configure(message.Configuration, message.LaunchProperties, message.LaunchNow);
} }
public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {

View File

@ -15,6 +15,5 @@ public sealed partial record InstanceConfiguration(
[property: MemoryPackOrder(6)] MinecraftServerKind MinecraftServerKind, [property: MemoryPackOrder(6)] MinecraftServerKind MinecraftServerKind,
[property: MemoryPackOrder(7)] RamAllocationUnits MemoryAllocation, [property: MemoryPackOrder(7)] RamAllocationUnits MemoryAllocation,
[property: MemoryPackOrder(8)] Guid JavaRuntimeGuid, [property: MemoryPackOrder(8)] Guid JavaRuntimeGuid,
[property: MemoryPackOrder(9)] ImmutableArray<string> JvmArguments, [property: MemoryPackOrder(9)] ImmutableArray<string> JvmArguments
[property: MemoryPackOrder(10)] bool LaunchAutomatically
); );

View File

@ -10,22 +10,24 @@ public enum InstanceLaunchFailReason : byte {
InvalidJvmArguments, InvalidJvmArguments,
CouldNotDownloadMinecraftServer, CouldNotDownloadMinecraftServer,
CouldNotConfigureMinecraftServer, CouldNotConfigureMinecraftServer,
CouldNotPrepareMinecraftServerLauncher,
CouldNotStartMinecraftServer CouldNotStartMinecraftServer
} }
public static class InstanceLaunchFailReasonExtensions { public static class InstanceLaunchFailReasonExtensions {
public static string ToSentence(this InstanceLaunchFailReason reason) { public static string ToSentence(this InstanceLaunchFailReason reason) {
return reason switch { return reason switch {
InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.", InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.",
InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.", InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.",
InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.", InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.",
InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.", InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.",
InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.", InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.",
InstanceLaunchFailReason.InvalidJvmArguments => "Invalid JVM arguments.", InstanceLaunchFailReason.InvalidJvmArguments => "Invalid JVM arguments.",
InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.", InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.",
InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.", InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.",
InstanceLaunchFailReason.CouldNotStartMinecraftServer => "Could not start Minecraft server.", InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher => "Could not prepare Minecraft server launcher.",
_ => "Unknown error." InstanceLaunchFailReason.CouldNotStartMinecraftServer => "Could not start Minecraft server.",
_ => "Unknown error."
}; };
} }
} }

View File

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

View File

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

View File

@ -6,7 +6,9 @@ namespace Phantom.Common.Messages.ToAgent;
[MemoryPackable] [MemoryPackable]
public sealed partial record ConfigureInstanceMessage( public sealed partial record ConfigureInstanceMessage(
[property: MemoryPackOrder(0)] InstanceConfiguration Configuration [property: MemoryPackOrder(0)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(1)] InstanceLaunchProperties LaunchProperties,
[property: MemoryPackOrder(2)] bool LaunchNow = false
) : IMessageToAgent<InstanceActionResult<ConfigureInstanceResult>> { ) : IMessageToAgent<InstanceActionResult<ConfigureInstanceResult>> {
public Task<InstanceActionResult<ConfigureInstanceResult>> Accept(IMessageToAgentListener listener) { public Task<InstanceActionResult<ConfigureInstanceResult>> Accept(IMessageToAgentListener listener) {
return listener.HandleConfigureInstance(this); return listener.HandleConfigureInstance(this);

View File

@ -1,13 +1,12 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Instance;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToAgent; namespace Phantom.Common.Messages.ToAgent;
[MemoryPackable] [MemoryPackable]
public sealed partial record RegisterAgentSuccessMessage( public sealed partial record RegisterAgentSuccessMessage(
[property: MemoryPackOrder(0)] ImmutableArray<InstanceConfiguration> InitialInstances [property: MemoryPackOrder(0)] ImmutableArray<ConfigureInstanceMessage> InitialInstanceConfigurations
) : IMessageToAgent { ) : IMessageToAgent {
public Task<NoReply> Accept(IMessageToAgentListener listener) { public Task<NoReply> Accept(IMessageToAgentListener listener) {
return listener.HandleRegisterAgentSuccess(this); return listener.HandleRegisterAgentSuccess(this);

View File

@ -1,10 +0,0 @@
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
namespace Phantom.Common.Minecraft;
public sealed record MinecraftServerExecutableInfo(
string DownloadUrl,
Sha1String Hash,
FileSize Size
);

View File

@ -6,11 +6,4 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
</ItemGroup>
</Project> </Project>

View File

@ -36,6 +36,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Database", "
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Database.Postgres", "Server\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj", "{81625B4A-3DB6-48BD-A739-D23DA02107D1}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Database.Postgres", "Server\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj", "{81625B4A-3DB6-48BD-A739-D23DA02107D1}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Minecraft", "Server\Phantom.Server.Minecraft\Phantom.Server.Minecraft.csproj", "{4B3B73E6-48DD-4846-87FD-DFB86619B67C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Rpc", "Server\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj", "{79312D72-44E0-431D-96A4-4C0A066B9671}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Rpc", "Server\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj", "{79312D72-44E0-431D-96A4-4C0A066B9671}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Services", "Server\Phantom.Server.Services\Phantom.Server.Services.csproj", "{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Services", "Server\Phantom.Server.Services\Phantom.Server.Services.csproj", "{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9}"
@ -118,6 +120,10 @@ Global
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Debug|Any CPU.Build.0 = Debug|Any CPU {81625B4A-3DB6-48BD-A739-D23DA02107D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.ActiveCfg = Release|Any CPU {81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.Build.0 = Release|Any CPU {81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.Build.0 = Release|Any CPU
{4B3B73E6-48DD-4846-87FD-DFB86619B67C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4B3B73E6-48DD-4846-87FD-DFB86619B67C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4B3B73E6-48DD-4846-87FD-DFB86619B67C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4B3B73E6-48DD-4846-87FD-DFB86619B67C}.Release|Any CPU.Build.0 = Release|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.Build.0 = Debug|Any CPU {79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Release|Any CPU.ActiveCfg = Release|Any CPU {79312D72-44E0-431D-96A4-4C0A066B9671}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -184,6 +190,7 @@ Global
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{81625B4A-3DB6-48BD-A739-D23DA02107D1} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {81625B4A-3DB6-48BD-A739-D23DA02107D1} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{4B3B73E6-48DD-4846-87FD-DFB86619B67C} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{79312D72-44E0-431D-96A4-4C0A066B9671} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {79312D72-44E0-431D-96A4-4C0A066B9671} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {8AC8FB6C-033A-4626-820F-ED0F908756B2} {7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}

View File

@ -9,7 +9,7 @@ using Phantom.Utils.IO;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Serilog; using Serilog;
namespace Phantom.Common.Minecraft; namespace Phantom.Server.Minecraft;
public sealed class MinecraftVersions : IDisposable { public sealed class MinecraftVersions : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftVersions>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftVersions>();
@ -35,12 +35,7 @@ public sealed class MinecraftVersions : IDisposable {
return earlyResult; return earlyResult;
} }
try { await cachedVersionsSemaphore.WaitAsync(cancellationToken);
await cachedVersionsSemaphore.WaitAsync(cancellationToken);
} catch (OperationCanceledException) {
return ImmutableArray<MinecraftVersion>.Empty;
}
try { try {
if (CachedVersionsUnlessExpired is {} racedResult) { if (CachedVersionsUnlessExpired is {} racedResult) {
return racedResult; return racedResult;
@ -64,7 +59,7 @@ public sealed class MinecraftVersions : IDisposable {
}); });
} }
public async Task<MinecraftServerExecutableInfo?> GetServerExecutableInfo(string version, CancellationToken cancellationToken) { public async Task<FileDownloadInfo?> GetServerExecutableInfo(string version, CancellationToken cancellationToken) {
return await FetchOrFailSilently(async () => { return await FetchOrFailSilently(async () => {
var versions = await GetVersions(cancellationToken); var versions = await GetVersions(cancellationToken);
var versionObject = versions.FirstOrDefault(v => v.Id == version); var versionObject = versions.FirstOrDefault(v => v.Id == version);
@ -81,8 +76,6 @@ public sealed class MinecraftVersions : IDisposable {
private static async Task<T?> FetchOrFailSilently<T>(Func<Task<T?>> task) { private static async Task<T?> FetchOrFailSilently<T>(Func<Task<T?>> task) {
try { try {
return await task(); return await task();
} catch (OperationCanceledException) {
return default;
} catch (StopProcedureException) { } catch (StopProcedureException) {
return default; return default;
} catch (Exception e) { } catch (Exception e) {
@ -96,9 +89,7 @@ public sealed class MinecraftVersions : IDisposable {
try { try {
return await http.GetFromJsonAsync<JsonElement>(url, cancellationToken); return await http.GetFromJsonAsync<JsonElement>(url, cancellationToken);
} catch (OperationCanceledException) { } catch (HttpRequestException e) {
throw StopProcedureException.Instance;
} catch (HttpRequestException e) {
Logger.Error(e, "Unable to download {Description}.", description); Logger.Error(e, "Unable to download {Description}.", description);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} catch (Exception e) { } catch (Exception e) {
@ -117,7 +108,7 @@ public sealed class MinecraftVersions : IDisposable {
} catch (StopProcedureException) {} } catch (StopProcedureException) {}
} }
return foundVersions.ToImmutable(); return foundVersions.MoveToImmutable();
} }
private static MinecraftVersion GetVersionFromManifestEntry(JsonElement versionElement) { private static MinecraftVersion GetVersionFromManifestEntry(JsonElement versionElement) {
@ -148,7 +139,7 @@ public sealed class MinecraftVersions : IDisposable {
return new MinecraftVersion(id, type, url); return new MinecraftVersion(id, type, url);
} }
private static MinecraftServerExecutableInfo GetServerExecutableInfoFromMetadata(JsonElement versionMetadata) { private static FileDownloadInfo GetServerExecutableInfoFromMetadata(JsonElement versionMetadata) {
JsonElement downloadsElement = GetJsonPropertyOrThrow(versionMetadata, "downloads", JsonValueKind.Object, "version metadata"); JsonElement downloadsElement = GetJsonPropertyOrThrow(versionMetadata, "downloads", JsonValueKind.Object, "version metadata");
JsonElement serverElement = GetJsonPropertyOrThrow(downloadsElement, "server", JsonValueKind.Object, "downloads object in version metadata"); JsonElement serverElement = GetJsonPropertyOrThrow(downloadsElement, "server", JsonValueKind.Object, "downloads object in version metadata");
JsonElement urlElement = GetJsonPropertyOrThrow(serverElement, "url", JsonValueKind.String, "downloads.server object in version metadata"); JsonElement urlElement = GetJsonPropertyOrThrow(serverElement, "url", JsonValueKind.String, "downloads.server object in version metadata");
@ -182,7 +173,7 @@ public sealed class MinecraftVersions : IDisposable {
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
return new MinecraftServerExecutableInfo(url, hash, new FileSize(size)); return new FileDownloadInfo(url, hash, new FileSize(size));
} }
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

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

View File

@ -82,7 +82,9 @@ public sealed class AgentManager {
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid); Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
await connection.Send(new RegisterAgentSuccessMessage(instanceManager.GetInstanceConfigurationsForAgent(agent.Guid))); var instanceConfigurations = await instanceManager.GetInstanceConfigurationsForAgent(agent.Guid);
await connection.Send(new RegisterAgentSuccessMessage(instanceConfigurations));
return true; return true;
} }

View File

@ -5,17 +5,19 @@ public enum AddOrEditInstanceResult : byte {
Success, Success,
InstanceNameMustNotBeEmpty, InstanceNameMustNotBeEmpty,
InstanceMemoryMustNotBeZero, InstanceMemoryMustNotBeZero,
MinecraftVersionDownloadInfoNotFound,
AgentNotFound AgentNotFound
} }
public static class AddOrEditInstanceResultExtensions { public static class AddOrEditInstanceResultExtensions {
public static string ToSentence(this AddOrEditInstanceResult reason) { public static string ToSentence(this AddOrEditInstanceResult reason) {
return reason switch { return reason switch {
AddOrEditInstanceResult.Success => "Success.", AddOrEditInstanceResult.Success => "Success.",
AddOrEditInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.", AddOrEditInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.",
AddOrEditInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.", AddOrEditInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.",
AddOrEditInstanceResult.AgentNotFound => "Agent not found.", AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.",
_ => "Unknown error." AddOrEditInstanceResult.AgentNotFound => "Agent not found.",
_ => "Unknown error."
}; };
} }
} }

View File

@ -4,7 +4,8 @@ namespace Phantom.Server.Services.Instances;
public sealed record Instance( public sealed record Instance(
InstanceConfiguration Configuration, InstanceConfiguration Configuration,
IInstanceStatus Status IInstanceStatus Status,
bool LaunchAutomatically
) { ) {
internal Instance(InstanceConfiguration configuration) : this(configuration, InstanceStatus.Offline) {} internal Instance(InstanceConfiguration configuration, bool launchAutomatically = false) : this(configuration, InstanceStatus.Offline, launchAutomatically) {}
} }

View File

@ -10,6 +10,7 @@ using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Minecraft; using Phantom.Common.Minecraft;
using Phantom.Server.Database; using Phantom.Server.Database;
using Phantom.Server.Database.Entities; using Phantom.Server.Database.Entities;
using Phantom.Server.Minecraft;
using Phantom.Server.Services.Agents; using Phantom.Server.Services.Agents;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Events; using Phantom.Utils.Events;
@ -26,12 +27,14 @@ public sealed class InstanceManager {
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly AgentManager agentManager; private readonly AgentManager agentManager;
private readonly MinecraftVersions minecraftVersions;
private readonly DatabaseProvider databaseProvider; private readonly DatabaseProvider databaseProvider;
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1); private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, DatabaseProvider databaseProvider) { public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, MinecraftVersions minecraftVersions, DatabaseProvider databaseProvider) {
this.cancellationToken = configuration.CancellationToken; this.cancellationToken = configuration.CancellationToken;
this.agentManager = agentManager; this.agentManager = agentManager;
this.minecraftVersions = minecraftVersions;
this.databaseProvider = databaseProvider; this.databaseProvider = databaseProvider;
} }
@ -49,11 +52,10 @@ public sealed class InstanceManager {
entity.MinecraftServerKind, entity.MinecraftServerKind,
entity.MemoryAllocation, entity.MemoryAllocation,
entity.JavaRuntimeGuid, entity.JavaRuntimeGuid,
JvmArgumentsHelper.Split(entity.JvmArguments), JvmArgumentsHelper.Split(entity.JvmArguments)
entity.LaunchAutomatically
); );
var instance = new Instance(configuration); var instance = new Instance(configuration, entity.LaunchAutomatically);
instances.ByGuid[instance.Configuration.InstanceGuid] = instance; instances.ByGuid[instance.Configuration.InstanceGuid] = instance;
} }
} }
@ -73,22 +75,28 @@ public sealed class InstanceManager {
return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceMemoryMustNotBeZero); return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceMemoryMustNotBeZero);
} }
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken);
if (serverExecutableInfo == null) {
return InstanceActionResult.Concrete(AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound);
}
InstanceActionResult<AddOrEditInstanceResult> result; InstanceActionResult<AddOrEditInstanceResult> result;
bool isNewInstance; bool isNewInstance;
await modifyInstancesSemaphore.WaitAsync(cancellationToken); await modifyInstancesSemaphore.WaitAsync(cancellationToken);
try { try {
var instance = new Instance(configuration); isNewInstance = !instances.ByGuid.TryReplace(configuration.InstanceGuid, instance => instance with { Configuration = configuration });
instances.ByGuid.AddOrReplace(instance.Configuration.InstanceGuid, instance, out var oldInstance); if (isNewInstance) {
instances.ByGuid.TryAdd(configuration.InstanceGuid, new Instance(configuration));
var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, new ConfigureInstanceMessage(configuration), TimeSpan.FromSeconds(10)); }
var message = new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo));
var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, message, TimeSpan.FromSeconds(10));
result = reply.DidNotReplyIfNull().Map(static result => result switch { result = reply.DidNotReplyIfNull().Map(static result => result switch {
ConfigureInstanceResult.Success => AddOrEditInstanceResult.Success, ConfigureInstanceResult.Success => AddOrEditInstanceResult.Success,
_ => AddOrEditInstanceResult.UnknownError _ => AddOrEditInstanceResult.UnknownError
}); });
isNewInstance = oldInstance == null;
if (result.Is(AddOrEditInstanceResult.Success)) { if (result.Is(AddOrEditInstanceResult.Success)) {
using var scope = databaseProvider.CreateScope(); using var scope = databaseProvider.CreateScope();
@ -103,7 +111,6 @@ public sealed class InstanceManager {
entity.MemoryAllocation = configuration.MemoryAllocation; entity.MemoryAllocation = configuration.MemoryAllocation;
entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid; entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments); entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
entity.LaunchAutomatically = configuration.LaunchAutomatically;
await scope.Ctx.SaveChangesAsync(cancellationToken); await scope.Ctx.SaveChangesAsync(cancellationToken);
} }
@ -180,9 +187,7 @@ public sealed class InstanceManager {
private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) { private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) {
await modifyInstancesSemaphore.WaitAsync(cancellationToken); await modifyInstancesSemaphore.WaitAsync(cancellationToken);
try { try {
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
Configuration = instance.Configuration with { LaunchAutomatically = shouldLaunchAutomatically }
});
using var scope = databaseProvider.CreateScope(); using var scope = databaseProvider.CreateScope();
var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken); var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken);
@ -199,8 +204,15 @@ public sealed class InstanceManager {
return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command));
} }
internal ImmutableArray<InstanceConfiguration> GetInstanceConfigurationsForAgent(Guid agentGuid) { internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) {
return instances.ByGuid.ValuesCopy.Select(static instance => instance.Configuration).Where(configuration => configuration.AgentGuid == agentGuid).ToImmutableArray(); var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
foreach (var (configuration, _, launchAutomatically) in instances.ByGuid.ValuesCopy.Where(instance => instance.Configuration.AgentGuid == agentGuid)) {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken);
configurationMessages.Add(new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
}
return configurationMessages.ToImmutable();
} }
private sealed class ObservableInstances : ObservableState<ImmutableDictionary<Guid, Instance>> { private sealed class ObservableInstances : ObservableState<ImmutableDictionary<Guid, Instance>> {

View File

@ -16,6 +16,7 @@
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" />
<ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" /> <ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" />
<ProjectReference Include="..\Phantom.Server.Minecraft\Phantom.Server.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj" /> <ProjectReference Include="..\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj" />
</ItemGroup> </ItemGroup>

View File

@ -4,15 +4,13 @@
@using Phantom.Server.Services.Agents @using Phantom.Server.Services.Agents
@using Phantom.Server.Services.Events @using Phantom.Server.Services.Events
@using Phantom.Server.Services.Instances @using Phantom.Server.Services.Instances
@using Microsoft.AspNetCore.Identity
@using Phantom.Server.Database.Enums @using Phantom.Server.Database.Enums
@implements IDisposable @implements IDisposable
@inject AgentManager AgentManager @inject AgentManager AgentManager
@inject EventLog EventLog @inject EventLog EventLog
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager
@inject UserManager<IdentityUser> UserManager
<h1>Audit Log</h1> <h1>Event Log</h1>
<table class="table"> <table class="table">
<thead> <thead>

View File

@ -29,7 +29,7 @@
</thead> </thead>
@if (!instances.IsEmpty) { @if (!instances.IsEmpty) {
<tbody> <tbody>
@foreach (var (configuration, status) in instances) { @foreach (var (configuration, status, _) in instances) {
var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty; var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty;
var instanceGuid = configuration.InstanceGuid.ToString(); var instanceGuid = configuration.InstanceGuid.ToString();
<tr> <tr>

View File

@ -1,5 +1,6 @@
@using Phantom.Common.Data.Minecraft @using Phantom.Common.Data.Minecraft
@using Phantom.Common.Minecraft @using Phantom.Common.Minecraft
@using Phantom.Server.Minecraft
@using Phantom.Server.Services.Agents @using Phantom.Server.Services.Agents
@using Phantom.Server.Services.Audit @using Phantom.Server.Services.Audit
@using Phantom.Server.Services.Instances @using Phantom.Server.Services.Instances
@ -324,8 +325,7 @@
form.MinecraftServerKind, form.MinecraftServerKind,
form.MemoryAllocation ?? RamAllocationUnits.Zero, form.MemoryAllocation ?? RamAllocationUnits.Zero,
form.JavaRuntimeGuid.GetValueOrDefault(), form.JavaRuntimeGuid.GetValueOrDefault(),
JvmArgumentsHelper.Split(form.JvmArguments), JvmArgumentsHelper.Split(form.JvmArguments)
EditedInstanceConfiguration?.LaunchAutomatically ?? false
); );
var result = await InstanceManager.AddOrEditInstance(instance); var result = await InstanceManager.AddOrEditInstance(instance);

View File

@ -14,10 +14,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj" /> <ProjectReference Include="..\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj" />
<ProjectReference Include="..\Phantom.Server.Minecraft\Phantom.Server.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj" /> <ProjectReference Include="..\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj" />
<ProjectReference Include="..\Phantom.Server.Services\Phantom.Server.Services.csproj" /> <ProjectReference Include="..\Phantom.Server.Services\Phantom.Server.Services.csproj" />
<ProjectReference Include="..\Phantom.Server.Web\Phantom.Server.Web.csproj" /> <ProjectReference Include="..\Phantom.Server.Web\Phantom.Server.Web.csproj" />

View File

@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Minecraft; using Phantom.Server.Minecraft;
using Phantom.Server.Services; using Phantom.Server.Services;
using Phantom.Server.Services.Agents; using Phantom.Server.Services.Agents;
using Phantom.Server.Services.Audit; using Phantom.Server.Services.Audit;

View File

@ -0,0 +1,69 @@
using System.Diagnostics;
using Serilog;
namespace Phantom.Utils.Runtime;
public sealed class OneShotProcess {
private readonly ILogger logger;
private readonly ProcessStartInfo startInfo;
public event DataReceivedEventHandler? Output;
public OneShotProcess(ILogger logger, ProcessStartInfo startInfo) {
this.logger = logger;
this.startInfo = startInfo;
this.startInfo.RedirectStandardOutput = true;
this.startInfo.RedirectStandardError = true;
}
public async Task<bool> Run(CancellationToken cancellationToken) {
using var process = new Process { StartInfo = startInfo };
process.OutputDataReceived += Output;
process.ErrorDataReceived += Output;
try {
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
} catch (Exception e) {
logger.Error(e, "Caught exception launching process.");
return false;
}
try {
await process.WaitForExitAsync(cancellationToken);
} catch (OperationCanceledException) {
await TryKillProcess(process);
return false;
} catch (Exception e) {
logger.Error(e, "Caught exception waiting for process to exit.");
return false;
}
if (!process.HasExited) {
await TryKillProcess(process);
return false;
}
if (process.ExitCode != 0) {
logger.Error("Process exited with code {ExitCode}.", process.ExitCode);
return false;
}
logger.Verbose("Process finished successfully.");
return true;
}
private async Task TryKillProcess(Process process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try {
process.Kill();
await process.WaitForExitAsync(timeout.Token);
} catch (OperationCanceledException) {
logger.Error("Timed out waiting for killed process to exit.");
} catch (Exception e) {
logger.Error(e, "Caught exception killing process.");
}
}
}