From b955bd3527c9d79f737473726699ef7ee30fb68d Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 29 May 2025 21:56:38 +0200 Subject: [PATCH] Refactored/recreated server system. Seperated into sub systems. Still wip --- .../Abstractions/Server.Console.cs | 80 --- .../Abstractions/Server.Crash.cs | 41 -- .../Abstractions/Server.Create.cs | 38 -- .../Abstractions/Server.Destroy.cs | 58 -- .../Abstractions/Server.Initialize.cs | 190 ------- .../Abstractions/Server.Installation.cs | 132 ----- .../Abstractions/Server.Kill.cs | 28 - .../Abstractions/Server.Notify.cs | 9 - .../Abstractions/Server.Start.cs | 44 -- .../Abstractions/Server.Stop.cs | 26 - .../Abstractions/Server.Storage.cs | 121 ---- .../Abstractions/Server.cs | 65 --- .../Controllers/Servers/DownloadController.cs | 17 +- .../Servers/ServerFileSystemController.cs | 56 +- .../Servers/ServerPowerController.cs | 17 +- .../Controllers/Servers/ServersController.cs | 14 +- .../Controllers/Servers/UploadController.cs | 10 +- .../ServerSystem/Server.cs | 183 ++++++ .../ServerSystem/ServerState.cs | 10 + .../ServerSystem/ServerSubSystem.cs | 27 + .../ServerSystem/ServerTrigger.cs | 12 + .../SubSystems/ConsoleSubSystem.cs | 145 +++++ .../ServerSystem/SubSystems/DebugSubSystem.cs | 19 + .../SubSystems/InstallationSubSystem.cs | 238 ++++++++ .../SubSystems/OnlineDetectionService.cs | 45 ++ .../SubSystems/ProvisionSubSystem.cs | 219 +++++++ .../SubSystems/RestoreSubSystem.cs | 110 ++++ .../SubSystems/ShutdownSubSystem.cs | 85 +++ .../SubSystems/StorageSubSystem.cs | 147 +++++ .../Services/DockerImageService.cs | 95 ++-- .../Services/ServerService.cs | 534 +++++++++--------- MoonlightServers.Daemon/Startup.cs | 1 + 32 files changed, 1642 insertions(+), 1174 deletions(-) delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Console.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Crash.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Create.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Destroy.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Initialize.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Installation.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Kill.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Notify.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Start.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Stop.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.Storage.cs delete mode 100644 MoonlightServers.Daemon/Abstractions/Server.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/Server.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/ServerState.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/ServerSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/ServerTrigger.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/ConsoleSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/DebugSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/InstallationSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/OnlineDetectionService.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/ProvisionSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/RestoreSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/ShutdownSubSystem.cs create mode 100644 MoonlightServers.Daemon/ServerSystem/SubSystems/StorageSubSystem.cs diff --git a/MoonlightServers.Daemon/Abstractions/Server.Console.cs b/MoonlightServers.Daemon/Abstractions/Server.Console.cs deleted file mode 100644 index d11948e..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Console.cs +++ /dev/null @@ -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(); - - 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 GetConsoleMessages() - => Task.FromResult(Console.Messages); -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Crash.cs b/MoonlightServers.Daemon/Abstractions/Server.Crash.cs deleted file mode 100644 index 9d1cc8c..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Crash.cs +++ /dev/null @@ -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(); - - 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"); - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Create.cs b/MoonlightServers.Daemon/Abstractions/Server.Create.cs deleted file mode 100644 index 80cf8a8..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Create.cs +++ /dev/null @@ -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(); - - // 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(); - - 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(); - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Destroy.cs b/MoonlightServers.Daemon/Abstractions/Server.Destroy.cs deleted file mode 100644 index 29bb911..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Destroy.cs +++ /dev/null @@ -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(); - - 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(); - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs b/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs deleted file mode 100644 index f70e262..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Initialize.cs +++ /dev/null @@ -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? 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(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 ReAttach(IList 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 -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Installation.cs b/MoonlightServers.Daemon/Abstractions/Server.Installation.cs deleted file mode 100644 index 545f3f1..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Installation.cs +++ /dev/null @@ -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(); - - 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(); - - // 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(); - - // 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(); - - 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(); - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Kill.cs b/MoonlightServers.Daemon/Abstractions/Server.Kill.cs deleted file mode 100644 index 9f08316..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Kill.cs +++ /dev/null @@ -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(); - 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); - } - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Notify.cs b/MoonlightServers.Daemon/Abstractions/Server.Notify.cs deleted file mode 100644 index 2ec5479..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Notify.cs +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Start.cs b/MoonlightServers.Daemon/Abstractions/Server.Start.cs deleted file mode 100644 index 259cfe3..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Start.cs +++ /dev/null @@ -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(); - 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(); - 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); - } - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Stop.cs b/MoonlightServers.Daemon/Abstractions/Server.Stop.cs deleted file mode 100644 index 174009c..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Stop.cs +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.Storage.cs b/MoonlightServers.Daemon/Abstractions/Server.Storage.cs deleted file mode 100644 index ed69e2b..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.Storage.cs +++ /dev/null @@ -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(); - - // 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; - } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Abstractions/Server.cs b/MoonlightServers.Daemon/Abstractions/Server.cs deleted file mode 100644 index 144d8aa..0000000 --- a/MoonlightServers.Daemon/Abstractions/Server.cs +++ /dev/null @@ -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? OnStateChanged; - public event Func? OnConsoleOutput; - - // Private stuff - - private readonly ILogger Logger; - private readonly IServiceProvider ServiceProvider; - private readonly ServerConsole Console; - - private readonly IHubContext WebSocketHub; - - private StateMachine StateMachine; - private ServerConfiguration Configuration; - private CancellationTokenSource Cancellation; - private AppConfiguration AppConfiguration; - - public Server( - ILogger logger, - IServiceProvider serviceProvider, - ServerConfiguration configuration, - IHubContext 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; -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/DownloadController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/DownloadController.cs index 54bebb7..6e5a51d 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/DownloadController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/DownloadController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MoonCore.Exceptions; -using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.ServerSystem.SubSystems; using MoonlightServers.Daemon.Services; 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 path = User.Claims.First(x => x.Type == "path").Value; - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); - await server.FileSystem.Read(path, - async dataStream => { await Results.File(dataStream).ExecuteAsync(HttpContext); }); + var storageSubSystem = server.GetRequiredSubSystem(); + + var fileSystem = await storageSubSystem.GetFileSystem(); + + await fileSystem.Read( + path, + async dataStream => + { + await Results.File(dataStream).ExecuteAsync(HttpContext); + } + ); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerFileSystemController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerFileSystemController.cs index 425b32a..ae4be8b 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerFileSystemController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerFileSystemController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MoonCore.Exceptions; +using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.ServerSystem.SubSystems; using MoonlightServers.Daemon.Services; using MoonlightServers.DaemonShared.DaemonSide.Http.Requests; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers; @@ -22,56 +24,41 @@ public class ServerFileSystemController : Controller [HttpGet("{id:int}/files/list")] public async Task List([FromRoute] int id, [FromQuery] string path = "") { - var server = ServerService.GetServer(id); + var fileSystem = await GetFileSystemById(id); - if (server == null) - throw new HttpApiException("No server with this id found", 404); - - return await server.FileSystem.List(path); + return await fileSystem.List(path); } [HttpPost("{id:int}/files/move")] 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) - throw new HttpApiException("No server with this id found", 404); - - await server.FileSystem.Move(oldPath, newPath); + await fileSystem.Move(oldPath, newPath); } [HttpDelete("{id:int}/files/delete")] public async Task Delete([FromRoute] int id, [FromQuery] string path) { - var server = ServerService.GetServer(id); + var fileSystem = await GetFileSystemById(id); - if (server == null) - throw new HttpApiException("No server with this id found", 404); - - await server.FileSystem.Delete(path); + await fileSystem.Delete(path); } [HttpPost("{id:int}/files/mkdir")] public async Task Mkdir([FromRoute] int id, [FromQuery] string path) { - var server = ServerService.GetServer(id); + var fileSystem = await GetFileSystemById(id); - if (server == null) - throw new HttpApiException("No server with this id found", 404); - - await server.FileSystem.Mkdir(path); + await fileSystem.Mkdir(path); } [HttpPost("{id:int}/files/compress")] public async Task Compress([FromRoute] int id, [FromBody] ServerFilesCompressRequest request) { - var server = ServerService.GetServer(id); + var fileSystem = await GetFileSystemById(id); - if (server == null) - throw new HttpApiException("No server with this id found", 404); - - await server.FileSystem.Compress( + await fileSystem.Compress( request.Items, request.Destination, request.Type @@ -81,15 +68,24 @@ public class ServerFileSystemController : Controller [HttpPost("{id:int}/files/decompress")] public async Task Decompress([FromRoute] int id, [FromBody] ServerFilesDecompressRequest request) { - var server = ServerService.GetServer(id); + var fileSystem = await GetFileSystemById(id); - if (server == null) - throw new HttpApiException("No server with this id found", 404); - - await server.FileSystem.Decompress( + await fileSystem.Decompress( request.Path, request.Destination, request.Type ); } + + private async Task 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(); + + return await storageSubSystem.GetFileSystem(); + } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs index e5031ca..7441757 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/ServerPowerController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using MoonCore.Exceptions; using MoonlightServers.Daemon.Enums; using MoonlightServers.Daemon.Services; +using ServerTrigger = MoonlightServers.Daemon.ServerSystem.ServerTrigger; namespace MoonlightServers.Daemon.Http.Controllers.Servers; @@ -21,44 +22,44 @@ public class ServerPowerController : Controller [HttpPost("{serverId:int}/start")] public async Task Start(int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); - await server.Start(); + await server.Trigger(ServerTrigger.Start); } [HttpPost("{serverId:int}/stop")] public async Task Stop(int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); - await server.Stop(); + await server.Trigger(ServerTrigger.Stop); } [HttpPost("{serverId:int}/install")] public async Task Install(int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); - await server.Install(); + await server.Trigger(ServerTrigger.Install); } [HttpPost("{serverId:int}/kill")] public async Task Kill(int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); - await server.Kill(); + await server.Trigger(ServerTrigger.Kill); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs index b7568ec..238c91e 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MoonCore.Exceptions; +using MoonlightServers.Daemon.ServerSystem.SubSystems; using MoonlightServers.Daemon.Services; using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers; using MoonlightServers.DaemonShared.Enums; @@ -28,20 +29,20 @@ public class ServersController : Controller [HttpDelete("{serverId:int}")] public async Task Delete([FromRoute] int serverId) { - await ServerService.Delete(serverId); + //await ServerService.Delete(serverId); } [HttpGet("{serverId:int}/status")] public Task GetStatus([FromRoute] int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); var result = new ServerStatusResponse() { - State = (ServerState)server.State + State = (ServerState)server.StateMachine.State }; return Task.FromResult(result); @@ -50,14 +51,17 @@ public class ServersController : Controller [HttpGet("{serverId:int}/logs")] public async Task GetLogs([FromRoute] int serverId) { - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); + + var consoleSubSystem = server.GetRequiredSubSystem(); + var messages = await consoleSubSystem.RetrieveCache(); return new ServerLogsResponse() { - Messages = await server.GetConsoleMessages() + Messages = messages }; } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/UploadController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/UploadController.cs index 91296d0..fc62007 100644 --- a/MoonlightServers.Daemon/Http/Controllers/Servers/UploadController.cs +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/UploadController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using MoonCore.Exceptions; using MoonCore.Helpers; using MoonlightServers.Daemon.Configuration; -using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.ServerSystem.SubSystems; using MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon.Http.Controllers.Servers; @@ -64,14 +64,18 @@ public class UploadController : Controller #endregion - var server = ServerService.GetServer(serverId); + var server = ServerService.Find(serverId); if (server == null) throw new HttpApiException("No server with this id found", 404); + var storageSubSystem = server.GetRequiredSubSystem(); + + var fileSystem = await storageSubSystem.GetFileSystem(); + var dataStream = file.OpenReadStream(); - await server.FileSystem.CreateChunk( + await fileSystem.CreateChunk( path, totalSize, positionToSkipTo, diff --git a/MoonlightServers.Daemon/ServerSystem/Server.cs b/MoonlightServers.Daemon/ServerSystem/Server.cs new file mode 100644 index 0000000..f778778 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/Server.cs @@ -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 StateMachine { get; private set; } + private CancellationTokenSource TaskCancellationSource; + + private Dictionary SubSystems = new(); + private ServerState InternalState = ServerState.Offline; + + private readonly IHubContext HubContext; + private readonly IServiceScope ServiceScope; + private readonly ILoggerFactory LoggerFactory; + private readonly ILogger Logger; + + + public Server( + ServerConfiguration configuration, + IServiceScope serviceScope, + IHubContext hubContext + ) + { + Configuration = configuration; + ServiceScope = serviceScope; + HubContext = hubContext; + + TaskCancellationSource = new CancellationTokenSource(); + + LoggerFactory = serviceScope.ServiceProvider.GetRequiredService(); + Logger = LoggerFactory.CreateLogger($"Server {Configuration.Id}"); + + StateMachine = new StateMachine( + () => 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() where T : ServerSubSystem + { + var type = typeof(T); + var subSystem = SubSystems.GetValueOrDefault(type); + + if (subSystem == null) + return null; + + return subSystem as T; + } + + public T GetRequiredSubSystem() where T : ServerSubSystem + { + var subSystem = GetSubSystem(); + + 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(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/ServerState.cs b/MoonlightServers.Daemon/ServerSystem/ServerState.cs new file mode 100644 index 0000000..693f495 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/ServerState.cs @@ -0,0 +1,10 @@ +namespace MoonlightServers.Daemon.ServerSystem; + +public enum ServerState +{ + Offline = 0, + Starting = 1, + Online = 2, + Stopping = 3, + Installing = 4 +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/ServerSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/ServerSubSystem.cs new file mode 100644 index 0000000..e977b32 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/ServerSubSystem.cs @@ -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 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; +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/ServerTrigger.cs b/MoonlightServers.Daemon/ServerSystem/ServerTrigger.cs new file mode 100644 index 0000000..48a3256 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/ServerTrigger.cs @@ -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 +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/ConsoleSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/ConsoleSubSystem.cs new file mode 100644 index 0000000..866f149 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/ConsoleSubSystem.cs @@ -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? OnOutput; + public event Func? OnInput; + + private MultiplexedStream? Stream; + private readonly List OutputCache = new(); + + private readonly IHubContext HubContext; + private readonly DockerClient DockerClient; + + public ConsoleSubSystem( + Server server, + ILogger logger, + IHubContext 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 RetrieveCache() + { + string[] result; + + lock (OutputCache) + result = OutputCache.ToArray(); + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/DebugSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/DebugSubSystem.cs new file mode 100644 index 0000000..bb22184 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/DebugSubSystem.cs @@ -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; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/InstallationSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/InstallationSubSystem.cs new file mode 100644 index 0000000..dc03392 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/InstallationSubSystem.cs @@ -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(); + + // 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(); + + 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(); + + // 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(); + + Logger.LogDebug("Removing installation data"); + await consoleSubSystem.WriteMoonlight("Removing installation data"); + + await storageSubSystem.DeleteInstallVolume(); + } + + #endregion +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/OnlineDetectionService.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/OnlineDetectionService.cs new file mode 100644 index 0000000..ffadb13 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/OnlineDetectionService.cs @@ -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.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; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/ProvisionSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/ProvisionSubSystem.cs new file mode 100644 index 0000000..aeeb503 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/ProvisionSubSystem.cs @@ -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(); + + // 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(); + + 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.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(); + + // 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 +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/RestoreSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/RestoreSubSystem.cs new file mode 100644 index 0000000..cde9c7d --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/RestoreSubSystem.cs @@ -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(); + + // Override values + provisionSubSystem.CurrentContainerId = runtimeContainer.ID; + Server.OverrideState(ServerState.Online); + + var consoleSubSystem = Server.GetRequiredSubSystem(); + + 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(); + + // Override values + installationSubSystem.CurrentContainerId = installContainer.ID; + Server.OverrideState(ServerState.Installing); + + var consoleSubSystem = Server.GetRequiredSubSystem(); + + 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 + } + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/ShutdownSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/ShutdownSubSystem.cs new file mode 100644 index 0000000..8865e59 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/ShutdownSubSystem.cs @@ -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(); + + // 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(); + 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(); + + await DockerClient.Containers.KillContainerAsync( + provisionSubSystem.CurrentContainerId, + new() + ); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSystem/SubSystems/StorageSubSystem.cs b/MoonlightServers.Daemon/ServerSystem/SubSystems/StorageSubSystem.cs new file mode 100644 index 0000000..052e72f --- /dev/null +++ b/MoonlightServers.Daemon/ServerSystem/SubSystems/StorageSubSystem.cs @@ -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 GetFileSystem() + { + if (!IsInitialized) + throw new HttpApiException("The file system is still initializing. Please try again later", 503); + + return Task.FromResult(ServerFileSystem); + } + + public Task IsRuntimeVolumeReady() + { + return Task.FromResult(IsInitialized); + } + + private async Task EnsureRuntimeVolume() + { + var path = await GetRuntimeHostPath(); + + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + + var consoleSubSystem = Server.GetRequiredSubSystem(); + + 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 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 EnsureInstallVolume() + { + var path = await GetInstallHostPath(); + + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + + return path; + } + + public Task 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; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Services/DockerImageService.cs b/MoonlightServers.Daemon/Services/DockerImageService.cs index c7b141a..0c6d6c6 100644 --- a/MoonlightServers.Daemon/Services/DockerImageService.cs +++ b/MoonlightServers.Daemon/Services/DockerImageService.cs @@ -12,6 +12,8 @@ public class DockerImageService private readonly AppConfiguration Configuration; private readonly ILogger Logger; + private readonly Dictionary PendingDownloads = new(); + public DockerImageService( DockerClient dockerClient, ILogger logger, @@ -23,55 +25,84 @@ public class DockerImageService Logger = logger; } - public async Task Ensure(string name, Action? onProgressUpdated) + public async Task Download(string name, Action? onProgressUpdated = null) { - // 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)); - - if (configuredCredentials != null) + // If there is already a download for this image occuring, we want to wait for this to complete instead + // of calling docker to download it again + if (PendingDownloads.TryGetValue(name, out var downloadTaskCompletion)) { - credentials.Username = configuredCredentials.Username; - credentials.Password = configuredCredentials.Password; - credentials.Email = configuredCredentials.Email; + await downloadTaskCompletion.Task; + return; } - - // Now we want to pull the image - await DockerClient.Images.CreateImageAsync(new() + + var tsc = new TaskCompletionSource(); + 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, - new Progress(async message => - { - if (message.Progress == null) - return; + credentials.Username = configuredCredentials.Username; + credentials.Password = configuredCredentials.Password; + credentials.Email = configuredCredentials.Email; + } - var line = $"[{message.ID}] {message.ProgressMessage}"; + // Now we want to pull the image + await DockerClient.Images.CreateImageAsync(new() + { + FromImage = name + }, + credentials, + new Progress(async message => + { + if (message.Progress == null) + return; - Logger.LogDebug("{line}", line); + var line = $"[{message.ID}] {message.ProgressMessage}"; - if (onProgressUpdated != null) - onProgressUpdated.Invoke(line); - }) - ); + Logger.LogDebug("{line}", 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 ;) { var nameParts = name.Split("/"); - + // If it has 1 part -> just the image name (e.g., "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 (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 "docker.io"; // Default Docker registry } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Services/ServerService.cs b/MoonlightServers.Daemon/Services/ServerService.cs index 7f6316b..1f615b8 100644 --- a/MoonlightServers.Daemon/Services/ServerService.cs +++ b/MoonlightServers.Daemon/Services/ServerService.cs @@ -1,15 +1,14 @@ +using System.Collections.Concurrent; using Docker.DotNet; using Docker.DotNet.Models; using Microsoft.AspNetCore.SignalR; using MoonCore.Attributes; -using MoonCore.Exceptions; using MoonCore.Models; -using MoonlightServers.Daemon.Abstractions; -using MoonlightServers.Daemon.Configuration; -using MoonlightServers.Daemon.Enums; using MoonlightServers.Daemon.Extensions; using MoonlightServers.Daemon.Http.Hubs; using MoonlightServers.Daemon.Models.Cache; +using MoonlightServers.Daemon.ServerSystem; +using MoonlightServers.Daemon.ServerSystem.SubSystems; using MoonlightServers.DaemonShared.PanelSide.Http.Responses; namespace MoonlightServers.Daemon.Services; @@ -17,271 +16,222 @@ namespace MoonlightServers.Daemon.Services; [Singleton] public class ServerService : IHostedLifecycleService { - private readonly List Servers = new(); - private readonly ILogger Logger; + private readonly Dictionary Servers = new(); + private readonly RemoteService RemoteService; + private readonly DockerClient DockerClient; private readonly IServiceProvider ServiceProvider; - private readonly ILoggerFactory LoggerFactory; - private readonly IHubContext WebSocketHub; - private readonly AppConfiguration Configuration; - private CancellationTokenSource Cancellation = new(); - private bool IsInitialized = false; + private readonly CancellationTokenSource TaskCancellation; + private readonly ILogger Logger; + private readonly IHubContext HubContext; public ServerService( RemoteService remoteService, - ILogger logger, IServiceProvider serviceProvider, - ILoggerFactory loggerFactory, - IHubContext webSocketHub, - AppConfiguration configuration + DockerClient dockerClient, + ILogger logger, + IHubContext hubContext ) { RemoteService = remoteService; - Logger = logger; ServiceProvider = serviceProvider; - LoggerFactory = loggerFactory; - WebSocketHub = webSocketHub; - Configuration = configuration; - } + DockerClient = dockerClient; + Logger = logger; + HubContext = hubContext; - public async Task Initialize() //TODO: Add initialize call from panel - { - 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.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(); - - 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(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(); - - var existingContainers = await dockerClient.Containers.ListContainersAsync(new() - { - All = true, - Limit = null, - Filters = new Dictionary>() - { - { - "label", - new Dictionary() - { - { - "Software=Moonlight-Panel", - true - } - } - } - } - }); - - foreach (var configuration in serverConfigurations) - await InitializeServer(configuration, existingContainers); - } - - public async Task InitializeServer( - ServerConfiguration serverConfiguration, - IList 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; + TaskCancellation = new CancellationTokenSource(); } 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(); + + for (var i = 0; i < pages; i++) + batchesLeft.Enqueue(i); + + var tasksCount = pages > 5 ? 5 : pages; + var tasks = new List(); + + 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 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 serverConfiguration = serverData.ToServerConfiguration(); + var configuration = serverData.ToServerConfiguration(); - var server = GetServer(serverId); - - if (server == null) - await InitializeServer(serverConfiguration, []); - else - server.UpdateConfiguration(serverConfiguration); + await Initialize(configuration); } - 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 - if (server == null) - return; + var server = new Server(configuration, serverScope, HubContext); - if (server.State == ServerState.Installing) - throw new HttpApiException("Unable to delete a server while it is installing", 400); + Type[] subSystems = + [ + 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(); - - 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(); + Servers[configuration.Id] = server; } - public Server? GetServer(int id) + #region Docker Monitoring + + private async Task MonitorContainers() { - lock (Servers) - return Servers.FirstOrDefault(x => x.Id == id); + Task.Run(async () => + { + // Restart unless shutdown is requested + while (!TaskCancellation.Token.IsCancellationRequested) + { + try + { + Logger.LogTrace("Starting to monitor events"); + + await DockerClient.System.MonitorEventsAsync(new(), + new Progress(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(); + + if (provisionSubSystem.CurrentContainerId == message.ID) + return true; + + var installationSubSystem = serverToCheck.GetRequiredSubSystem(); + + 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) => Task.CompletedTask; @@ -291,33 +241,97 @@ public class ServerService : IHostedLifecycleService public async Task StartedAsync(CancellationToken cancellationToken) { - try - { - await Initialize(); - } - catch (Exception e) - { - Logger.LogCritical("Unable to initialize servers. Is the panel online? Error: {e}", e); - } + await MonitorContainers(); + + await InitializeAll(); } public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - public Task StoppedAsync(CancellationToken cancellationToken) - => Task.CompletedTask; - - public async Task StoppingAsync(CancellationToken cancellationToken) + public async Task StoppedAsync(CancellationToken cancellationToken) { - try - { - await Stop(); - } - catch (Exception e) - { - Logger.LogCritical("Unable to stop server handling: {e}", e); - } + foreach (var server in Servers.Values) + await server.DisposeAsync(); + + await TaskCancellation.CancelAsync(); } + public Task StoppingAsync(CancellationToken cancellationToken) + => Task.CompletedTask; + #endregion + + /* + *var existingContainers = await dockerClient.Containers.ListContainersAsync(new() + { + All = true, + Limit = null, + Filters = new Dictionary>() + { + { + "label", + new Dictionary() + { + { + "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(); + } + * + */ } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Startup.cs b/MoonlightServers.Daemon/Startup.cs index a6efe95..ab7c369 100644 --- a/MoonlightServers.Daemon/Startup.cs +++ b/MoonlightServers.Daemon/Startup.cs @@ -11,6 +11,7 @@ using MoonCore.Helpers; using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Helpers; using MoonlightServers.Daemon.Http.Hubs; +using MoonlightServers.Daemon.ServerSystem; using MoonlightServers.Daemon.Services; namespace MoonlightServers.Daemon;