mirror of
				https://github.com/chylex/Minecraft-Phantom-Panel.git
				synced 2025-10-31 11:17:15 +01:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			68459461c4
			...
			820852d096
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 820852d096 | |||
| 94c0af9f64 | |||
| 374bcd21f4 | 
| @@ -15,8 +15,9 @@ 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; } | ||||
|  | ||||
| @@ -27,10 +28,12 @@ public sealed class AgentServices { | ||||
| 		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.InstanceSessionManager = new InstanceSessionManager(ActorSystem, controllerConnection, agentInfo, agentFolders, AgentState, JavaRuntimeRepository, TaskManager, BackupManager); | ||||
| 	} | ||||
|  | ||||
| 	public async Task Initialize() { | ||||
|   | ||||
							
								
								
									
										15
									
								
								Agent/Phantom.Agent.Services/AgentState.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								Agent/Phantom.Agent.Services/AgentState.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| 	} | ||||
| } | ||||
| @@ -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,7 @@ sealed class BackupScheduler : CancellableBackgroundTask { | ||||
| 		} | ||||
| 		 | ||||
| 		try { | ||||
| 			var procedure = new BackupInstanceProcedure(backupManager); | ||||
| 			context.EnqueueProcedure(procedure); | ||||
| 			return await procedure.Result; | ||||
| 			return await context.Actor.Request(new InstanceActor.BackupInstanceCommand(backupManager, CancellationToken.None /* TODO */)); | ||||
| 		} finally { | ||||
| 			backupSemaphore.Release(); | ||||
| 		} | ||||
|   | ||||
| @@ -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)); | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
|   | ||||
| @@ -1,139 +1,150 @@ | ||||
| using Phantom.Agent.Minecraft.Instance; | ||||
| using Phantom.Agent.Minecraft.Launcher; | ||||
| using Phantom.Agent.Minecraft.Server; | ||||
| using Phantom.Agent.Minecraft.Launcher; | ||||
| using Phantom.Agent.Services.Backups; | ||||
| using Phantom.Agent.Services.Instances.Procedures; | ||||
| using Phantom.Agent.Services.Instances.States; | ||||
| 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; | ||||
| using Serilog; | ||||
|  | ||||
| namespace Phantom.Agent.Services.Instances; | ||||
|  | ||||
| sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { | ||||
| 	public readonly record struct Init(Guid InstanceGuid, string ShortName, InstanceServices Services); | ||||
| 	public readonly record struct Init(Guid InstanceGuid, string ShortName, InstanceServices Services, AgentState AgentState); | ||||
|  | ||||
| 	public static Props<ICommand> Factory(Init init) { | ||||
| 		return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume }); | ||||
| 		return Props<ICommand>.Create(() => new InstanceActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); | ||||
| 	} | ||||
|  | ||||
| 	private readonly Guid instanceGuid; | ||||
| 	private readonly InstanceServices services; | ||||
| 	private readonly AgentState agentState; | ||||
| 	 | ||||
| 	private readonly ILogger logger; | ||||
| 	private readonly InstanceContext context; | ||||
|  | ||||
| 	private IInstanceStatus currentStatus = InstanceStatus.NotRunning; | ||||
| 	private IInstanceState currentState = new InstanceNotRunningState(); | ||||
| 	private bool IsRunning => currentState is not InstanceNotRunningState; | ||||
|  | ||||
| 	private InstanceActor(Init init) { | ||||
| 		this.instanceGuid = init.InstanceGuid; | ||||
| 		this.services = init.Services; | ||||
| 		this.agentState = init.AgentState; | ||||
| 		 | ||||
| 		this.logger = PhantomLogger.Create<InstanceActor>(init.ShortName); | ||||
| 		this.context = new InstanceContext(instanceGuid, init.ShortName, logger, services, SelfTyped); | ||||
|  | ||||
| 		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 ReportAndSetStatus(IInstanceStatus status) { | ||||
| 	private void SetAndReportStatus(IInstanceStatus status) { | ||||
| 		currentStatus = status; | ||||
| 		services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, status)); | ||||
| 		ReportCurrentStatus(); | ||||
| 	} | ||||
|  | ||||
| 	private void ReportEvent(IInstanceEvent instanceEvent) { | ||||
| 		services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, instanceGuid, instanceEvent)); | ||||
| 	private void ReportCurrentStatus() { | ||||
| 		agentState.UpdateInstance(new Instance(instanceGuid, currentStatus)); | ||||
| 		services.ControllerConnection.Send(new ReportInstanceStatusMessage(instanceGuid, currentStatus)); | ||||
| 	} | ||||
|  | ||||
| 	private void SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason reason) { | ||||
| 		ReportAndSetStatus(InstanceStatus.Failed(reason)); | ||||
| 		ReportEvent(new InstanceLaunchFailedEvent(reason)); | ||||
| 	} | ||||
| 	private 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); | ||||
| 		 | ||||
| 		currentState = newState; | ||||
| 		currentState.Initialize(); | ||||
| 	} | ||||
| 	 | ||||
| 	public interface ICommand {} | ||||
|  | ||||
| 	public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting, CancellationToken CancellationToken) : ICommand; | ||||
| 	public sealed record ReportInstanceStatusCommand : ICommand; | ||||
| 	 | ||||
| 	public sealed record LaunchInstanceCommand(InstanceConfiguration Configuration, IServerLauncher Launcher, InstanceTicketManager.Ticket Ticket, bool IsRestarting, CancellationToken CancellationToken) : ICommand; | ||||
| 	 | ||||
| 	public sealed record StopInstanceCommand(MinecraftStopStrategy StopStrategy, CancellationToken CancellationToken) : ICommand; | ||||
| 	 | ||||
| 	public sealed record SendCommandToInstanceCommand(string Command, CancellationToken CancellationToken) : ICommand, ICanReply<SendCommandToInstanceResult>; | ||||
| 	 | ||||
| 	public sealed record BackupInstanceCommand(BackupManager BackupManager, CancellationToken CancellationToken) : 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 || currentState is not InstanceRunningState) { | ||||
| 			currentState = await LaunchInstance2(command); | ||||
| 			SetAndReportStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching); | ||||
| 			TransitionState(await InstanceLaunchProcedure.Run(context, command.Configuration, command.Launcher, command.Ticket, SetAndReportStatus, command.CancellationToken)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private async Task<IInstanceState> LaunchInstance2(LaunchInstanceCommand command) { | ||||
| 		ReportAndSetStatus(command.IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching); | ||||
|  | ||||
| 		InstanceLaunchFailReason? failReason = services.PortManager.Reserve(command.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) { | ||||
| 			SetLaunchFailedStatusAndReportEvent(reason); | ||||
| 			return new InstanceNotRunningState(); | ||||
| 	private async Task StopInstance(StopInstanceCommand command) { | ||||
| 		if (currentState is not InstanceRunningState runningState) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		logger.Information("Session starting..."); | ||||
|  | ||||
| 		var newState = await LaunchInstance3(command); | ||||
| 		if (newState is not InstanceRunningState) { | ||||
| 			services.PortManager.Release(command.Configuration); | ||||
| 		IInstanceStatus oldStatus = currentStatus; | ||||
| 		SetAndReportStatus(InstanceStatus.Stopping); | ||||
| 		 | ||||
| 		var newState = await InstanceStopProcedure.Run(context, command.StopStrategy, runningState, SetAndReportStatus, command.CancellationToken); | ||||
| 		if (newState is not null) { | ||||
| 			TransitionState(newState); | ||||
| 		} | ||||
| 		else { | ||||
| 			SetAndReportStatus(oldStatus); | ||||
| 		} | ||||
|  | ||||
| 		return newState; | ||||
| 	} | ||||
|  | ||||
| 	private async Task<IInstanceState> LaunchInstance3(LaunchInstanceCommand command) { | ||||
| 		try { | ||||
| 			InstanceProcess process = await TryLaunchInstance(command.Launcher, command.CancellationToken); | ||||
| 			ReportAndSetStatus(InstanceStatus.Running); | ||||
| 			ReportEvent(InstanceEvent.LaunchSucceeded); | ||||
| 			return new InstanceRunningState(instanceGuid, command.Configuration, command.Launcher, process, context); | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			ReportAndSetStatus(InstanceStatus.NotRunning); | ||||
| 		} catch (LaunchFailureException e) { | ||||
| 			logger.Error(e.LogMessage); | ||||
| 			SetLaunchFailedStatusAndReportEvent(e.Reason); | ||||
| 		} catch (Exception e) { | ||||
| 			logger.Error(e, "Caught exception while launching instance."); | ||||
| 			SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError); | ||||
| 	private async Task<SendCommandToInstanceResult> SendCommandToInstance(SendCommandToInstanceCommand command) { | ||||
| 		if (currentState is InstanceRunningState runningState) { | ||||
| 			return await runningState.SendCommand(command.Command, command.CancellationToken); | ||||
| 		} | ||||
| 		else { | ||||
| 			return SendCommandToInstanceResult.InstanceNotRunning; | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	private async Task<BackupCreationResult> BackupInstance(BackupInstanceCommand command) { | ||||
| 		if (currentState is not InstanceRunningState runningState || runningState.Process.HasEnded) { | ||||
| 			return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning); | ||||
| 		} | ||||
| 		else { | ||||
| 			return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, command.CancellationToken); | ||||
| 		} | ||||
|  | ||||
| 		return new InstanceNotRunningState(); | ||||
| 	} | ||||
|  | ||||
| 	private async Task<InstanceProcess> TryLaunchInstance(IServerLauncher launcher, 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; | ||||
| 				ReportAndSetStatus(InstanceStatus.Downloading(progress)); | ||||
| 			} | ||||
| 	private void HandleProcessEnded(HandleProcessEndedCommand command) { | ||||
| 		if (currentState is InstanceRunningState { Process.HasEnded: true }) { | ||||
| 			SetAndReportStatus(command.Status); | ||||
| 			context.ReportEvent(InstanceEvent.Stopped); | ||||
| 			TransitionState(new InstanceNotRunningState()); | ||||
| 		} | ||||
|  | ||||
| 		return await launcher.Launch(logger, services.LaunchServices, OnDownloadProgress, cancellationToken) switch { | ||||
| 			LaunchResult.Success launchSuccess                  => launchSuccess.Process, | ||||
| 			LaunchResult.InvalidJavaRuntime                     => throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime."), | ||||
| 			LaunchResult.CouldNotDownloadMinecraftServer        => throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server."), | ||||
| 			LaunchResult.CouldNotPrepareMinecraftServerLauncher => throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher."), | ||||
| 			LaunchResult.CouldNotConfigureMinecraftServer       => throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server."), | ||||
| 			LaunchResult.CouldNotStartMinecraftServer           => throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server."), | ||||
| 			_                                                   => throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.") | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	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; | ||||
| 		} | ||||
| 	private async Task Shutdown(ShutdownCommand command) { | ||||
| 		await StopInstance(new StopInstanceCommand(MinecraftStopStrategy.Instant, CancellationToken.None)); | ||||
| 		Context.Stop(Self); | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										12
									
								
								Agent/Phantom.Agent.Services/Instances/InstanceContext.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Agent/Phantom.Agent.Services/Instances/InstanceContext.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -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) { | ||||
| 	public void ReportEvent(IInstanceEvent instanceEvent) { | ||||
| 		Services.ControllerConnection.Send(new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, InstanceGuid, instanceEvent)); | ||||
| 	} | ||||
| } | ||||
| @@ -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(); | ||||
| 	} | ||||
| } | ||||
| @@ -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, InstanceTicketManager InstanceTicketManager, BackupManager BackupManager, LaunchServices LaunchServices); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using System.Diagnostics.CodeAnalysis; | ||||
| using Akka.Actor; | ||||
| using Phantom.Agent.Minecraft.Instance; | ||||
| using Phantom.Agent.Minecraft.Java; | ||||
| using Phantom.Agent.Minecraft.Launcher; | ||||
| @@ -8,12 +8,11 @@ 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.Actor; | ||||
| using Phantom.Utils.IO; | ||||
| using Phantom.Utils.Logging; | ||||
| using Phantom.Utils.Tasks; | ||||
| @@ -24,12 +23,12 @@ 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 ActorSystem actorSystem; | ||||
| 	private readonly AgentState agentState; | ||||
| 	private readonly string basePath; | ||||
|  | ||||
| 	private readonly InstanceServices instanceServices; | ||||
| 	private readonly Dictionary<Guid, Instance> instances = new (); | ||||
| 	private readonly Dictionary<Guid, InstanceInfo> instances = new (); | ||||
|  | ||||
| 	private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); | ||||
| 	private readonly CancellationToken shutdownCancellationToken; | ||||
| @@ -37,19 +36,22 @@ sealed class InstanceSessionManager : IAsyncDisposable { | ||||
|  | ||||
| 	private uint instanceLoggerSequenceId = 0; | ||||
|  | ||||
| 	public InstanceSessionManager(ControllerConnection controllerConnection, AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) { | ||||
| 		this.controllerConnection = controllerConnection; | ||||
| 		this.agentInfo = agentInfo; | ||||
| 	public InstanceSessionManager(ActorSystem actorSystem, ControllerConnection controllerConnection, AgentInfo agentInfo, AgentFolders agentFolders, AgentState agentState, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) { | ||||
| 		this.actorSystem = actorSystem; | ||||
| 		this.agentState = agentState; | ||||
| 		this.actorSystem = actorSystem; | ||||
| 		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); | ||||
| 		var instanceTicketManager = new InstanceTicketManager(agentInfo, controllerConnection); | ||||
|  | ||||
| 		this.instanceServices = new InstanceServices(controllerConnection, taskManager, portManager, backupManager, launchServices); | ||||
| 		this.instanceServices = new InstanceServices(controllerConnection, taskManager, instanceTicketManager, backupManager, launchServices); | ||||
| 	} | ||||
|  | ||||
| 	private sealed record InstanceInfo(Guid Guid, ActorRef<InstanceActor.ICommand> Actor, InstanceConfiguration Configuration, IServerLauncher Launcher); | ||||
| 	 | ||||
| 	private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) { | ||||
| 		try { | ||||
| 			await semaphore.WaitAsync(shutdownCancellationToken); | ||||
| @@ -64,7 +66,7 @@ sealed class InstanceSessionManager : IAsyncDisposable { | ||||
| 	} | ||||
|  | ||||
| 	[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")] | ||||
| 	private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<Instance, Task<T>> func) { | ||||
| 	private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<InstanceInfo, Task<T>> func) { | ||||
| 		return AcquireSemaphoreAndRun(async () => { | ||||
| 			if (instances.TryGetValue(instanceGuid, out var instance)) { | ||||
| 				return InstanceActionResult.Concrete(await func(instance)); | ||||
| @@ -104,19 +106,24 @@ sealed class InstanceSessionManager : IAsyncDisposable { | ||||
| 			}; | ||||
|  | ||||
| 			if (instances.TryGetValue(instanceGuid, out var instance)) { | ||||
| 				await instance.Reconfigure(configuration, launcher, shutdownCancellationToken); | ||||
| 				instances[instanceGuid] = instance with { | ||||
| 					Configuration = configuration, | ||||
| 					Launcher = launcher | ||||
| 				}; | ||||
| 				 | ||||
| 				Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); | ||||
|  | ||||
| 				if (alwaysReportStatus) { | ||||
| 					instance.ReportLastStatus(); | ||||
| 					instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); | ||||
| 				} | ||||
| 			} | ||||
| 			else { | ||||
| 				instances[instanceGuid] = instance = new Instance(instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher); | ||||
| 				var instanceActorInit = new InstanceActor.Init(instanceGuid, GetInstanceLoggerName(instanceGuid), instanceServices, agentState); | ||||
| 				instances[instanceGuid] = instance = new InstanceInfo(instanceGuid, actorSystem.ActorOf(InstanceActor.Factory(instanceActorInit), "Instance-" + instanceGuid), configuration, launcher); | ||||
| 				 | ||||
| 				Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, instanceGuid); | ||||
|  | ||||
| 				instance.ReportLastStatus(); | ||||
| 				instance.IsRunningChanged += OnInstanceIsRunningChanged; | ||||
| 				instance.Actor.Tell(new InstanceActor.ReportInstanceStatusCommand()); | ||||
| 			} | ||||
|  | ||||
| 			if (launchNow) { | ||||
| @@ -132,54 +139,54 @@ sealed class InstanceSessionManager : IAsyncDisposable { | ||||
| 		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; | ||||
| 	private async Task<LaunchInstanceResult> LaunchInternal(InstanceInfo instance) { | ||||
| 		var ticket = instanceServices.InstanceTicketManager.Reserve(instance.Guid, instance.Configuration); | ||||
| 		if (ticket is Result<InstanceTicketManager.Ticket, LaunchInstanceResult>.Fail fail) { | ||||
| 			return fail.Error; | ||||
| 		} | ||||
|  | ||||
| 		var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation); | ||||
| 		if (availableMemory < instance.Configuration.MemoryAllocation) { | ||||
| 			return LaunchInstanceResult.MemoryLimitExceeded; | ||||
| 		if (agentState.InstancesByGuid.TryGetValue(instance.Guid, out var i)) { | ||||
| 			var status = i.Status; | ||||
| 			if (status.IsRunning()) { | ||||
| 				return LaunchInstanceResult.InstanceAlreadyRunning; | ||||
| 			} | ||||
| 			else if (status.IsLaunching()) { | ||||
| 				return LaunchInstanceResult.InstanceAlreadyLaunching; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return await instance.Launch(shutdownCancellationToken); | ||||
| 		 | ||||
| 		// TODO report status? | ||||
| 		instance.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instance.Configuration, instance.Launcher, ticket.Value, false, shutdownCancellationToken)); | ||||
| 		return LaunchInstanceResult.LaunchInitiated; | ||||
| 	} | ||||
|  | ||||
| 	public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { | ||||
| 		return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy, shutdownCancellationToken)); | ||||
| 		return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => { | ||||
| 			if (agentState.InstancesByGuid.TryGetValue(instance.Guid, out var i)) { | ||||
| 				var status = i.Status; | ||||
| 				if (status.IsStopping()) { | ||||
| 					return StopInstanceResult.InstanceAlreadyStopping; | ||||
| 				} | ||||
| 				else if (!status.IsRunning()) { | ||||
| 					return StopInstanceResult.InstanceAlreadyStopped; | ||||
| 				} | ||||
| 			} | ||||
| 			 | ||||
| 			instance.Actor.Tell(new InstanceActor.StopInstanceCommand(stopStrategy, shutdownCancellationToken)); | ||||
| 			return StopInstanceResult.StopInitiated; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { | ||||
| 		return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError); | ||||
| 		return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command, shutdownCancellationToken), shutdownCancellationToken)); | ||||
| 	} | ||||
|  | ||||
| 	public void RefreshAgentStatus() { | ||||
| 		instanceServices.InstanceTicketManager.RefreshAgentStatus(); | ||||
| 	} | ||||
|  | ||||
| 	public async ValueTask DisposeAsync() { | ||||
| @@ -188,10 +195,12 @@ sealed class InstanceSessionManager : IAsyncDisposable { | ||||
| 		shutdownCancellationTokenSource.Cancel(); | ||||
|  | ||||
| 		await semaphore.WaitAsync(CancellationToken.None); | ||||
| 		await Task.WhenAll(instances.Values.Select(static instance => instance.DisposeAsync().AsTask())); | ||||
| 		await Task.WhenAll(instances.Values.Select(static instance => instance.Actor.Stop(new InstanceActor.ShutdownCommand()))); | ||||
| 		instances.Clear(); | ||||
| 		 | ||||
| 		shutdownCancellationTokenSource.Dispose(); | ||||
| 		semaphore.Dispose(); | ||||
| 		 | ||||
| 		Logger.Information("All instances stopped."); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,86 @@ | ||||
| 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.Tasks; | ||||
|  | ||||
| namespace Phantom.Agent.Services.Instances; | ||||
|  | ||||
| sealed class InstanceTicketManager { | ||||
| 	private readonly AgentInfo agentInfo; | ||||
| 	private readonly ControllerConnection controllerConnection; | ||||
| 	 | ||||
| 	private readonly HashSet<Guid> runningInstanceGuids = 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(Guid instanceGuid, 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 (runningInstanceGuids.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; | ||||
| 			} | ||||
|  | ||||
| 			runningInstanceGuids.Add(instanceGuid); | ||||
| 			usedMemory += memoryAllocation; | ||||
| 			usedPorts.Add(serverPort); | ||||
| 			usedPorts.Add(rconPort); | ||||
| 			 | ||||
| 			RefreshAgentStatus(); | ||||
| 			 | ||||
| 			return new Ticket(instanceGuid, memoryAllocation, serverPort, rconPort); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void Release(Ticket ticket) { | ||||
| 		lock (this) { | ||||
| 			if (!runningInstanceGuids.Remove(ticket.InstanceGuid)) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			usedMemory -= ticket.MemoryAllocation; | ||||
| 			usedPorts.Remove(ticket.ServerPort); | ||||
| 			usedPorts.Remove(ticket.RconPort); | ||||
| 			 | ||||
| 			RefreshAgentStatus(); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public void RefreshAgentStatus() { | ||||
| 		lock (this) { | ||||
| 			controllerConnection.Send(new ReportAgentStatusMessage(runningInstanceGuids.Count, usedMemory)); | ||||
| 		} | ||||
| 	} | ||||
| 	 | ||||
| 	public sealed record Ticket(Guid InstanceGuid, RamAllocationUnits MemoryAllocation, ushort ServerPort, ushort RconPort); | ||||
| } | ||||
| @@ -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 | ||||
| 	} | ||||
| } | ||||
| @@ -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; | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| 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; | ||||
| using Phantom.Utils.Tasks; | ||||
|  | ||||
| namespace Phantom.Agent.Services.Instances.Procedures; | ||||
|  | ||||
| static class InstanceLaunchProcedure { | ||||
| 	public static async Task<IInstanceState> Run(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { | ||||
| 		context.Logger.Information("Session starting..."); | ||||
|  | ||||
| 		var newState = await TryLaunchInstance(context, configuration, launcher, ticket, reportStatus, cancellationToken); | ||||
| 		if (newState is not InstanceRunningState) { | ||||
| 			context.Services.InstanceTicketManager.Release(ticket); | ||||
| 		} | ||||
| 		 | ||||
| 		return newState; | ||||
| 	} | ||||
|  | ||||
| 	private static async Task<IInstanceState> TryLaunchInstance(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, Action<IInstanceStatus> reportStatus, CancellationToken cancellationToken) { | ||||
| 		Result<InstanceProcess, InstanceLaunchFailReason> result; | ||||
| 		 | ||||
| 		try { | ||||
| 			result = await LaunchInstanceImpl(context, launcher, reportStatus, cancellationToken); | ||||
| 		} catch (OperationCanceledException) { | ||||
| 			reportStatus(InstanceStatus.NotRunning); | ||||
| 			return new InstanceNotRunningState(); | ||||
| 		} catch (Exception e) { | ||||
| 			context.Logger.Error(e, "Caught exception while launching instance."); | ||||
| 			result = InstanceLaunchFailReason.UnknownError; | ||||
| 		} | ||||
|  | ||||
| 		if (result) { | ||||
| 			reportStatus(InstanceStatus.Running); | ||||
| 			context.ReportEvent(InstanceEvent.LaunchSucceeded); | ||||
| 			return new InstanceRunningState(context, configuration, launcher, ticket, result.Value); | ||||
| 		} | ||||
| 		else { | ||||
| 			reportStatus(InstanceStatus.Failed(result.Error)); | ||||
| 			context.ReportEvent(new InstanceLaunchFailedEvent(result.Error)); | ||||
| 			return new InstanceNotRunningState(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private static async Task<Result<InstanceProcess, InstanceLaunchFailReason>> LaunchInstanceImpl(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; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -7,20 +7,14 @@ using Phantom.Common.Data.Minecraft; | ||||
| 
 | ||||
| namespace Phantom.Agent.Services.Instances.Procedures; | ||||
| 
 | ||||
| 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<IInstanceState?> 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); | ||||
| @@ -38,14 +32,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(); | ||||
| 	} | ||||
| 
 | ||||
| 	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 +60,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 +68,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,7 +81,7 @@ sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInsta | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	private async Task WaitForSessionToEnd(IInstanceContext context, InstanceProcess process) { | ||||
| 	private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) { | ||||
| 		using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55)); | ||||
| 		try { | ||||
| 			await process.WaitForExit(timeout.Token); | ||||
| @@ -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; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -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); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @@ -2,5 +2,4 @@ | ||||
|  | ||||
| interface IInstanceState { | ||||
| 	void Initialize(); | ||||
| 	Task<bool> SendCommand(string command, CancellationToken cancellationToken); | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| 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; | ||||
|  | ||||
| @@ -12,26 +12,26 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { | ||||
|  | ||||
| 	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 InstanceTicketManager.Ticket ticket; | ||||
|  | ||||
| 	private readonly InstanceLogSender logSender; | ||||
| 	private readonly BackupScheduler backupScheduler; | ||||
|  | ||||
| 	private bool isDisposed; | ||||
|  | ||||
| 	public InstanceRunningState(Guid instanceGuid, InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, InstanceServices services) { | ||||
| 		this.instanceGuid = instanceGuid; | ||||
| 	public InstanceRunningState(InstanceContext context, InstanceConfiguration configuration, IServerLauncher launcher, InstanceTicketManager.Ticket ticket, InstanceProcess process) { | ||||
| 		this.context = context; | ||||
| 		this.configuration = configuration; | ||||
| 		this.launcher = launcher; | ||||
| 		this.context = context; | ||||
| 		this.ticket = ticket; | ||||
| 		this.Process = process; | ||||
|  | ||||
| 		this.logSender = new InstanceLogSender(services.ControllerConnection, services.TaskManager, instanceGuid, context.ShortName); | ||||
| 		this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.Services.TaskManager, context.InstanceGuid, context.ShortName); | ||||
|  | ||||
| 		this.backupScheduler = new BackupScheduler(services.TaskManager, services.BackupManager, process, context, configuration.ServerPort); | ||||
| 		this.backupScheduler = new BackupScheduler(context, process, configuration.ServerPort); | ||||
| 		this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; | ||||
| 	} | ||||
|  | ||||
| @@ -41,7 +41,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 { | ||||
| @@ -61,12 +61,12 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { | ||||
| 		} | ||||
|  | ||||
| 		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, CancellationToken.None /* TODO */)); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @@ -74,16 +74,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 +100,7 @@ sealed class InstanceRunningState : IInstanceState, IDisposable { | ||||
| 		backupScheduler.Stop(); | ||||
| 		 | ||||
| 		Process.Dispose(); | ||||
| 		context.Services.PortManager.Release(configuration); | ||||
| 		context.Services.InstanceTicketManager.Release(ticket); | ||||
| 		 | ||||
| 		return true; | ||||
| 	} | ||||
|   | ||||
| @@ -57,7 +57,7 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent | ||||
| 		connection.SetIsReady(); | ||||
| 		 | ||||
| 		await connection.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All)); | ||||
| 		await agent.InstanceSessionManager.RefreshAgentStatus(); | ||||
| 		agent.InstanceSessionManager.RefreshAgentStatus(); | ||||
| 	} | ||||
|  | ||||
| 	private void HandleRegisterAgentFailure(RegisterAgentFailureMessage message) { | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -2,10 +2,6 @@ | ||||
|  | ||||
| public enum InstanceLaunchFailReason : byte { | ||||
| 	UnknownError                           = 0, | ||||
| 	ServerPortNotAllowed                   = 1, | ||||
| 	ServerPortAlreadyInUse                 = 2, | ||||
| 	RconPortNotAllowed                     = 3, | ||||
| 	RconPortAlreadyInUse                   = 4, | ||||
| 	JavaRuntimeNotFound                    = 5, | ||||
| 	CouldNotDownloadMinecraftServer        = 6, | ||||
| 	CouldNotConfigureMinecraftServer       = 7, | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| namespace Phantom.Common.Data.Replies; | ||||
|  | ||||
| public enum SendCommandToInstanceResult : byte { | ||||
| 	UnknownError, | ||||
| 	Success | ||||
| 	UnknownError = 0, | ||||
| 	Success = 1, | ||||
| 	InstanceNotRunning = 2 | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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); | ||||
| 	} | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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.", | ||||
|   | ||||
| @@ -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." | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user