diff --git a/Agent/Phantom.Agent.Minecraft/Instance/InstanceProcess.cs b/Agent/Phantom.Agent.Minecraft/Instance/InstanceProcess.cs index 7efa7d3..ed8503e 100644 --- a/Agent/Phantom.Agent.Minecraft/Instance/InstanceProcess.cs +++ b/Agent/Phantom.Agent.Minecraft/Instance/InstanceProcess.cs @@ -1,5 +1,6 @@ using Phantom.Utils.Collections; using Phantom.Utils.Processes; +using Phantom.Utils.Tasks; namespace Phantom.Agent.Minecraft.Instance; @@ -13,6 +14,7 @@ public sealed class InstanceProcess : IDisposable { public bool HasEnded { get; private set; } private readonly Process process; + private readonly TaskCompletionSource processExited = AsyncTasks.CreateCompletionSource(); internal InstanceProcess(InstanceProperties instanceProperties, Process process) { this.InstanceProperties = instanceProperties; @@ -46,16 +48,15 @@ public sealed class InstanceProcess : IDisposable { OutputEvent = null; HasEnded = true; Ended?.Invoke(this, EventArgs.Empty); + processExited.SetResult(); } public void Kill() { process.Kill(true); } - public async Task WaitForExit(CancellationToken cancellationToken) { - if (!HasEnded) { - await process.WaitForExitAsync(cancellationToken); - } + public async Task WaitForExit(TimeSpan timeout) { + await processExited.Task.WaitAsync(timeout); } public void Dispose() { diff --git a/Agent/Phantom.Agent.Services/AgentServices.cs b/Agent/Phantom.Agent.Services/AgentServices.cs index dedbac1..c31e118 100644 --- a/Agent/Phantom.Agent.Services/AgentServices.cs +++ b/Agent/Phantom.Agent.Services/AgentServices.cs @@ -1,8 +1,10 @@ -using Phantom.Agent.Minecraft.Java; +using Akka.Actor; +using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Rpc; using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Instances; using Phantom.Common.Data.Agent; +using Phantom.Utils.Actor; using Phantom.Utils.Logging; using Phantom.Utils.Tasks; using Serilog; @@ -12,19 +14,30 @@ namespace Phantom.Agent.Services; public sealed class AgentServices { private static readonly ILogger Logger = PhantomLogger.Create<AgentServices>(); + public ActorSystem ActorSystem { get; } + private AgentFolders AgentFolders { get; } + private AgentState AgentState { get; } private TaskManager TaskManager { get; } private BackupManager BackupManager { get; } internal JavaRuntimeRepository JavaRuntimeRepository { get; } - internal InstanceSessionManager InstanceSessionManager { get; } + internal InstanceTicketManager InstanceTicketManager { get; } + internal ActorRef<InstanceManagerActor.ICommand> InstanceManager { get; } public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders, AgentServiceConfiguration serviceConfiguration, ControllerConnection controllerConnection) { + this.ActorSystem = ActorSystemFactory.Create("Agent"); + this.AgentFolders = agentFolders; + this.AgentState = new AgentState(); this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>()); this.BackupManager = new BackupManager(agentFolders, serviceConfiguration.MaxConcurrentCompressionTasks); + this.JavaRuntimeRepository = new JavaRuntimeRepository(); - this.InstanceSessionManager = new InstanceSessionManager(controllerConnection, agentInfo, agentFolders, JavaRuntimeRepository, TaskManager, BackupManager); + this.InstanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection); + + var instanceManagerInit = new InstanceManagerActor.Init(controllerConnection, agentFolders, AgentState, JavaRuntimeRepository, InstanceTicketManager, TaskManager, BackupManager); + this.InstanceManager = ActorSystem.ActorOf(InstanceManagerActor.Factory(instanceManagerInit), "InstanceManager"); } public async Task Initialize() { @@ -36,11 +49,14 @@ public sealed class AgentServices { public async Task Shutdown() { Logger.Information("Stopping services..."); - await InstanceSessionManager.DisposeAsync(); + await InstanceManager.Stop(new InstanceManagerActor.ShutdownCommand()); await TaskManager.Stop(); BackupManager.Dispose(); + await ActorSystem.Terminate(); + ActorSystem.Dispose(); + Logger.Information("Services stopped."); } } diff --git a/Agent/Phantom.Agent.Services/AgentState.cs b/Agent/Phantom.Agent.Services/AgentState.cs new file mode 100644 index 0000000..1e251a2 --- /dev/null +++ b/Agent/Phantom.Agent.Services/AgentState.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using Phantom.Agent.Services.Instances; +using Phantom.Utils.Actor.Event; + +namespace Phantom.Agent.Services; + +sealed class AgentState { + private readonly ObservableState<ImmutableDictionary<Guid, Instance>> instancesByGuid = new (ImmutableDictionary<Guid, Instance>.Empty); + + public ImmutableDictionary<Guid, Instance> InstancesByGuid => instancesByGuid.State; + + public void UpdateInstance(Instance instance) { + instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance); + } +} diff --git a/Agent/Phantom.Agent.Services/Backups/BackupScheduler.cs b/Agent/Phantom.Agent.Services/Backups/BackupScheduler.cs index 31fa578..3853625 100644 --- a/Agent/Phantom.Agent.Services/Backups/BackupScheduler.cs +++ b/Agent/Phantom.Agent.Services/Backups/BackupScheduler.cs @@ -1,7 +1,6 @@ using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Services.Instances; -using Phantom.Agent.Services.Instances.Procedures; using Phantom.Common.Data.Backups; using Phantom.Utils.Logging; using Phantom.Utils.Tasks; @@ -16,8 +15,8 @@ sealed class BackupScheduler : CancellableBackgroundTask { private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5); private readonly BackupManager backupManager; + private readonly InstanceContext context; private readonly InstanceProcess process; - private readonly IInstanceContext context; private readonly SemaphoreSlim backupSemaphore = new (1, 1); private readonly int serverPort; private readonly ServerStatusProtocol serverStatusProtocol; @@ -25,10 +24,10 @@ sealed class BackupScheduler : CancellableBackgroundTask { public event EventHandler<BackupCreationResult>? BackupCompleted; - public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceProcess process, IInstanceContext context, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName), taskManager, "Backup scheduler for " + context.ShortName) { - this.backupManager = backupManager; - this.process = process; + public BackupScheduler(InstanceContext context, InstanceProcess process, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName), context.Services.TaskManager, "Backup scheduler for " + context.ShortName) { + this.backupManager = context.Services.BackupManager; this.context = context; + this.process = process; this.serverPort = serverPort; this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName); Start(); @@ -60,9 +59,10 @@ sealed class BackupScheduler : CancellableBackgroundTask { } try { - var procedure = new BackupInstanceProcedure(backupManager); - context.EnqueueProcedure(procedure); - return await procedure.Result; + context.ActorCancellationToken.ThrowIfCancellationRequested(); + return await context.Actor.Request(new InstanceActor.BackupInstanceCommand(backupManager), context.ActorCancellationToken); + } catch (OperationCanceledException) { + return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning); } finally { backupSemaphore.Release(); } diff --git a/Agent/Phantom.Agent.Services/Instances/IInstanceContext.cs b/Agent/Phantom.Agent.Services/Instances/IInstanceContext.cs deleted file mode 100644 index dd72b2f..0000000 --- a/Agent/Phantom.Agent.Services/Instances/IInstanceContext.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Phantom.Agent.Services.Instances.Procedures; -using Phantom.Agent.Services.Instances.States; -using Phantom.Common.Data.Instance; -using Serilog; - -namespace Phantom.Agent.Services.Instances; - -interface IInstanceContext { - string ShortName { get; } - ILogger Logger { get; } - - InstanceServices Services { get; } - IInstanceState CurrentState { get; } - - void SetStatus(IInstanceStatus newStatus); - void ReportEvent(IInstanceEvent instanceEvent); - void EnqueueProcedure(IInstanceProcedure procedure, bool immediate = false); -} - -static class InstanceContextExtensions { - public static void SetLaunchFailedStatusAndReportEvent(this IInstanceContext context, InstanceLaunchFailReason reason) { - context.SetStatus(InstanceStatus.Failed(reason)); - context.ReportEvent(new InstanceLaunchFailedEvent(reason)); - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/Instance.cs b/Agent/Phantom.Agent.Services/Instances/Instance.cs index a8abd07..d298cdd 100644 --- a/Agent/Phantom.Agent.Services/Instances/Instance.cs +++ b/Agent/Phantom.Agent.Services/Instances/Instance.cs @@ -1,171 +1,5 @@ -using Phantom.Agent.Minecraft.Launcher; -using Phantom.Agent.Services.Instances.Procedures; -using Phantom.Agent.Services.Instances.States; -using Phantom.Common.Data.Instance; -using Phantom.Common.Data.Minecraft; -using Phantom.Common.Data.Replies; -using Phantom.Common.Messages.Agent.ToController; -using Phantom.Utils.Logging; -using Serilog; +using Phantom.Common.Data.Instance; namespace Phantom.Agent.Services.Instances; -sealed class Instance : IAsyncDisposable { - private InstanceServices Services { get; } - - public InstanceConfiguration Configuration { get; private set; } - private IServerLauncher Launcher { get; set; } - private readonly SemaphoreSlim configurationSemaphore = new (1, 1); - - private readonly Guid instanceGuid; - private readonly string shortName; - private readonly ILogger logger; - - private IInstanceStatus currentStatus; - - private IInstanceState currentState; - public bool IsRunning => currentState is not InstanceNotRunningState; - - public event EventHandler? IsRunningChanged; - - private readonly InstanceProcedureManager procedureManager; - - public Instance(Guid instanceGuid, string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) { - this.instanceGuid = instanceGuid; - this.shortName = shortName; - this.logger = PhantomLogger.Create<Instance>(shortName); - - this.Services = services; - this.Configuration = configuration; - this.Launcher = launcher; - - this.currentStatus = InstanceStatus.NotRunning; - this.currentState = new InstanceNotRunningState(); - - this.procedureManager = new InstanceProcedureManager(this, new Context(this), services.TaskManager); - } - - public void ReportLastStatus() { - Services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, currentStatus)); - } - - private void ReportAndSetStatus(IInstanceStatus status) { - currentStatus = status; - Services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, status)); - } - - private void ReportEvent(IInstanceEvent instanceEvent) { - Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, instanceGuid, instanceEvent)); - } - - internal void TransitionState(IInstanceState newState) { - if (currentState == newState) { - return; - } - - if (currentState is IDisposable disposable) { - disposable.Dispose(); - } - - logger.Debug("Transitioning instance state to: {NewState}", newState.GetType().Name); - - var wasRunning = IsRunning; - currentState = newState; - currentState.Initialize(); - - if (IsRunning != wasRunning) { - IsRunningChanged?.Invoke(this, EventArgs.Empty); - } - } - - public async Task Reconfigure(InstanceConfiguration configuration, IServerLauncher launcher, CancellationToken cancellationToken) { - await configurationSemaphore.WaitAsync(cancellationToken); - try { - Configuration = configuration; - Launcher = launcher; - } finally { - configurationSemaphore.Release(); - } - } - - public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) { - if (IsRunning) { - return LaunchInstanceResult.InstanceAlreadyRunning; - } - - if (await procedureManager.GetCurrentProcedure(cancellationToken) is LaunchInstanceProcedure) { - return LaunchInstanceResult.InstanceAlreadyLaunching; - } - - LaunchInstanceProcedure procedure; - - await configurationSemaphore.WaitAsync(cancellationToken); - try { - procedure = new LaunchInstanceProcedure(instanceGuid, Configuration, Launcher); - } finally { - configurationSemaphore.Release(); - } - - ReportAndSetStatus(InstanceStatus.Launching); - await procedureManager.Enqueue(procedure); - return LaunchInstanceResult.LaunchInitiated; - } - - public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) { - if (!IsRunning) { - return StopInstanceResult.InstanceAlreadyStopped; - } - - if (await procedureManager.GetCurrentProcedure(cancellationToken) is StopInstanceProcedure) { - return StopInstanceResult.InstanceAlreadyStopping; - } - - ReportAndSetStatus(InstanceStatus.Stopping); - await procedureManager.Enqueue(new StopInstanceProcedure(stopStrategy)); - return StopInstanceResult.StopInitiated; - } - - public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { - return await currentState.SendCommand(command, cancellationToken); - } - - public async ValueTask DisposeAsync() { - await procedureManager.DisposeAsync(); - - while (currentState is not InstanceNotRunningState) { - await Task.Delay(TimeSpan.FromMilliseconds(250), CancellationToken.None); - } - - if (currentState is IDisposable disposable) { - disposable.Dispose(); - } - - configurationSemaphore.Dispose(); - } - - private sealed class Context : IInstanceContext { - public string ShortName => instance.shortName; - public ILogger Logger => instance.logger; - - public InstanceServices Services => instance.Services; - public IInstanceState CurrentState => instance.currentState; - - private readonly Instance instance; - - public Context(Instance instance) { - this.instance = instance; - } - - public void SetStatus(IInstanceStatus newStatus) { - instance.ReportAndSetStatus(newStatus); - } - - public void ReportEvent(IInstanceEvent instanceEvent) { - instance.ReportEvent(instanceEvent); - } - - public void EnqueueProcedure(IInstanceProcedure procedure, bool immediate) { - Services.TaskManager.Run("Enqueue procedure for instance " + instance.shortName, () => instance.procedureManager.Enqueue(procedure, immediate)); - } - } -} +sealed record Instance(Guid InstanceGuid, IInstanceStatus Status); diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceActor.cs b/Agent/Phantom.Agent.Services/Instances/InstanceActor.cs new file mode 100644 index 0000000..908a8b5 --- /dev/null +++ b/Agent/Phantom.Agent.Services/Instances/InstanceActor.cs @@ -0,0 +1,156 @@ +using Phantom.Agent.Minecraft.Launcher; +using Phantom.Agent.Services.Backups; +using Phantom.Agent.Services.Instances.State; +using Phantom.Common.Data.Backups; +using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Common.Messages.Agent.ToController; +using Phantom.Utils.Actor; +using Phantom.Utils.Actor.Mailbox; +using Phantom.Utils.Logging; + +namespace Phantom.Agent.Services.Instances; + +sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { + public readonly record struct Init(AgentState AgentState, Guid InstanceGuid, string ShortName, InstanceServices InstanceServices, InstanceTicketManager InstanceTicketManager, CancellationToken ShutdownCancellationToken); + + public static Props<ICommand> Factory(Init init) { + return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); + } + + private readonly AgentState agentState; + private readonly CancellationToken shutdownCancellationToken; + + private readonly Guid instanceGuid; + private readonly InstanceServices instanceServices; + private readonly InstanceTicketManager instanceTicketManager; + private readonly InstanceContext context; + + private readonly CancellationTokenSource actorCancellationTokenSource = new (); + + private IInstanceStatus currentStatus = InstanceStatus.NotRunning; + private InstanceRunningState? runningState = null; + + private InstanceActor(Init init) { + this.agentState = init.AgentState; + this.instanceGuid = init.InstanceGuid; + this.instanceServices = init.InstanceServices; + this.instanceTicketManager = init.InstanceTicketManager; + this.shutdownCancellationToken = init.ShutdownCancellationToken; + + var logger = PhantomLogger.Create<InstanceActor>(init.ShortName); + this.context = new InstanceContext(instanceGuid, init.ShortName, logger, instanceServices, SelfTyped, actorCancellationTokenSource.Token); + + Receive<ReportInstanceStatusCommand>(ReportInstanceStatus); + ReceiveAsync<LaunchInstanceCommand>(LaunchInstance); + ReceiveAsync<StopInstanceCommand>(StopInstance); + ReceiveAsyncAndReply<SendCommandToInstanceCommand, SendCommandToInstanceResult>(SendCommandToInstance); + ReceiveAsyncAndReply<BackupInstanceCommand, BackupCreationResult>(BackupInstance); + Receive<HandleProcessEndedCommand>(HandleProcessEnded); + ReceiveAsync<ShutdownCommand>(Shutdown); + } + + private void SetAndReportStatus(IInstanceStatus status) { + currentStatus = status; + ReportCurrentStatus(); + } + + private void ReportCurrentStatus() { + agentState.UpdateInstance(new Instance(instanceGuid, currentStatus)); + instanceServices.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, currentStatus)); + } + + private void TransitionState(InstanceRunningState? newState) { + if (runningState == newState) { + return; + } + + runningState?.Dispose(); + runningState = newState; + runningState?.Initialize(); + } + + public interface ICommand {} + + public sealed record ReportInstanceStatusCommand : ICommand; + + public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting) : ICommand; + + public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy) : ICommand; + + public sealed record SendCommandToInstanceCommand(string Command) : ICommand, ICanReply<SendCommandToInstanceResult>; + + public sealed record BackupInstanceCommand(BackupManager BackupManager) : ICommand, ICanReply<BackupCreationResult>; + + public sealed record HandleProcessEndedCommand(IInstanceStatus Status) : ICommand, IJumpAhead; + + public sealed record ShutdownCommand : ICommand; + + private void ReportInstanceStatus(ReportInstanceStatusCommand command) { + ReportCurrentStatus(); + } + + private async Task LaunchInstance(LaunchInstanceCommand command) { + if (command.IsRestarting || runningState is null) { + SetAndReportStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching); + + var newState = await InstanceLaunchProcedure.Run(context, command.Configuration, command.Launcher, instanceTicketManager, command.Ticket, SetAndReportStatus, shutdownCancellationToken); + if (newState is null) { + instanceTicketManager.Release(command.Ticket); + } + + TransitionState(newState); + } + } + + private async Task StopInstance(StopInstanceCommand command) { + if (runningState is null) { + return; + } + + IInstanceStatus oldStatus = currentStatus; + SetAndReportStatus(InstanceStatus.Stopping); + + if (await InstanceStopProcedure.Run(context, command.StopStrategy, runningState, SetAndReportStatus, shutdownCancellationToken)) { + instanceTicketManager.Release(runningState.Ticket); + TransitionState(null); + } + else { + SetAndReportStatus(oldStatus); + } + } + + private async Task<SendCommandToInstanceResult> SendCommandToInstance(SendCommandToInstanceCommand command) { + if (runningState is null) { + return SendCommandToInstanceResult.InstanceNotRunning; + } + else { + return await runningState.SendCommand(command.Command, shutdownCancellationToken); + } + } + + private async Task<BackupCreationResult> BackupInstance(BackupInstanceCommand command) { + if (runningState is null || runningState.Process.HasEnded) { + return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning); + } + else { + return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken); + } + } + + private void HandleProcessEnded(HandleProcessEndedCommand command) { + if (runningState is { Process.HasEnded: true }) { + SetAndReportStatus(command.Status); + context.ReportEvent(InstanceEvent.Stopped); + instanceTicketManager.Release(runningState.Ticket); + TransitionState(null); + } + } + + private async Task Shutdown(ShutdownCommand command) { + await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant)); + await actorCancellationTokenSource.CancelAsync(); + Context.Stop(Self); + } +} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceContext.cs b/Agent/Phantom.Agent.Services/Instances/InstanceContext.cs new file mode 100644 index 0000000..25b5d8a --- /dev/null +++ b/Agent/Phantom.Agent.Services/Instances/InstanceContext.cs @@ -0,0 +1,12 @@ +using Phantom.Common.Data.Instance; +using Phantom.Common.Messages.Agent.ToController; +using Phantom.Utils.Actor; +using Serilog; + +namespace Phantom.Agent.Services.Instances; + +sealed record InstanceContext(Guid InstanceGuid, string ShortName, ILogger Logger, InstanceServices Services, ActorRef<InstanceActor.ICommand> Actor, CancellationToken ActorCancellationToken) { + public void ReportEvent(IInstanceEvent instanceEvent) { + Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent)); + } +} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceManagerActor.cs b/Agent/Phantom.Agent.Services/Instances/InstanceManagerActor.cs new file mode 100644 index 0000000..f036b51 --- /dev/null +++ b/Agent/Phantom.Agent.Services/Instances/InstanceManagerActor.cs @@ -0,0 +1,208 @@ +using Phantom.Agent.Minecraft.Instance; +using Phantom.Agent.Minecraft.Java; +using Phantom.Agent.Minecraft.Launcher; +using Phantom.Agent.Minecraft.Launcher.Types; +using Phantom.Agent.Minecraft.Properties; +using Phantom.Agent.Minecraft.Server; +using Phantom.Agent.Rpc; +using Phantom.Agent.Services.Backups; +using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Utils.Actor; +using Phantom.Utils.IO; +using Phantom.Utils.Logging; +using Phantom.Utils.Tasks; +using Serilog; + +namespace Phantom.Agent.Services.Instances; + +sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand> { + private static readonly ILogger Logger = PhantomLogger.Create<InstanceManagerActor>(); + + public readonly record struct Init(ControllerConnection ControllerConnection, AgentFolders AgentFolders, AgentState AgentState, JavaRuntimeRepository JavaRuntimeRepository, InstanceTicketManager InstanceTicketManager, TaskManager TaskManager, BackupManager BackupManager); + + public static Props<ICommand> Factory(Init init) { + return Props<ICommand>.Create(() => new InstanceManagerActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume }); + } + + private readonly AgentState agentState; + private readonly string basePath; + + private readonly InstanceServices instanceServices; + private readonly InstanceTicketManager instanceTicketManager; + private readonly Dictionary<Guid, InstanceInfo> instances = new (); + + private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); + private readonly CancellationToken shutdownCancellationToken; + + private uint instanceLoggerSequenceId = 0; + + private InstanceManagerActor(Init init) { + this.agentState = init.AgentState; + this.basePath = init.AgentFolders.InstancesFolderPath; + this.instanceTicketManager = init.InstanceTicketManager; + this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; + + var minecraftServerExecutables = new MinecraftServerExecutables(init.AgentFolders.ServerExecutableFolderPath); + var launchServices = new LaunchServices(minecraftServerExecutables, init.JavaRuntimeRepository); + + this.instanceServices = new InstanceServices(init.ControllerConnection, init.TaskManager, init.BackupManager, launchServices); + + ReceiveAndReply<ConfigureInstanceCommand, InstanceActionResult<ConfigureInstanceResult>>(ConfigureInstance); + ReceiveAndReply<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance); + ReceiveAndReply<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance); + ReceiveAsyncAndReply<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendCommandToInstance); + ReceiveAsync<ShutdownCommand>(Shutdown); + } + + private string GetInstanceLoggerName(Guid guid) { + var prefix = guid.ToString(); + return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId); + } + + private sealed record InstanceInfo(ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher); + + public interface ICommand {} + + public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<InstanceActionResult<ConfigureInstanceResult>>; + + public sealed record LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>; + + public sealed record StopInstanceCommand(Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>; + + public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; + + public sealed record ShutdownCommand : ICommand; + + private InstanceActionResult<ConfigureInstanceResult> ConfigureInstance(ConfigureInstanceCommand command) { + var instanceGuid = command.InstanceGuid; + var configuration = command.Configuration; + + var instanceFolder = Path.Combine(basePath, instanceGuid.ToString()); + Directories.Create(instanceFolder, Chmod.URWX_GRX); + + var heapMegabytes = configuration.MemoryAllocation.InMegabytes; + var jvmProperties = new JvmProperties( + InitialHeapMegabytes: heapMegabytes / 2, + MaximumHeapMegabytes: heapMegabytes + ); + + var properties = new InstanceProperties( + instanceGuid, + configuration.JavaRuntimeGuid, + jvmProperties, + configuration.JvmArguments, + instanceFolder, + configuration.MinecraftVersion, + new ServerProperties(configuration.ServerPort, configuration.RconPort), + command.LaunchProperties + ); + + IServerLauncher launcher = configuration.MinecraftServerKind switch { + MinecraftServerKind.Vanilla => new VanillaLauncher(properties), + MinecraftServerKind.Fabric => new FabricLauncher(properties), + _ => InvalidLauncher.Instance + }; + + if (instances.TryGetValue(instanceGuid, out var instance)) { + instances[instanceGuid] = instance with { + Configuration = configuration, + Launcher = launcher + }; + + Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); + + if (command.AlwaysReportStatus) { + instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); + } + } + else { + var instanceInit = new InstanceActor.Init(agentState, instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, instanceTicketManager, shutdownCancellationToken); + instances[instanceGuid] = instance = new InstanceInfo(Context.ActorOf(InstanceActor.Factory(instanceInit), "Instance-" + instanceGuid), configuration, launcher); + + Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); + + instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); + } + + if (command.LaunchNow) { + LaunchInstance(new LaunchInstanceCommand(instanceGuid)); + } + + return InstanceActionResult.Concrete(ConfigureInstanceResult.Success); + } + + private InstanceActionResult<LaunchInstanceResult> LaunchInstance(LaunchInstanceCommand command) { + var instanceGuid = command.InstanceGuid; + if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { + return InstanceActionResult.General<LaunchInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); + } + + var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration); + if (ticket is Result<InstanceTicketManager.Ticket, LaunchInstanceResult>.Fail fail) { + return InstanceActionResult.Concrete(fail.Error); + } + + if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) { + var status = instance.Status; + if (status.IsRunning()) { + return InstanceActionResult.Concrete(LaunchInstanceResult.InstanceAlreadyRunning); + } + else if (status.IsLaunching()) { + return InstanceActionResult.Concrete(LaunchInstanceResult.InstanceAlreadyLaunching); + } + } + + instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false)); + return InstanceActionResult.Concrete(LaunchInstanceResult.LaunchInitiated); + } + + private InstanceActionResult<StopInstanceResult> StopInstance(StopInstanceCommand command) { + var instanceGuid = command.InstanceGuid; + if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { + return InstanceActionResult.General<StopInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); + } + + if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) { + var status = instance.Status; + if (status.IsStopping()) { + return InstanceActionResult.Concrete(StopInstanceResult.InstanceAlreadyStopping); + } + else if (!status.CanStop()) { + return InstanceActionResult.Concrete(StopInstanceResult.InstanceAlreadyStopped); + } + } + + instanceInfo.Actor.Tell(new InstanceActor.StopInstanceCommand(command.StopStrategy)); + return InstanceActionResult.Concrete(StopInstanceResult.StopInitiated); + } + + private async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(SendCommandToInstanceCommand command) { + var instanceGuid = command.InstanceGuid; + if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { + return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); + } + + try { + return InstanceActionResult.Concrete(await instanceInfo.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command.Command), shutdownCancellationToken)); + } catch (OperationCanceledException) { + return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.AgentShuttingDown); + } + } + + private async Task Shutdown(ShutdownCommand command) { + Logger.Information("Stopping all instances..."); + + await shutdownCancellationTokenSource.CancelAsync(); + + await Task.WhenAll(instances.Values.Select(static instance => instance.Actor.Stop(new InstanceActor.ShutdownCommand()))); + instances.Clear(); + + shutdownCancellationTokenSource.Dispose(); + + Logger.Information("All instances stopped."); + + Context.Stop(Self); + } +} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceProcedureManager.cs b/Agent/Phantom.Agent.Services/Instances/InstanceProcedureManager.cs deleted file mode 100644 index 695340e..0000000 --- a/Agent/Phantom.Agent.Services/Instances/InstanceProcedureManager.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Phantom.Agent.Services.Instances.Procedures; -using Phantom.Common.Data.Minecraft; -using Phantom.Utils.Tasks; -using Phantom.Utils.Threading; - -namespace Phantom.Agent.Services.Instances; - -sealed class InstanceProcedureManager : IAsyncDisposable { - private readonly record struct CurrentProcedure(IInstanceProcedure Procedure, CancellationTokenSource CancellationTokenSource); - - private readonly ThreadSafeStructRef<CurrentProcedure> currentProcedure = new (); - private readonly ThreadSafeLinkedList<IInstanceProcedure> procedureQueue = new (); - private readonly AutoResetEvent procedureQueueReady = new (false); - private readonly ManualResetEventSlim procedureQueueFinished = new (false); - - private readonly Instance instance; - private readonly IInstanceContext context; - private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); - - public InstanceProcedureManager(Instance instance, IInstanceContext context, TaskManager taskManager) { - this.instance = instance; - this.context = context; - taskManager.Run("Procedure manager for instance " + context.ShortName, Run); - } - - public async Task Enqueue(IInstanceProcedure procedure, bool immediate = false) { - await procedureQueue.Add(procedure, toFront: immediate, shutdownCancellationTokenSource.Token); - procedureQueueReady.Set(); - } - - public async Task<IInstanceProcedure?> GetCurrentProcedure(CancellationToken cancellationToken) { - return (await currentProcedure.Get(cancellationToken))?.Procedure; - } - - private async Task Run() { - try { - var shutdownCancellationToken = shutdownCancellationTokenSource.Token; - while (true) { - await procedureQueueReady.WaitOneAsync(shutdownCancellationToken); - while (await procedureQueue.TryTakeFromFront(shutdownCancellationToken) is {} nextProcedure) { - using var procedureCancellationTokenSource = new CancellationTokenSource(); - await currentProcedure.Set(new CurrentProcedure(nextProcedure, procedureCancellationTokenSource), shutdownCancellationToken); - await RunProcedure(nextProcedure, procedureCancellationTokenSource.Token); - await currentProcedure.Set(null, shutdownCancellationToken); - } - } - } catch (OperationCanceledException) { - // Ignore. - } - - await RunProcedure(new StopInstanceProcedure(MinecraftStopStrategy.Instant), CancellationToken.None); - procedureQueueFinished.Set(); - } - - private async Task RunProcedure(IInstanceProcedure procedure, CancellationToken cancellationToken) { - var procedureName = procedure.GetType().Name; - - context.Logger.Debug("Started procedure: {Procedure}", procedureName); - try { - var newState = await procedure.Run(context, cancellationToken); - context.Logger.Debug("Finished procedure: {Procedure}", procedureName); - - if (newState != null) { - instance.TransitionState(newState); - } - } catch (OperationCanceledException) { - context.Logger.Debug("Cancelled procedure: {Procedure}", procedureName); - } catch (Exception e) { - context.Logger.Error(e, "Caught exception while running procedure: {Procedure}", procedureName); - } - } - - public async ValueTask DisposeAsync() { - shutdownCancellationTokenSource.Cancel(); - - (await currentProcedure.Get(CancellationToken.None))?.CancellationTokenSource.Cancel(); - await procedureQueueFinished.WaitHandle.WaitOneAsync(); - - currentProcedure.Dispose(); - procedureQueue.Dispose(); - procedureQueueReady.Dispose(); - procedureQueueFinished.Dispose(); - shutdownCancellationTokenSource.Dispose(); - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceServices.cs b/Agent/Phantom.Agent.Services/Instances/InstanceServices.cs index c2d3f5c..b8363ba 100644 --- a/Agent/Phantom.Agent.Services/Instances/InstanceServices.cs +++ b/Agent/Phantom.Agent.Services/Instances/InstanceServices.cs @@ -5,4 +5,4 @@ using Phantom.Utils.Tasks; namespace Phantom.Agent.Services.Instances; -sealed record InstanceServices(ControllerConnection ControllerConnection, TaskManager TaskManager, PortManager PortManager, BackupManager BackupManager, LaunchServices LaunchServices); +sealed record InstanceServices(ControllerConnection ControllerConnection, TaskManager TaskManager, BackupManager BackupManager, LaunchServices LaunchServices); diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs b/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs deleted file mode 100644 index 2289691..0000000 --- a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; -using Phantom.Agent.Minecraft.Instance; -using Phantom.Agent.Minecraft.Java; -using Phantom.Agent.Minecraft.Launcher; -using Phantom.Agent.Minecraft.Launcher.Types; -using Phantom.Agent.Minecraft.Properties; -using Phantom.Agent.Minecraft.Server; -using Phantom.Agent.Rpc; -using Phantom.Agent.Services.Backups; -using Phantom.Common.Data; -using Phantom.Common.Data.Agent; -using Phantom.Common.Data.Instance; -using Phantom.Common.Data.Minecraft; -using Phantom.Common.Data.Replies; -using Phantom.Common.Messages.Agent.ToController; -using Phantom.Utils.IO; -using Phantom.Utils.Logging; -using Phantom.Utils.Tasks; -using Serilog; - -namespace Phantom.Agent.Services.Instances; - -sealed class InstanceSessionManager : IAsyncDisposable { - private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>(); - - private readonly ControllerConnection controllerConnection; - private readonly AgentInfo agentInfo; - private readonly string basePath; - - private readonly InstanceServices instanceServices; - private readonly Dictionary<Guid, Instance> instances = new (); - - private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); - private readonly CancellationToken shutdownCancellationToken; - private readonly SemaphoreSlim semaphore = new (1, 1); - - private uint instanceLoggerSequenceId = 0; - - public InstanceSessionManager(ControllerConnection controllerConnection, AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) { - this.controllerConnection = controllerConnection; - this.agentInfo = agentInfo; - this.basePath = agentFolders.InstancesFolderPath; - this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; - - var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath); - var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository); - var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts); - - this.instanceServices = new InstanceServices(controllerConnection, taskManager, portManager, backupManager, launchServices); - } - - private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) { - try { - await semaphore.WaitAsync(shutdownCancellationToken); - try { - return await func(); - } finally { - semaphore.Release(); - } - } catch (OperationCanceledException) { - return InstanceActionResult.General<T>(InstanceActionGeneralResult.AgentShuttingDown); - } - } - - [SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")] - private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<Instance, Task<T>> func) { - return AcquireSemaphoreAndRun(async () => { - if (instances.TryGetValue(instanceGuid, out var instance)) { - return InstanceActionResult.Concrete(await func(instance)); - } - else { - return InstanceActionResult.General<T>(InstanceActionGeneralResult.InstanceDoesNotExist); - } - }); - } - - public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(Guid instanceGuid, InstanceConfiguration configuration, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) { - return await AcquireSemaphoreAndRun(async () => { - var instanceFolder = Path.Combine(basePath, instanceGuid.ToString()); - Directories.Create(instanceFolder, Chmod.URWX_GRX); - - var heapMegabytes = configuration.MemoryAllocation.InMegabytes; - var jvmProperties = new JvmProperties( - InitialHeapMegabytes: heapMegabytes / 2, - MaximumHeapMegabytes: heapMegabytes - ); - - var properties = new InstanceProperties( - instanceGuid, - configuration.JavaRuntimeGuid, - jvmProperties, - configuration.JvmArguments, - instanceFolder, - configuration.MinecraftVersion, - new ServerProperties(configuration.ServerPort, configuration.RconPort), - launchProperties - ); - - IServerLauncher launcher = configuration.MinecraftServerKind switch { - MinecraftServerKind.Vanilla => new VanillaLauncher(properties), - MinecraftServerKind.Fabric => new FabricLauncher(properties), - _ => InvalidLauncher.Instance - }; - - if (instances.TryGetValue(instanceGuid, out var instance)) { - await instance.Reconfigure(configuration, launcher, shutdownCancellationToken); - Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); - - if (alwaysReportStatus) { - instance.ReportLastStatus(); - } - } - else { - instances[instanceGuid] = instance = new Instance(instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher); - Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); - - instance.ReportLastStatus(); - instance.IsRunningChanged += OnInstanceIsRunningChanged; - } - - if (launchNow) { - await LaunchInternal(instance); - } - - return InstanceActionResult.Concrete(ConfigureInstanceResult.Success); - }); - } - - private string GetInstanceLoggerName(Guid guid) { - var prefix = guid.ToString(); - return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId); - } - - private ImmutableArray<Instance> GetRunningInstancesInternal() { - return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray(); - } - - private void OnInstanceIsRunningChanged(object? sender, EventArgs e) { - instanceServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus); - } - - public async Task RefreshAgentStatus() { - try { - await semaphore.WaitAsync(shutdownCancellationToken); - try { - var runningInstances = GetRunningInstancesInternal(); - var runningInstanceCount = runningInstances.Length; - var runningInstanceMemory = runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation); - await controllerConnection.Send(new ReportAgentStatusMessage(runningInstanceCount, runningInstanceMemory)); - } finally { - semaphore.Release(); - } - } catch (OperationCanceledException) { - // ignore - } - } - - public Task<InstanceActionResult<LaunchInstanceResult>> Launch(Guid instanceGuid) { - return AcquireSemaphoreAndRunWithInstance(instanceGuid, LaunchInternal); - } - - private async Task<LaunchInstanceResult> LaunchInternal(Instance instance) { - var runningInstances = GetRunningInstancesInternal(); - if (runningInstances.Length + 1 > agentInfo.MaxInstances) { - return LaunchInstanceResult.InstanceLimitExceeded; - } - - var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation); - if (availableMemory < instance.Configuration.MemoryAllocation) { - return LaunchInstanceResult.MemoryLimitExceeded; - } - - return await instance.Launch(shutdownCancellationToken); - } - - public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { - return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy, shutdownCancellationToken)); - } - - public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { - return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError); - } - - public async ValueTask DisposeAsync() { - Logger.Information("Stopping all instances..."); - - shutdownCancellationTokenSource.Cancel(); - - await semaphore.WaitAsync(CancellationToken.None); - await Task.WhenAll(instances.Values.Select(static instance => instance.DisposeAsync().AsTask())); - instances.Clear(); - - shutdownCancellationTokenSource.Dispose(); - semaphore.Dispose(); - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceTicketManager.cs b/Agent/Phantom.Agent.Services/Instances/InstanceTicketManager.cs new file mode 100644 index 0000000..b195644 --- /dev/null +++ b/Agent/Phantom.Agent.Services/Instances/InstanceTicketManager.cs @@ -0,0 +1,100 @@ +using Phantom.Agent.Rpc; +using Phantom.Common.Data; +using Phantom.Common.Data.Agent; +using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Replies; +using Phantom.Common.Messages.Agent.ToController; +using Phantom.Utils.Logging; +using Phantom.Utils.Tasks; +using Serilog; + +namespace Phantom.Agent.Services.Instances; + +sealed class InstanceTicketManager { + private static readonly ILogger Logger = PhantomLogger.Create<InstanceTicketManager>(); + + private readonly AgentInfo agentInfo; + private readonly ControllerConnection controllerConnection; + + private readonly HashSet<Guid> activeTicketGuids = new (); + private readonly HashSet<ushort> usedPorts = new (); + private RamAllocationUnits usedMemory = new (); + + public InstanceTicketManager(AgentInfo agentInfo, ControllerConnection controllerConnection) { + this.agentInfo = agentInfo; + this.controllerConnection = controllerConnection; + } + + public Result<Ticket, LaunchInstanceResult> Reserve(InstanceConfiguration configuration) { + var memoryAllocation = configuration.MemoryAllocation; + var serverPort = configuration.ServerPort; + var rconPort = configuration.RconPort; + + if (!agentInfo.AllowedServerPorts.Contains(serverPort)) { + return LaunchInstanceResult.ServerPortNotAllowed; + } + + if (!agentInfo.AllowedRconPorts.Contains(rconPort)) { + return LaunchInstanceResult.RconPortNotAllowed; + } + + lock (this) { + if (activeTicketGuids.Count + 1 > agentInfo.MaxInstances) { + return LaunchInstanceResult.InstanceLimitExceeded; + } + + if (usedMemory + memoryAllocation > agentInfo.MaxMemory) { + return LaunchInstanceResult.MemoryLimitExceeded; + } + + if (usedPorts.Contains(serverPort)) { + return LaunchInstanceResult.ServerPortAlreadyInUse; + } + + if (usedPorts.Contains(rconPort)) { + return LaunchInstanceResult.RconPortAlreadyInUse; + } + + var ticket = new Ticket(Guid.NewGuid(), memoryAllocation, serverPort, rconPort); + + activeTicketGuids.Add(ticket.TicketGuid); + usedMemory += memoryAllocation; + usedPorts.Add(serverPort); + usedPorts.Add(rconPort); + + RefreshAgentStatus(); + Logger.Debug("Reserved ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes); + + return ticket; + } + } + + public bool IsValid(Ticket ticket) { + lock (this) { + return activeTicketGuids.Contains(ticket.TicketGuid); + } + } + + public void Release(Ticket ticket) { + lock (this) { + if (!activeTicketGuids.Remove(ticket.TicketGuid)) { + return; + } + + usedMemory -= ticket.MemoryAllocation; + usedPorts.Remove(ticket.ServerPort); + usedPorts.Remove(ticket.RconPort); + + RefreshAgentStatus(); + Logger.Debug("Released ticket {TicketGuid} (server port {ServerPort}, rcon port {RconPort}, memory allocation {MemoryAllocation} MB).", ticket.TicketGuid, ticket.ServerPort, ticket.RconPort, ticket.MemoryAllocation.InMegabytes); + } + } + + public void RefreshAgentStatus() { + lock (this) { + controllerConnection.Send(new ReportAgentStatusMessage(activeTicketGuids.Count, usedMemory)); + } + } + + public sealed record Ticket(Guid TicketGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort); +} diff --git a/Agent/Phantom.Agent.Services/Instances/PortManager.cs b/Agent/Phantom.Agent.Services/Instances/PortManager.cs deleted file mode 100644 index a98c40c..0000000 --- a/Agent/Phantom.Agent.Services/Instances/PortManager.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Phantom.Common.Data; -using Phantom.Common.Data.Instance; - -namespace Phantom.Agent.Services.Instances; - -sealed class PortManager { - private readonly AllowedPorts allowedServerPorts; - private readonly AllowedPorts allowedRconPorts; - private readonly HashSet<ushort> usedPorts = new (); - - public PortManager(AllowedPorts allowedServerPorts, AllowedPorts allowedRconPorts) { - this.allowedServerPorts = allowedServerPorts; - this.allowedRconPorts = allowedRconPorts; - } - - public Result Reserve(InstanceConfiguration configuration) { - var serverPort = configuration.ServerPort; - var rconPort = configuration.RconPort; - - if (!allowedServerPorts.Contains(serverPort)) { - return Result.ServerPortNotAllowed; - } - - if (!allowedRconPorts.Contains(rconPort)) { - return Result.RconPortNotAllowed; - } - - lock (usedPorts) { - if (usedPorts.Contains(serverPort)) { - return Result.ServerPortAlreadyInUse; - } - - if (usedPorts.Contains(rconPort)) { - return Result.RconPortAlreadyInUse; - } - - usedPorts.Add(serverPort); - usedPorts.Add(rconPort); - } - - return Result.Success; - } - - public void Release(InstanceConfiguration configuration) { - lock (usedPorts) { - usedPorts.Remove(configuration.ServerPort); - usedPorts.Remove(configuration.RconPort); - } - } - - public enum Result { - Success, - ServerPortNotAllowed, - ServerPortAlreadyInUse, - RconPortNotAllowed, - RconPortAlreadyInUse - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/BackupInstanceProcedure.cs b/Agent/Phantom.Agent.Services/Instances/Procedures/BackupInstanceProcedure.cs deleted file mode 100644 index abb7e48..0000000 --- a/Agent/Phantom.Agent.Services/Instances/Procedures/BackupInstanceProcedure.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Phantom.Agent.Services.Backups; -using Phantom.Agent.Services.Instances.States; -using Phantom.Common.Data.Backups; - -namespace Phantom.Agent.Services.Instances.Procedures; - -sealed record BackupInstanceProcedure(BackupManager BackupManager) : IInstanceProcedure { - private readonly TaskCompletionSource<BackupCreationResult> resultCompletionSource = new (); - - public Task<BackupCreationResult> Result => resultCompletionSource.Task; - - public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) { - if (context.CurrentState is not InstanceRunningState runningState || runningState.Process.HasEnded) { - resultCompletionSource.SetResult(new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning)); - return null; - } - - try { - var result = await BackupManager.CreateBackup(context.ShortName, runningState.Process, cancellationToken); - resultCompletionSource.SetResult(result); - } catch (OperationCanceledException) { - resultCompletionSource.SetCanceled(cancellationToken); - } catch (Exception e) { - resultCompletionSource.SetException(e); - } - - return null; - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/IInstanceProcedure.cs b/Agent/Phantom.Agent.Services/Instances/Procedures/IInstanceProcedure.cs deleted file mode 100644 index 0e1e37d..0000000 --- a/Agent/Phantom.Agent.Services/Instances/Procedures/IInstanceProcedure.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Phantom.Agent.Services.Instances.States; - -namespace Phantom.Agent.Services.Instances.Procedures; - -interface IInstanceProcedure { - Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken); -} diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs b/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs deleted file mode 100644 index 1038f09..0000000 --- a/Agent/Phantom.Agent.Services/Instances/Procedures/LaunchInstanceProcedure.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Phantom.Agent.Minecraft.Instance; -using Phantom.Agent.Minecraft.Launcher; -using Phantom.Agent.Minecraft.Server; -using Phantom.Agent.Services.Instances.States; -using Phantom.Common.Data.Instance; - -namespace Phantom.Agent.Services.Instances.Procedures; - -sealed record LaunchInstanceProcedure(Guid InstanceGuid, InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting = false) : IInstanceProcedure { - public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) { - if (!IsRestarting && context.CurrentState is InstanceRunningState) { - return null; - } - - context.SetStatus(IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching); - - InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(Configuration) switch { - PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed, - PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse, - PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed, - PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse, - _ => null - }; - - if (failReason is {} reason) { - context.SetLaunchFailedStatusAndReportEvent(reason); - return new InstanceNotRunningState(); - } - - context.Logger.Information("Session starting..."); - try { - InstanceProcess process = await DoLaunch(context, cancellationToken); - return new InstanceRunningState(InstanceGuid, Configuration, Launcher, process, context); - } catch (OperationCanceledException) { - context.SetStatus(InstanceStatus.NotRunning); - } catch (LaunchFailureException e) { - context.Logger.Error(e.LogMessage); - context.SetLaunchFailedStatusAndReportEvent(e.Reason); - } catch (Exception e) { - context.Logger.Error(e, "Caught exception while launching instance."); - context.SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError); - } - - context.Services.PortManager.Release(Configuration); - return new InstanceNotRunningState(); - } - - private async Task<InstanceProcess> DoLaunch(IInstanceContext context, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - - byte lastDownloadProgress = byte.MaxValue; - - void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) { - byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100); - - if (lastDownloadProgress != progress) { - lastDownloadProgress = progress; - context.SetStatus(InstanceStatus.Downloading(progress)); - } - } - - var launchResult = await Launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken); - if (launchResult is LaunchResult.InvalidJavaRuntime) { - throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime."); - } - else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) { - throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server."); - } - else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) { - throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher."); - } - else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) { - throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server."); - } - else if (launchResult is LaunchResult.CouldNotStartMinecraftServer) { - throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server."); - } - - if (launchResult is not LaunchResult.Success launchSuccess) { - throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch."); - } - - context.SetStatus(InstanceStatus.Running); - context.ReportEvent(InstanceEvent.LaunchSucceeded); - return launchSuccess.Process; - } - - private sealed class LaunchFailureException : Exception { - public InstanceLaunchFailReason Reason { get; } - public string LogMessage { get; } - - public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) { - this.Reason = reason; - this.LogMessage = logMessage; - } - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/SetInstanceToNotRunningStateProcedure.cs b/Agent/Phantom.Agent.Services/Instances/Procedures/SetInstanceToNotRunningStateProcedure.cs deleted file mode 100644 index c597721..0000000 --- a/Agent/Phantom.Agent.Services/Instances/Procedures/SetInstanceToNotRunningStateProcedure.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Phantom.Agent.Services.Instances.States; -using Phantom.Common.Data.Instance; - -namespace Phantom.Agent.Services.Instances.Procedures; - -sealed record SetInstanceToNotRunningStateProcedure(IInstanceStatus Status) : IInstanceProcedure { - public Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) { - if (context.CurrentState is InstanceRunningState { Process.HasEnded: true }) { - context.SetStatus(Status); - context.ReportEvent(InstanceEvent.Stopped); - return Task.FromResult<IInstanceState?>(new InstanceNotRunningState()); - } - else { - return Task.FromResult<IInstanceState?>(null); - } - } -} diff --git a/Agent/Phantom.Agent.Services/Instances/State/InstanceLaunchProcedure.cs b/Agent/Phantom.Agent.Services/Instances/State/InstanceLaunchProcedure.cs new file mode 100644 index 0000000..1308dfa --- /dev/null +++ b/Agent/Phantom.Agent.Services/Instances/State/InstanceLaunchProcedure.cs @@ -0,0 +1,86 @@ +using Phantom.Agent.Minecraft.Instance; +using Phantom.Agent.Minecraft.Launcher; +using Phantom.Agent.Minecraft.Server; +using Phantom.Common.Data.Instance; +using Phantom.Utils.Tasks; + +namespace Phantom.Agent.Services.Instances.State; + +static class InstanceLaunchProcedure { + public static async Task<InstanceRunningState?> Run(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager ticketManager, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { + context.Logger.Information("Session starting..."); + + Result<InstanceProcess, InstanceLaunchFailReason> result; + + if (ticketManager.IsValid(ticket)) { + try { + result = await LaunchInstance(context, launcher, reportStatus, cancellationToken); + } catch (OperationCanceledException) { + reportStatus(InstanceStatus.NotRunning); + return null; + } catch (Exception e) { + context.Logger.Error(e, "Caught exception while launching instance."); + result = InstanceLaunchFailReason.UnknownError; + } + } + else { + context.Logger.Error("Attempted to launch instance with an invalid ticket!"); + result = InstanceLaunchFailReason.UnknownError; + } + + if (result) { + reportStatus(InstanceStatus.Running); + context.ReportEvent(InstanceEvent.LaunchSucceeded); + return new InstanceRunningState(context, configuration, launcher, ticket, result.Value, cancellationToken); + } + else { + reportStatus(InstanceStatus.Failed(result.Error)); + context.ReportEvent(new InstanceLaunchFailedEvent(result.Error)); + return null; + } + } + + private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstance(InstanceContext context, IServerLauncher launcher, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + + byte lastDownloadProgress = byte.MaxValue; + + void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) { + byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100); + + if (lastDownloadProgress != progress) { + lastDownloadProgress = progress; + reportStatus(InstanceStatus.Downloading(progress)); + } + } + + switch (await launcher.Launch(context.Logger, context.Services.LaunchServices, OnDownloadProgress, cancellationToken)) { + case LaunchResult.Success launchSuccess: + return launchSuccess.Process; + + case LaunchResult.InvalidJavaRuntime: + context.Logger.Error("Session failed to launch, invalid Java runtime."); + return InstanceLaunchFailReason.JavaRuntimeNotFound; + + case LaunchResult.CouldNotDownloadMinecraftServer: + context.Logger.Error("Session failed to launch, could not download Minecraft server."); + return InstanceLaunchFailReason.CouldNotDownloadMinecraftServer; + + case LaunchResult.CouldNotPrepareMinecraftServerLauncher: + context.Logger.Error("Session failed to launch, could not prepare Minecraft server launcher."); + return InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher; + + case LaunchResult.CouldNotConfigureMinecraftServer: + context.Logger.Error("Session failed to launch, could not configure Minecraft server."); + return InstanceLaunchFailReason.CouldNotConfigureMinecraftServer; + + case LaunchResult.CouldNotStartMinecraftServer: + context.Logger.Error("Session failed to launch, could not start Minecraft server."); + return InstanceLaunchFailReason.CouldNotStartMinecraftServer; + + default: + context.Logger.Error("Session failed to launch."); + return InstanceLaunchFailReason.UnknownError; + } + } +} diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs b/Agent/Phantom.Agent.Services/Instances/State/InstanceLogSender.cs similarity index 98% rename from Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs rename to Agent/Phantom.Agent.Services/Instances/State/InstanceLogSender.cs index 3702416..daac981 100644 --- a/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs +++ b/Agent/Phantom.Agent.Services/Instances/State/InstanceLogSender.cs @@ -5,7 +5,7 @@ using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Logging; using Phantom.Utils.Tasks; -namespace Phantom.Agent.Services.Instances; +namespace Phantom.Agent.Services.Instances.State; sealed class InstanceLogSender : CancellableBackgroundTask { private static readonly BoundedChannelOptions BufferOptions = new (capacity: 64) { diff --git a/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs b/Agent/Phantom.Agent.Services/Instances/State/InstanceRunningState.cs similarity index 60% rename from Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs rename to Agent/Phantom.Agent.Services/Instances/State/InstanceRunningState.cs index b0af65a..3d30b3b 100644 --- a/Agent/Phantom.Agent.Services/Instances/States/InstanceRunningState.cs +++ b/Agent/Phantom.Agent.Services/Instances/State/InstanceRunningState.cs @@ -1,37 +1,39 @@ using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Services.Backups; -using Phantom.Agent.Services.Instances.Procedures; using Phantom.Common.Data.Backups; using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Replies; -namespace Phantom.Agent.Services.Instances.States; +namespace Phantom.Agent.Services.Instances.State; -sealed class InstanceRunningState : IInstanceState, IDisposable { +sealed class InstanceRunningState : IDisposable { + public InstanceTicketManager.Ticket Ticket { get; } public InstanceProcess Process { get; } internal bool IsStopping { get; set; } - private readonly Guid instanceGuid; + private readonly InstanceContext context; private readonly InstanceConfiguration configuration; private readonly IServerLauncher launcher; - private readonly IInstanceContext context; + private readonly CancellationToken cancellationToken; private readonly InstanceLogSender logSender; private readonly BackupScheduler backupScheduler; private bool isDisposed; - public InstanceRunningState(Guid instanceGuid, InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) { - this.instanceGuid = instanceGuid; + public InstanceRunningState(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process, CancellationToken cancellationToken) { + this.context = context; this.configuration = configuration; this.launcher = launcher; - this.context = context; + this.Ticket = ticket; this.Process = process; + this.cancellationToken = cancellationToken; - this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.Services.TaskManager, instanceGuid, context.ShortName); + this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.Services.TaskManager, context.InstanceGuid, context.ShortName); - this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context, configuration.ServerPort); + this.backupScheduler = new BackupScheduler(context, process, configuration.ServerPort); this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; } @@ -41,7 +43,7 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { if (Process.HasEnded) { if (TryDispose()) { context.Logger.Warning("Session ended immediately after it was started."); - context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)), immediate: true); + context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError))); } } else { @@ -60,13 +62,17 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { return; } + if (cancellationToken.IsCancellationRequested) { + return; + } + if (IsStopping) { - context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.NotRunning), immediate: true); + context.Actor.Tell(new InstanceActor.HandleProcessEndedCommand(InstanceStatus.NotRunning)); } else { context.Logger.Information("Session ended unexpectedly, restarting..."); context.ReportEvent(InstanceEvent.Crashed); - context.EnqueueProcedure(new LaunchInstanceProcedure(instanceGuid, configuration, launcher, IsRestarting: true)); + context.Actor.Tell(new InstanceActor.LaunchInstanceCommand(configuration, launcher, Ticket, IsRestarting: true)); } } @@ -74,16 +80,16 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings)); } - public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { + public async Task<SendCommandToInstanceResult> SendCommand(string command, CancellationToken cancellationToken) { try { context.Logger.Information("Sending command: {Command}", command); await Process.SendCommand(command, cancellationToken); - return true; + return SendCommandToInstanceResult.Success; } catch (OperationCanceledException) { - return false; + return SendCommandToInstanceResult.UnknownError; } catch (Exception e) { context.Logger.Warning(e, "Caught exception while sending command."); - return false; + return SendCommandToInstanceResult.UnknownError; } } @@ -100,7 +106,6 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { backupScheduler.Stop(); Process.Dispose(); - context.Services.PortManager.Release(configuration); return true; } diff --git a/Agent/Phantom.Agent.Services/Instances/Procedures/StopInstanceProcedure.cs b/Agent/Phantom.Agent.Services/Instances/State/InstanceStopProcedure.cs similarity index 67% rename from Agent/Phantom.Agent.Services/Instances/Procedures/StopInstanceProcedure.cs rename to Agent/Phantom.Agent.Services/Instances/State/InstanceStopProcedure.cs index 09d68eb..6c43968 100644 --- a/Agent/Phantom.Agent.Services/Instances/Procedures/StopInstanceProcedure.cs +++ b/Agent/Phantom.Agent.Services/Instances/State/InstanceStopProcedure.cs @@ -1,32 +1,25 @@ using System.Diagnostics; using Phantom.Agent.Minecraft.Command; using Phantom.Agent.Minecraft.Instance; -using Phantom.Agent.Services.Instances.States; using Phantom.Common.Data.Instance; using Phantom.Common.Data.Minecraft; -namespace Phantom.Agent.Services.Instances.Procedures; +namespace Phantom.Agent.Services.Instances.State; -sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInstanceProcedure { +static class InstanceStopProcedure { private static readonly ushort[] Stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 }; - public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) { - if (context.CurrentState is not InstanceRunningState runningState) { - return null; - } - + public static async Task<bool> Run(InstanceContext context, MinecraftStopStrategy stopStrategy, InstanceRunningState runningState, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { var process = runningState.Process; - runningState.IsStopping = true; - context.SetStatus(InstanceStatus.Stopping); - var seconds = StopStrategy.Seconds; + var seconds = stopStrategy.Seconds; if (seconds > 0) { try { await CountDownWithAnnouncements(context, process, seconds, cancellationToken); } catch (OperationCanceledException) { runningState.IsStopping = false; - return null; + return false; } } @@ -38,14 +31,14 @@ sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInsta } } finally { context.Logger.Information("Session stopped."); - context.SetStatus(InstanceStatus.NotRunning); + reportStatus(InstanceStatus.NotRunning); context.ReportEvent(InstanceEvent.Stopped); } - return new InstanceNotRunningState(); + return true; } - private async Task CountDownWithAnnouncements(IInstanceContext context, InstanceProcess process, ushort seconds, CancellationToken cancellationToken) { + private static async Task CountDownWithAnnouncements(InstanceContext context, InstanceProcess process, ushort seconds, CancellationToken cancellationToken) { context.Logger.Information("Session stopping in {Seconds} seconds.", seconds); foreach (var stop in Stops) { @@ -66,7 +59,7 @@ sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInsta return MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds.")); } - private async Task DoStop(IInstanceContext context, InstanceProcess process) { + private static async Task DoStop(InstanceContext context, InstanceProcess process) { context.Logger.Information("Sending stop command..."); await TrySendStopCommand(context, process); @@ -74,7 +67,7 @@ sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInsta await WaitForSessionToEnd(context, process); } - private async Task TrySendStopCommand(IInstanceContext context, InstanceProcess process) { + private static async Task TrySendStopCommand(InstanceContext context, InstanceProcess process) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await process.SendCommand(MinecraftCommand.Stop, timeout.Token); @@ -87,10 +80,9 @@ sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInsta } } - private async Task WaitForSessionToEnd(IInstanceContext context, InstanceProcess process) { - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55)); + private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) { try { - await process.WaitForExit(timeout.Token); + await process.WaitForExit(TimeSpan.FromSeconds(55)); } catch (OperationCanceledException) { try { context.Logger.Warning("Waiting timed out, killing session..."); diff --git a/Agent/Phantom.Agent.Services/Instances/States/IInstanceState.cs b/Agent/Phantom.Agent.Services/Instances/States/IInstanceState.cs deleted file mode 100644 index 6acc25e..0000000 --- a/Agent/Phantom.Agent.Services/Instances/States/IInstanceState.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Phantom.Agent.Services.Instances.States; - -interface IInstanceState { - void Initialize(); - Task<bool> SendCommand(string command, CancellationToken cancellationToken); -} diff --git a/Agent/Phantom.Agent.Services/Instances/States/InstanceNotRunningState.cs b/Agent/Phantom.Agent.Services/Instances/States/InstanceNotRunningState.cs deleted file mode 100644 index 3d009fd..0000000 --- a/Agent/Phantom.Agent.Services/Instances/States/InstanceNotRunningState.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Phantom.Agent.Services.Instances.States; - -sealed class InstanceNotRunningState : IInstanceState { - public void Initialize() {} - - public Task<bool> SendCommand(string command, CancellationToken cancellationToken) { - return Task.FromResult(false); - } -} diff --git a/Agent/Phantom.Agent.Services/Rpc/ControllerMessageHandlerActor.cs b/Agent/Phantom.Agent.Services/Rpc/ControllerMessageHandlerActor.cs index 91e4cc9..c89b893 100644 --- a/Agent/Phantom.Agent.Services/Rpc/ControllerMessageHandlerActor.cs +++ b/Agent/Phantom.Agent.Services/Rpc/ControllerMessageHandlerActor.cs @@ -1,4 +1,5 @@ -using Phantom.Common.Data.Instance; +using Phantom.Agent.Services.Instances; +using Phantom.Common.Data.Instance; using Phantom.Common.Data.Replies; using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent.BiDirectional; @@ -57,7 +58,7 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent connection.SetIsReady(); await connection.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All)); - await agent.InstanceSessionManager.RefreshAgentStatus(); + agent.InstanceTicketManager.RefreshAgentStatus(); } private void HandleRegisterAgentFailure(RegisterAgentFailureMessage message) { @@ -74,7 +75,7 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent } private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) { - return agent.InstanceSessionManager.Configure(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus); + return agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus)); } private async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) { @@ -82,15 +83,15 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent } private async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { - return await agent.InstanceSessionManager.Launch(message.InstanceGuid); + return await agent.InstanceManager.Request(new InstanceManagerActor.LaunchInstanceCommand(message.InstanceGuid)); } private async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) { - return await agent.InstanceSessionManager.Stop(message.InstanceGuid, message.StopStrategy); + return await agent.InstanceManager.Request(new InstanceManagerActor.StopInstanceCommand(message.InstanceGuid, message.StopStrategy)); } private async Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { - return await agent.InstanceSessionManager.SendCommand(message.InstanceGuid, message.Command); + return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command)); } private void HandleReply(ReplyMessage message) { diff --git a/Agent/Phantom.Agent/Program.cs b/Agent/Phantom.Agent/Program.cs index e4156a9..0543439 100644 --- a/Agent/Phantom.Agent/Program.cs +++ b/Agent/Phantom.Agent/Program.cs @@ -58,10 +58,8 @@ try { var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks), new ControllerConnection(rpcSocket.Connection)); await agentServices.Initialize(); - using var actorSystem = ActorSystemFactory.Create("Agent"); - var rpcMessageHandlerInit = new ControllerMessageHandlerActor.Init(rpcSocket.Connection, agentServices, shutdownCancellationTokenSource); - var rpcMessageHandlerActor = actorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler"); + var rpcMessageHandlerActor = agentServices.ActorSystem.ActorOf(ControllerMessageHandlerActor.Factory(rpcMessageHandlerInit), "ControllerMessageHandler"); var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1); var rpcTask = RpcClientRuntime.Launch(rpcSocket, rpcMessageHandlerActor, rpcDisconnectSemaphore, shutdownCancellationToken); diff --git a/Common/Phantom.Common.Data/Instance/IInstanceStatus.cs b/Common/Phantom.Common.Data/Instance/IInstanceStatus.cs index dafadec..b1d937e 100644 --- a/Common/Phantom.Common.Data/Instance/IInstanceStatus.cs +++ b/Common/Phantom.Common.Data/Instance/IInstanceStatus.cs @@ -52,6 +52,18 @@ public static class InstanceStatus { public static IInstanceStatus Invalid(string reason) => new InstanceIsInvalid(reason); public static IInstanceStatus Downloading(byte progress) => new InstanceIsDownloading(progress); public static IInstanceStatus Failed(InstanceLaunchFailReason reason) => new InstanceIsFailed(reason); + + public static bool IsLaunching(this IInstanceStatus status) { + return status is InstanceIsDownloading or InstanceIsLaunching or InstanceIsRestarting; + } + + public static bool IsRunning(this IInstanceStatus status) { + return status is InstanceIsRunning; + } + + public static bool IsStopping(this IInstanceStatus status) { + return status is InstanceIsStopping; + } public static bool CanLaunch(this IInstanceStatus status) { return status is InstanceIsNotRunning or InstanceIsFailed; diff --git a/Common/Phantom.Common.Data/Instance/InstanceLaunchFailReason.cs b/Common/Phantom.Common.Data/Instance/InstanceLaunchFailReason.cs index 149f43c..80cc5ae 100644 --- a/Common/Phantom.Common.Data/Instance/InstanceLaunchFailReason.cs +++ b/Common/Phantom.Common.Data/Instance/InstanceLaunchFailReason.cs @@ -2,10 +2,6 @@ public enum InstanceLaunchFailReason : byte { UnknownError = 0, - ServerPortNotAllowed = 1, - ServerPortAlreadyInUse = 2, - RconPortNotAllowed = 3, - RconPortAlreadyInUse = 4, JavaRuntimeNotFound = 5, CouldNotDownloadMinecraftServer = 6, CouldNotConfigureMinecraftServer = 7, diff --git a/Common/Phantom.Common.Data/Replies/LaunchInstanceResult.cs b/Common/Phantom.Common.Data/Replies/LaunchInstanceResult.cs index d5ee83d..d81322b 100644 --- a/Common/Phantom.Common.Data/Replies/LaunchInstanceResult.cs +++ b/Common/Phantom.Common.Data/Replies/LaunchInstanceResult.cs @@ -5,5 +5,9 @@ public enum LaunchInstanceResult : byte { InstanceAlreadyLaunching = 2, InstanceAlreadyRunning = 3, InstanceLimitExceeded = 4, - MemoryLimitExceeded = 5 + MemoryLimitExceeded = 5, + ServerPortNotAllowed = 6, + ServerPortAlreadyInUse = 7, + RconPortNotAllowed = 8, + RconPortAlreadyInUse = 9 } diff --git a/Common/Phantom.Common.Data/Replies/SendCommandToInstanceResult.cs b/Common/Phantom.Common.Data/Replies/SendCommandToInstanceResult.cs index 34c86bd..57292ac 100644 --- a/Common/Phantom.Common.Data/Replies/SendCommandToInstanceResult.cs +++ b/Common/Phantom.Common.Data/Replies/SendCommandToInstanceResult.cs @@ -1,6 +1,7 @@ namespace Phantom.Common.Data.Replies; public enum SendCommandToInstanceResult : byte { - UnknownError, - Success + UnknownError = 0, + Success = 1, + InstanceNotRunning = 2 } diff --git a/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs index 59b94c8..597e99d 100644 --- a/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs +++ b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs @@ -100,7 +100,7 @@ public sealed class UserRepository { user.PasswordHash = UserPasswords.Hash(password); - return Result.Ok<SetUserPasswordError>(); + return Result.Ok; } public void DeleteUser(UserEntity user) { diff --git a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj index 7eaaadf..167c6af 100644 --- a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj +++ b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj @@ -15,7 +15,6 @@ <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Actor\Phantom.Utils.Actor.csproj" /> - <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> <ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" /> <ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" /> </ItemGroup> diff --git a/Utils/Phantom.Utils.Actor/ActorRef.cs b/Utils/Phantom.Utils.Actor/ActorRef.cs index 89444a0..259d9b8 100644 --- a/Utils/Phantom.Utils.Actor/ActorRef.cs +++ b/Utils/Phantom.Utils.Actor/ActorRef.cs @@ -29,6 +29,10 @@ public readonly struct ActorRef<TMessage> { return Request(message, timeout: null, cancellationToken); } + public Task<bool> Stop(TMessage message, TimeSpan? timeout = null) { + return actorRef.GracefulStop(timeout ?? Timeout.InfiniteTimeSpan, message); + } + public Task<bool> Stop(TimeSpan? timeout = null) { return actorRef.GracefulStop(timeout ?? Timeout.InfiniteTimeSpan); } diff --git a/Utils/Phantom.Utils/Processes/Process.cs b/Utils/Phantom.Utils/Processes/Process.cs index 5c42370..cdb598d 100644 --- a/Utils/Phantom.Utils/Processes/Process.cs +++ b/Utils/Phantom.Utils/Processes/Process.cs @@ -37,8 +37,8 @@ public sealed class Process : IDisposable { // https://github.com/dotnet/runtime/issues/81896 if (OperatingSystem.IsWindows()) { - Task.Factory.StartNew(ReadStandardOutputSynchronously, TaskCreationOptions.LongRunning); - Task.Factory.StartNew(ReadStandardErrorSynchronously, TaskCreationOptions.LongRunning); + Task.Factory.StartNew(ReadStandardOutputSynchronously, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + Task.Factory.StartNew(ReadStandardErrorSynchronously, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); } else { this.wrapped.BeginOutputReadLine(); @@ -79,7 +79,11 @@ public sealed class Process : IDisposable { } public Task WaitForExitAsync(CancellationToken cancellationToken) { - return wrapped.WaitForExitAsync(cancellationToken); + try { + return wrapped.WaitForExitAsync(cancellationToken); + } catch (InvalidOperationException) { + return Task.CompletedTask; + } } public void Kill(bool entireProcessTree = false) { diff --git a/Utils/Phantom.Utils/Tasks/Result.cs b/Utils/Phantom.Utils/Tasks/Result.cs index eba7082..4988ef4 100644 --- a/Utils/Phantom.Utils/Tasks/Result.cs +++ b/Utils/Phantom.Utils/Tasks/Result.cs @@ -41,14 +41,16 @@ public abstract record Result<TError> { public static implicit operator Result<TError>(TError error) { return new Fail(error); } + + public static implicit operator Result<TError>(Result.OkType _) { + return new Ok(); + } public static implicit operator bool(Result<TError> result) { return result is Ok; } public sealed record Ok : Result<TError> { - internal static Ok Instance { get; } = new (); - public override TError Error { get => throw new InvalidOperationException("Attempted to get error from Ok result."); init {} @@ -59,19 +61,7 @@ public abstract record Result<TError> { } public static class Result { - public static Result<TError> Ok<TError>() { - return Result<TError>.Ok.Instance; - } - - public static Result<TError> Fail<TError>(TError error) { - return new Result<TError>.Fail(error); - } - - public static Result<TValue, TError> Ok<TValue, TError>(TValue value) { - return new Result<TValue, TError>.Ok(value); - } - - public static Result<TValue, TError> Fail<TValue, TError>(TError error) { - return new Result<TValue, TError>.Fail(error); - } + public static OkType Ok { get; } = new (); + + public readonly record struct OkType; } diff --git a/Web/Phantom.Web/Pages/Setup.razor b/Web/Phantom.Web/Pages/Setup.razor index a4a730c..62a52ed 100644 --- a/Web/Phantom.Web/Pages/Setup.razor +++ b/Web/Phantom.Web/Pages/Setup.razor @@ -90,7 +90,7 @@ private async Task<Result<string>> CreateOrUpdateAdministrator() { var reply = await ControllerConnection.Send<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUserMessage(form.Username, form.Password), Timeout.InfiniteTimeSpan); return reply switch { - Success => Result.Ok<string>(), + Success => Result.Ok, CreationFailed fail => fail.Error.ToSentences("\n"), UpdatingFailed fail => fail.Error.ToSentences("\n"), AddingToRoleFailed => "Could not assign administrator role to user.", diff --git a/Web/Phantom.Web/Utils/Messages.cs b/Web/Phantom.Web/Utils/Messages.cs index 46a23d4..ab2681d 100644 --- a/Web/Phantom.Web/Utils/Messages.cs +++ b/Web/Phantom.Web/Utils/Messages.cs @@ -57,16 +57,16 @@ static class Messages { LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.", LaunchInstanceResult.InstanceLimitExceeded => "Agent does not have any more available instances.", LaunchInstanceResult.MemoryLimitExceeded => "Agent does not have enough available memory.", + LaunchInstanceResult.ServerPortNotAllowed => "Server port not allowed.", + LaunchInstanceResult.ServerPortAlreadyInUse => "Server port already in use.", + LaunchInstanceResult.RconPortNotAllowed => "Rcon port not allowed.", + LaunchInstanceResult.RconPortAlreadyInUse => "Rcon port already in use.", _ => "Unknown error." }; } public static string ToSentence(this InstanceLaunchFailReason reason) { return reason switch { - InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.", - InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.", - InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.", - InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.", InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.", InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.", InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.", @@ -78,8 +78,9 @@ static class Messages { public static string ToSentence(this SendCommandToInstanceResult reason) { return reason switch { - SendCommandToInstanceResult.Success => "Command sent.", - _ => "Unknown error." + SendCommandToInstanceResult.Success => "Command sent.", + SendCommandToInstanceResult.InstanceNotRunning => "Instance is not running.", + _ => "Unknown error." }; }