Refactored/recreated server system. Seperated into sub systems. Still wip

This commit is contained in:
2025-05-29 21:56:38 +02:00
parent f2771acb49
commit b955bd3527
32 changed files with 1642 additions and 1174 deletions

View File

@@ -1,80 +0,0 @@
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\x1b[38;5;250m\x1b[3m {message}\x1b[0m\n\r");
}
public Task<string[]> GetConsoleMessages()
=> Task.FromResult(Console.Messages);
}

View File

@@ -1,41 +0,0 @@
using Docker.DotNet;
using Docker.DotNet.Models;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task InternalCrash()
{
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
ContainerInspectResponse? container;
try
{
container = await dockerClient.Containers.InspectContainerAsync(RuntimeContainerId);
}
catch (DockerContainerNotFoundException)
{
container = null;
}
if(container == null)
return;
var exitCode = container.State.ExitCode;
// TODO: Report to panel
await LogToConsole($"Server crashed. Exit code: {exitCode}");
await Destroy();
}
public async Task InternalError()
{
await LogToConsole("An unhandled error occured performing action");
// TODO:
Logger.LogInformation("Reporting or smth");
}
}

View File

@@ -1,38 +0,0 @@
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); });
await EnsureRuntimeVolume();
await LogToConsole("Creating container");
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
var parameters = Configuration.ToRuntimeCreateParameters(
appConfiguration: AppConfiguration,
hostPath: RuntimeVolumePath,
containerName: RuntimeContainerName
);
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
RuntimeContainerId = container.ID;
}
private async Task ReCreate()
{
await Destroy();
await Create();
}
}

View File

@@ -1,58 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Configuration;
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 = (uint)AppConfiguration.Server.WaitBeforeKillSeconds
});
}
await LogToConsole("Removing container");
await dockerClient.Containers.RemoveContainerAsync(container.ID, new());
RuntimeContainerId = null;
}
catch (DockerContainerNotFoundException){}
// Canceling server tasks & listeners and start new ones
await ResetTasks();
}
public async Task ResetTasks()
{
// Note: This will keep the docker container running, it will just cancel the server cancellation token
// and recreate the token
await CancelTasks();
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

@@ -1,190 +0,0 @@
using System.Text.RegularExpressions;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions;
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);
// Now 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();
// Load storage configuration
await InitializeStorage();
}
private Task InitializeStateMachine(ServerState initialState)
{
StateMachine = new StateMachine<ServerState, ServerTrigger>(initialState);
// Setup transitions
StateMachine.Configure(ServerState.Offline)
.Permit(ServerTrigger.Start, ServerState.Starting) // Allow to start
.Permit(ServerTrigger.Reinstall, ServerState.Installing) // Allow to install
.OnEntryFromAsync(ServerTrigger.NotifyInternalError, InternalError); // Handle unhandled errors
StateMachine.Configure(ServerState.Starting)
.Permit(ServerTrigger.Stop, ServerState.Stopping) // Allow stopping
.Permit(ServerTrigger.Kill, ServerState.Stopping) // Allow killing while starting
.Permit(ServerTrigger.NotifyOnline, ServerState.Online) // Allow the server to report as online
.Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death
.Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the action below
.OnEntryAsync(InternalStart) // Perform start action
.OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); // Define a runtime container death as a crash
StateMachine.Configure(ServerState.Online)
.Permit(ServerTrigger.Stop, ServerState.Stopping) // Allow stopping
.Permit(ServerTrigger.Kill, ServerState.Stopping) // Allows killing
.Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death
.OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalCrash); // Define a runtime container death as a crash
StateMachine.Configure(ServerState.Stopping)
.PermitReentry(ServerTrigger.Kill) // Allow killing, will return to stopping to trigger kill and handle the death correctly
.Permit(ServerTrigger.NotifyRuntimeContainerDied, ServerState.Offline) // Allow server to handle container death
.Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the actions below
.OnEntryFromAsync(ServerTrigger.Stop, InternalStop) // Perform stop action
.OnEntryFromAsync(ServerTrigger.Kill, InternalKill) // Perform kill action
.OnExitFromAsync(ServerTrigger.NotifyRuntimeContainerDied, InternalFinishStop); // Define a runtime container death as a successful stop
StateMachine.Configure(ServerState.Installing)
.Permit(ServerTrigger.NotifyInstallationContainerDied, ServerState.Offline) // Allow server to handle container death
.Permit(ServerTrigger.NotifyInternalError, ServerState.Offline) // Error handling for the action below
.OnEntryAsync(InternalInstall) // Perform install action
.OnExitFromAsync(ServerTrigger.NotifyInstallationContainerDied, InternalFinishInstall); // Define the death of the installation container as successful
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.LogDebug(
"{source} => {destination} ({trigger})",
transition.Source,
transition.Destination,
transition.Trigger
);
});
StateMachine.OnTransitionCompleted(transition =>
{
Logger.LogDebug("State: {state}", transition.Destination);
});
// Proxy the events so outside subscribes can react to it and notify websockets
StateMachine.OnTransitionCompletedAsync(async transition =>
{
// Notify all clients interested in the server
await WebSocketHub.Clients
.Group(Id.ToString()) //TODO: Consider saving the string value in memory
.SendAsync("StateChanged", transition.Destination.ToString());
// Notify all external listeners
if (OnStateChanged != null)
await OnStateChanged(transition.Destination);
});
Console.OnOutput += (async message =>
{
// Notify all clients interested in the server
await WebSocketHub.Clients
.Group(Id.ToString()) //TODO: Consider saving the string value in memory
.SendAsync("ConsoleOutput", 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

@@ -1,132 +0,0 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Helpers;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task Install() => await StateMachine.FireAsync(ServerTrigger.Reinstall);
private async Task InternalInstall()
{
try
{
// TODO: Consider if checking for existing install containers is actually useful, because
// when the daemon is starting and a installation is still ongoing it will reattach anyways
// and the container has the auto remove flag enabled by default (maybe also consider this for the normal runtime container)
await LogToConsole("Fetching installation configuration");
// Fetching remote configuration and install config
var remoteService = ServiceProvider.GetRequiredService<RemoteService>();
var installData = await remoteService.GetServerInstallation(Configuration.Id);
var serverData = await remoteService.GetServer(Configuration.Id);
// We are updating the regular server config here as well
// as changes to variables and other settings wouldn't sync otherwise
// because they won't trigger a sync
var serverConfiguration = serverData.ToServerConfiguration();
UpdateConfiguration(serverConfiguration);
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(installData.DockerImage, async message => { await LogToConsole(message); });
// Ensuring storage
await EnsureInstallationVolume();
await EnsureRuntimeVolume();
// Write installation script to path
var content = installData.Script.Replace("\r\n", "\n");
await File.WriteAllTextAsync(PathBuilder.File(InstallationVolumePath, "install.sh"), content);
// Creating container configuration
var parameters = Configuration.ToInstallationCreateParameters(
appConfiguration: AppConfiguration,
RuntimeVolumePath,
InstallationVolumePath,
InstallationContainerName,
installData.DockerImage,
installData.Shell
);
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
// Ensure we can actually spawn the container
try
{
var existingContainer = await dockerClient.Containers.InspectContainerAsync(InstallationContainerName);
// Perform automatic cleanup / restore
if (existingContainer.State.Running)
await dockerClient.Containers.KillContainerAsync(existingContainer.ID, new());
await dockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// Spawn the container
var container = await dockerClient.Containers.CreateContainerAsync(parameters);
InstallationContainerId = container.ID;
await AttachConsole(InstallationContainerId);
await dockerClient.Containers.StartContainerAsync(InstallationContainerId, new());
}
catch (Exception e)
{
Logger.LogError("An error occured while performing install trigger: {e}", e);
await StateMachine.FireAsync(ServerTrigger.NotifyInternalError);
}
}
private async Task InternalFinishInstall()
{
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
ContainerInspectResponse? container;
try
{
container = await dockerClient.Containers.InspectContainerAsync(InstallationContainerId, CancellationToken.None);
}
catch (DockerContainerNotFoundException)
{
container = null;
}
if(container == null)
return;
var exitCode = container.State.ExitCode;
await LogToConsole($"Installation finished with exit code: {exitCode}");
if (exitCode != 0)
{
// TODO: Report installation failure
}
await LogToConsole("Removing container");
//await dockerClient.Containers.RemoveContainerAsync(InstallationContainerId, new());
InstallationContainerId = null;
await ResetTasks();
await RemoveInstallationVolume();
}
}

View File

@@ -1,28 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Enums;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task Kill() => await StateMachine.FireAsync(ServerTrigger.Kill);
private async Task InternalKill()
{
try
{
if (RuntimeContainerId == null)
return;
await LogToConsole("Killing container");
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
await dockerClient.Containers.KillContainerAsync(RuntimeContainerId, new());
}
catch (Exception e)
{
Logger.LogError("An error occured while performing stop trigger: {e}", e);
await StateMachine.FireAsync(ServerTrigger.NotifyInternalError);
}
}
}

View File

@@ -1,9 +0,0 @@
using MoonlightServers.Daemon.Enums;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task NotifyRuntimeContainerDied() => await StateMachine.FireAsync(ServerTrigger.NotifyRuntimeContainerDied);
public async Task NotifyInstallationContainerDied() => await StateMachine.FireAsync(ServerTrigger.NotifyInstallationContainerDied);
}

View File

@@ -1,44 +0,0 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public async Task Start() => await StateMachine.FireAsync(ServerTrigger.Start);
private async Task InternalStart()
{
try
{
await LogToConsole("Fetching configuration");
var remoteService = ServiceProvider.GetRequiredService<RemoteService>();
var serverData = await remoteService.GetServer(Configuration.Id);
// We are updating the server config here
// as changes to variables and other settings wouldn't sync otherwise
// because they won't trigger a sync
var serverConfiguration = serverData.ToServerConfiguration();
UpdateConfiguration(serverConfiguration);
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());
}
catch (Exception e)
{
Logger.LogError("An error occured while performing start trigger: {e}", e);
await StateMachine.FireAsync(ServerTrigger.NotifyInternalError);
}
}
}

View File

@@ -1,26 +0,0 @@
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()
{
try
{
await Console.WriteToInput($"{Configuration.StopCommand}\n\r");
}
catch (Exception e)
{
Logger.LogError("An error occured while performing stop trigger: {e}", e);
await StateMachine.FireAsync(ServerTrigger.NotifyInternalError);
}
}
private async Task InternalFinishStop()
{
await Destroy();
}
}

View File

@@ -1,121 +0,0 @@
using MoonCore.Helpers;
using MoonCore.Unix.SecureFs;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers;
namespace MoonlightServers.Daemon.Abstractions;
public partial class Server
{
public ServerFileSystem FileSystem { get; private set; }
private SpinLock FsLock = new();
private SecureFileSystem? InternalFileSystem;
private string RuntimeVolumePath;
private string InstallationVolumePath;
private async Task InitializeStorage()
{
#region Configure paths
var appConfiguration = ServiceProvider.GetRequiredService<AppConfiguration>();
// Runtime
var runtimePath = PathBuilder.Dir(appConfiguration.Storage.Volumes, Configuration.Id.ToString());
if (appConfiguration.Storage.Volumes.StartsWith("/"))
RuntimeVolumePath = runtimePath;
else
RuntimeVolumePath = PathBuilder.Dir(Directory.GetCurrentDirectory(), runtimePath);
// Installation
var installationPath = PathBuilder.Dir(appConfiguration.Storage.Install, Configuration.Id.ToString());
if (appConfiguration.Storage.Install.StartsWith("/"))
InstallationVolumePath = installationPath;
else
InstallationVolumePath = PathBuilder.Dir(Directory.GetCurrentDirectory(), installationPath);
#endregion
await ConnectRuntimeVolume();
}
public async Task DestroyStorage()
{
await DisconnectRuntimeVolume();
}
private async Task ConnectRuntimeVolume()
{
var gotLock = false;
try
{
FsLock.Enter(ref gotLock);
// We want to dispose the old fs if existing, to make sure we wont leave any file descriptors open
if(InternalFileSystem != null && !InternalFileSystem.IsDisposed)
InternalFileSystem.Dispose();
await EnsureRuntimeVolume();
InternalFileSystem = new SecureFileSystem(RuntimeVolumePath);
FileSystem = new ServerFileSystem(InternalFileSystem);
}
finally
{
if(gotLock)
FsLock.Exit();
}
}
private Task DisconnectRuntimeVolume()
{
if(InternalFileSystem != null && !InternalFileSystem.IsDisposed)
InternalFileSystem.Dispose();
return Task.CompletedTask;
}
private Task EnsureRuntimeVolume()
{
if (!Directory.Exists(RuntimeVolumePath))
Directory.CreateDirectory(RuntimeVolumePath);
// TODO: Virtual disk
return Task.CompletedTask;
}
public Task RemoveRuntimeVolume()
{
// Remove volume if existing
if (Directory.Exists(RuntimeVolumePath))
Directory.Delete(RuntimeVolumePath, true);
// TODO: Virtual disk
return Task.CompletedTask;
}
private Task EnsureInstallationVolume()
{
// Create volume if missing
if (!Directory.Exists(InstallationVolumePath))
Directory.CreateDirectory(InstallationVolumePath);
return Task.CompletedTask;
}
public Task RemoveInstallationVolume()
{
// Remove install volume if existing
if (Directory.Exists(InstallationVolumePath))
Directory.Delete(InstallationVolumePath, true);
return Task.CompletedTask;
}
}

View File

@@ -1,65 +0,0 @@
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Http.Hubs;
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 readonly IHubContext<ServerWebSocketHub> WebSocketHub;
private StateMachine<ServerState, ServerTrigger> StateMachine;
private ServerConfiguration Configuration;
private CancellationTokenSource Cancellation;
private AppConfiguration AppConfiguration;
public Server(
ILogger logger,
IServiceProvider serviceProvider,
ServerConfiguration configuration,
IHubContext<ServerWebSocketHub> webSocketHub,
AppConfiguration appConfiguration
)
{
Logger = logger;
ServiceProvider = serviceProvider;
Configuration = configuration;
WebSocketHub = webSocketHub;
AppConfiguration = appConfiguration;
Console = new(AppConfiguration.Server.ConsoleMessageCacheLimit);
Cancellation = new();
RuntimeContainerName = $"moonlight-runtime-{Configuration.Id}";
InstallationContainerName = $"moonlight-install-{Configuration.Id}";
}
public void UpdateConfiguration(ServerConfiguration configuration)
=> Configuration = configuration;
}

View File

@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers; namespace MoonlightServers.Daemon.Http.Controllers.Servers;
@@ -24,12 +24,21 @@ public class DownloadController : Controller
var serverId = int.Parse(User.Claims.First(x => x.Type == "serverId").Value); var serverId = int.Parse(User.Claims.First(x => x.Type == "serverId").Value);
var path = User.Claims.First(x => x.Type == "path").Value; var path = User.Claims.First(x => x.Type == "path").Value;
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Read(path, var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
async dataStream => { await Results.File(dataStream).ExecuteAsync(HttpContext); });
var fileSystem = await storageSubSystem.GetFileSystem();
await fileSystem.Read(
path,
async dataStream =>
{
await Results.File(dataStream).ExecuteAsync(HttpContext);
}
);
} }
} }

View File

@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.DaemonSide.Http.Requests; using MoonlightServers.DaemonShared.DaemonSide.Http.Requests;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers;
@@ -22,56 +24,41 @@ public class ServerFileSystemController : Controller
[HttpGet("{id:int}/files/list")] [HttpGet("{id:int}/files/list")]
public async Task<ServerFileSystemResponse[]> List([FromRoute] int id, [FromQuery] string path = "") public async Task<ServerFileSystemResponse[]> List([FromRoute] int id, [FromQuery] string path = "")
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) return await fileSystem.List(path);
throw new HttpApiException("No server with this id found", 404);
return await server.FileSystem.List(path);
} }
[HttpPost("{id:int}/files/move")] [HttpPost("{id:int}/files/move")]
public async Task Move([FromRoute] int id, [FromQuery] string oldPath, [FromQuery] string newPath) public async Task Move([FromRoute] int id, [FromQuery] string oldPath, [FromQuery] string newPath)
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) await fileSystem.Move(oldPath, newPath);
throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Move(oldPath, newPath);
} }
[HttpDelete("{id:int}/files/delete")] [HttpDelete("{id:int}/files/delete")]
public async Task Delete([FromRoute] int id, [FromQuery] string path) public async Task Delete([FromRoute] int id, [FromQuery] string path)
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) await fileSystem.Delete(path);
throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Delete(path);
} }
[HttpPost("{id:int}/files/mkdir")] [HttpPost("{id:int}/files/mkdir")]
public async Task Mkdir([FromRoute] int id, [FromQuery] string path) public async Task Mkdir([FromRoute] int id, [FromQuery] string path)
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) await fileSystem.Mkdir(path);
throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Mkdir(path);
} }
[HttpPost("{id:int}/files/compress")] [HttpPost("{id:int}/files/compress")]
public async Task Compress([FromRoute] int id, [FromBody] ServerFilesCompressRequest request) public async Task Compress([FromRoute] int id, [FromBody] ServerFilesCompressRequest request)
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) await fileSystem.Compress(
throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Compress(
request.Items, request.Items,
request.Destination, request.Destination,
request.Type request.Type
@@ -81,15 +68,24 @@ public class ServerFileSystemController : Controller
[HttpPost("{id:int}/files/decompress")] [HttpPost("{id:int}/files/decompress")]
public async Task Decompress([FromRoute] int id, [FromBody] ServerFilesDecompressRequest request) public async Task Decompress([FromRoute] int id, [FromBody] ServerFilesDecompressRequest request)
{ {
var server = ServerService.GetServer(id); var fileSystem = await GetFileSystemById(id);
if (server == null) await fileSystem.Decompress(
throw new HttpApiException("No server with this id found", 404);
await server.FileSystem.Decompress(
request.Path, request.Path,
request.Destination, request.Destination,
request.Type request.Type
); );
} }
private async Task<ServerFileSystem> GetFileSystemById(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
return await storageSubSystem.GetFileSystem();
}
} }

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonlightServers.Daemon.Enums; using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using ServerTrigger = MoonlightServers.Daemon.ServerSystem.ServerTrigger;
namespace MoonlightServers.Daemon.Http.Controllers.Servers; namespace MoonlightServers.Daemon.Http.Controllers.Servers;
@@ -21,44 +22,44 @@ public class ServerPowerController : Controller
[HttpPost("{serverId:int}/start")] [HttpPost("{serverId:int}/start")]
public async Task Start(int serverId) public async Task Start(int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
await server.Start(); await server.Trigger(ServerTrigger.Start);
} }
[HttpPost("{serverId:int}/stop")] [HttpPost("{serverId:int}/stop")]
public async Task Stop(int serverId) public async Task Stop(int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
await server.Stop(); await server.Trigger(ServerTrigger.Stop);
} }
[HttpPost("{serverId:int}/install")] [HttpPost("{serverId:int}/install")]
public async Task Install(int serverId) public async Task Install(int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
await server.Install(); await server.Trigger(ServerTrigger.Install);
} }
[HttpPost("{serverId:int}/kill")] [HttpPost("{serverId:int}/kill")]
public async Task Kill(int serverId) public async Task Kill(int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
await server.Kill(); await server.Trigger(ServerTrigger.Kill);
} }
} }

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers;
using MoonlightServers.DaemonShared.Enums; using MoonlightServers.DaemonShared.Enums;
@@ -28,20 +29,20 @@ public class ServersController : Controller
[HttpDelete("{serverId:int}")] [HttpDelete("{serverId:int}")]
public async Task Delete([FromRoute] int serverId) public async Task Delete([FromRoute] int serverId)
{ {
await ServerService.Delete(serverId); //await ServerService.Delete(serverId);
} }
[HttpGet("{serverId:int}/status")] [HttpGet("{serverId:int}/status")]
public Task<ServerStatusResponse> GetStatus([FromRoute] int serverId) public Task<ServerStatusResponse> GetStatus([FromRoute] int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
var result = new ServerStatusResponse() var result = new ServerStatusResponse()
{ {
State = (ServerState)server.State State = (ServerState)server.StateMachine.State
}; };
return Task.FromResult(result); return Task.FromResult(result);
@@ -50,14 +51,17 @@ public class ServersController : Controller
[HttpGet("{serverId:int}/logs")] [HttpGet("{serverId:int}/logs")]
public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId) public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId)
{ {
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
var consoleSubSystem = server.GetRequiredSubSystem<ConsoleSubSystem>();
var messages = await consoleSubSystem.RetrieveCache();
return new ServerLogsResponse() return new ServerLogsResponse()
{ {
Messages = await server.GetConsoleMessages() Messages = messages
}; };
} }
} }

View File

@@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions; using MoonCore.Exceptions;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers; using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers; namespace MoonlightServers.Daemon.Http.Controllers.Servers;
@@ -64,14 +64,18 @@ public class UploadController : Controller
#endregion #endregion
var server = ServerService.GetServer(serverId); var server = ServerService.Find(serverId);
if (server == null) if (server == null)
throw new HttpApiException("No server with this id found", 404); throw new HttpApiException("No server with this id found", 404);
var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
var fileSystem = await storageSubSystem.GetFileSystem();
var dataStream = file.OpenReadStream(); var dataStream = file.OpenReadStream();
await server.FileSystem.CreateChunk( await fileSystem.CreateChunk(
path, path,
totalSize, totalSize,
positionToSkipTo, positionToSkipTo,

View File

@@ -0,0 +1,183 @@
using Microsoft.AspNetCore.SignalR;
using MoonCore.Exceptions;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models.Cache;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem;
public class Server : IAsyncDisposable
{
public ServerConfiguration Configuration { get; set; }
public CancellationToken TaskCancellation => TaskCancellationSource.Token;
internal StateMachine<ServerState, ServerTrigger> StateMachine { get; private set; }
private CancellationTokenSource TaskCancellationSource;
private Dictionary<Type, ServerSubSystem> SubSystems = new();
private ServerState InternalState = ServerState.Offline;
private readonly IHubContext<ServerWebSocketHub> HubContext;
private readonly IServiceScope ServiceScope;
private readonly ILoggerFactory LoggerFactory;
private readonly ILogger Logger;
public Server(
ServerConfiguration configuration,
IServiceScope serviceScope,
IHubContext<ServerWebSocketHub> hubContext
)
{
Configuration = configuration;
ServiceScope = serviceScope;
HubContext = hubContext;
TaskCancellationSource = new CancellationTokenSource();
LoggerFactory = serviceScope.ServiceProvider.GetRequiredService<ILoggerFactory>();
Logger = LoggerFactory.CreateLogger($"Server {Configuration.Id}");
StateMachine = new StateMachine<ServerState, ServerTrigger>(
() => InternalState,
state => InternalState = state,
FiringMode.Queued
);
// Configure basic state machine flow
StateMachine.Configure(ServerState.Offline)
.Permit(ServerTrigger.Start, ServerState.Starting)
.Permit(ServerTrigger.Install, ServerState.Installing)
.PermitReentry(ServerTrigger.FailSafe);
StateMachine.Configure(ServerState.Starting)
.Permit(ServerTrigger.OnlineDetected, ServerState.Online)
.Permit(ServerTrigger.FailSafe, ServerState.Offline)
.Permit(ServerTrigger.Exited, ServerState.Offline)
.Permit(ServerTrigger.Stop, ServerState.Stopping)
.Permit(ServerTrigger.Kill, ServerState.Stopping);
StateMachine.Configure(ServerState.Online)
.Permit(ServerTrigger.Stop, ServerState.Stopping)
.Permit(ServerTrigger.Kill, ServerState.Stopping)
.Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Stopping)
.PermitReentry(ServerTrigger.FailSafe)
.PermitReentry(ServerTrigger.Kill)
.Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Installing)
.Permit(ServerTrigger.FailSafe, ServerState.Offline)
.Permit(ServerTrigger.Exited, ServerState.Offline);
// Configure task reset when server goes offline
StateMachine.Configure(ServerState.Offline)
.OnEntryAsync(async () =>
{
if (!TaskCancellationSource.IsCancellationRequested)
await TaskCancellationSource.CancelAsync();
TaskCancellationSource = new();
});
// Setup websocket notify for state changes
StateMachine.OnTransitionedAsync(async transition =>
{
await HubContext.Clients
.Group(Configuration.Id.ToString())
.SendAsync("StateChanged", transition.Destination.ToString());
});
}
public async Task Initialize(Type[] subSystemTypes)
{
foreach (var type in subSystemTypes)
{
var logger = LoggerFactory.CreateLogger($"Server {Configuration.Id} - {type.Name}");
var subSystem = ActivatorUtilities.CreateInstance(
ServiceScope.ServiceProvider,
type,
this,
logger
) as ServerSubSystem;
if (subSystem == null)
{
Logger.LogError("Unable to construct server sub system: {name}", type.Name);
continue;
}
SubSystems.Add(type, subSystem);
}
foreach (var type in SubSystems.Keys)
{
try
{
await SubSystems[type].Initialize();
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while initializing sub system {name}: {e}", type.Name, e);
}
}
}
public async Task Trigger(ServerTrigger trigger)
{
if (!StateMachine.CanFire(trigger))
throw new HttpApiException($"The trigger {trigger} is not supported during the state {StateMachine.State}", 400);
await StateMachine.FireAsync(trigger);
}
public async Task Delete()
{
foreach (var subSystem in SubSystems.Values)
await subSystem.Delete();
}
// This method completely bypasses the state machine.
// Using this method without any checks will lead to
// broken server states. Use with caution
public void OverrideState(ServerState state)
{
InternalState = state;
}
public T? GetSubSystem<T>() where T : ServerSubSystem
{
var type = typeof(T);
var subSystem = SubSystems.GetValueOrDefault(type);
if (subSystem == null)
return null;
return subSystem as T;
}
public T GetRequiredSubSystem<T>() where T : ServerSubSystem
{
var subSystem = GetSubSystem<T>();
if (subSystem == null)
throw new AggregateException("Unable to resolve requested sub system");
return subSystem;
}
public async ValueTask DisposeAsync()
{
if (!TaskCancellationSource.IsCancellationRequested)
await TaskCancellationSource.CancelAsync();
foreach (var subSystem in SubSystems.Values)
await subSystem.DisposeAsync();
ServiceScope.Dispose();
}
}

View File

@@ -0,0 +1,10 @@
namespace MoonlightServers.Daemon.ServerSystem;
public enum ServerState
{
Offline = 0,
Starting = 1,
Online = 2,
Stopping = 3,
Installing = 4
}

View File

@@ -0,0 +1,27 @@
using MoonlightServers.Daemon.Models.Cache;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem;
public abstract class ServerSubSystem : IAsyncDisposable
{
protected Server Server { get; private set; }
protected ServerConfiguration Configuration => Server.Configuration;
protected ILogger Logger { get; private set; }
protected StateMachine<ServerState, ServerTrigger> StateMachine => Server.StateMachine;
protected ServerSubSystem(Server server, ILogger logger)
{
Server = server;
Logger = logger;
}
public virtual Task Initialize()
=> Task.CompletedTask;
public virtual Task Delete()
=> Task.CompletedTask;
public virtual ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,12 @@
namespace MoonlightServers.Daemon.ServerSystem;
public enum ServerTrigger
{
Start = 0,
Stop = 1,
Kill = 2,
Install = 3,
Exited = 4,
OnlineDetected = 5,
FailSafe = 6
}

View File

@@ -0,0 +1,145 @@
using System.Text;
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Http.Hubs;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class ConsoleSubSystem : ServerSubSystem
{
public event Func<string, Task>? OnOutput;
public event Func<string, Task>? OnInput;
private MultiplexedStream? Stream;
private readonly List<string> OutputCache = new();
private readonly IHubContext<ServerWebSocketHub> HubContext;
private readonly DockerClient DockerClient;
public ConsoleSubSystem(
Server server,
ILogger logger,
IHubContext<ServerWebSocketHub> hubContext,
DockerClient dockerClient
) : base(server, logger)
{
HubContext = hubContext;
DockerClient = dockerClient;
}
public override Task Initialize()
{
OnInput += async content =>
{
if(Stream == null)
return;
var contentBuffer = Encoding.UTF8.GetBytes(content);
await Stream.WriteAsync(contentBuffer, 0, contentBuffer.Length, Server.TaskCancellation);
};
return Task.CompletedTask;
}
public async Task Attach(string containerId)
{
Stream = await DockerClient.Containers.AttachContainerAsync(containerId,
true,
new ContainerAttachParameters()
{
Stderr = true,
Stdin = true,
Stdout = true,
Stream = true
},
Server.TaskCancellation
);
// Reading
Task.Run(async () =>
{
while (!Server.TaskCancellation.IsCancellationRequested)
{
var buffer = new byte[1024];
try
{
var readResult = await Stream.ReadOutputAsync(
buffer,
0,
buffer.Length,
Server.TaskCancellation
);
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 WriteOutput(decodedText);
}
catch (TaskCanceledException)
{
// Ignored
}
catch (OperationCanceledException)
{
// Ignored
}
catch (Exception e)
{
Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
}
}
// Reset stream so no further inputs will be piped to it
Stream = null;
Logger.LogDebug("Disconnected from container stream");
});
}
public async Task WriteOutput(string output)
{
lock (OutputCache)
{
// Shrink cache if it exceeds the maximum
if (OutputCache.Count > 400)
OutputCache.RemoveRange(0, 100);
OutputCache.Add(output);
}
if (OnOutput != null)
await OnOutput.Invoke(output);
await HubContext.Clients
.Group(Configuration.Id.ToString())
.SendAsync("ConsoleOutput", output);
}
public async Task WriteMoonlight(string output)
{
await WriteOutput($"\x1b[38;5;16;48;5;135m\x1b[39m\x1b[1m Moonlight \x1b[0m\x1b[38;5;250m\x1b[3m {output}\x1b[0m\n\r");
}
public async Task WriteInput(string input)
{
if (OnInput != null)
await OnInput.Invoke(input);
}
public Task<string[]> RetrieveCache()
{
string[] result;
lock (OutputCache)
result = OutputCache.ToArray();
return Task.FromResult(result);
}
}

View File

@@ -0,0 +1,19 @@
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class DebugSubSystem : ServerSubSystem
{
public DebugSubSystem(Server server, ILogger logger) : base(server, logger)
{
}
public override Task Initialize()
{
StateMachine.OnTransitioned(transition =>
{
Logger.LogTrace("State: {state} via {trigger}", transition.Destination, transition.Trigger);
});
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,238 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class InstallationSubSystem : ServerSubSystem
{
public string? CurrentContainerId { get; set; }
private readonly DockerClient DockerClient;
private readonly RemoteService RemoteService;
private readonly DockerImageService DockerImageService;
private readonly AppConfiguration AppConfiguration;
public InstallationSubSystem(
Server server,
ILogger logger,
DockerClient dockerClient,
RemoteService remoteService,
DockerImageService dockerImageService,
AppConfiguration appConfiguration
) : base(server, logger)
{
DockerClient = dockerClient;
RemoteService = remoteService;
DockerImageService = dockerImageService;
AppConfiguration = appConfiguration;
}
public override Task Initialize()
{
StateMachine.Configure(ServerState.Installing)
.OnEntryAsync(HandleProvision);
StateMachine.Configure(ServerState.Installing)
.OnExitAsync(HandleDeprovision);
return Task.CompletedTask;
}
#region Provision
private async Task HandleProvision()
{
try
{
await Provision();
}
catch (Exception e)
{
Logger.LogError("An error occured while provisioning installation: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Provision()
{
// What will happen here:
// 1. Remove possible existing container
// 2. Fetch latest configuration & install configuration
// 3. Ensure the storage location exists
// 4. Copy script to set location
// 5. Ensure the docker image has been downloaded
// 6. Create the docker container
// 7. Attach the console
// 8. Start the container
// Define some shared variables:
var containerName = $"moonlight-install-{Configuration.Id}";
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Reset container tracking id, so if we kill an old container it won't
// trigger an Exited event :>
CurrentContainerId = null;
// 1. Remove possible existing container
try
{
var existingContainer = await DockerClient.Containers
.InspectContainerAsync(containerName);
if (existingContainer.State.Running)
{
Logger.LogDebug("Killing old docker container");
await consoleSubSystem.WriteMoonlight("Killing old container");
await DockerClient.Containers.KillContainerAsync(existingContainer.ID, new());
}
Logger.LogDebug("Removing old docker container");
await consoleSubSystem.WriteMoonlight("Removing old container");
await DockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// 2. Fetch latest configuration
Logger.LogDebug("Fetching latest configuration from panel");
await consoleSubSystem.WriteMoonlight("Updating configuration");
var serverData = await RemoteService.GetServer(Configuration.Id);
var latestConfiguration = serverData.ToServerConfiguration();
Server.Configuration = latestConfiguration;
var installData = await RemoteService.GetServerInstallation(Configuration.Id);
// 3. Ensure the storage location exists
Logger.LogDebug("Ensuring storage");
var storageSubSystem = Server.GetRequiredSubSystem<StorageSubSystem>();
if (!await storageSubSystem.IsRuntimeVolumeReady())
{
Logger.LogDebug("Unable to continue provision because the server file system isn't ready");
await consoleSubSystem.WriteMoonlight("Server file system is not ready yet. Try again later");
await StateMachine.FireAsync(ServerTrigger.FailSafe);
return;
}
var runtimePath = await storageSubSystem.GetRuntimeHostPath();
var installPath = await storageSubSystem.EnsureInstallVolume();
// 4. Copy script to location
var content = installData.Script.Replace("\r\n", "\n");
await File.WriteAllTextAsync(Path.Combine(installPath, "install.sh"), content);
// 5. Ensure the docker image is downloaded
Logger.LogDebug("Downloading docker image");
await consoleSubSystem.WriteMoonlight("Downloading docker image");
await DockerImageService.Download(installData.DockerImage,
async updateMessage => { await consoleSubSystem.WriteMoonlight(updateMessage); });
Logger.LogDebug("Docker image downloaded");
await consoleSubSystem.WriteMoonlight("Downloaded docker image");
// 6. Create the docker container
Logger.LogDebug("Creating docker container");
await consoleSubSystem.WriteMoonlight("Creating container");
var containerParams = Configuration.ToInstallationCreateParameters(
AppConfiguration,
runtimePath,
installPath,
containerName,
installData.DockerImage,
installData.Shell
);
var creationResult = await DockerClient.Containers.CreateContainerAsync(containerParams);
CurrentContainerId = creationResult.ID;
// 7. Attach the console
Logger.LogDebug("Attaching console");
await consoleSubSystem.Attach(CurrentContainerId);
// 8. Start the docker container
Logger.LogDebug("Starting docker container");
await consoleSubSystem.WriteMoonlight("Starting container");
await DockerClient.Containers.StartContainerAsync(containerName, new());
}
#endregion
#region Deprovision
private async Task HandleDeprovision()
{
try
{
await Deprovision();
}
catch (Exception e)
{
Logger.LogError("An error occured while deprovisioning installation: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Deprovision()
{
// Handle possible unknown container id calls
if (string.IsNullOrEmpty(CurrentContainerId))
{
Logger.LogDebug("Skipping deprovisioning as the current container id is not set");
return;
}
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Destroy container
try
{
Logger.LogDebug("Removing docker container");
await consoleSubSystem.WriteMoonlight("Removing container");
await DockerClient.Containers.RemoveContainerAsync(CurrentContainerId, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
CurrentContainerId = null;
// Remove install volume
var storageSubSystem = Server.GetRequiredSubSystem<StorageSubSystem>();
Logger.LogDebug("Removing installation data");
await consoleSubSystem.WriteMoonlight("Removing installation data");
await storageSubSystem.DeleteInstallVolume();
}
#endregion
}

View File

@@ -0,0 +1,45 @@
using System.Text.RegularExpressions;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class OnlineDetectionService : ServerSubSystem
{
// We are compiling the regex when the first output has been received
// and resetting it after the server has stopped to maximize the performance
// but allowing the startup detection string to change :>
private Regex? CompiledRegex = null;
public OnlineDetectionService(Server server, ILogger logger) : base(server, logger)
{
}
public override Task Initialize()
{
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
consoleSubSystem.OnOutput += async line =>
{
if(StateMachine.State != ServerState.Starting)
return;
if (CompiledRegex == null)
CompiledRegex = new Regex(Configuration.OnlineDetection, RegexOptions.Compiled);
if (Regex.Matches(line, Configuration.OnlineDetection).Count == 0)
return;
await StateMachine.FireAsync(ServerTrigger.OnlineDetected);
};
StateMachine.Configure(ServerState.Offline)
.OnEntryAsync(_ =>
{
CompiledRegex = null;
return Task.CompletedTask;
});
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,219 @@
using Docker.DotNet;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Services;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class ProvisionSubSystem : ServerSubSystem
{
public string? CurrentContainerId { get; set; }
private readonly DockerClient DockerClient;
private readonly AppConfiguration AppConfiguration;
private readonly RemoteService RemoteService;
private readonly DockerImageService DockerImageService;
public ProvisionSubSystem(
Server server,
ILogger logger,
DockerClient dockerClient,
AppConfiguration appConfiguration,
RemoteService remoteService,
DockerImageService dockerImageService
) : base(server, logger)
{
DockerClient = dockerClient;
AppConfiguration = appConfiguration;
RemoteService = remoteService;
DockerImageService = dockerImageService;
}
public override Task Initialize()
{
StateMachine.Configure(ServerState.Starting)
.OnEntryFromAsync(ServerTrigger.Start, HandleProvision);
StateMachine.Configure(ServerState.Offline)
.OnEntryAsync(HandleDeprovision);
return Task.CompletedTask;
}
#region Provisioning
private async Task HandleProvision()
{
try
{
await Provision();
}
catch (Exception e)
{
Logger.LogError("An error occured while provisioning server: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Provision()
{
// What will happen here:
// 1. Remove possible existing container
// 2. Fetch latest configuration
// 3. Ensure the storage location exists
// 4. Ensure the docker image has been downloaded
// 5. Create the docker container
// 6. Attach the console
// 7. Start the container
// Define some shared variables:
var containerName = $"moonlight-runtime-{Configuration.Id}";
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Reset container tracking id, so if we kill an old container it won't
// trigger an Exited event :>
CurrentContainerId = null;
// 1. Remove possible existing container
try
{
var existingContainer = await DockerClient.Containers
.InspectContainerAsync(containerName);
if (existingContainer.State.Running)
{
Logger.LogDebug("Killing old docker container");
await consoleSubSystem.WriteMoonlight("Killing old container");
await DockerClient.Containers.KillContainerAsync(existingContainer.ID, new());
}
Logger.LogDebug("Removing old docker container");
await consoleSubSystem.WriteMoonlight("Removing old container");
await DockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// 2. Fetch latest configuration
Logger.LogDebug("Fetching latest configuration from panel");
await consoleSubSystem.WriteMoonlight("Updating configuration");
var serverData = await RemoteService.GetServer(Configuration.Id);
var latestConfiguration = serverData.ToServerConfiguration();
Server.Configuration = latestConfiguration;
// 3. Ensure the storage location exists
Logger.LogDebug("Ensuring storage");
var storageSubSystem = Server.GetRequiredSubSystem<StorageSubSystem>();
if (!await storageSubSystem.IsRuntimeVolumeReady())
{
Logger.LogDebug("Unable to continue provision because the server file system isn't ready");
await consoleSubSystem.WriteMoonlight("Server file system is not ready yet. Try again later");
await StateMachine.FireAsync(ServerTrigger.FailSafe);
return;
}
var volumePath = await storageSubSystem.GetRuntimeHostPath();
// 4. Ensure the docker image is downloaded
Logger.LogDebug("Downloading docker image");
await consoleSubSystem.WriteMoonlight("Downloading docker image");
await DockerImageService.Download(Configuration.DockerImage, async updateMessage =>
{
await consoleSubSystem.WriteMoonlight(updateMessage);
});
Logger.LogDebug("Docker image downloaded");
await consoleSubSystem.WriteMoonlight("Downloaded docker image");
// 5. Create the docker container
Logger.LogDebug("Creating docker container");
await consoleSubSystem.WriteMoonlight("Creating container");
var containerParams = Configuration.ToRuntimeCreateParameters(
AppConfiguration,
volumePath,
containerName
);
var creationResult = await DockerClient.Containers.CreateContainerAsync(containerParams);
CurrentContainerId = creationResult.ID;
// 6. Attach the console
Logger.LogDebug("Attaching console");
await consoleSubSystem.Attach(CurrentContainerId);
// 7. Start the docker container
Logger.LogDebug("Starting docker container");
await consoleSubSystem.WriteMoonlight("Starting container");
await DockerClient.Containers.StartContainerAsync(containerName, new());
}
#endregion
#region Deprovision
private async Task HandleDeprovision(StateMachine<ServerState, ServerTrigger>.Transition transition)
{
try
{
await Deprovision();
}
catch (Exception e)
{
Logger.LogError("An error occured while provisioning server: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Deprovision()
{
// Handle possible unknown container id calls
if (string.IsNullOrEmpty(CurrentContainerId))
{
Logger.LogDebug("Skipping deprovisioning as the current container id is not set");
return;
}
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Destroy container
try
{
Logger.LogDebug("Removing docker container");
await consoleSubSystem.WriteMoonlight("Removing container");
await DockerClient.Containers.RemoveContainerAsync(CurrentContainerId, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
CurrentContainerId = null;
}
#endregion
}

View File

@@ -0,0 +1,110 @@
using Docker.DotNet;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class RestoreSubSystem : ServerSubSystem
{
private readonly DockerClient DockerClient;
public RestoreSubSystem(Server server, ILogger logger, DockerClient dockerClient) : base(server, logger)
{
DockerClient = dockerClient;
}
public override async Task Initialize()
{
Logger.LogDebug("Searching for restorable container");
// Handle possible runtime container
var runtimeContainerName = $"moonlight-runtime-{Configuration.Id}";
try
{
var runtimeContainer = await DockerClient.Containers.InspectContainerAsync(runtimeContainerName);
if (runtimeContainer.State.Running)
{
var provisionSubSystem = Server.GetRequiredSubSystem<ProvisionSubSystem>();
// Override values
provisionSubSystem.CurrentContainerId = runtimeContainer.ID;
Server.OverrideState(ServerState.Online);
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
var logStream = await DockerClient.Containers.GetContainerLogsAsync(runtimeContainerName, true, new ()
{
Follow = false,
ShowStderr = true,
ShowStdout = true
});
var (standardOutput, standardError) = await logStream.ReadOutputToEndAsync(CancellationToken.None);
// We split up the read output data into their lines to prevent overloading
// the console by one large string
foreach (var line in standardOutput.Split("\n"))
await consoleSubSystem.WriteOutput(line + "\n");
foreach (var line in standardError.Split("\n"))
await consoleSubSystem.WriteOutput(line + "\n");
await consoleSubSystem.Attach(provisionSubSystem.CurrentContainerId);
Logger.LogInformation("Restored runtime container successfully");
return;
}
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// Handle possible installation container
var installContainerName = $"moonlight-install-{Configuration.Id}";
try
{
var installContainer = await DockerClient.Containers.InspectContainerAsync(installContainerName);
if (installContainer.State.Running)
{
var installationSubSystem = Server.GetRequiredSubSystem<InstallationSubSystem>();
// Override values
installationSubSystem.CurrentContainerId = installContainer.ID;
Server.OverrideState(ServerState.Installing);
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
var logStream = await DockerClient.Containers.GetContainerLogsAsync(installContainerName, true, new ()
{
Follow = false,
ShowStderr = true,
ShowStdout = true
});
var (standardOutput, standardError) = await logStream.ReadOutputToEndAsync(CancellationToken.None);
// We split up the read output data into their lines to prevent overloading
// the console by one large string
foreach (var line in standardOutput.Split("\n"))
await consoleSubSystem.WriteOutput(line + "\n");
foreach (var line in standardError.Split("\n"))
await consoleSubSystem.WriteOutput(line + "\n");
await consoleSubSystem.Attach(installationSubSystem.CurrentContainerId);
return;
}
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
}
}

View File

@@ -0,0 +1,85 @@
using Docker.DotNet;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class ShutdownSubSystem : ServerSubSystem
{
private readonly DockerClient DockerClient;
public ShutdownSubSystem(
Server server,
ILogger logger,
DockerClient dockerClient
) : base(server, logger)
{
DockerClient = dockerClient;
}
public override Task Initialize()
{
StateMachine.Configure(ServerState.Stopping)
.OnEntryFromAsync(ServerTrigger.Stop, HandleStop)
.OnEntryFromAsync(ServerTrigger.Kill, HandleKill);
return Task.CompletedTask;
}
#region Stopping
private async Task HandleStop()
{
try
{
await Stop();
}
catch (Exception e)
{
Logger.LogError("An error occured while stopping container: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Stop()
{
var provisionSubSystem = Server.GetRequiredSubSystem<ProvisionSubSystem>();
// Handle signal stopping
if (Configuration.StopCommand.StartsWith('^'))
{
await DockerClient.Containers.KillContainerAsync(provisionSubSystem.CurrentContainerId, new()
{
Signal = Configuration.StopCommand.Replace("^", "")
});
}
else // Handle input stopping
{
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
await consoleSubSystem.WriteInput($"{Configuration.StopCommand}\n\r");
}
}
#endregion
private async Task HandleKill()
{
try
{
await Kill();
}
catch (Exception e)
{
Logger.LogError("An error occured while killing container: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Kill()
{
var provisionSubSystem = Server.GetRequiredSubSystem<ProvisionSubSystem>();
await DockerClient.Containers.KillContainerAsync(
provisionSubSystem.CurrentContainerId,
new()
);
}
}

View File

@@ -0,0 +1,147 @@
using MoonCore.Exceptions;
using MoonCore.Unix.SecureFs;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class StorageSubSystem : ServerSubSystem
{
private readonly AppConfiguration AppConfiguration;
private SecureFileSystem SecureFileSystem;
private ServerFileSystem ServerFileSystem;
private bool IsInitialized = false;
public StorageSubSystem(
Server server,
ILogger logger,
AppConfiguration appConfiguration
) : base(server, logger)
{
AppConfiguration = appConfiguration;
}
public override async Task Initialize()
{
Logger.LogDebug("Lazy initializing server file system");
Task.Run(async () =>
{
try
{
await EnsureRuntimeVolume();
var hostPath = await GetRuntimeHostPath();
SecureFileSystem = new(hostPath);
ServerFileSystem = new(SecureFileSystem);
IsInitialized = true;
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while lazy initializing server file system: {e}", e);
}
});
}
#region Runtime
public Task<ServerFileSystem> GetFileSystem()
{
if (!IsInitialized)
throw new HttpApiException("The file system is still initializing. Please try again later", 503);
return Task.FromResult(ServerFileSystem);
}
public Task<bool> IsRuntimeVolumeReady()
{
return Task.FromResult(IsInitialized);
}
private async Task EnsureRuntimeVolume()
{
var path = await GetRuntimeHostPath();
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
await consoleSubSystem.WriteMoonlight("Creating virtual disk file. Please be patient");
await Task.Delay(TimeSpan.FromSeconds(8));
await consoleSubSystem.WriteMoonlight("Formatting virtual disk. This can take a bit");
await Task.Delay(TimeSpan.FromSeconds(8));
await consoleSubSystem.WriteMoonlight("Mounting virtual disk. Please be patient");
await Task.Delay(TimeSpan.FromSeconds(3));
await consoleSubSystem.WriteMoonlight("Virtual disk ready");
// TODO: Implement virtual disk
}
public Task<string> GetRuntimeHostPath()
{
var path = Path.Combine(
AppConfiguration.Storage.Volumes,
Configuration.Id.ToString()
);
if (!path.StartsWith('/'))
path = Path.Combine(Directory.GetCurrentDirectory(), path);
return Task.FromResult(path);
}
#endregion
#region Installation
public async Task<string> EnsureInstallVolume()
{
var path = await GetInstallHostPath();
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
return path;
}
public Task<string> GetInstallHostPath()
{
var path = Path.Combine(
AppConfiguration.Storage.Install,
Configuration.Id.ToString()
);
if (!path.StartsWith('/'))
path = Path.Combine(Directory.GetCurrentDirectory(), path);
return Task.FromResult(path);
}
public async Task DeleteInstallVolume()
{
var path = await GetInstallHostPath();
if(!Directory.Exists(path))
return;
Directory.Delete(path, true);
}
#endregion
public override ValueTask DisposeAsync()
{
if (IsInitialized)
{
if(!SecureFileSystem.IsDisposed)
SecureFileSystem.Dispose();
}
return ValueTask.CompletedTask;
}
}

View File

@@ -12,6 +12,8 @@ public class DockerImageService
private readonly AppConfiguration Configuration; private readonly AppConfiguration Configuration;
private readonly ILogger<DockerImageService> Logger; private readonly ILogger<DockerImageService> Logger;
private readonly Dictionary<string, TaskCompletionSource> PendingDownloads = new();
public DockerImageService( public DockerImageService(
DockerClient dockerClient, DockerClient dockerClient,
ILogger<DockerImageService> logger, ILogger<DockerImageService> logger,
@@ -23,42 +25,70 @@ public class DockerImageService
Logger = logger; Logger = logger;
} }
public async Task Ensure(string name, Action<string>? onProgressUpdated) public async Task Download(string name, Action<string>? onProgressUpdated = null)
{ {
// Figure out if and which credentials to use by checking for the domain // If there is already a download for this image occuring, we want to wait for this to complete instead
AuthConfig credentials = new(); // of calling docker to download it again
if (PendingDownloads.TryGetValue(name, out var downloadTaskCompletion))
var domain = GetDomainFromDockerImageName(name);
var configuredCredentials = Configuration.Docker.Credentials
.FirstOrDefault(x => x.Domain.Equals(domain, StringComparison.InvariantCultureIgnoreCase));
if (configuredCredentials != null)
{ {
credentials.Username = configuredCredentials.Username; await downloadTaskCompletion.Task;
credentials.Password = configuredCredentials.Password; return;
credentials.Email = configuredCredentials.Email;
} }
// Now we want to pull the image var tsc = new TaskCompletionSource();
await DockerClient.Images.CreateImageAsync(new() PendingDownloads.Add(name, tsc);
try
{
// Figure out if and which credentials to use by checking for the domain
AuthConfig credentials = new();
var domain = GetDomainFromDockerImageName(name);
var configuredCredentials = Configuration.Docker.Credentials.FirstOrDefault(x =>
x.Domain.Equals(domain, StringComparison.InvariantCultureIgnoreCase)
);
// Apply credentials configuration if specified
if (configuredCredentials != null)
{ {
FromImage = name credentials.Username = configuredCredentials.Username;
}, credentials.Password = configuredCredentials.Password;
credentials, credentials.Email = configuredCredentials.Email;
new Progress<JSONMessage>(async message => }
{
if (message.Progress == null)
return;
var line = $"[{message.ID}] {message.ProgressMessage}"; // Now we want to pull the image
await DockerClient.Images.CreateImageAsync(new()
{
FromImage = name
},
credentials,
new Progress<JSONMessage>(async message =>
{
if (message.Progress == null)
return;
Logger.LogDebug("{line}", line); var line = $"[{message.ID}] {message.ProgressMessage}";
if (onProgressUpdated != null) Logger.LogDebug("{line}", line);
onProgressUpdated.Invoke(line);
}) if (onProgressUpdated != null)
); onProgressUpdated.Invoke(line);
})
);
tsc.SetResult();
PendingDownloads.Remove(name);
}
catch (Exception e)
{
Logger.LogError("An error occured while download image {name}: {e}", name, e);
tsc.SetException(e);
PendingDownloads.Remove(name);
throw;
}
} }
private string GetDomainFromDockerImageName(string name) // Method names are my passion ;) private string GetDomainFromDockerImageName(string name) // Method names are my passion ;)
@@ -69,7 +99,8 @@ public class DockerImageService
// If it has 2 parts -> usually "user/image" (e.g., "library/ubuntu") // If it has 2 parts -> usually "user/image" (e.g., "library/ubuntu")
// If it has 3 or more -> assume first part is the registry domain // If it has 3 or more -> assume first part is the registry domain
if (nameParts.Length >= 3 || (nameParts.Length >= 2 && nameParts[0].Contains('.') || nameParts[0].Contains(':'))) if (nameParts.Length >= 3 ||
(nameParts.Length >= 2 && nameParts[0].Contains('.') || nameParts[0].Contains(':')))
return nameParts[0]; // Registry domain is explicitly specified return nameParts[0]; // Registry domain is explicitly specified
return "docker.io"; // Default Docker registry return "docker.io"; // Default Docker registry

View File

@@ -1,15 +1,14 @@
using System.Collections.Concurrent;
using Docker.DotNet; using Docker.DotNet;
using Docker.DotNet.Models; using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using MoonCore.Attributes; using MoonCore.Attributes;
using MoonCore.Exceptions;
using MoonCore.Models; using MoonCore.Models;
using MoonlightServers.Daemon.Abstractions;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Enums;
using MoonlightServers.Daemon.Extensions; using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Http.Hubs; using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models.Cache; using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses; using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
namespace MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon.Services;
@@ -17,271 +16,222 @@ namespace MoonlightServers.Daemon.Services;
[Singleton] [Singleton]
public class ServerService : IHostedLifecycleService public class ServerService : IHostedLifecycleService
{ {
private readonly List<Server> Servers = new(); private readonly Dictionary<int, Server> Servers = new();
private readonly ILogger<ServerService> Logger;
private readonly RemoteService RemoteService; private readonly RemoteService RemoteService;
private readonly DockerClient DockerClient;
private readonly IServiceProvider ServiceProvider; private readonly IServiceProvider ServiceProvider;
private readonly ILoggerFactory LoggerFactory; private readonly CancellationTokenSource TaskCancellation;
private readonly IHubContext<ServerWebSocketHub> WebSocketHub; private readonly ILogger<ServerService> Logger;
private readonly AppConfiguration Configuration; private readonly IHubContext<ServerWebSocketHub> HubContext;
private CancellationTokenSource Cancellation = new();
private bool IsInitialized = false;
public ServerService( public ServerService(
RemoteService remoteService, RemoteService remoteService,
ILogger<ServerService> logger,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILoggerFactory loggerFactory, DockerClient dockerClient,
IHubContext<ServerWebSocketHub> webSocketHub, ILogger<ServerService> logger,
AppConfiguration configuration IHubContext<ServerWebSocketHub> hubContext
) )
{ {
RemoteService = remoteService; RemoteService = remoteService;
Logger = logger;
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
LoggerFactory = loggerFactory; DockerClient = dockerClient;
WebSocketHub = webSocketHub; Logger = logger;
Configuration = configuration; HubContext = hubContext;
}
public async Task Initialize() //TODO: Add initialize call from panel TaskCancellation = new CancellationTokenSource();
{
if (IsInitialized)
{
Logger.LogWarning("Ignoring initialize call: Already initialized");
return;
}
else
IsInitialized = true;
// Loading models and converting them
Logger.LogInformation("Fetching servers from panel");
var servers = await PagedData<ServerDataResponse>.All(async (page, pageSize) =>
await RemoteService.GetServers(page, pageSize)
);
var configurations = servers
.Select(x => x.ToServerConfiguration())
.ToArray();
Logger.LogInformation("Initializing {count} servers", servers.Length);
await InitializeServerRange(configurations); // TODO: Initialize them multi threaded (maybe)
// Attach to docker events
await AttachToDockerEvents();
}
public async Task Stop()
{
Server[] servers;
lock (Servers)
servers = Servers.ToArray();
//
Logger.LogTrace("Canceling server tasks and disconnecting storage");
foreach (var server in servers)
{
try
{
await server.CancelTasks();
await server.DestroyStorage();
}
catch (Exception e)
{
Logger.LogCritical(
"An unhandled error occured while stopping the server management for server {id}: {e}",
server.Id,
e
);
}
}
//
Logger.LogTrace("Canceling own tasks");
await Cancellation.CancelAsync();
}
private Task AttachToDockerEvents()
{
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
Task.Run(async () =>
{
// This lets the event monitor restart
while (!Cancellation.Token.IsCancellationRequested)
{
try
{
Logger.LogTrace("Attached to docker events");
await dockerClient.System.MonitorEventsAsync(new ContainerEventsParameters(),
new Progress<Message>(async message =>
{
if (message.Action != "die")
return;
Server? server;
// TODO: Maybe implement a lookup for containers which id isn't set in the cache
// Check if it's a runtime container
lock (Servers)
server = Servers.FirstOrDefault(x => x.RuntimeContainerId == message.ID);
if (server != null)
{
await server.NotifyRuntimeContainerDied();
return;
}
// Check if it's an installation container
lock (Servers)
server = Servers.FirstOrDefault(x => x.InstallationContainerId == message.ID);
if (server != null)
{
await server.NotifyInstallationContainerDied();
return;
}
}), Cancellation.Token);
}
catch (TaskCanceledException)
{
} // Can be ignored
catch (Exception e)
{
Logger.LogError("An unhandled error occured while attaching to docker events: {e}", e);
}
}
});
return Task.CompletedTask;
}
public async Task InitializeServerRange(ServerConfiguration[] serverConfigurations)
{
var dockerClient = ServiceProvider.GetRequiredService<DockerClient>();
var existingContainers = await dockerClient.Containers.ListContainersAsync(new()
{
All = true,
Limit = null,
Filters = new Dictionary<string, IDictionary<string, bool>>()
{
{
"label",
new Dictionary<string, bool>()
{
{
"Software=Moonlight-Panel",
true
}
}
}
}
});
foreach (var configuration in serverConfigurations)
await InitializeServer(configuration, existingContainers);
}
public async Task<Server> InitializeServer(
ServerConfiguration serverConfiguration,
IList<ContainerListResponse> existingContainers
)
{
Logger.LogTrace("Initializing server '{id}'", serverConfiguration.Id);
var server = new Server(
LoggerFactory.CreateLogger($"Server {serverConfiguration.Id}"),
ServiceProvider,
serverConfiguration,
WebSocketHub,
Configuration
);
await server.Initialize(existingContainers);
lock (Servers)
Servers.Add(server);
return server;
} }
public async Task Sync(int serverId) public async Task Sync(int serverId)
{
if (Servers.TryGetValue(serverId, out var server))
{
var serverData = await RemoteService.GetServer(serverId);
var configuration = serverData.ToServerConfiguration();
server.Configuration = configuration;
}
else
await Initialize(serverId);
}
public async Task Sync(int serverId, ServerConfiguration configuration)
{
if (Servers.TryGetValue(serverId, out var server))
server.Configuration = configuration;
else
await Initialize(serverId);
}
public async Task InitializeAll()
{
var initialPage = await RemoteService.GetServers(0, 1);
const int pageSize = 25;
var pages = (initialPage.TotalItems == 0 ? 0 : (initialPage.TotalItems - 1) / pageSize) +
1; // The +1 is to handle the pages starting at 0
// Create and fill a queue with pages to initialize
var batchesLeft = new ConcurrentQueue<int>();
for (var i = 0; i < pages; i++)
batchesLeft.Enqueue(i);
var tasksCount = pages > 5 ? 5 : pages;
var tasks = new List<Task>();
Logger.LogInformation(
"Starting initialization for {count} server(s) with {tasksCount} worker(s)",
initialPage.TotalItems,
tasksCount
);
for (var i = 0; i < tasksCount; i++)
{
var id = i + 0;
var task = Task.Run(() => BatchRunner(batchesLeft, id));
tasks.Add(task);
}
await Task.WhenAll(tasks);
Logger.LogInformation("Initialization completed");
}
private async Task BatchRunner(ConcurrentQueue<int> queue, int id)
{
while (!queue.IsEmpty)
{
if (!queue.TryDequeue(out var page))
continue;
await InitializeBatch(page, 25);
Logger.LogDebug("Worker {id}: Finished initialization of page {page}", id, page);
}
Logger.LogDebug("Worker {id}: Finished", id);
}
private async Task InitializeBatch(int page, int pageSize)
{
var servers = await RemoteService.GetServers(page, pageSize);
var configurations = servers.Items
.Select(x => x.ToServerConfiguration())
.ToArray();
foreach (var configuration in configurations)
{
try
{
await Initialize(configuration);
}
catch (Exception e)
{
Logger.LogError(
"An unhandled error occured while initializing server {id}: {e}",
configuration.Id,
e
);
}
}
}
public async Task Initialize(int serverId)
{ {
var serverData = await RemoteService.GetServer(serverId); var serverData = await RemoteService.GetServer(serverId);
var serverConfiguration = serverData.ToServerConfiguration(); var configuration = serverData.ToServerConfiguration();
var server = GetServer(serverId); await Initialize(configuration);
if (server == null)
await InitializeServer(serverConfiguration, []);
else
server.UpdateConfiguration(serverConfiguration);
} }
public async Task Delete(int serverId) public Server? Find(int serverId)
=> Servers.GetValueOrDefault(serverId);
public async Task Initialize(ServerConfiguration configuration)
{ {
var server = GetServer(serverId); var serverScope = ServiceProvider.CreateScope();
// If a server with this id doesn't exist we can just exit var server = new Server(configuration, serverScope, HubContext);
if (server == null)
return;
if (server.State == ServerState.Installing) Type[] subSystems =
throw new HttpApiException("Unable to delete a server while it is installing", 400); [
typeof(ProvisionSubSystem),
typeof(StorageSubSystem),
typeof(DebugSubSystem),
typeof(ShutdownSubSystem),
typeof(ConsoleSubSystem),
typeof(RestoreSubSystem),
typeof(OnlineDetectionService),
typeof(InstallationSubSystem)
];
#region Callbacks await server.Initialize(subSystems);
var deleteCompletion = new TaskCompletionSource(); Servers[configuration.Id] = server;
async Task HandleStateChange(ServerState state)
{
if (state == ServerState.Offline)
await DeleteServer();
}
async Task DeleteServer()
{
await server.CancelTasks();
await server.DestroyStorage();
await server.RemoveInstallationVolume();
await server.RemoveRuntimeVolume();
deleteCompletion.SetResult();
lock (Servers)
Servers.Remove(server);
}
#endregion
// If the server is still online, we are killing it and then
// waiting for the callback to trigger notifying us that the server is now offline
// so we can delete it. The request will pause until then using the deleteCompletion task
if (server.State != ServerState.Offline)
{
server.OnStateChanged += HandleStateChange;
await server.Kill();
await deleteCompletion.Task;
}
else
await DeleteServer();
} }
public Server? GetServer(int id) #region Docker Monitoring
private async Task MonitorContainers()
{ {
lock (Servers) Task.Run(async () =>
return Servers.FirstOrDefault(x => x.Id == id); {
// Restart unless shutdown is requested
while (!TaskCancellation.Token.IsCancellationRequested)
{
try
{
Logger.LogTrace("Starting to monitor events");
await DockerClient.System.MonitorEventsAsync(new(),
new Progress<Message>(async message =>
{
// Filter out unwanted events
if (message.Action != "die")
return;
// TODO: Implement a cached lookup using a shared dictionary by the sub system
var server = Servers.Values.FirstOrDefault(serverToCheck =>
{
var provisionSubSystem = serverToCheck.GetRequiredSubSystem<ProvisionSubSystem>();
if (provisionSubSystem.CurrentContainerId == message.ID)
return true;
var installationSubSystem = serverToCheck.GetRequiredSubSystem<InstallationSubSystem>();
if (installationSubSystem.CurrentContainerId == message.ID)
return true;
return false;
});
// If the container does not match any server we can ignore it
if (server == null)
return;
await server.StateMachine.FireAsync(ServerTrigger.Exited);
}), TaskCancellation.Token);
}
catch (TaskCanceledException)
{
// Can be ignored
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while monitoring events: {e}", e);
}
}
});
} }
#region Lifecycle #endregion
#region Lifetime
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask; => Task.CompletedTask;
@@ -291,33 +241,97 @@ public class ServerService : IHostedLifecycleService
public async Task StartedAsync(CancellationToken cancellationToken) public async Task StartedAsync(CancellationToken cancellationToken)
{ {
try await MonitorContainers();
{
await Initialize(); await InitializeAll();
}
catch (Exception e)
{
Logger.LogCritical("Unable to initialize servers. Is the panel online? Error: {e}", e);
}
} }
public Task StartingAsync(CancellationToken cancellationToken) public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask; => Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) public async Task StoppedAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StoppingAsync(CancellationToken cancellationToken)
{ {
try foreach (var server in Servers.Values)
{ await server.DisposeAsync();
await Stop();
} await TaskCancellation.CancelAsync();
catch (Exception e)
{
Logger.LogCritical("Unable to stop server handling: {e}", e);
}
} }
public Task StoppingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
#endregion #endregion
/*
*var existingContainers = await dockerClient.Containers.ListContainersAsync(new()
{
All = true,
Limit = null,
Filters = new Dictionary<string, IDictionary<string, bool>>()
{
{
"label",
new Dictionary<string, bool>()
{
{
"Software=Moonlight-Panel",
true
}
}
}
}
});
*
*
*public async Task Delete(int serverId)
{
var server = GetServer(serverId);
// If a server with this id doesn't exist we can just exit
if (server == null)
return;
if (server.State == ServerState.Installing)
throw new HttpApiException("Unable to delete a server while it is installing", 400);
#region Callbacks
var deleteCompletion = new TaskCompletionSource();
async Task HandleStateChange(ServerState state)
{
if (state == ServerState.Offline)
await DeleteServer();
}
async Task DeleteServer()
{
await server.CancelTasks();
await server.DestroyStorage();
await server.RemoveInstallationVolume();
await server.RemoveRuntimeVolume();
deleteCompletion.SetResult();
lock (Servers)
Servers.Remove(server);
}
#endregion
// If the server is still online, we are killing it and then
// waiting for the callback to trigger notifying us that the server is now offline
// so we can delete it. The request will pause until then using the deleteCompletion task
if (server.State != ServerState.Offline)
{
server.OnStateChanged += HandleStateChange;
await server.Kill();
await deleteCompletion.Task;
}
else
await DeleteServer();
}
*
*/
} }

View File

@@ -11,6 +11,7 @@ using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers; using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Http.Hubs; using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon; namespace MoonlightServers.Daemon;