Re-implemented server state machine. Cleaned up code

TODO: Handle trigger errors
This commit is contained in:
2025-02-12 23:02:00 +01:00
parent 4088bfaef5
commit f45699f300
44 changed files with 913 additions and 831 deletions

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

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

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

View 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
}

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

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

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

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

View 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}";
}
}