Cleaned up interfaces. Extracted server state machine trigger handler to seperated classes. Removed legacy code

This commit is contained in:
2025-09-06 15:34:35 +02:00
parent 7587a7e8e3
commit 348e9560ab
97 changed files with 1256 additions and 4670 deletions

View File

@@ -1,44 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[ApiController]
[Route("api/servers/download")]
[Authorize(AuthenticationSchemes = "accessToken", Policy = "serverDownload")]
public class DownloadController : Controller
{
private readonly ServerService ServerService;
public DownloadController(ServerService serverService)
{
ServerService = serverService;
}
[HttpGet]
public async Task Download()
{
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.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
var fileSystem = await storageSubSystem.GetFileSystem();
await fileSystem.Read(
path,
async dataStream =>
{
await Results.File(dataStream).ExecuteAsync(HttpContext);
}
);
}
}

View File

@@ -1,99 +0,0 @@
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;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[Authorize]
[ApiController]
[Route("api/servers/{id:int}/files")]
public class ServerFileSystemController : Controller
{
private readonly ServerService ServerService;
public ServerFileSystemController(ServerService serverService)
{
ServerService = serverService;
}
[HttpGet("list")]
public async Task<ServerFileSystemResponse[]> List([FromRoute] int id, [FromQuery] string path = "")
{
var fileSystem = await GetFileSystemById(id);
return await fileSystem.List(path);
}
[HttpPost("move")]
public async Task Move([FromRoute] int id, [FromQuery] string oldPath, [FromQuery] string newPath)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Move(oldPath, newPath);
}
[HttpDelete("delete")]
public async Task Delete([FromRoute] int id, [FromQuery] string path)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Delete(path);
}
[HttpPost("mkdir")]
public async Task Mkdir([FromRoute] int id, [FromQuery] string path)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Mkdir(path);
}
[HttpPost("touch")]
public async Task Touch([FromRoute] int id, [FromQuery] string path)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Touch(path);
}
[HttpPost("compress")]
public async Task Compress([FromRoute] int id, [FromBody] ServerFilesCompressRequest request)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Compress(
request.Items,
request.Destination,
request.Type
);
}
[HttpPost("decompress")]
public async Task Decompress([FromRoute] int id, [FromBody] ServerFilesDecompressRequest request)
{
var fileSystem = await GetFileSystemById(id);
await fileSystem.Decompress(
request.Path,
request.Destination,
request.Type
);
}
private async Task<ServerFileSystem> GetFileSystemById(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
return await storageSubSystem.GetFileSystem();
}
}

View File

@@ -1,65 +0,0 @@
using Microsoft.AspNetCore.Authorization;
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;
[Authorize]
[ApiController]
[Route("api/servers")]
public class ServerPowerController : Controller
{
private readonly NewServerService ServerService;
public ServerPowerController(NewServerService serverService)
{
ServerService = serverService;
}
[HttpPost("{serverId:int}/start")]
public async Task Start(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.StateMachine.FireAsync(ServerTrigger.Start);
}
[HttpPost("{serverId:int}/stop")]
public async Task Stop(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.StateMachine.FireAsync(ServerTrigger.Stop);
}
[HttpPost("{serverId:int}/install")]
public async Task Install(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.StateMachine.FireAsync(ServerTrigger.Install);
}
[HttpPost("{serverId:int}/kill")]
public async Task Kill(int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.StateMachine.FireAsync(ServerTrigger.Kill);
}
}

View File

@@ -1,109 +0,0 @@
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.Requests;
using MoonlightServers.DaemonShared.DaemonSide.Http.Responses.Servers;
using MoonlightServers.DaemonShared.Enums;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[Authorize]
[ApiController]
[Route("api/servers/{serverId:int}")]
public class ServersController : Controller
{
private readonly NewServerService ServerService;
public ServersController(NewServerService serverService)
{
ServerService = serverService;
}
[HttpPost("sync")]
public async Task Sync([FromRoute] int serverId)
{
await ServerService.Sync(serverId);
}
[HttpDelete]
public async Task Delete([FromRoute] int serverId)
{
await ServerService.Delete(serverId);
}
[HttpGet("status")]
public Task<ServerStatusResponse> GetStatus([FromRoute] int 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.StateMachine.State
};
return Task.FromResult(result);
}
[HttpGet("logs")]
public async Task<ServerLogsResponse> GetLogs([FromRoute] int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var messages = server.Console.GetOutput();
return new ServerLogsResponse()
{
Messages = messages
};
}
[HttpGet("stats")]
public Task<ServerStatsResponse> GetStats([FromRoute] int serverId)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
/*
var statsSubSystem = server.GetRequiredSubSystem<StatsSubSystem>();
return Task.FromResult<ServerStatsResponse>(new()
{
CpuUsage = statsSubSystem.CurrentStats.CpuUsage,
MemoryUsage = statsSubSystem.CurrentStats.MemoryUsage,
NetworkRead = statsSubSystem.CurrentStats.NetworkRead,
NetworkWrite = statsSubSystem.CurrentStats.NetworkWrite,
IoRead = statsSubSystem.CurrentStats.IoRead,
IoWrite = statsSubSystem.CurrentStats.IoWrite
});*/
return Task.FromResult<ServerStatsResponse>(new()
{
CpuUsage = 0,
MemoryUsage = 0,
NetworkRead = 0,
NetworkWrite = 0,
IoRead = 0,
IoWrite = 0
});
}
[HttpPost("command")]
public async Task Command([FromRoute] int serverId, [FromBody] ServerCommandRequest request)
{
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
await server.Console.WriteToInput(request.Command);
}
}

View File

@@ -1,85 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.ServerSystem.SubSystems;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.Http.Controllers.Servers;
[ApiController]
[Route("api/servers/upload")]
[Authorize(AuthenticationSchemes = "accessToken", Policy = "serverUpload")]
public class UploadController : Controller
{
private readonly AppConfiguration Configuration;
private readonly ServerService ServerService;
public UploadController(
ServerService serverService,
AppConfiguration configuration
)
{
ServerService = serverService;
Configuration = configuration;
}
[HttpPost]
public async Task Upload(
[FromQuery] long totalSize,
[FromQuery] int chunkId,
[FromQuery] string path
)
{
var chunkSize = ByteConverter.FromMegaBytes(Configuration.Files.UploadChunkSize).Bytes;
var uploadLimit = ByteConverter.FromMegaBytes(Configuration.Files.UploadSizeLimit).Bytes;
#region File validation
if (Request.Form.Files.Count != 1)
throw new HttpApiException("You need to provide exactly one file", 400);
var file = Request.Form.Files[0];
if (file.Length > chunkSize)
throw new HttpApiException("The provided data exceeds the chunk size limit", 400);
#endregion
var serverId = int.Parse(User.Claims.First(x => x.Type == "serverId").Value);
#region Chunk calculation and validation
if(totalSize > uploadLimit)
throw new HttpApiException("Invalid upload request: Exceeding upload limit", 400);
var chunks = totalSize / chunkSize;
chunks += totalSize % chunkSize > 0 ? 1 : 0;
if (chunkId > chunks)
throw new HttpApiException("Invalid chunk id: Out of bounds", 400);
var positionToSkipTo = chunkSize * chunkId;
#endregion
var server = ServerService.Find(serverId);
if (server == null)
throw new HttpApiException("No server with this id found", 404);
var storageSubSystem = server.GetRequiredSubSystem<StorageSubSystem>();
var fileSystem = await storageSubSystem.GetFileSystem();
var dataStream = file.OpenReadStream();
await fileSystem.CreateChunk(
path,
totalSize,
positionToSkipTo,
dataStream
);
}
}

View File

@@ -19,7 +19,9 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Http\Controllers\Servers\" />
<Folder Include="Http\Middleware\" />
<Folder Include="ServerSystem\Docker\Components\" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,26 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IConsole : IServerComponent
{
public IAsyncObservable<string> OnOutput { get; }
public IAsyncObservable<string> OnInput { get; }
public Task AttachToRuntime();
public Task AttachToInstallation();
/// <summary>
/// Detaches any attached consoles. Usually either runtime or install is attached
/// </summary>
/// <returns></returns>
public Task Detach();
public Task CollectFromRuntime();
public Task CollectFromInstallation();
public Task WriteToOutput(string content);
public Task WriteToInput(string content);
public Task WriteToMoonlight(string content);
public Task ClearOutput();
public string[] GetOutput();
}

View File

@@ -1,14 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IFileSystem : IServerComponent
{
public bool IsMounted { get; }
public bool Exists { get; }
public Task Create();
public Task Mount();
public Task Unmount();
public Task Delete();
public string GetExternalPath();
}

View File

@@ -1,14 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IInstaller : IServerComponent
{
public IAsyncObservable<object> OnExited { get; }
public bool IsRunning { get; }
public Task Setup();
public Task Start();
public Task Abort();
public Task Cleanup();
public Task<ServerCrash?> SearchForCrash();
}

View File

@@ -1,6 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IOnlineDetection : IServerComponent
{
}

View File

@@ -1,15 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IProvisioner : IServerComponent
{
public IAsyncObservable<object> OnExited { get; }
public bool IsProvisioned { get; }
public Task Provision();
public Task Start();
public Task Stop();
public Task Kill();
public Task Deprovision();
public Task<ServerCrash?> SearchForCrash();
}

View File

@@ -1,8 +0,0 @@
using MoonlightServers.Daemon.ServerSystem;
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IRestorer : IServerComponent
{
public Task<ServerState> Restore();
}

View File

@@ -1,7 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IServerComponent : IAsyncDisposable
{
public Task Initialize();
public Task Sync();
}

View File

@@ -1,11 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IStatistics : IServerComponent
{
public IAsyncObservable<ServerStats> OnStats { get; }
public Task SubscribeToRuntime();
public Task SubscribeToInstallation();
public ServerStats[] GetStats(int count);
}

View File

@@ -1,348 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using MoonCore.Observability;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.Daemon.Services;
using Stateless;
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public class Server : IAsyncDisposable
{
public IConsole Console { get; }
public IFileSystem FileSystem { get; }
public IInstaller Installer { get; }
public IProvisioner Provisioner { get; }
public IRestorer Restorer { get; }
public IStatistics Statistics { get; }
public IOnlineDetection OnlineDetection { get; }
public StateMachine<ServerState, ServerTrigger> StateMachine { get; private set; }
public ServerContext Context { get; }
public IAsyncObservable<ServerState> OnState => OnStateSubject;
private readonly EventSubject<ServerState> OnStateSubject = new();
private readonly ILogger Logger;
private readonly RemoteService RemoteService;
private readonly ServerConfigurationMapper Mapper;
private readonly IHubContext<ServerWebSocketHub> HubContext;
private IAsyncDisposable? ProvisionExitSubscription;
private IAsyncDisposable? InstallerExitSubscription;
private IAsyncDisposable? ConsoleSubscription;
public Server(
ILoggerFactory loggerFactory,
IConsole console,
IFileSystem fileSystem,
IInstaller installer,
IProvisioner provisioner,
IRestorer restorer,
IStatistics statistics,
IOnlineDetection onlineDetection,
ServerContext context,
RemoteService remoteService,
ServerConfigurationMapper mapper,
IHubContext<ServerWebSocketHub> hubContext)
{
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(Server)}");
Console = console;
FileSystem = fileSystem;
Installer = installer;
Provisioner = provisioner;
Restorer = restorer;
Statistics = statistics;
Context = context;
RemoteService = remoteService;
Mapper = mapper;
HubContext = hubContext;
OnlineDetection = onlineDetection;
}
public async Task Initialize()
{
Logger.LogDebug("Initializing server components");
IServerComponent[] components = [Console, Restorer, FileSystem, Installer, Provisioner, Statistics, OnlineDetection];
foreach (var serverComponent in components)
{
try
{
await serverComponent.Initialize();
}
catch (Exception e)
{
Logger.LogError(
e,
"Error initializing server component: {type}",
serverComponent.GetType().Name.GetType().FullName
);
throw;
}
}
Logger.LogDebug("Restoring server");
var restoredState = await Restorer.Restore();
if (restoredState == ServerState.Offline)
Logger.LogDebug("Restorer didnt find anything to restore. State is offline");
else
Logger.LogDebug("Restored server to state: {state}", restoredState);
CreateStateMachine(restoredState);
await SetupHubEvents();
// Setup event handling
ProvisionExitSubscription = await Provisioner.OnExited.SubscribeEventAsync(async _ =>
await StateMachine.FireAsync(ServerTrigger.Exited)
);
InstallerExitSubscription = await Installer.OnExited.SubscribeEventAsync(async _ =>
await StateMachine.FireAsync(ServerTrigger.Exited)
);
}
public async Task Sync()
{
IServerComponent[] components = [Console, Restorer, FileSystem, Installer, Provisioner, Statistics, OnlineDetection];
foreach (var component in components)
await component.Sync();
}
private void CreateStateMachine(ServerState initialState)
{
StateMachine = new StateMachine<ServerState, ServerTrigger>(initialState, FiringMode.Queued);
StateMachine.OnTransitionedAsync(async transition
=> await OnStateSubject.OnNextAsync(transition.Destination)
);
// 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) // TODO: Add kill
.Permit(ServerTrigger.Exited, ServerState.Offline);
// Handle transitions
StateMachine.Configure(ServerState.Starting)
.OnEntryAsync(HandleStart)
.OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit);
StateMachine.Configure(ServerState.Installing)
.OnEntryAsync(HandleInstall)
.OnExitFromAsync(ServerTrigger.Exited, HandleInstallExit);
StateMachine.Configure(ServerState.Online)
.OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit);
StateMachine.Configure(ServerState.Stopping)
.OnEntryFromAsync(ServerTrigger.Stop, HandleStop)
.OnEntryFromAsync(ServerTrigger.Kill, HandleKill)
.OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit);
}
private async Task SetupHubEvents()
{
var groupName = Context.Configuration.Id.ToString();
ConsoleSubscription = await Console.OnOutput.SubscribeAsync(async line =>
{
await HubContext.Clients.Group(groupName).SendAsync(
"ConsoleOutput",
line
);
});
StateMachine.OnTransitionedAsync(async transition =>
{
await HubContext.Clients.Group(groupName).SendAsync(
"StateChanged",
transition.Destination.ToString()
);
});
}
public async Task Delete()
{
if (Installer.IsRunning)
{
Logger.LogDebug("Installer still running. Aborting and cleaning up");
await Installer.Abort();
await Installer.Cleanup();
}
if (Provisioner.IsProvisioned)
await Provisioner.Deprovision();
if (FileSystem.IsMounted)
await FileSystem.Unmount();
await FileSystem.Delete();
}
#region State machine handlers
private async Task HandleStart()
{
try
{
// Plan for starting the server:
// 1. Fetch latest configuration from panel (maybe: and perform sync)
// 2. Ensure that the file system exists
// 3. Mount the file system
// 4. Provision the container
// 5. Attach console to container
// 6. Start the container
// 1. Fetch latest configuration from panel
Logger.LogDebug("Fetching latest server configuration");
await Console.WriteToMoonlight("Fetching latest server configuration");
var serverDataResponse = await RemoteService.GetServer(Context.Configuration.Id);
Context.Configuration = Mapper.FromServerDataResponse(serverDataResponse);
// 2. Ensure that the file system exists
if (!FileSystem.Exists)
{
await Console.WriteToMoonlight("Creating storage");
await FileSystem.Create();
}
// 3. Mount the file system
if (!FileSystem.IsMounted)
{
await Console.WriteToMoonlight("Mounting storage");
await FileSystem.Mount();
}
// 4. Provision the container
await Console.WriteToMoonlight("Provisioning runtime");
await Provisioner.Provision();
// 5. Attach console to container
await Console.AttachToRuntime();
// 6. Start the container
await Provisioner.Start();
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while starting the server");
}
}
private async Task HandleStop()
{
await Provisioner.Stop();
}
private async Task HandleKill()
{
await Provisioner.Kill();
}
private async Task HandleRuntimeExit()
{
Logger.LogDebug("Detected runtime exit");
Logger.LogDebug("Detaching from console");
await Console.Detach();
Logger.LogDebug("Deprovisioning");
await Console.WriteToMoonlight("Deprovisioning");
await Provisioner.Deprovision();
}
private async Task HandleInstall()
{
// Plan:
// 1. Fetch the latest installation data
// 2. Setup installation environment
// 3. Attach console to installation
// 4. Start the installation
Logger.LogDebug("Installing");
Logger.LogDebug("Setting up");
await Console.WriteToMoonlight("Setting up installation");
// 1. Fetch the latest installation data
Logger.LogDebug("Fetching installation data");
await Console.WriteToMoonlight("Fetching installation data");
Context.InstallConfiguration = await RemoteService.GetServerInstallation(Context.Configuration.Id);
// 2. Setup installation environment
await Installer.Setup();
// 3. Attach console to installation
await Console.AttachToInstallation();
// 4. Start the installation
await Installer.Start();
}
private async Task HandleInstallExit()
{
Logger.LogDebug("Detected install exit");
Logger.LogDebug("Detaching from console");
await Console.Detach();
Logger.LogDebug("Cleaning up");
await Console.WriteToMoonlight("Cleaning up");
await Installer.Cleanup();
await Console.WriteToMoonlight("Installation completed");
}
#endregion
public async ValueTask DisposeAsync()
{
if (ProvisionExitSubscription != null)
await ProvisionExitSubscription.DisposeAsync();
if (InstallerExitSubscription != null)
await InstallerExitSubscription.DisposeAsync();
if (ConsoleSubscription != null)
await ConsoleSubscription.DisposeAsync();
await Console.DisposeAsync();
await FileSystem.DisposeAsync();
await Installer.DisposeAsync();
await Provisioner.DisposeAsync();
await Restorer.DisposeAsync();
await Statistics.DisposeAsync();
}
}

View File

@@ -1,12 +0,0 @@
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public record ServerContext
{
public ServerConfiguration Configuration { get; set; }
public AsyncServiceScope ServiceScope { get; set; }
public ServerInstallDataResponse InstallConfiguration { get; set; }
public Server Self { get; set; }
}

View File

@@ -1,3 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public record ServerCrash();

View File

@@ -1,3 +0,0 @@
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public record ServerStats();

View File

@@ -1,68 +0,0 @@
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSystem;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DefaultRestorer : IRestorer
{
private readonly ILogger Logger;
private readonly IConsole Console;
private readonly IProvisioner Provisioner;
private readonly IInstaller Installer;
private readonly IStatistics Statistics;
public DefaultRestorer(
ILoggerFactory loggerFactory,
ServerContext context,
IConsole console,
IProvisioner provisioner,
IStatistics statistics,
IInstaller installer
)
{
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(DefaultRestorer)}");
Console = console;
Provisioner = provisioner;
Statistics = statistics;
Installer = installer;
}
public Task Initialize()
=> Task.CompletedTask;
public Task Sync()
=> Task.CompletedTask;
public async Task<ServerState> Restore()
{
Logger.LogDebug("Restoring server state");
if (Provisioner.IsProvisioned)
{
Logger.LogDebug("Detected runtime to restore");
await Console.CollectFromRuntime();
await Console.AttachToRuntime();
await Statistics.SubscribeToRuntime();
return ServerState.Online;
}
if (Installer.IsRunning)
{
Logger.LogDebug("Detected installation to restore");
await Console.CollectFromInstallation();
await Console.AttachToInstallation();
await Statistics.SubscribeToInstallation();
return ServerState.Installing;
}
Logger.LogDebug("Nothing found to restore");
return ServerState.Offline;
}
public ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

View File

@@ -1,230 +0,0 @@
using System.Text;
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Helpers;
using MoonCore.Observability;
using MoonlightServers.Daemon.ServerSys.Abstractions;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerConsole : IConsole
{
public IAsyncObservable<string> OnOutput => OnOutputSubject;
public IAsyncObservable<string> OnInput => OnInputSubject;
private readonly EventSubject<string> OnOutputSubject = new();
private readonly EventSubject<string> OnInputSubject = new();
private readonly ConcurrentList<string> OutputCache = new();
private readonly DockerClient DockerClient;
private readonly ILogger Logger;
private readonly ServerContext Context;
private MultiplexedStream? CurrentStream;
private CancellationTokenSource Cts = new();
private const string MlPrefix =
"\x1b[1;38;2;200;90;200mM\x1b[1;38;2;204;110;230mo\x1b[1;38;2;170;130;245mo\x1b[1;38;2;140;150;255mn\x1b[1;38;2;110;180;255ml\x1b[1;38;2;100;200;255mi\x1b[1;38;2;100;220;255mg\x1b[1;38;2;120;235;255mh\x1b[1;38;2;140;250;255mt\x1b[0m \x1b[3;38;2;200;200;200m{0}\x1b[0m\n\r";
public DockerConsole(
ServerContext context,
DockerClient dockerClient,
ILoggerFactory loggerFactory
)
{
Context = context;
DockerClient = dockerClient;
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(DockerConsole)}");
}
public Task Initialize()
=> Task.CompletedTask;
public Task Sync()
=> Task.CompletedTask;
public async Task AttachToRuntime()
{
var containerName = $"moonlight-runtime-{Context.Configuration.Id}";
await AttachStream(containerName);
}
public async Task AttachToInstallation()
{
var containerName = $"moonlight-install-{Context.Configuration.Id}";
await AttachStream(containerName);
}
public async Task Detach()
{
Logger.LogDebug("Detaching stream");
if (!Cts.IsCancellationRequested)
await Cts.CancelAsync();
}
public async Task CollectFromRuntime()
=> await CollectFromContainer($"moonlight-runtime-{Context.Configuration.Id}");
public async Task CollectFromInstallation()
=> await CollectFromContainer($"moonlight-install-{Context.Configuration.Id}");
private async Task CollectFromContainer(string containerName)
{
var logStream = await DockerClient.Containers.GetContainerLogsAsync(containerName, true, new()
{
Follow = false,
ShowStderr = true,
ShowStdout = true
});
var combinedOutput = await logStream.ReadOutputToEndAsync(CancellationToken.None);
var contentToAdd = combinedOutput.stdout + combinedOutput.stderr;
await WriteToOutput(contentToAdd);
}
private async Task AttachStream(string containerName)
{
// This stops any previously existing stream reading if
// any is currently running
if (!Cts.IsCancellationRequested)
await Cts.CancelAsync();
// Reset
Cts = new();
Task.Run(async () =>
{
// This loop is here to reconnect to the container if for some reason the container
// attach stream fails before the server tasks have been canceled i.e. the before the server
// goes offline
while (!Cts.Token.IsCancellationRequested)
{
try
{
CurrentStream = await DockerClient.Containers.AttachContainerAsync(
containerName,
true,
new ContainerAttachParameters()
{
Stderr = true,
Stdin = true,
Stdout = true,
Stream = true
},
Cts.Token
);
var buffer = new byte[1024];
try
{
// Read while server tasks are not canceled
while (!Cts.Token.IsCancellationRequested)
{
var readResult = await CurrentStream.ReadOutputAsync(
buffer,
0,
buffer.Length,
Cts.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 WriteToOutput(decodedText);
}
}
catch (TaskCanceledException)
{
// Ignored
}
catch (OperationCanceledException)
{
// Ignored
}
catch (Exception e)
{
Logger.LogWarning(e, "An unhandled error occured while reading from container stream");
}
finally
{
CurrentStream.Dispose();
}
}
catch (TaskCanceledException)
{
// ignored
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while attaching to container");
}
}
// Reset stream so no further inputs will be piped to it
CurrentStream = null;
Logger.LogDebug("Disconnected from container stream");
}, Cts.Token);
}
public async Task WriteToOutput(string content)
{
OutputCache.Add(content);
if (OutputCache.Count > 250) // TODO: Config
OutputCache.RemoveRange(0, 100);
await OnOutputSubject.OnNextAsync(content);
}
public async Task WriteToInput(string content)
{
if (CurrentStream == null)
return;
var contentBuffer = Encoding.UTF8.GetBytes(content);
await CurrentStream.WriteAsync(
contentBuffer,
0,
contentBuffer.Length,
Cts.Token
);
await OnInputSubject.OnNextAsync(content);
}
public async Task WriteToMoonlight(string content)
=> await WriteToOutput(string.Format(MlPrefix, content));
public Task ClearOutput()
{
OutputCache.Clear();
return Task.CompletedTask;
}
public string[] GetOutput()
=> OutputCache.ToArray();
public async ValueTask DisposeAsync()
{
if (!Cts.IsCancellationRequested)
{
await Cts.CancelAsync();
Cts.Dispose();
}
if (CurrentStream != null)
CurrentStream.Dispose();
}
}

View File

@@ -1,278 +0,0 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Observability;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerInstaller : IInstaller
{
public IAsyncObservable<object> OnExited => OnExitedSubject;
public bool IsRunning { get; private set; } = false;
private readonly EventSubject<Message> OnExitedSubject = new();
private readonly ILogger Logger;
private readonly DockerEventService EventService;
private readonly IConsole Console;
private readonly DockerClient DockerClient;
private readonly ServerContext Context;
private readonly DockerImageService ImageService;
private readonly IFileSystem FileSystem;
private readonly AppConfiguration Configuration;
private readonly ServerConfigurationMapper Mapper;
private string? ContainerId;
private string ContainerName;
private string InstallHostPath;
private IAsyncDisposable? ContainerEventSubscription;
public DockerInstaller(
ILoggerFactory loggerFactory,
DockerEventService eventService,
IConsole console,
DockerClient dockerClient,
ServerContext context,
DockerImageService imageService,
IFileSystem fileSystem,
AppConfiguration configuration,
ServerConfigurationMapper mapper
)
{
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(DockerInstaller)}");
EventService = eventService;
Console = console;
DockerClient = dockerClient;
Context = context;
ImageService = imageService;
FileSystem = fileSystem;
Configuration = configuration;
Mapper = mapper;
}
public async Task Initialize()
{
ContainerName = $"moonlight-install-{Context.Configuration.Id}";
InstallHostPath =
Path.GetFullPath(Path.Combine(Configuration.Storage.Install, Context.Configuration.Id.ToString()));
ContainerEventSubscription = await EventService
.OnContainerEvent
.SubscribeEventAsync(HandleContainerEvent);
// Check for any already existing runtime container to reclaim
Logger.LogDebug("Searching for orphan container to reclaim");
try
{
var container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
ContainerId = container.ID;
IsRunning = container.State.Running;
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
}
private async ValueTask HandleContainerEvent(Message message)
{
// Only handle events for our own container
if (message.ID != ContainerId)
return;
// Only handle die events
if (message.Action != "die")
return;
await OnExitedSubject.OnNextAsync(message);
}
public Task Sync()
=> Task.CompletedTask;
public async Task Setup()
{
// Plan of action:
// 1. Ensure no other container with that name exist
// 2. Ensure the docker image has been downloaded
// 3. Create the installation volume and place script in there
// 4. Create the container from the configuration in the meta
// 1. Ensure no other container with that name exist
try
{
Logger.LogDebug("Searching for orphan container");
var possibleContainer = await DockerClient.Containers.InspectContainerAsync(ContainerName);
Logger.LogDebug("Orphan container found. Removing it");
await Console.WriteToMoonlight("Found orphan container. Removing it");
await EnsureContainerOffline(possibleContainer);
Logger.LogInformation("Removing orphan container");
await DockerClient.Containers.RemoveContainerAsync(ContainerName, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// 2. Ensure the docker image has been downloaded
await Console.WriteToMoonlight("Downloading docker image");
await ImageService.Download(Context.Configuration.DockerImage, async message =>
{
try
{
await Console.WriteToMoonlight(message);
}
catch (Exception)
{
// Ignored. Not handling it here could cause an application wide crash afaik
}
});
// 3. Create the installation volume and place script in there
await Console.WriteToMoonlight("Creating storage");
if(Directory.Exists(InstallHostPath))
Directory.Delete(InstallHostPath, true);
Directory.CreateDirectory(InstallHostPath);
await File.WriteAllTextAsync(Path.Combine(InstallHostPath, "install.sh"), Context.InstallConfiguration.Script);
// 4. Create the container from the configuration in the meta
var runtimeFsPath = FileSystem.GetExternalPath();
var parameters = Mapper.ToInstallParameters(
Context.Configuration,
Context.InstallConfiguration,
runtimeFsPath,
InstallHostPath,
ContainerName
);
var createdContainer = await DockerClient.Containers.CreateContainerAsync(parameters);
ContainerId = createdContainer.ID;
Logger.LogDebug("Created container");
await Console.WriteToMoonlight("Created container");
}
public async Task Start()
{
Logger.LogDebug("Starting container");
await Console.WriteToMoonlight("Starting container");
await DockerClient.Containers.StartContainerAsync(ContainerId, new());
}
public async Task Abort()
{
await EnsureContainerOffline();
}
public async Task Cleanup()
{
// Plan of action:
// 1. Search for the container by id or name
// 2. Ensure container is offline
// 3. Remove the container
// 4. Delete installation volume if it exists
// 1. Search for the container by id or name
ContainerInspectResponse? container = null;
try
{
if (string.IsNullOrEmpty(ContainerId))
container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
else
container = await DockerClient.Containers.InspectContainerAsync(ContainerId);
}
catch (DockerContainerNotFoundException)
{
// Ignored
Logger.LogDebug("Runtime container could not be found. Reporting deprovision success");
}
// No container found? We are done here then
if (container == null)
return;
// 2. Ensure container is offline
await EnsureContainerOffline(container);
// 3. Remove the container
Logger.LogInformation("Removing container");
await Console.WriteToMoonlight("Removing container");
await DockerClient.Containers.RemoveContainerAsync(container.ID, new());
// 4. Delete installation volume if it exists
if (Directory.Exists(InstallHostPath))
{
Logger.LogInformation("Removing storage");
await Console.WriteToMoonlight("Removing storage");
Directory.Delete(InstallHostPath, true);
}
}
public async Task<ServerCrash?> SearchForCrash()
{
return null;
}
private async Task EnsureContainerOffline(ContainerInspectResponse? container = null)
{
try
{
if (string.IsNullOrEmpty(ContainerId))
container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
else
container = await DockerClient.Containers.InspectContainerAsync(ContainerId);
}
catch (DockerContainerNotFoundException)
{
Logger.LogDebug("No container found to ensure its offline");
// Ignored
}
// No container found? We are done here then
if (container == null)
return;
// Check if container is running
if (!container.State.Running)
return;
await Console.WriteToMoonlight("Killing container");
await DockerClient.Containers.KillContainerAsync(ContainerId, new());
}
public async ValueTask DisposeAsync()
{
OnExitedSubject.Dispose();
if (ContainerEventSubscription != null)
await ContainerEventSubscription.DisposeAsync();
}
}

View File

@@ -1,261 +0,0 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Docker.DotNet;
using Docker.DotNet.Models;
using MoonCore.Observability;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.Services;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerProvisioner : IProvisioner
{
public IAsyncObservable<object> OnExited => OnExitedSubject;
public bool IsProvisioned { get; private set; }
private readonly DockerClient DockerClient;
private readonly ILogger Logger;
private readonly DockerEventService EventService;
private readonly ServerContext Context;
private readonly IConsole Console;
private readonly DockerImageService ImageService;
private readonly ServerConfigurationMapper Mapper;
private readonly IFileSystem FileSystem;
private EventSubject<object> OnExitedSubject = new();
private string? ContainerId;
private string ContainerName;
private IAsyncDisposable? ContainerEventSubscription;
public DockerProvisioner(
DockerClient dockerClient,
ILoggerFactory loggerFactory,
DockerEventService eventService,
ServerContext context,
IConsole console,
DockerImageService imageService,
ServerConfigurationMapper mapper,
IFileSystem fileSystem
)
{
DockerClient = dockerClient;
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(DockerProvisioner)}");
EventService = eventService;
Context = context;
Console = console;
ImageService = imageService;
Mapper = mapper;
FileSystem = fileSystem;
}
public async Task Initialize()
{
ContainerName = $"moonlight-runtime-{Context.Configuration.Id}";
ContainerEventSubscription = await EventService
.OnContainerEvent
.SubscribeEventAsync(HandleContainerEvent);
// Check for any already existing runtime container to reclaim
Logger.LogDebug("Searching for orphan container to reclaim");
try
{
var container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
ContainerId = container.ID;
IsProvisioned = container.State.Running;
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
}
private async ValueTask HandleContainerEvent(Message message)
{
// Only handle events for our own container
if (message.ID != ContainerId)
return;
// Only handle die events
if (message.Action != "die")
return;
await OnExitedSubject.OnNextAsync(message);
}
public Task Sync()
{
return Task.CompletedTask; // TODO: Implement
}
public async Task Provision()
{
// Plan of action:
// 1. Ensure no other container with that name exist
// 2. Ensure the docker image has been downloaded
// 3. Create the container from the configuration in the meta
// 1. Ensure no other container with that name exist
try
{
Logger.LogDebug("Searching for orphan container");
var possibleContainer = await DockerClient.Containers.InspectContainerAsync(ContainerName);
Logger.LogDebug("Orphan container found. Removing it");
await Console.WriteToMoonlight("Found orphan container. Removing it");
await EnsureContainerOffline(possibleContainer);
Logger.LogDebug("Removing orphan container");
await DockerClient.Containers.RemoveContainerAsync(ContainerName, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// 2. Ensure the docker image has been downloaded
await Console.WriteToMoonlight("Downloading docker image");
await ImageService.Download(Context.Configuration.DockerImage, async message =>
{
try
{
await Console.WriteToMoonlight(message);
}
catch (Exception)
{
// Ignored. Not handling it here could cause an application wide crash afaik
}
});
// 3. Create the container from the configuration in the meta
var hostFsPath = FileSystem.GetExternalPath();
var parameters = Mapper.ToRuntimeParameters(
Context.Configuration,
hostFsPath,
ContainerName
);
var createdContainer = await DockerClient.Containers.CreateContainerAsync(parameters);
ContainerId = createdContainer.ID;
Logger.LogDebug("Created container");
await Console.WriteToMoonlight("Created container");
}
public async Task Start()
{
if(string.IsNullOrEmpty(ContainerId))
throw new ArgumentNullException(nameof(ContainerId), "Container id of runtime is unknown");
await Console.WriteToMoonlight("Starting container");
await DockerClient.Containers.StartContainerAsync(ContainerId, new());
}
public async Task Stop()
{
if (Context.Configuration.StopCommand.StartsWith('^'))
{
await DockerClient.Containers.KillContainerAsync(ContainerId, new()
{
Signal = Context.Configuration.StopCommand.Substring(1)
});
}
else
await Console.WriteToInput(Context.Configuration.StopCommand + "\n\r");
}
public async Task Kill()
{
await EnsureContainerOffline();
}
public async Task Deprovision()
{
// Plan of action:
// 1. Search for the container by id or name
// 2. Ensure container is offline
// 3. Remove the container
// 1. Search for the container by id or name
ContainerInspectResponse? container = null;
try
{
if (string.IsNullOrEmpty(ContainerId))
container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
else
container = await DockerClient.Containers.InspectContainerAsync(ContainerId);
}
catch (DockerContainerNotFoundException)
{
// Ignored
Logger.LogDebug("Runtime container could not be found. Reporting deprovision success");
}
// No container found? We are done here then
if (container == null)
return;
// 2. Ensure container is offline
await EnsureContainerOffline(container);
// 3. Remove the container
Logger.LogDebug("Removing container");
await Console.WriteToMoonlight("Removing container");
await DockerClient.Containers.RemoveContainerAsync(container.ID, new());
}
private async Task EnsureContainerOffline(ContainerInspectResponse? container = null)
{
try
{
if (string.IsNullOrEmpty(ContainerId))
container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
else
container = await DockerClient.Containers.InspectContainerAsync(ContainerId);
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// No container found? We are done here then
if (container == null)
return;
// Check if container is running
if (!container.State.Running)
return;
await Console.WriteToMoonlight("Killing container");
await DockerClient.Containers.KillContainerAsync(ContainerId, new());
}
public Task<ServerCrash?> SearchForCrash()
{
throw new NotImplementedException();
}
public async ValueTask DisposeAsync()
{
OnExitedSubject.Dispose();
if (ContainerEventSubscription != null)
await ContainerEventSubscription.DisposeAsync();
}
}

View File

@@ -1,34 +0,0 @@
using MoonCore.Observability;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.ServerSys.Abstractions;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerStatistics : IStatistics
{
public IAsyncObservable<ServerStats> OnStats => OnStatsSubject;
private readonly EventSubject<ServerStats> OnStatsSubject = new();
public Task Initialize()
=> Task.CompletedTask;
public Task Sync()
=> Task.CompletedTask;
public Task SubscribeToRuntime()
=> Task.CompletedTask;
public Task SubscribeToInstallation()
=> Task.CompletedTask;
public ServerStats[] GetStats(int count)
{
return [];
}
public async ValueTask DisposeAsync()
{
OnStatsSubject.Dispose();
}
}

View File

@@ -1,60 +0,0 @@
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.ServerSys.Abstractions;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class RawFileSystem : IFileSystem
{
public bool IsMounted { get; private set; }
public bool Exists { get; private set; }
private readonly ServerContext Context;
private readonly AppConfiguration Configuration;
private string HostPath;
public RawFileSystem(ServerContext context, AppConfiguration configuration)
{
Context = context;
Configuration = configuration;
}
public Task Initialize()
{
HostPath = Path.Combine(Directory.GetCurrentDirectory(), Configuration.Storage.Volumes, Context.Configuration.Id.ToString());
return Task.CompletedTask;
}
public Task Sync()
=> Task.CompletedTask;
public Task Create()
{
Directory.CreateDirectory(HostPath);
return Task.CompletedTask;
}
public Task Mount()
{
IsMounted = true;
return Task.CompletedTask;
}
public Task Unmount()
{
IsMounted = false;
return Task.CompletedTask;
}
public Task Delete()
{
Directory.Delete(HostPath, true);
return Task.CompletedTask;
}
public string GetExternalPath()
=> HostPath;
public ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

View File

@@ -1,90 +0,0 @@
using System.Text.RegularExpressions;
using MoonCore.Observability;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSystem;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class RegexOnlineDetection : IOnlineDetection
{
private readonly ServerContext Context;
private readonly IConsole Console;
private readonly ILogger Logger;
private Regex? Regex;
private IAsyncDisposable? ConsoleSubscription;
private IAsyncDisposable? StateSubscription;
public RegexOnlineDetection(
ServerContext context,
IConsole console,
ILoggerFactory loggerFactory)
{
Context = context;
Console = console;
Logger = loggerFactory.CreateLogger($"Servers.Instance.{context.Configuration.Id}.{nameof(RegexOnlineDetection)}");
}
public async Task Initialize()
{
Logger.LogDebug("Subscribing to state changes");
StateSubscription = await Context.Self.OnState.SubscribeAsync(async state =>
{
if (state == ServerState.Starting) // Subscribe to console when starting
{
Logger.LogDebug("Detected state change to online. Subscribing to console in order to check for the regex matches");
if(ConsoleSubscription != null)
await ConsoleSubscription.DisposeAsync();
try
{
Regex = new(Context.Configuration.OnlineDetection, RegexOptions.Compiled);
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while building regex expression. Please make sure the regex is valid");
}
ConsoleSubscription = await Console.OnOutput.SubscribeEventAsync(HandleOutput);
}
else if (ConsoleSubscription != null) // Unsubscribe from console when any other state and not already unsubscribed
{
Logger.LogDebug("Detected state change to {state}. Unsubscribing from console", state);
await ConsoleSubscription.DisposeAsync();
ConsoleSubscription = null;
}
});
}
private async ValueTask HandleOutput(string line)
{
// Handle here just to make sure. Shouldn't be required as we
// unsubscribe from the console, as soon as we go online (or any other state).
// The regex should also not be null as we initialize it in the handler above but whatevers
if(Context.Self.StateMachine.State != ServerState.Starting || Regex == null)
return;
if(Regex.Matches(line).Count == 0)
return;
await Context.Self.StateMachine.FireAsync(ServerTrigger.OnlineDetected);
}
public Task Sync()
{
Regex = new(Context.Configuration.OnlineDetection, RegexOptions.Compiled);
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if(ConsoleSubscription != null)
await ConsoleSubscription.DisposeAsync();
if(StateSubscription != null)
await StateSubscription.DisposeAsync();
}
}

View File

@@ -1,30 +0,0 @@
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSys.Abstractions;
namespace MoonlightServers.Daemon.ServerSys;
public class ServerFactory
{
private readonly IServiceProvider ServiceProvider;
public ServerFactory(IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
}
public Server CreateServer(ServerConfiguration configuration)
{
var scope = ServiceProvider.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<ServerContext>();
context.Configuration = configuration;
context.ServiceScope = scope;
var server = scope.ServiceProvider.GetRequiredService<Server>();
context.Self = server;
return server;
}
}

View File

@@ -1,4 +1,4 @@
namespace MoonlightServers.Daemon.ServerSystem;
namespace MoonlightServers.Daemon.ServerSystem.Enums;
public enum ServerState
{
@@ -6,5 +6,6 @@ public enum ServerState
Starting = 1,
Online = 2,
Stopping = 3,
Installing = 4
Installing = 4,
Locked = 5
}

View File

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

View File

@@ -0,0 +1,42 @@
using MoonlightServers.Daemon.ServerSystem.Enums;
using MoonlightServers.Daemon.ServerSystem.Interfaces;
using MoonlightServers.Daemon.ServerSystem.Models;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem.Handlers;
public class ShutdownHandler : IServerStateHandler
{
private readonly ServerContext ServerContext;
public ShutdownHandler(ServerContext serverContext)
{
ServerContext = serverContext;
}
public async Task ExecuteAsync(StateMachine<ServerState, ServerTrigger>.Transition transition)
{
// Filter (we only want to handle exists from the runtime, so we filter out the installing state)
if (transition is
{
Destination: ServerState.Offline,
Source: not ServerState.Installing,
Trigger: ServerTrigger.Exited
})
return;
// Plan:
// 1. Handle possible crash
// 2. Remove runtime
// 1. Handle possible crash
// TODO: Handle crash here
// 2. Remove runtime
await ServerContext.Server.Runtime.DestroyAsync();
}
public ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,91 @@
using MoonlightServers.Daemon.ServerSystem.Enums;
using MoonlightServers.Daemon.ServerSystem.Interfaces;
using MoonlightServers.Daemon.ServerSystem.Models;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem.Handlers;
public class StartupHandler : IServerStateHandler
{
private IAsyncDisposable? ExitSubscription;
private readonly ServerContext Context;
private Server Server => Context.Server;
public StartupHandler(ServerContext context)
{
Context = context;
}
public async Task ExecuteAsync(StateMachine<ServerState, ServerTrigger>.Transition transition)
{
// Filter
if (transition is not {Source: ServerState.Offline, Destination: ServerState.Starting, Trigger: ServerTrigger.Start})
return;
// Plan:
// 1. Fetch latest configuration
// 2. Check if file system exists
// 3. Check if file system is mounted
// 4. Run file system checks
// 5. Create runtime
// 6. Attach console
// 7. Attach statistics collector
// 8. Create online detector
// 9. Start runtime
// 1. Fetch latest configuration
// TODO
// 2. Check if file system exists
if (!await Server.RuntimeFileSystem.CheckExistsAsync())
await Server.RuntimeFileSystem.CreateAsync();
// 3. Check if file system is mounted
if (!await Server.RuntimeFileSystem.CheckMountedAsync())
await Server.RuntimeFileSystem.CheckMountedAsync();
// 4. Run file system checks
await Server.RuntimeFileSystem.PerformChecksAsync();
// 5. Create runtime
var hostPath = await Server.RuntimeFileSystem.GetPathAsync();
await Server.Runtime.CreateAsync(hostPath);
if (ExitSubscription == null)
{
ExitSubscription = await Server.Runtime.SubscribeExited(OnRuntimeExited);
}
// 6. Attach console
await Server.Console.AttachRuntimeAsync();
// 7. Attach statistics collector
await Server.Statistics.AttachRuntimeAsync();
// 8. Create online detector
await Server.OnlineDetector.CreateAsync();
await Server.OnlineDetector.DestroyAsync();
// 9. Start runtime
await Server.Runtime.StartAsync();
}
private async Task OnRuntimeExited(int exitCode)
{
// TODO: Notify the crash handler component of the exit code
await Server.StateMachine.FireAsync(ServerTrigger.Exited);
}
public async ValueTask DisposeAsync()
{
if (ExitSubscription != null)
await ExitSubscription.DisposeAsync();
}
}

View File

@@ -0,0 +1,72 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IConsole : IServerComponent
{
/// <summary>
/// Writes to the standard input of the console. If attached to the runtime when using docker for example this
/// would write into the containers standard input.
/// <remarks>This method does not add a newline separator at the end of the content. The caller needs to add this themselves if required</remarks>
/// </summary>
/// <param name="content">The content to write</param>
/// <returns></returns>
public Task WriteStdInAsync(string content);
/// <summary>
/// Writes to the standard output of the console. If attached to the runtime when using docker for example this
/// would write into the containers standard output.
/// <remarks>This method does not add a newline separator at the end of the content. The caller needs to add this themselves if required</remarks>
/// </summary>
/// <param name="content">The content to write</param>
/// <returns></returns>
public Task WriteStdOutAsync(string content);
/// <summary>
/// Writes a system message to the standard output with the moonlight console prefix
/// <remarks>This method *does* add the newline separator at the end</remarks>
/// </summary>
/// <param name="content">The content to write into the standard output</param>
/// <returns></returns>
public Task WriteMoonlightAsync(string content);
/// <summary>
/// Attaches the console to the runtime environment
/// </summary>
/// <returns></returns>
public Task AttachRuntimeAsync();
/// <summary>
/// Attaches the console to the installation environment
/// </summary>
/// <returns></returns>
public Task AttachInstallationAsync();
/// <summary>
/// Fetches all output from the runtime environment and write them into the cache without triggering any events
/// </summary>
/// <returns></returns>
public Task FetchRuntimeAsync();
/// <summary>
/// Fetches all output from the installation environment and write them into the cache without triggering any events
/// </summary>
/// <returns></returns>
public Task FetchInstallationAsync();
/// <summary>
/// Clears the cache of the standard output received by the environments
/// </summary>
/// <returns></returns>
public Task ClearCacheAsync();
/// <summary>
/// Gets the content from the standard output cache
/// </summary>
/// <returns>The content from the cache</returns>
public Task<IEnumerable<string>> GetCacheAsync();
/// <summary>
/// Subscribes to standard output receive events
/// </summary>
/// <param name="callback">Callback which will be invoked whenever a new line is received</param>
/// <returns>Subscription disposable to unsubscribe from the event</returns>
public Task<IAsyncDisposable> SubscribeStdOutAsync(Func<string, Task> callback);
}

View File

@@ -0,0 +1,54 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IFileSystem : IServerComponent
{
/// <summary>
/// Gets the path of the file system on the host operating system to be reused by other components
/// </summary>
/// <returns>Path to the file systems storage location</returns>
public Task<string> GetPathAsync();
/// <summary>
/// Checks if the file system exists
/// </summary>
/// <returns>True if it does exist. False if it doesn't exist</returns>
public Task<bool> CheckExistsAsync();
/// <summary>
/// Checks if the file system is mounted
/// </summary>
/// <returns>True if its mounted, False if it is not mounted</returns>
public Task<bool> CheckMountedAsync();
/// <summary>
/// Creates the file system. E.g. Creating a virtual disk, formatting it
/// </summary>
/// <returns></returns>
public Task CreateAsync();
/// <summary>
/// Performs checks and optimisations on the file system.
/// E.g. checking for corrupted files, resizing a virtual disk or adjusting file permissions
/// <remarks>Requires <see cref="MountAsync"/> to be called before or the file system to be in a mounted state</remarks>
/// </summary>
/// <returns></returns>
public Task PerformChecksAsync();
/// <summary>
/// Mounts the file system
/// </summary>
/// <returns></returns>
public Task MountAsync();
/// <summary>
/// Unmounts the file system
/// </summary>
/// <returns></returns>
public Task UnmountAsync();
/// <summary>
/// Destroys the file system and its contents
/// </summary>
/// <returns></returns>
public Task DestroyAsync();
}

View File

@@ -0,0 +1,50 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IInstallation : IServerComponent
{
/// <summary>
/// Checks if the installation environment exists. It doesn't matter if it is currently running or not
/// </summary>
/// <returns>True if it exists, False if it doesn't</returns>
public Task<bool> CheckExistsAsync();
/// <summary>
/// Creates the installation environment
/// </summary>
/// <param name="runtimePath">The host path of the runtime storage location</param>
/// <param name="hostPath">The host path of the installation file system</param>
/// <returns></returns>
public Task CreateAsync(string runtimePath, string hostPath);
/// <summary>
/// Starts the installation
/// </summary>
/// <returns></returns>
public Task StartAsync();
/// <summary>
/// Kills the current installation immediately
/// </summary>
/// <returns></returns>
public Task KillAsync();
/// <summary>
/// Removes the installation. E.g. removes the docker container
/// </summary>
/// <returns></returns>
public Task DestroyAsync();
/// <summary>
/// Subscribes to the event when the installation exists
/// </summary>
/// <param name="callback">The callback to invoke whenever the installation exists</param>
/// <returns>Subscription disposable to unsubscribe from the event</returns>
public Task<IAsyncDisposable> SubscribeExited(Func<int, Task> callback);
/// <summary>
/// Connects an existing installation to this abstraction in order to restore it.
/// E.g. fetching the container id and using it for exit events
/// </summary>
/// <returns></returns>
public Task RestoreAsync();
}

View File

@@ -0,0 +1,23 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IOnlineDetector : IServerComponent
{
/// <summary>
/// Creates the detection engine for the online state
/// </summary>
/// <returns></returns>
public Task CreateAsync();
/// <summary>
/// Handles the detection of the online state based on the received output
/// </summary>
/// <param name="line">The excerpt of the output</param>
/// <returns>True if the detection showed that the server is online. False if the detection didnt find anything</returns>
public Task<bool> HandleOutputAsync(string line);
/// <summary>
/// Destroys the detection engine for the online state
/// </summary>
/// <returns></returns>
public Task DestroyAsync();
}

View File

@@ -0,0 +1,18 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IReporter : IServerComponent
{
/// <summary>
/// Writes both in the server logs as well in the server console the provided message as a status update
/// </summary>
/// <param name="message">The message to write</param>
/// <returns></returns>
public Task StatusAsync(string message);
/// <summary>
/// Writes both in the server logs as well in the server console the provided message as an error
/// </summary>
/// <param name="message">The message to write</param>
/// <returns></returns>
public Task ErrorAsync(string message);
}

View File

@@ -0,0 +1,16 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IRestorer : IServerComponent
{
/// <summary>
/// Checks for any running runtime environment from which the state can be restored from
/// </summary>
/// <returns></returns>
public Task<bool> HandleRuntimeAsync();
/// <summary>
/// Checks for any running installation environment from which the state can be restored from
/// </summary>
/// <returns></returns>
public Task<bool> HandleInstallationAsync();
}

View File

@@ -0,0 +1,55 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IRuntime : IServerComponent
{
/// <summary>
/// Checks if the runtime does exist. This includes already running instances
/// </summary>
/// <returns>True if it exists, False if it doesn't</returns>
public Task<bool> CheckExistsAsync();
/// <summary>
/// Creates the runtime with the specified path as the storage path where the server files should be stored in
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public Task CreateAsync(string path);
/// <summary>
/// Starts the runtime. This requires <see cref="CreateAsync"/> to be called before this function
/// </summary>
/// <returns></returns>
public Task StartAsync();
/// <summary>
/// Performs a live update on the runtime. When this method is called the current server configuration has already been updated
/// </summary>
/// <returns></returns>
public Task UpdateAsync();
/// <summary>
/// Kills the current runtime immediately
/// </summary>
/// <returns></returns>
public Task KillAsync();
/// <summary>
/// Destroys the runtime. When implemented using docker this would remove the container used for hosting the runtime
/// </summary>
/// <returns></returns>
public Task DestroyAsync();
/// <summary>
/// This subscribes to the exited event of the runtime
/// </summary>
/// <param name="callback">The callback gets invoked whenever the runtime exites</param>
/// <returns>Subscription disposable to unsubscribe from the event</returns>
public Task<IAsyncDisposable> SubscribeExited(Func<int, Task> callback);
/// <summary>
/// Connects an existing runtime to this abstraction in order to restore it.
/// E.g. fetching the container id and using it for exit events
/// </summary>
/// <returns></returns>
public Task RestoreAsync();
}

View File

@@ -0,0 +1,10 @@
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IServerComponent : IAsyncDisposable
{
/// <summary>
/// Initializes the server component
/// </summary>
/// <returns></returns>
public Task InitializeAsync();
}

View File

@@ -0,0 +1,9 @@
using MoonlightServers.Daemon.ServerSystem.Enums;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IServerStateHandler : IAsyncDisposable
{
public Task ExecuteAsync(StateMachine<ServerState, ServerTrigger>.Transition transition);
}

View File

@@ -0,0 +1,30 @@
using MoonlightServers.Daemon.ServerSystem.Models;
namespace MoonlightServers.Daemon.ServerSystem.Interfaces;
public interface IStatistics : IServerComponent
{
/// <summary>
/// Attaches the statistics collector to the currently running runtime
/// </summary>
/// <returns></returns>
public Task AttachRuntimeAsync();
/// <summary>
/// Attaches the statistics collector to the currently running installation
/// </summary>
/// <returns></returns>
public Task AttachInstallationAsync();
/// <summary>
/// Clears the statistics cache
/// </summary>
/// <returns></returns>
public Task ClearCacheAsync();
/// <summary>
/// Gets the statistics data from the cache
/// </summary>
/// <returns>All data from the cache</returns>
public Task<IEnumerable<StatisticsData>> GetCacheAsync();
}

View File

@@ -0,0 +1,11 @@
using MoonlightServers.Daemon.Models.Cache;
namespace MoonlightServers.Daemon.ServerSystem.Models;
public class ServerContext
{
public ServerConfiguration Configuration { get; set; }
public int Identifier { get; set; }
public AsyncServiceScope ServiceScope { get; set; }
public Server Server { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace MoonlightServers.Daemon.ServerSystem.Models;
public class StatisticsData
{
}

View File

@@ -1,57 +1,82 @@
using Microsoft.AspNetCore.SignalR;
using MoonCore.Exceptions;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSystem.Enums;
using MoonlightServers.Daemon.ServerSystem.Interfaces;
using MoonlightServers.Daemon.ServerSystem.Models;
using Stateless;
namespace MoonlightServers.Daemon.ServerSystem;
public class Server : IAsyncDisposable
public partial class Server : IAsyncDisposable
{
public ServerConfiguration Configuration { get; set; }
public CancellationToken TaskCancellation => TaskCancellationSource.Token;
internal StateMachine<ServerState, ServerTrigger> StateMachine { get; private set; }
private CancellationTokenSource TaskCancellationSource;
public int Identifier => InnerContext.Identifier;
public ServerContext Context => InnerContext;
private Dictionary<Type, ServerSubSystem> SubSystems = new();
private ServerState InternalState = ServerState.Offline;
public IConsole Console { get; }
public IFileSystem RuntimeFileSystem { get; }
public IFileSystem InstallationFileSystem { get; }
public IInstallation Installation { get; }
public IOnlineDetector OnlineDetector { get; }
public IReporter Reporter { get; }
public IRestorer Restorer { get; }
public IRuntime Runtime { get; }
public IStatistics Statistics { get; }
public StateMachine<ServerState, ServerTrigger> StateMachine { get; private set; }
private readonly IHubContext<ServerWebSocketHub> HubContext;
private readonly IServiceScope ServiceScope;
private readonly ILoggerFactory LoggerFactory;
private readonly IServerStateHandler[] Handlers;
private readonly IServerComponent[] AllComponents;
private readonly ServerContext InnerContext;
private readonly ILogger Logger;
public Server(
ServerConfiguration configuration,
IServiceScope serviceScope,
IHubContext<ServerWebSocketHub> hubContext
ILogger logger,
ServerContext context,
IConsole console,
IFileSystem runtimeFileSystem,
IFileSystem installationFileSystem,
IInstallation installation,
IOnlineDetector onlineDetector,
IReporter reporter,
IRestorer restorer,
IRuntime runtime,
IStatistics statistics,
IServerStateHandler[] handlers
)
{
Configuration = configuration;
ServiceScope = serviceScope;
HubContext = hubContext;
Logger = logger;
InnerContext = context;
Console = console;
RuntimeFileSystem = runtimeFileSystem;
InstallationFileSystem = installationFileSystem;
Installation = installation;
OnlineDetector = onlineDetector;
Reporter = reporter;
Restorer = restorer;
Runtime = runtime;
Statistics = statistics;
TaskCancellationSource = new CancellationTokenSource();
AllComponents =
[
Console, RuntimeFileSystem, InstallationFileSystem, Installation, OnlineDetector, Reporter, Restorer,
Runtime, Statistics
];
LoggerFactory = serviceScope.ServiceProvider.GetRequiredService<ILoggerFactory>();
Logger = LoggerFactory.CreateLogger($"Server {Configuration.Id}");
Handlers = handlers;
}
private void ConfigureStateMachine(ServerState initialState)
{
StateMachine = new StateMachine<ServerState, ServerTrigger>(
() => InternalState,
state => InternalState = state,
FiringMode.Queued
initialState, FiringMode.Queued
);
// Configure basic state machine flow
StateMachine.Configure(ServerState.Offline)
.Permit(ServerTrigger.Start, ServerState.Starting)
.Permit(ServerTrigger.Install, ServerState.Installing)
.PermitReentry(ServerTrigger.FailSafe);
.PermitReentry(ServerTrigger.Fail);
StateMachine.Configure(ServerState.Starting)
.Permit(ServerTrigger.OnlineDetected, ServerState.Online)
.Permit(ServerTrigger.FailSafe, ServerState.Offline)
.Permit(ServerTrigger.DetectOnline, ServerState.Online)
.Permit(ServerTrigger.Fail, ServerState.Offline)
.Permit(ServerTrigger.Exited, ServerState.Offline)
.Permit(ServerTrigger.Stop, ServerState.Stopping)
.Permit(ServerTrigger.Kill, ServerState.Stopping);
@@ -62,128 +87,98 @@ public class Server : IAsyncDisposable
.Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Stopping)
.PermitReentry(ServerTrigger.FailSafe)
.PermitReentry(ServerTrigger.Fail)
.PermitReentry(ServerTrigger.Kill)
.Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Installing)
.Permit(ServerTrigger.FailSafe, ServerState.Offline)
.Permit(ServerTrigger.Fail, ServerState.Offline) // TODO: Add kill
.Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Offline)
.OnEntryAsync(async () =>
{
// Configure task reset when server goes offline
if (!TaskCancellationSource.IsCancellationRequested)
await TaskCancellationSource.CancelAsync();
})
.OnExit(() =>
{
// Activate tasks when the server goes online
// If we don't separate the disabling and enabling
// of the tasks and would do both it in just the offline handler
// we would have edge cases where reconnect loops would already have the new task activated
// while they are supposed to shut down. I tested the handling of the state machine,
// and it executes on exit before the other listeners from the other sub systems
TaskCancellationSource = new();
});
}
// Setup websocket notify for state changes
private void ConfigureStateMachineEvents()
{
// Configure the calling of the handlers
StateMachine.OnTransitionedAsync(async transition =>
{
await HubContext.Clients
.Group(Configuration.Id.ToString())
.SendAsync("StateChanged", transition.Destination.ToString());
var hasFailed = false;
foreach (var handler in Handlers)
{
try
{
await handler.ExecuteAsync(transition);
}
catch (Exception e)
{
Logger.LogError(
e,
"Handler {name} has thrown an unexpected exception",
handler.GetType().FullName
);
hasFailed = true;
break;
}
}
if(!hasFailed)
return; // Everything went fine, we can exit now
// Something has failed, lets check if we can handle the error
// via a fail trigger
if(!StateMachine.CanFire(ServerTrigger.Fail))
return;
// Trigger the fail so the server gets a chance to handle the error softly
await StateMachine.FireAsync(ServerTrigger.Fail);
});
}
public async Task Initialize(Type[] subSystemTypes)
private async Task HandleSaveAsync(Func<Task> callback)
{
foreach (var type in subSystemTypes)
try
{
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);
await callback.Invoke();
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while handling");
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);
}
await StateMachine.FireAsync(ServerTrigger.Fail);
}
}
public async Task Trigger(ServerTrigger trigger)
private async Task HandleIgnoredAsync(Func<Task> callback)
{
if (!StateMachine.CanFire(trigger))
throw new HttpApiException($"The trigger {trigger} is not supported during the state {StateMachine.State}", 400);
await StateMachine.FireAsync(trigger);
try
{
await callback.Invoke();
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while handling");
}
}
public async Task Delete()
public async Task InitializeAsync()
{
foreach (var subSystem in SubSystems.Values)
await subSystem.Delete();
}
foreach (var component in AllComponents)
await component.InitializeAsync();
// 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;
}
var restoredState = ServerState.Offline;
public T? GetSubSystem<T>() where T : ServerSubSystem
{
var type = typeof(T);
var subSystem = SubSystems.GetValueOrDefault(type);
if (subSystem == null)
return null;
return subSystem as T;
}
public T GetRequiredSubSystem<T>() where T : ServerSubSystem
{
var subSystem = GetSubSystem<T>();
if (subSystem == null)
throw new AggregateException("Unable to resolve requested sub system");
return subSystem;
ConfigureStateMachine(restoredState);
ConfigureStateMachineEvents();
}
public async ValueTask DisposeAsync()
{
if (!TaskCancellationSource.IsCancellationRequested)
await TaskCancellationSource.CancelAsync();
foreach (var subSystem in SubSystems.Values)
await subSystem.DisposeAsync();
foreach (var handler in Handlers)
await handler.DisposeAsync();
ServiceScope.Dispose();
foreach (var component in AllComponents)
await component.DisposeAsync();
}
}

View File

@@ -0,0 +1,66 @@
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSystem.Interfaces;
using MoonlightServers.Daemon.ServerSystem.Models;
namespace MoonlightServers.Daemon.ServerSystem;
public class ServerFactory
{
private readonly IServiceProvider ServiceProvider;
public ServerFactory(IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
}
public async Task<Server> Create(ServerConfiguration configuration)
{
var scope = ServiceProvider.CreateAsyncScope();
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger($"Servers.Instance.{configuration.Id}.{nameof(Server)}");
var context = scope.ServiceProvider.GetRequiredService<ServerContext>();
context.Identifier = configuration.Id;
context.Configuration = configuration;
context.ServiceScope = scope;
// Define all required components
IConsole console;
IFileSystem runtimeFs;
IFileSystem installFs;
IInstallation installation;
IOnlineDetector onlineDetector;
IReporter reporter;
IRestorer restorer;
IRuntime runtime;
IStatistics statistics;
// Resolve the components
// TODO: Add a plugin hook for dynamically resolving components and checking if any is unset
// Resolve server from di
var server = new Server(
logger,
context,
// Now all components
console,
runtimeFs,
installFs,
installation,
onlineDetector,
reporter,
restorer,
runtime,
statistics,
// And now all the handlers
[]
);
context.Server = server;
return server;
}
}

View File

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

View File

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

View File

@@ -1,178 +0,0 @@
using System.Text;
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Http.Hubs;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class ConsoleSubSystem : ServerSubSystem
{
public event Func<string, Task>? OnOutput;
public event Func<string, Task>? OnInput;
private MultiplexedStream? Stream;
private readonly List<string> OutputCache = new();
private readonly IHubContext<ServerWebSocketHub> HubContext;
private readonly DockerClient DockerClient;
public ConsoleSubSystem(
Server server,
ILogger logger,
IHubContext<ServerWebSocketHub> hubContext,
DockerClient dockerClient
) : base(server, logger)
{
HubContext = hubContext;
DockerClient = dockerClient;
}
public override Task Initialize()
{
OnInput += async content =>
{
if (Stream == null)
return;
var contentBuffer = Encoding.UTF8.GetBytes(content);
await Stream.WriteAsync(
contentBuffer,
0,
contentBuffer.Length,
Server.TaskCancellation
);
};
return Task.CompletedTask;
}
public Task Attach(string containerId)
{
// Reading
Task.Run(async () =>
{
// This loop is here to reconnect to the container if for some reason the container
// attach stream fails before the server tasks have been canceled i.e. the before the server
// goes offline
while (!Server.TaskCancellation.IsCancellationRequested)
{
try
{
Stream = await DockerClient.Containers.AttachContainerAsync(containerId,
true,
new ContainerAttachParameters()
{
Stderr = true,
Stdin = true,
Stdout = true,
Stream = true
},
Server.TaskCancellation
);
var buffer = new byte[1024];
try
{
// Read while server tasks are not canceled
while (!Server.TaskCancellation.IsCancellationRequested)
{
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);
}
finally
{
Stream.Dispose();
}
}
catch (TaskCanceledException)
{
// ignored
}
catch (Exception e)
{
Logger.LogError("An error occured while attaching to container: {e}", e);
}
}
// Reset stream so no further inputs will be piped to it
Stream = null;
Logger.LogDebug("Disconnected from container stream");
});
return Task.CompletedTask;
}
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[0;38;2;255;255;255;48;2;124;28;230m Moonlight \x1b[0m\x1b[38;5;250m\x1b[3m {output}\x1b[0m\n\r");
}
public async Task WriteInput(string input)
{
if (OnInput != null)
await OnInput.Invoke(input);
}
public Task<string[]> RetrieveCache()
{
string[] result;
lock (OutputCache)
result = OutputCache.ToArray();
return Task.FromResult(result);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,226 +0,0 @@
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. Attach to stats
// 8. Start the container
// Define some shared variables:
var containerName = $"moonlight-runtime-{Configuration.Id}";
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Reset container tracking id, so if we kill an old container it won't
// trigger an Exited event :>
CurrentContainerId = null;
// 1. Remove possible existing container
try
{
var existingContainer = await DockerClient.Containers
.InspectContainerAsync(containerName);
if (existingContainer.State.Running)
{
Logger.LogDebug("Killing old docker container");
await consoleSubSystem.WriteMoonlight("Killing old container");
await DockerClient.Containers.KillContainerAsync(existingContainer.ID, new());
}
Logger.LogDebug("Removing old docker container");
await consoleSubSystem.WriteMoonlight("Removing old container");
await DockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
// 2. Fetch latest configuration
Logger.LogDebug("Fetching latest configuration from panel");
await consoleSubSystem.WriteMoonlight("Updating configuration");
var serverData = await RemoteService.GetServer(Configuration.Id);
var latestConfiguration = serverData.ToServerConfiguration();
Server.Configuration = latestConfiguration;
// 3. Ensure the storage location exists
Logger.LogDebug("Ensuring storage");
var storageSubSystem = Server.GetRequiredSubSystem<StorageSubSystem>();
if (!await storageSubSystem.RequestRuntimeVolume())
{
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 = storageSubSystem.RuntimeVolumePath;
// 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. Attach stats stream
var statsSubSystem = Server.GetRequiredSubSystem<StatsSubSystem>();
await statsSubSystem.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(StateMachine<ServerState, ServerTrigger>.Transition transition)
{
try
{
await Deprovision();
}
catch (Exception e)
{
Logger.LogError("An error occured while provisioning server: {e}", e);
await StateMachine.FireAsync(ServerTrigger.FailSafe);
}
}
private async Task Deprovision()
{
// Handle possible unknown container id calls
if (string.IsNullOrEmpty(CurrentContainerId))
{
Logger.LogDebug("Skipping deprovisioning as the current container id is not set");
return;
}
var consoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
// Destroy container
try
{
Logger.LogDebug("Removing docker container");
await consoleSubSystem.WriteMoonlight("Removing container");
await DockerClient.Containers.RemoveContainerAsync(CurrentContainerId, new());
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
CurrentContainerId = null;
}
#endregion
}

View File

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

View File

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

View File

@@ -1,150 +0,0 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.AspNetCore.SignalR;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.DaemonShared.DaemonSide.Models;
namespace MoonlightServers.Daemon.ServerSystem.SubSystems;
public class StatsSubSystem : ServerSubSystem
{
public ServerStats CurrentStats { get; private set; }
private readonly DockerClient DockerClient;
private readonly IHubContext<ServerWebSocketHub> HubContext;
public StatsSubSystem(
Server server,
ILogger logger,
DockerClient dockerClient,
IHubContext<ServerWebSocketHub> hubContext
) : base(server, logger)
{
DockerClient = dockerClient;
HubContext = hubContext;
CurrentStats = new();
}
public Task Attach(string containerId)
{
Logger.LogDebug("Attaching to stats stream");
Task.Run(async () =>
{
while (!Server.TaskCancellation.IsCancellationRequested)
{
try
{
await DockerClient.Containers.GetContainerStatsAsync(
containerId,
new()
{
Stream = true
},
new Progress<ContainerStatsResponse>(async response =>
{
try
{
var stats = ConvertToStats(response);
// Update current stats for usage of other components
CurrentStats = stats;
await HubContext.Clients
.Group(Configuration.Id.ToString())
.SendAsync("StatsUpdated", stats);
}
catch (Exception e)
{
Logger.LogError("An error occured handling stats update: {e}", e);
}
}),
Server.TaskCancellation
);
}
catch (TaskCanceledException)
{
// Ignored
}
catch (Exception e)
{
Logger.LogError("An error occured while loading container stats: {e}", e);
}
}
// Reset current stats
CurrentStats = new();
Logger.LogDebug("Stopped fetching container stats");
});
return Task.CompletedTask;
}
private ServerStats ConvertToStats(ContainerStatsResponse response)
{
var result = new ServerStats();
#region CPU
if(response.CPUStats != null && response.PreCPUStats.CPUUsage != null) // Sometimes some values are just null >:/
{
var cpuDelta = (float)response.CPUStats.CPUUsage.TotalUsage - response.PreCPUStats.CPUUsage.TotalUsage;
var cpuSystemDelta = (float)response.CPUStats.SystemUsage - response.PreCPUStats.SystemUsage;
var cpuCoreCount = (int)response.CPUStats.OnlineCPUs;
if (cpuCoreCount == 0 && response.CPUStats.CPUUsage.PercpuUsage != null)
cpuCoreCount = response.CPUStats.CPUUsage.PercpuUsage.Count;
var cpuPercent = 0f;
if (cpuSystemDelta > 0.0f && cpuDelta > 0.0f)
{
cpuPercent = (cpuDelta / cpuSystemDelta) * 100;
if (cpuCoreCount > 0)
cpuPercent *= cpuCoreCount;
}
result.CpuUsage = Math.Round(cpuPercent * 1000) / 1000;
}
#endregion
#region Memory
result.MemoryUsage = response.MemoryStats.Usage;
#endregion
#region Network
if (response.Networks != null)
{
foreach (var network in response.Networks)
{
result.NetworkRead += network.Value.RxBytes;
result.NetworkWrite += network.Value.TxBytes;
}
}
#endregion
#region IO
if (response.BlkioStats.IoServiceBytesRecursive != null)
{
result.IoRead = response.BlkioStats.IoServiceBytesRecursive
.FirstOrDefault(x => x.Op == "read")?.Value ?? 0;
result.IoWrite = response.BlkioStats.IoServiceBytesRecursive
.FirstOrDefault(x => x.Op == "write")?.Value ?? 0;
}
#endregion
return result;
}
}

View File

@@ -1,464 +0,0 @@
using System.Diagnostics;
using Mono.Unix.Native;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using MoonCore.Unix.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 ConsoleSubSystem ConsoleSubSystem;
public string RuntimeVolumePath { get; private set; }
public string InstallVolumePath { get; private set; }
public string VirtualDiskPath { get; private set; }
public bool IsVirtualDiskMounted { get; private set; }
public bool IsInitialized { get; private set; } = false;
public bool IsFileSystemAccessorCreated { get; private set; } = false;
public StorageSubSystem(
Server server,
ILogger logger,
AppConfiguration appConfiguration
) : base(server, logger)
{
AppConfiguration = appConfiguration;
// Runtime Volume
var runtimePath = Path.Combine(AppConfiguration.Storage.Volumes, Configuration.Id.ToString());
if (!runtimePath.StartsWith('/'))
runtimePath = Path.Combine(Directory.GetCurrentDirectory(), runtimePath);
RuntimeVolumePath = runtimePath;
// Install Volume
var installPath = Path.Combine(AppConfiguration.Storage.Install, Configuration.Id.ToString());
if (!installPath.StartsWith('/'))
installPath = Path.Combine(Directory.GetCurrentDirectory(), installPath);
InstallVolumePath = installPath;
// Virtual Disk
if (!Configuration.UseVirtualDisk)
return;
var virtualDiskPath = Path.Combine(AppConfiguration.Storage.VirtualDisks, $"{Configuration.Id}.img");
if (!virtualDiskPath.StartsWith('/'))
virtualDiskPath = Path.Combine(Directory.GetCurrentDirectory(), virtualDiskPath);
VirtualDiskPath = virtualDiskPath;
}
public override async Task Initialize()
{
ConsoleSubSystem = Server.GetRequiredSubSystem<ConsoleSubSystem>();
try
{
await Reinitialize();
}
catch (Exception e)
{
await ConsoleSubSystem.WriteMoonlight(
"Unable to initialize server file system. Please contact the administrator"
);
Logger.LogError("An unhandled error occured while lazy initializing server file system: {e}", e);
throw;
}
}
public override async Task Delete()
{
if (Configuration.UseVirtualDisk)
await DeleteVirtualDisk();
await DeleteRuntimeVolume();
await DeleteInstallVolume();
}
public async Task Reinitialize()
{
if (IsInitialized && StateMachine.State != ServerState.Offline)
{
throw new HttpApiException(
"Unable to reinitialize storage sub system while the server is not offline",
400
);
}
IsInitialized = false;
await EnsureRuntimeVolumeCreated();
if (Configuration.UseVirtualDisk)
{
// Load the state of a possible mount already existing.
// This ensures we are aware of the mount state after a restart.
// Without that we would get errors when mounting
IsVirtualDiskMounted = await CheckVirtualDiskMounted();
// Ensure we have the virtual disk created and in the correct size
await EnsureVirtualDisk();
}
IsInitialized = true;
}
#region Runtime
public async Task<ServerFileSystem> GetFileSystem()
{
if (!await RequestRuntimeVolume(skipPermissions: true))
throw new HttpApiException("The file system is still initializing. Please try again later", 503);
return ServerFileSystem;
}
// This method allows other sub systems to request access to the runtime volume.
// The return value specifies if the request to the runtime volume is possible or not
public async Task<bool> RequestRuntimeVolume(bool skipPermissions = false)
{
// If the initialization is still running we don't want to allow access to the runtime volume at all
if (!IsInitialized)
return false;
// If we use virtual disks and the disk isn't already mounted, we need to mount it now
if (Configuration.UseVirtualDisk && !IsVirtualDiskMounted)
await MountVirtualDisk();
if (!IsFileSystemAccessorCreated)
await CreateFileSystemAccessor();
if (!skipPermissions)
await EnsureRuntimePermissions();
return true;
}
private Task EnsureRuntimeVolumeCreated()
{
// Create the volume directory if required
if (!Directory.Exists(RuntimeVolumePath))
Directory.CreateDirectory(RuntimeVolumePath);
return Task.CompletedTask;
}
private async Task DeleteRuntimeVolume()
{
// Already deleted? Then we don't want to care about anything at all
if (!Directory.Exists(RuntimeVolumePath))
return;
// If we use a virtual disk there are no files to delete via the
// secure file system as the virtual disk is already gone by now
if (Configuration.UseVirtualDisk)
{
Directory.Delete(RuntimeVolumePath, true);
return;
}
// If we still habe a file system accessor, we reuse it :)
if (IsFileSystemAccessorCreated)
{
foreach (var entry in SecureFileSystem.ReadDir("/"))
{
if (entry.IsFile)
SecureFileSystem.Remove(entry.Name);
else
SecureFileSystem.RemoveAll(entry.Name);
}
await DestroyFileSystemAccessor();
}
else
{
// If the file system accessor has already been removed we create a temporary one.
// This handles the case when a server was never accessed and as such there is no accessor created yet
var sfs = new SecureFileSystem(RuntimeVolumePath);
foreach (var entry in sfs.ReadDir("/"))
{
if (entry.IsFile)
sfs.Remove(entry.Name);
else
sfs.RemoveAll(entry.Name);
}
sfs.Dispose();
}
Directory.Delete(RuntimeVolumePath, true);
}
private Task EnsureRuntimePermissions()
{
ArgumentNullException.ThrowIfNull(SecureFileSystem);
//TODO: Config
var uid = (int)Syscall.getuid();
var gid = (int)Syscall.getgid();
if (uid == 0)
{
uid = 998;
gid = 998;
}
// Chown all content of the runtime volume
foreach (var entry in SecureFileSystem.ReadDir("/"))
{
if (entry.IsFile)
SecureFileSystem.Chown(entry.Name, uid, gid);
else
SecureFileSystem.ChownAll(entry.Name, uid, gid);
}
// Chown also the main path of the volume
if (Syscall.chown(RuntimeVolumePath, uid, gid) != 0)
{
var error = Stdlib.GetLastError();
throw new SyscallException(error, "An error occured while chowning runtime volume");
}
return Task.CompletedTask;
}
private Task CreateFileSystemAccessor()
{
SecureFileSystem = new(RuntimeVolumePath);
ServerFileSystem = new(SecureFileSystem);
IsFileSystemAccessorCreated = true;
return Task.CompletedTask;
}
private Task DestroyFileSystemAccessor()
{
if (!SecureFileSystem.IsDisposed)
SecureFileSystem.Dispose();
IsFileSystemAccessorCreated = false;
return Task.CompletedTask;
}
#endregion
#region Installation
public Task EnsureInstallVolume()
{
if (!Directory.Exists(InstallVolumePath))
Directory.CreateDirectory(InstallVolumePath);
return Task.CompletedTask;
}
public Task DeleteInstallVolume()
{
if (!Directory.Exists(InstallVolumePath))
return Task.CompletedTask;
Directory.Delete(InstallVolumePath, true);
return Task.CompletedTask;
}
#endregion
#region Virtual disks
private async Task MountVirtualDisk()
{
await ConsoleSubSystem.WriteMoonlight("Mounting virtual disk");
await ExecuteCommand("mount", $"-t auto -o loop {VirtualDiskPath} {RuntimeVolumePath}", true);
IsVirtualDiskMounted = true;
}
private async Task UnmountVirtualDisk()
{
await ConsoleSubSystem.WriteMoonlight("Unmounting virtual disk");
await ExecuteCommand("umount", RuntimeVolumePath, handleExitCode: true);
IsVirtualDiskMounted = false;
}
private async Task<bool> CheckVirtualDiskMounted()
=> await ExecuteCommand("findmnt", RuntimeVolumePath) == 0;
private async Task EnsureVirtualDisk()
{
var existingDiskInfo = new FileInfo(VirtualDiskPath);
// Check if we need to create the disk or just check for the size
if (existingDiskInfo.Exists)
{
var expectedSize = ByteConverter.FromMegaBytes(Configuration.Disk).Bytes;
// If the disk size matches, we are done here
if (expectedSize == existingDiskInfo.Length)
{
Logger.LogDebug("Virtual disk size matches expected size");
return;
}
// We cant resize while the server is running as this would lead to possible file corruptions
// and crashes of the software the server is running
if (StateMachine.State != ServerState.Offline)
{
Logger.LogDebug("Skipping disk resizing while server is not offline");
await ConsoleSubSystem.WriteMoonlight("Skipping disk resizing as the server is not offline");
return;
}
if (expectedSize > existingDiskInfo.Length)
{
Logger.LogDebug("Detected smaller disk size as expected. Resizing now");
await ConsoleSubSystem.WriteMoonlight("Preparing to resize virtual disk");
// If the file system accessor is still open we need to destroy it in order to to resize
if (IsFileSystemAccessorCreated)
await DestroyFileSystemAccessor();
// If the disk is still mounted we need to unmount it in order to resize
if (IsVirtualDiskMounted)
await UnmountVirtualDisk();
// Resize the disk image file
Logger.LogDebug("Resizing virtual disk file");
var fileStream = File.Open(VirtualDiskPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
fileStream.SetLength(expectedSize);
await fileStream.FlushAsync();
fileStream.Close();
await fileStream.DisposeAsync();
// Now we need to run the file system check on the disk
Logger.LogDebug("Checking virtual disk for corruptions using e2fsck");
await ConsoleSubSystem.WriteMoonlight("Checking virtual disk for any corruptions");
await ExecuteCommand(
"e2fsck",
$"{AppConfiguration.Storage.VirtualDiskOptions.E2FsckParameters} {VirtualDiskPath}",
handleExitCode: true
);
// Resize the file system
Logger.LogDebug("Resizing filesystem of virtual disk using resize2fs");
await ConsoleSubSystem.WriteMoonlight("Resizing virtual disk");
await ExecuteCommand("resize2fs", VirtualDiskPath, handleExitCode: true);
// Done :>
Logger.LogDebug("Successfully resized virtual disk");
await ConsoleSubSystem.WriteMoonlight("Resize of virtual disk completed");
}
else if (existingDiskInfo.Length > expectedSize)
{
Logger.LogDebug("Shrink from {expected} to {existing} detected", expectedSize, existingDiskInfo.Length);
await ConsoleSubSystem.WriteMoonlight(
"Unable to shrink virtual disk. Virtual disk will stay unmodified"
);
Logger.LogWarning(
"Server disk limit was lower then the size of the virtual disk. Virtual disk wont be resized to prevent loss of files");
}
}
else
{
// Create the image file and adjust the size
Logger.LogDebug("Creating virtual disk");
await ConsoleSubSystem.WriteMoonlight("Creating virtual disk file. Please be patient");
var fileStream = File.Open(VirtualDiskPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
fileStream.SetLength(
ByteConverter.FromMegaBytes(Configuration.Disk).Bytes
);
await fileStream.FlushAsync();
fileStream.Close();
await fileStream.DisposeAsync();
// Now we want to format it
Logger.LogDebug("Formatting virtual disk");
await ConsoleSubSystem.WriteMoonlight("Formatting virtual disk. This can take a bit");
await ExecuteCommand(
"mkfs",
$"-t {AppConfiguration.Storage.VirtualDiskOptions.FileSystemType} {VirtualDiskPath}",
handleExitCode: true
);
// Done :)
Logger.LogDebug("Successfully created virtual disk");
await ConsoleSubSystem.WriteMoonlight("Virtual disk created");
}
}
private async Task DeleteVirtualDisk()
{
if (IsFileSystemAccessorCreated)
await DestroyFileSystemAccessor();
if (IsVirtualDiskMounted)
await UnmountVirtualDisk();
File.Delete(VirtualDiskPath);
}
private async Task<int> ExecuteCommand(string command, string arguments, bool handleExitCode = false)
{
var psi = new ProcessStartInfo()
{
FileName = command,
Arguments = arguments,
RedirectStandardError = true,
RedirectStandardOutput = true
};
var process = Process.Start(psi);
if (process == null)
throw new AggregateException("The spawned process reference is null");
await process.WaitForExitAsync();
if (process.ExitCode == 0 || !handleExitCode)
return process.ExitCode;
var output = await process.StandardOutput.ReadToEndAsync();
output += await process.StandardError.ReadToEndAsync();
throw new Exception($"The command {command} failed: {output}");
}
#endregion
public override async ValueTask DisposeAsync()
{
// We check for that just to ensure that we no longer access the file system
if (IsFileSystemAccessorCreated)
await DestroyFileSystemAccessor();
}
}

View File

@@ -1,183 +0,0 @@
using System.Collections.Concurrent;
using MoonCore.Exceptions;
using MoonCore.Helpers;
using MoonCore.Models;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSys;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
using Server = MoonlightServers.Daemon.ServerSys.Abstractions.Server;
namespace MoonlightServers.Daemon.Services;
public class NewServerService : IHostedLifecycleService
{
private readonly ILogger<ServerService> Logger;
private readonly ServerFactory ServerFactory;
private readonly RemoteService RemoteService;
private readonly ServerConfigurationMapper Mapper;
private readonly ConcurrentDictionary<int, Server> Servers = new();
public NewServerService(
ILogger<ServerService> logger,
ServerFactory serverFactory,
RemoteService remoteService,
ServerConfigurationMapper mapper
)
{
Logger = logger;
ServerFactory = serverFactory;
RemoteService = remoteService;
Mapper = mapper;
}
public async Task InitializeAllFromPanel()
{
var servers = await PagedData<ServerDataResponse>.All(async (page, pageSize) =>
await RemoteService.GetServers(page, pageSize)
);
foreach (var serverDataResponse in servers)
{
var configuration = Mapper.FromServerDataResponse(serverDataResponse);
try
{
await Initialize(configuration);
}
catch (Exception e)
{
Logger.LogError(e, "An error occured while initializing server: {id}", serverDataResponse.Id);
}
}
}
public async Task<Server> Initialize(ServerConfiguration serverConfiguration)
{
var server = ServerFactory.CreateServer(serverConfiguration);
Servers[serverConfiguration.Id] = server;
await server.Initialize();
return server;
}
public Server? Find(int serverId)
=> Servers.GetValueOrDefault(serverId);
public async Task Sync(int serverId)
{
var server = Find(serverId);
if (server == null)
throw new ArgumentException("No server with this id found", nameof(serverId));
var serverData = await RemoteService.GetServer(serverId);
var config = Mapper.FromServerDataResponse(serverData);
server.Context.Configuration = config;
await server.Sync();
}
public async Task Delete(int serverId)
{
var server = Find(serverId);
if (server == null)
throw new ArgumentException("No server with this id found", nameof(serverId));
if (server.StateMachine.State == ServerState.Installing)
throw new HttpApiException("Unable to delete a server while it is installing", 400);
if (server.StateMachine.State != ServerState.Offline)
{
// If the server is not offline we need to wait until it goes offline, we
// do that by creating the serverOfflineWaiter task completion source which will get triggered
// when the event handler for state changes gets informed that the server state is now offline
var serverOfflineWaiter = new TaskCompletionSource();
var timeoutCancellation = new CancellationTokenSource();
// Set timeout to 10 seconds, this gives the server 10 seconds to go offline, before the request fails
timeoutCancellation.CancelAfter(TimeSpan.FromSeconds(10));
// Subscribe to state updates in order to get notified when the server is offline
server.StateMachine.OnTransitioned(transition =>
{
// Only listen for changes to offline
if (transition.Destination != ServerState.Offline)
return;
// If the timeout has already been reached, ignore all changes
if (timeoutCancellation.IsCancellationRequested)
return;
// Server is finally offline, notify the request that we now can delete the server
serverOfflineWaiter.SetResult();
});
// Now we trigger the kill and waiting for the server to be deleted
await server.StateMachine.FireAsync(ServerTrigger.Kill);
try
{
await serverOfflineWaiter.Task.WaitAsync(timeoutCancellation.Token);
await DeleteServer_Unhandled(server);
}
catch (TaskCanceledException)
{
Logger.LogWarning(
"Deletion of server {id} failed because it didnt stop in time despite being killed",
server.Context.Configuration.Id
);
throw new HttpApiException(
"Could not kill the server in time for the deletion. Please try again later",
500
);
}
}
else
await DeleteServer_Unhandled(server);
}
private async Task DeleteServer_Unhandled(Server server)
{
await server.Delete();
await server.DisposeAsync();
Servers.Remove(server.Context.Configuration.Id, out _);
}
#region Lifetime
public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StartedAsync(CancellationToken cancellationToken)
{
await InitializeAllFromPanel();
}
public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StoppingAsync(CancellationToken cancellationToken)
{
foreach (var server in Servers.Values)
await server.DisposeAsync();
}
#endregion
}

View File

@@ -1,364 +0,0 @@
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.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;
[Singleton]
public class ServerService : IHostedLifecycleService
{
private readonly ConcurrentDictionary<int, Server> Servers = new();
private readonly RemoteService RemoteService;
private readonly DockerClient DockerClient;
private readonly IServiceProvider ServiceProvider;
private readonly CancellationTokenSource TaskCancellation;
private readonly ILogger<ServerService> Logger;
private readonly IHubContext<ServerWebSocketHub> HubContext;
public ServerService(
RemoteService remoteService,
IServiceProvider serviceProvider,
DockerClient dockerClient,
ILogger<ServerService> logger,
IHubContext<ServerWebSocketHub> hubContext
)
{
RemoteService = remoteService;
ServiceProvider = serviceProvider;
DockerClient = dockerClient;
Logger = logger;
HubContext = hubContext;
TaskCancellation = new CancellationTokenSource();
}
public async Task Sync(int serverId)
{
var serverData = await RemoteService.GetServer(serverId);
var configuration = serverData.ToServerConfiguration();
await Sync(serverId, configuration);
}
public async Task Sync(int serverId, ServerConfiguration configuration)
{
if (Servers.TryGetValue(serverId, out var server))
server.Configuration = configuration;
else
await Initialize(serverId);
}
public Server? Find(int serverId)
=> Servers.GetValueOrDefault(serverId);
public async Task Initialize(int serverId)
{
var serverData = await RemoteService.GetServer(serverId);
var configuration = serverData.ToServerConfiguration();
await Initialize(configuration);
}
public async Task Initialize(ServerConfiguration configuration)
{
var serverScope = ServiceProvider.CreateScope();
var server = new Server(configuration, serverScope, HubContext);
Type[] subSystems =
[
// The restore sub system needs to be on top in order for the state machine having the
// correct state when all other sub systems initialize
typeof(RestoreSubSystem),
typeof(ProvisionSubSystem),
typeof(StorageSubSystem),
typeof(DebugSubSystem),
typeof(ShutdownSubSystem),
typeof(ConsoleSubSystem),
typeof(OnlineDetectionService),
typeof(InstallationSubSystem),
typeof(StatsSubSystem)
];
await server.Initialize(subSystems);
Servers[configuration.Id] = server;
}
public async Task Delete(int serverId)
{
var server = Find(serverId);
// If a server with this id doesn't exist we can just exit
if (server == null)
return;
if (server.StateMachine.State == ServerState.Installing)
throw new HttpApiException("Unable to delete a server while it is installing", 400);
if (server.StateMachine.State != ServerState.Offline)
{
// If the server is not offline we need to wait until it goes offline, we
// do that by creating the serverOfflineWaiter task completion source which will get triggered
// when the event handler for state changes gets informed that the server state is now offline
var serverOfflineWaiter = new TaskCompletionSource();
var timeoutCancellation = new CancellationTokenSource();
// Set timeout to 10 seconds, this gives the server 10 seconds to go offline, before the request fails
timeoutCancellation.CancelAfter(TimeSpan.FromSeconds(10));
// Subscribe to state updates in order to get notified when the server is offline
server.StateMachine.OnTransitioned(transition =>
{
// Only listen for changes to offline
if (transition.Destination != ServerState.Offline)
return;
// If the timeout has already been reached, ignore all changes
if (timeoutCancellation.IsCancellationRequested)
return;
// Server is finally offline, notify the request that we now can delete the server
serverOfflineWaiter.SetResult();
});
// Now we trigger the kill and waiting for the server to be deleted
await server.StateMachine.FireAsync(ServerTrigger.Kill);
try
{
await serverOfflineWaiter.Task.WaitAsync(timeoutCancellation.Token);
await DeleteServer_Unhandled(server);
}
catch (TaskCanceledException)
{
Logger.LogWarning(
"Deletion of server {id} failed because it didnt stop in time despite being killed",
server.Configuration.Id
);
throw new HttpApiException(
"Could not kill the server in time for the deletion. Please try again later",
500
);
}
}
else
await DeleteServer_Unhandled(server);
}
private async Task DeleteServer_Unhandled(Server server)
{
await server.Delete();
await server.DisposeAsync();
Servers.Remove(server.Configuration.Id, out _);
}
#region Batch Initialization
public async Task InitializeAll()
{
var initialPage = await RemoteService.GetServers(0, 1);
const int pageSize = 25;
var pages = (initialPage.TotalItems == 0 ? 0 : (initialPage.TotalItems - 1) / pageSize) +
1; // The +1 is to handle the pages starting at 0
// Create and fill a queue with pages to initialize
var batchesLeft = new ConcurrentQueue<int>();
for (var i = 0; i < pages; i++)
batchesLeft.Enqueue(i);
var tasksCount = pages > 5 ? 5 : pages;
var tasks = new List<Task>();
Logger.LogInformation(
"Starting initialization for {count} server(s) with {tasksCount} worker(s)",
initialPage.TotalItems,
tasksCount
);
for (var i = 0; i < tasksCount; i++)
{
var id = i + 0;
var task = Task.Run(() => BatchRunner(batchesLeft, id));
tasks.Add(task);
}
await Task.WhenAll(tasks);
Logger.LogInformation("Initialization completed");
}
private async Task BatchRunner(ConcurrentQueue<int> queue, int id)
{
while (!queue.IsEmpty)
{
if (!queue.TryDequeue(out var page))
continue;
await InitializeBatch(page, 25);
Logger.LogDebug("Worker {id}: Finished initialization of page {page}", id, page);
}
Logger.LogDebug("Worker {id}: Finished", id);
}
private async Task InitializeBatch(int page, int pageSize)
{
var servers = await RemoteService.GetServers(page, pageSize);
var configurations = servers.Items
.Select(x => x.ToServerConfiguration())
.ToArray();
foreach (var configuration in configurations)
{
try
{
await Sync(configuration.Id, configuration);
}
catch (Exception e)
{
Logger.LogError(
"An unhandled error occured while initializing server {id}: {e}",
configuration.Id,
e
);
}
}
}
#endregion
#region Docker Monitoring
private Task StartContainerMonitoring()
{
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<Message>(async message =>
{
// Filter out unwanted events
if (message.Action != "die")
return;
// TODO: Implement a cached lookup using a shared dictionary by the sub system
var server = Servers.Values.FirstOrDefault(serverToCheck =>
{
var provisionSubSystem = serverToCheck.GetRequiredSubSystem<ProvisionSubSystem>();
if (provisionSubSystem.CurrentContainerId == message.ID)
return true;
var installationSubSystem = serverToCheck.GetRequiredSubSystem<InstallationSubSystem>();
if (installationSubSystem.CurrentContainerId == message.ID)
return true;
return false;
});
// If the container does not match any server we can ignore it
if (server == null)
return;
await server.StateMachine.FireAsync(ServerTrigger.Exited);
}), TaskCancellation.Token);
}
catch (TaskCanceledException)
{
// Can be ignored
}
catch (Exception e)
{
Logger.LogError("An unhandled error occured while monitoring events: {e}", e);
}
}
});
return Task.CompletedTask;
}
#endregion
#region Lifetime
public Task StartAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StartedAsync(CancellationToken cancellationToken)
{
await StartContainerMonitoring();
await InitializeAll();
}
public Task StartingAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
public async Task StoppedAsync(CancellationToken cancellationToken)
{
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<string, IDictionary<string, bool>>()
{
{
"label",
new Dictionary<string, bool>()
{
{
"Software=Moonlight-Panel",
true
}
}
}
}
});
*
*
*/
}

View File

@@ -1,29 +1,16 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Text;
using System.Text.Json;
using Docker.DotNet;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.IdentityModel.Tokens;
using MoonCore.EnvConfiguration;
using MoonCore.Extended.Extensions;
using MoonCore.Extensions;
using MoonCore.Helpers;
using MoonCore.Logging;
using MoonCore.Observability;
using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Extensions;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSys;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSys.Implementations;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.Daemon.Services;
using Server = MoonlightServers.Daemon.ServerSystem.Server;
namespace MoonlightServers.Daemon;
@@ -73,79 +60,6 @@ public class Startup
await MapBase();
await MapHubs();
Task.Run(async () =>
{
try
{
Console.WriteLine("Press enter to create server instance");
Console.ReadLine();
var config = new ServerConfiguration()
{
Allocations = [
new ServerConfiguration.AllocationConfiguration()
{
IpAddress = "0.0.0.0",
Port = 25565
}
],
Cpu = 400,
Disk = 10240,
DockerImage = "ghcr.io/parkervcp/yolks:java_21",
Id = 69,
Memory = 4096,
OnlineDetection = "\\)! For help, type ",
StartupCommand = "java -Xms128M -Xmx{{SERVER_MEMORY}}M -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}",
StopCommand = "stop",
Variables = new()
{
{
"SERVER_JARFILE",
"server.jar"
}
}
};
var factory = WebApplication.Services.GetRequiredService<ServerFactory>();
var server = factory.CreateServer(config);
await using var consoleSub = await server.Console.OnOutput
.SubscribeEventAsync(line =>
{
Console.Write(line);
return ValueTask.CompletedTask;
});
await using var stateSub = await server.OnState.SubscribeEventAsync(state =>
{
Console.WriteLine($"State: {state}");
return ValueTask.CompletedTask;
});
await server.Initialize();
Console.ReadLine();
if(server.StateMachine.State == ServerState.Offline)
await server.StateMachine.FireAsync(ServerTrigger.Start);
else
await server.StateMachine.FireAsync(ServerTrigger.Stop);
Console.ReadLine();
await server.StateMachine.FireAsync(ServerTrigger.Install);
Console.ReadLine();
await server.Context.ServiceScope.DisposeAsync();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
});
await WebApplication.RunAsync();
}
@@ -331,28 +245,6 @@ public class Startup
private Task RegisterServers()
{
WebApplicationBuilder.Services.AddSingleton<NewServerService>();
WebApplicationBuilder.Services.AddHostedService(sp => sp.GetRequiredService<NewServerService>());
WebApplicationBuilder.Services.AddSingleton<DockerEventService>();
WebApplicationBuilder.Services.AddHostedService(sp => sp.GetRequiredService<DockerEventService>());
WebApplicationBuilder.Services.AddSingleton<ServerConfigurationMapper>();
WebApplicationBuilder.Services.AddSingleton<ServerFactory>();
// Server scoped stuff
WebApplicationBuilder.Services.AddScoped<IConsole, DockerConsole>();
WebApplicationBuilder.Services.AddScoped<IFileSystem, RawFileSystem>();
WebApplicationBuilder.Services.AddScoped<IRestorer, DefaultRestorer>();
WebApplicationBuilder.Services.AddScoped<IInstaller, DockerInstaller>();
WebApplicationBuilder.Services.AddScoped<IProvisioner, DockerProvisioner>();
WebApplicationBuilder.Services.AddScoped<IStatistics, DockerStatistics>();
WebApplicationBuilder.Services.AddScoped<IOnlineDetection, RegexOnlineDetection>();
WebApplicationBuilder.Services.AddScoped<ServerContext>();
WebApplicationBuilder.Services.AddScoped<ServerSys.Abstractions.Server>();
return Task.CompletedTask;
}