Re-implemented server state machine. Cleaned up code
TODO: Handle trigger errors
This commit is contained in:
80
MoonlightServers.Daemon/Abstractions/Server.Console.cs
Normal file
80
MoonlightServers.Daemon/Abstractions/Server.Console.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Text;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
private async Task AttachConsole(string containerId)
|
||||
{
|
||||
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
|
||||
|
||||
var stream = await dockerClient.Containers.AttachContainerAsync(containerId, true,
|
||||
new ContainerAttachParameters()
|
||||
{
|
||||
Stderr = true,
|
||||
Stdin = true,
|
||||
Stdout = true,
|
||||
Stream = true
|
||||
},
|
||||
Cancellation.Token
|
||||
);
|
||||
|
||||
// Reading
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (!Cancellation.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var buffer = new byte[1024];
|
||||
|
||||
var readResult = await stream.ReadOutputAsync(
|
||||
buffer,
|
||||
0,
|
||||
buffer.Length,
|
||||
Cancellation.Token
|
||||
);
|
||||
|
||||
if (readResult.EOF)
|
||||
break;
|
||||
|
||||
var resizedBuffer = new byte[readResult.Count];
|
||||
Array.Copy(buffer, resizedBuffer, readResult.Count);
|
||||
buffer = new byte[buffer.Length];
|
||||
|
||||
var decodedText = Encoding.UTF8.GetString(resizedBuffer);
|
||||
await Console.WriteToOutput(decodedText);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Ignored
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Ignored
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Writing
|
||||
Console.OnInput += async content =>
|
||||
{
|
||||
var contentBuffer = Encoding.UTF8.GetBytes(content);
|
||||
await stream.WriteAsync(contentBuffer, 0, contentBuffer.Length, Cancellation.Token);
|
||||
};
|
||||
}
|
||||
|
||||
private async Task LogToConsole(string message)
|
||||
{
|
||||
await Console.WriteToOutput($"\x1b[38;5;16;48;5;135m\x1b[39m\x1b[1m Moonlight \x1b[0m {message}\n\r");
|
||||
}
|
||||
|
||||
public Task<string[]> GetConsoleMessages()
|
||||
=> Task.FromResult(Console.Messages);
|
||||
}
|
||||
37
MoonlightServers.Daemon/Abstractions/Server.Create.cs
Normal file
37
MoonlightServers.Daemon/Abstractions/Server.Create.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Docker.DotNet;
|
||||
using MoonlightServers.Daemon.Extensions;
|
||||
using MoonlightServers.Daemon.Services;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
private async Task Create()
|
||||
{
|
||||
var dockerImageService = ServiceProvider.GetRequiredService<DockerImageService>();
|
||||
|
||||
// We call an external service for that, as we want to have a central management point of images
|
||||
// for analytics and automatic deletion
|
||||
await dockerImageService.Ensure(Configuration.DockerImage, async message => { await LogToConsole(message); });
|
||||
|
||||
var hostPath = await EnsureRuntimeVolume();
|
||||
|
||||
await LogToConsole("Creating container");
|
||||
|
||||
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
|
||||
|
||||
var parameters = Configuration.ToRuntimeCreateParameters(
|
||||
hostPath: hostPath,
|
||||
containerName: RuntimeContainerName
|
||||
);
|
||||
|
||||
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
|
||||
RuntimeContainerId = container.ID;
|
||||
}
|
||||
|
||||
private async Task ReCreate()
|
||||
{
|
||||
await Destroy();
|
||||
await Create();
|
||||
}
|
||||
}
|
||||
50
MoonlightServers.Daemon/Abstractions/Server.Destroy.cs
Normal file
50
MoonlightServers.Daemon/Abstractions/Server.Destroy.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Docker.DotNet;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
private async Task Destroy()
|
||||
{
|
||||
// Note: This only destroys the container, it doesn't delete any data
|
||||
|
||||
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
|
||||
|
||||
try
|
||||
{
|
||||
var container = await dockerClient.Containers.InspectContainerAsync(
|
||||
RuntimeContainerName
|
||||
);
|
||||
|
||||
if (container.State.Running)
|
||||
{
|
||||
// Stop container when running
|
||||
|
||||
await LogToConsole("Stopping container");
|
||||
|
||||
await dockerClient.Containers.StopContainerAsync(container.ID, new()
|
||||
{
|
||||
WaitBeforeKillSeconds = 30 // TODO: Config
|
||||
});
|
||||
}
|
||||
|
||||
await LogToConsole("Removing container");
|
||||
await dockerClient.Containers.RemoveContainerAsync(container.ID, new());
|
||||
}
|
||||
catch (DockerContainerNotFoundException){}
|
||||
|
||||
// Canceling server tasks & listeners
|
||||
await CancelTasks();
|
||||
|
||||
// and recreating cancellation token
|
||||
Cancellation = new();
|
||||
}
|
||||
|
||||
public async Task CancelTasks()
|
||||
{
|
||||
// Note: This will keep the docker container running, it will just cancel the server cancellation token
|
||||
|
||||
if (!Cancellation.IsCancellationRequested)
|
||||
await Cancellation.CancelAsync();
|
||||
}
|
||||
}
|
||||
166
MoonlightServers.Daemon/Abstractions/Server.Initialize.cs
Normal file
166
MoonlightServers.Daemon/Abstractions/Server.Initialize.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Docker.DotNet.Models;
|
||||
using MoonlightServers.Daemon.Enums;
|
||||
using Stateless;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
// We are expecting a list of running containers, as we don't wont to inspect every possible container just to check if it exists.
|
||||
// If none are provided, we skip the checks. Use this overload if you are creating a new server which didn't exist before
|
||||
public async Task Initialize(IList<ContainerListResponse>? runningContainers = null)
|
||||
{
|
||||
if (runningContainers != null)
|
||||
{
|
||||
var reAttachSuccessful = await ReAttach(runningContainers);
|
||||
|
||||
// If we weren't able to reattach with the current running containers, we initialize the
|
||||
// state machine as offline
|
||||
if(!reAttachSuccessful)
|
||||
await InitializeStateMachine(ServerState.Offline);
|
||||
}
|
||||
else
|
||||
await InitializeStateMachine(ServerState.Offline);
|
||||
|
||||
// And at last we initialize all events, so we can react to certain state changes and outputs.
|
||||
// We need to do this regardless if the server was reattached or not, as it hasn't been initialized yet
|
||||
await InitializeEvents();
|
||||
}
|
||||
|
||||
private Task InitializeStateMachine(ServerState initialState)
|
||||
{
|
||||
StateMachine = new StateMachine<ServerState, ServerTrigger>(initialState);
|
||||
|
||||
// Setup transitions
|
||||
StateMachine.Configure(ServerState.Offline)
|
||||
.Permit(ServerTrigger.Start, ServerState.Starting)
|
||||
.Permit(ServerTrigger.Reinstall, ServerState.Installing);
|
||||
|
||||
StateMachine.Configure(ServerState.Starting)
|
||||
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
|
||||
.Permit(ServerTrigger.NotifyOnline, ServerState.Online)
|
||||
.Permit(ServerTrigger.Stop, ServerState.Stopping)
|
||||
.OnEntryAsync(InternalStart);
|
||||
|
||||
StateMachine.Configure(ServerState.Online)
|
||||
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
|
||||
.Permit(ServerTrigger.Stop, ServerState.Stopping);
|
||||
|
||||
StateMachine.Configure(ServerState.Stopping)
|
||||
.Permit(ServerTrigger.NotifyContainerDied, ServerState.Offline)
|
||||
.Permit(ServerTrigger.Kill, ServerState.Offline)
|
||||
.OnEntryAsync(InternalStop);
|
||||
|
||||
StateMachine.Configure(ServerState.Installing)
|
||||
.Permit(ServerTrigger.NotifyInstallContainerDied, ServerState.Offline);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task InitializeEvents()
|
||||
{
|
||||
Console.OnOutput += async content =>
|
||||
{
|
||||
if (StateMachine.State == ServerState.Starting)
|
||||
{
|
||||
if (Regex.Matches(content, Configuration.OnlineDetection).Count > 0)
|
||||
await StateMachine.FireAsync(ServerTrigger.NotifyOnline);
|
||||
}
|
||||
};
|
||||
|
||||
StateMachine.OnTransitioned(transition =>
|
||||
{
|
||||
Logger.LogInformation(
|
||||
"{source} => {destination} ({trigger})",
|
||||
transition.Source,
|
||||
transition.Destination,
|
||||
transition.Trigger
|
||||
);
|
||||
});
|
||||
|
||||
StateMachine.OnTransitionCompleted(transition =>
|
||||
{
|
||||
Logger.LogInformation("State: {state}", transition.Destination);
|
||||
});
|
||||
|
||||
// Proxy the events so outside subscribes can react to it
|
||||
StateMachine.OnTransitionCompletedAsync(async transition =>
|
||||
{
|
||||
if (OnStateChanged != null)
|
||||
{
|
||||
await OnStateChanged(transition.Destination);
|
||||
}
|
||||
});
|
||||
|
||||
Console.OnOutput += (async message =>
|
||||
{
|
||||
if (OnConsoleOutput != null)
|
||||
{
|
||||
await OnConsoleOutput(message);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#region Reattaching & reattach strategies
|
||||
|
||||
private async Task<bool> ReAttach(IList<ContainerListResponse> runningContainers)
|
||||
{
|
||||
// Docker container names are starting with a / when returned in the docker container list api endpoint,
|
||||
// so we trim it from the name when searching
|
||||
|
||||
var existingRuntimeContainer = runningContainers.FirstOrDefault(
|
||||
x => x.Names.Any(y => y.TrimStart('/') == RuntimeContainerName)
|
||||
);
|
||||
|
||||
if (existingRuntimeContainer != null)
|
||||
{
|
||||
await ReAttachToRuntime(existingRuntimeContainer);
|
||||
return true;
|
||||
}
|
||||
|
||||
var existingInstallContainer = runningContainers.FirstOrDefault(
|
||||
x => x.Names.Any(y => y.TrimStart('/') == InstallationContainerName)
|
||||
);
|
||||
|
||||
if (existingInstallContainer != null)
|
||||
{
|
||||
await ReAttachToInstallation(existingInstallContainer);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ReAttachToRuntime(ContainerListResponse runtimeContainer)
|
||||
{
|
||||
if (runtimeContainer.State == "running")
|
||||
{
|
||||
RuntimeContainerId = runtimeContainer.ID;
|
||||
|
||||
await InitializeStateMachine(ServerState.Online);
|
||||
|
||||
await AttachConsole(runtimeContainer.ID);
|
||||
}
|
||||
else
|
||||
await InitializeStateMachine(ServerState.Offline);
|
||||
}
|
||||
|
||||
private async Task ReAttachToInstallation(ContainerListResponse installationContainer)
|
||||
{
|
||||
if (installationContainer.State == "running")
|
||||
{
|
||||
InstallationContainerId = installationContainer.ID;
|
||||
|
||||
await InitializeStateMachine(ServerState.Installing);
|
||||
|
||||
await AttachConsole(installationContainer.ID);
|
||||
}
|
||||
else
|
||||
await InitializeStateMachine(ServerState.Offline);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
8
MoonlightServers.Daemon/Abstractions/Server.Notify.cs
Normal file
8
MoonlightServers.Daemon/Abstractions/Server.Notify.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using MoonlightServers.Daemon.Enums;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
public async Task NotifyContainerDied() => await StateMachine.FireAsync(ServerTrigger.NotifyContainerDied);
|
||||
}
|
||||
23
MoonlightServers.Daemon/Abstractions/Server.Start.cs
Normal file
23
MoonlightServers.Daemon/Abstractions/Server.Start.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Docker.DotNet;
|
||||
using MoonlightServers.Daemon.Enums;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
public async Task Start() => await StateMachine.FireAsync(ServerTrigger.Start);
|
||||
|
||||
private async Task InternalStart()
|
||||
{
|
||||
await ReCreate();
|
||||
|
||||
await LogToConsole("Starting container");
|
||||
|
||||
// We can disable the null check for the runtime container id, as we set it by calling ReCreate();
|
||||
await AttachConsole(RuntimeContainerId!);
|
||||
|
||||
// Start container
|
||||
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
|
||||
await dockerClient.Containers.StartContainerAsync(RuntimeContainerId, new());
|
||||
}
|
||||
}
|
||||
13
MoonlightServers.Daemon/Abstractions/Server.Stop.cs
Normal file
13
MoonlightServers.Daemon/Abstractions/Server.Stop.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using MoonlightServers.Daemon.Enums;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
public async Task Stop() => await StateMachine.FireAsync(ServerTrigger.Stop);
|
||||
|
||||
private async Task InternalStop()
|
||||
{
|
||||
await Console.WriteToInput($"{Configuration.StopCommand}\n\r");
|
||||
}
|
||||
}
|
||||
55
MoonlightServers.Daemon/Abstractions/Server.Storage.cs
Normal file
55
MoonlightServers.Daemon/Abstractions/Server.Storage.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using MoonCore.Helpers;
|
||||
using MoonlightServers.Daemon.Configuration;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
private async Task<string> EnsureRuntimeVolume()
|
||||
{
|
||||
var appConfiguration = ServiceProvider.GetRequiredService<AppConfiguration>();
|
||||
|
||||
var hostPath = PathBuilder.Dir(
|
||||
appConfiguration.Storage.Volumes,
|
||||
Configuration.Id.ToString()
|
||||
);
|
||||
|
||||
await LogToConsole("Creating storage");
|
||||
|
||||
// TODO: Virtual disk
|
||||
|
||||
// Create volume if missing
|
||||
if (!Directory.Exists(hostPath))
|
||||
Directory.CreateDirectory(hostPath);
|
||||
|
||||
if (hostPath.StartsWith("/"))
|
||||
return hostPath;
|
||||
else
|
||||
return PathBuilder.JoinPaths(Directory.GetCurrentDirectory(), hostPath);
|
||||
|
||||
return hostPath;
|
||||
}
|
||||
|
||||
private async Task<string> EnsureInstallationVolume()
|
||||
{
|
||||
var appConfiguration = ServiceProvider.GetRequiredService<AppConfiguration>();
|
||||
|
||||
var hostPath = PathBuilder.Dir(
|
||||
appConfiguration.Storage.Volumes,
|
||||
Configuration.Id.ToString()
|
||||
);
|
||||
|
||||
await LogToConsole("Creating storage");
|
||||
|
||||
// Create volume if missing
|
||||
if (!Directory.Exists(hostPath))
|
||||
Directory.CreateDirectory(hostPath);
|
||||
|
||||
if (hostPath.StartsWith("/"))
|
||||
return hostPath;
|
||||
else
|
||||
return PathBuilder.JoinPaths(Directory.GetCurrentDirectory(), hostPath);
|
||||
|
||||
return hostPath;
|
||||
}
|
||||
}
|
||||
52
MoonlightServers.Daemon/Abstractions/Server.cs
Normal file
52
MoonlightServers.Daemon/Abstractions/Server.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Docker.DotNet.Models;
|
||||
using MoonlightServers.Daemon.Enums;
|
||||
using MoonlightServers.Daemon.Models;
|
||||
using MoonlightServers.Daemon.Models.Cache;
|
||||
using Stateless;
|
||||
|
||||
namespace MoonlightServers.Daemon.Abstractions;
|
||||
|
||||
public partial class Server
|
||||
{
|
||||
// Exposed configuration/state values
|
||||
public int Id => Configuration.Id;
|
||||
public ServerState State => StateMachine.State;
|
||||
|
||||
// Exposed container names and ids
|
||||
public string RuntimeContainerName { get; private set; }
|
||||
public string? RuntimeContainerId { get; private set; }
|
||||
|
||||
public string InstallationContainerName { get; private set; }
|
||||
public string? InstallationContainerId { get; private set; }
|
||||
|
||||
// Events
|
||||
public event Func<ServerState, Task> OnStateChanged;
|
||||
public event Func<string, Task> OnConsoleOutput;
|
||||
|
||||
// Private stuff
|
||||
|
||||
private readonly ILogger Logger;
|
||||
private readonly IServiceProvider ServiceProvider;
|
||||
private readonly ServerConsole Console;
|
||||
|
||||
private StateMachine<ServerState, ServerTrigger> StateMachine;
|
||||
private ServerConfiguration Configuration;
|
||||
private CancellationTokenSource Cancellation;
|
||||
|
||||
public Server(
|
||||
ILogger logger,
|
||||
IServiceProvider serviceProvider,
|
||||
ServerConfiguration configuration
|
||||
)
|
||||
{
|
||||
Logger = logger;
|
||||
ServiceProvider = serviceProvider;
|
||||
Configuration = configuration;
|
||||
|
||||
Console = new();
|
||||
Cancellation = new();
|
||||
|
||||
RuntimeContainerName = $"moonlight-runtime-{Configuration.Id}";
|
||||
InstallationContainerName = $"moonlight-install-{Configuration.Id}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user