From 039db222076e169f689ca604ab8e9b33736249f2 Mon Sep 17 00:00:00 2001 From: ChiaraBm Date: Thu, 26 Dec 2024 21:09:22 +0100 Subject: [PATCH] Started with adding container creation and a server state machine --- .../Configuration/AppConfiguration.cs | 10 +- .../Helpers/ServerActionHelper.cs | 118 ++++++++++ .../Helpers/ServerConfigurationHelper.cs | 202 ++++++++++++++++++ .../Helpers/StateMachine.cs | 69 ++++++ .../Controllers/Servers/ServersController.cs | 29 +++ MoonlightServers.Daemon/Models/Allocation.cs | 7 - .../ServerConfiguration.cs} | 30 ++- MoonlightServers.Daemon/Models/Server.cs | 12 ++ MoonlightServers.Daemon/Models/ServerState.cs | 10 + MoonlightServers.Daemon/Models/ServerTask.cs | 11 + .../MoonlightServers.Daemon.csproj | 1 + .../Services/ServerService.cs | 81 ++++++- 12 files changed, 548 insertions(+), 32 deletions(-) create mode 100644 MoonlightServers.Daemon/Helpers/ServerActionHelper.cs create mode 100644 MoonlightServers.Daemon/Helpers/ServerConfigurationHelper.cs create mode 100644 MoonlightServers.Daemon/Helpers/StateMachine.cs create mode 100644 MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs delete mode 100644 MoonlightServers.Daemon/Models/Allocation.cs rename MoonlightServers.Daemon/Models/{ServerData.cs => Cache/ServerConfiguration.cs} (57%) create mode 100644 MoonlightServers.Daemon/Models/Server.cs create mode 100644 MoonlightServers.Daemon/Models/ServerState.cs create mode 100644 MoonlightServers.Daemon/Models/ServerTask.cs diff --git a/MoonlightServers.Daemon/Configuration/AppConfiguration.cs b/MoonlightServers.Daemon/Configuration/AppConfiguration.cs index 096693f..eea42db 100644 --- a/MoonlightServers.Daemon/Configuration/AppConfiguration.cs +++ b/MoonlightServers.Daemon/Configuration/AppConfiguration.cs @@ -1,3 +1,5 @@ +using MoonCore.Helpers; + namespace MoonlightServers.Daemon.Configuration; public class AppConfiguration @@ -24,9 +26,9 @@ public class AppConfiguration public class StorageData { - public string Volumes { get; set; } - public string VirtualDisks { get; set; } - public string Backups { get; set; } - public string Install { get; set; } + public string Volumes { get; set; } = PathBuilder.Dir("volumes"); + public string VirtualDisks { get; set; } = PathBuilder.Dir("virtualDisks"); + public string Backups { get; set; } = PathBuilder.Dir("backups"); + public string Install { get; set; } = PathBuilder.Dir("install"); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/ServerActionHelper.cs b/MoonlightServers.Daemon/Helpers/ServerActionHelper.cs new file mode 100644 index 0000000..f5b48e8 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/ServerActionHelper.cs @@ -0,0 +1,118 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using MoonCore.Helpers; +using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.Models; + +namespace MoonlightServers.Daemon.Helpers; + +public class ServerActionHelper +{ + public static async Task Start(Server server, IServiceProvider serviceProvider) + { + await EnsureStorage(server, serviceProvider); + await EnsureDockerImage(server, serviceProvider); + await CreateRuntimeContainer(server, serviceProvider); + await StartRuntimeContainer(server, serviceProvider); + } + + private static async Task EnsureStorage(Server server, IServiceProvider serviceProvider) + { + await NotifyTask(server, serviceProvider, ServerTask.CreatingStorage); + + // Build paths + var configuration = serviceProvider.GetRequiredService(); + + var volumePath = PathBuilder.Dir( + configuration.Storage.Volumes, + server.Configuration.Id.ToString() + ); + + // Create volume if missing + if (!Directory.Exists(volumePath)) + Directory.CreateDirectory(volumePath); + + // TODO: Virtual disk + } + + private static async Task EnsureDockerImage(Server server, IServiceProvider serviceProvider) + { + await NotifyTask(server, serviceProvider, ServerTask.PullingDockerImage); + + var dockerClient = serviceProvider.GetRequiredService(); + + await dockerClient.Images.CreateImageAsync(new() + { + FromImage = server.Configuration.DockerImage + }, + new AuthConfig(), + new Progress(async message => + { + //var percentage = (int)(message.Progress.Current / message.Progress.Total); + //await UpdateProgress(server, serviceProvider, percentage); + }) + ); + } + + private static async Task CreateRuntimeContainer(Server server, IServiceProvider serviceProvider) + { + var dockerClient = serviceProvider.GetRequiredService(); + + try + { + var existingContainer = await dockerClient.Containers.InspectContainerAsync( + $"moonlight-runtime-{server.Configuration.Id}" + ); + + await NotifyTask(server, serviceProvider, ServerTask.RemovingContainer); + + if (existingContainer.State.Running) // Stop already running container + { + await dockerClient.Containers.StopContainerAsync(existingContainer.ID, new() + { + WaitBeforeKillSeconds = 30 // TODO: Config + }); + } + + await dockerClient.Containers.RemoveContainerAsync(existingContainer.ID, new()); + } + catch (DockerContainerNotFoundException) + { + } + + await NotifyTask(server, serviceProvider, ServerTask.CreatingContainer); + + // Create a new container + var parameters = new CreateContainerParameters(); + + ServerConfigurationHelper.ApplyRuntimeOptions( + parameters, + server.Configuration, + serviceProvider.GetRequiredService() + ); + + var container = await dockerClient.Containers.CreateContainerAsync(parameters); + server.ContainerId = container.ID; + } + + private static async Task StartRuntimeContainer(Server server, IServiceProvider serviceProvider) + { + await NotifyTask(server, serviceProvider, ServerTask.StartingContainer); + + var dockerClient = serviceProvider.GetRequiredService(); + + await dockerClient.Containers.StartContainerAsync(server.ContainerId, new()); + } + + private static async Task NotifyTask(Server server, IServiceProvider serviceProvider, ServerTask task) + { + var loggerFactory = serviceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger($"Server {server.Configuration.Id}"); + + logger.LogInformation("Task: {task}", task); + } + + private static async Task UpdateProgress(Server server, IServiceProvider serviceProvider, int progress) + { + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/ServerConfigurationHelper.cs b/MoonlightServers.Daemon/Helpers/ServerConfigurationHelper.cs new file mode 100644 index 0000000..c353789 --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/ServerConfigurationHelper.cs @@ -0,0 +1,202 @@ +using Docker.DotNet.Models; +using MoonCore.Helpers; +using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.Models.Cache; + +namespace MoonlightServers.Daemon.Helpers; + +public static class ServerConfigurationHelper +{ + public static void ApplyRuntimeOptions(CreateContainerParameters parameters, ServerConfiguration configuration, AppConfiguration appConfiguration) + { + ApplySharedOptions(parameters, configuration); + + // -- Cap drops + parameters.HostConfig.CapDrop = new List() + { + "setpcap", "mknod", "audit_write", "net_raw", "dac_override", + "fowner", "fsetid", "net_bind_service", "sys_chroot", "setfcap" + }; + + // -- More security options + parameters.HostConfig.ReadonlyRootfs = true; + parameters.HostConfig.SecurityOpt = new List() + { + "no-new-privileges" + }; + + // - Name + var name = $"moonlight-runtime-{configuration.Id}"; + parameters.Name = name; + parameters.Hostname = name; + + // - Image + parameters.Image = configuration.DockerImage; + + // - Env + parameters.Env = ConstructEnv(configuration) + .Select(x => $"{x.Key}={x.Value}") + .ToList(); + + // -- Working directory + parameters.WorkingDir = "/home/container"; + + // - User + //TODO: use config + parameters.User = $"998:998"; + + // -- Mounts + parameters.HostConfig.Mounts = new List(); + + parameters.HostConfig.Mounts.Add(new() + { + Source = GetRuntimeVolume(configuration, appConfiguration), + Target = "/home/container", + ReadOnly = false, + Type = "bind" + }); + + // -- Ports + //var config = configService.Get(); + + if (true) // TODO: Add network toggle + { + parameters.ExposedPorts = new Dictionary(); + parameters.HostConfig.PortBindings = new Dictionary>(); + + foreach (var allocation in configuration.Allocations) + { + parameters.ExposedPorts.Add($"{allocation.Port}/tcp", new()); + parameters.ExposedPorts.Add($"{allocation.Port}/udp", new()); + + parameters.HostConfig.PortBindings.Add($"{allocation.Port}/tcp", new List + { + new() + { + HostPort = allocation.Port.ToString(), + HostIP = allocation.IpAddress + } + }); + + parameters.HostConfig.PortBindings.Add($"{allocation.Port}/udp", new List + { + new() + { + HostPort = allocation.Port.ToString(), + HostIP = allocation.IpAddress + } + }); + } + } + } + + public static void ApplySharedOptions(CreateContainerParameters parameters, ServerConfiguration configuration) + { + // - Input, output & error streams and tty + parameters.Tty = true; + parameters.AttachStderr = true; + parameters.AttachStdin = true; + parameters.AttachStdout = true; + parameters.OpenStdin = true; + + // - Host config + parameters.HostConfig = new HostConfig(); + + // -- CPU limits + parameters.HostConfig.CPUQuota = configuration.Cpu * 1000; + parameters.HostConfig.CPUPeriod = 100000; + parameters.HostConfig.CPUShares = 1024; + + // -- Memory and swap limits + var memoryLimit = configuration.Memory; + + // The overhead multiplier gives the container a little bit more memory to prevent crashes + var memoryOverhead = memoryLimit + (memoryLimit * 0.05f); // TODO: Config + + long swapLimit = -1; + + /* + + // If swap is enabled globally and not disabled on this server, set swap + if (!configuration.Limits.DisableSwap && config.Server.EnableSwap) + swapLimit = (long)(memoryOverhead + memoryOverhead * config.Server.SwapMultiplier); + co + */ + + // Finalize limits by converting and updating the host config + parameters.HostConfig.Memory = ByteConverter.FromMegaBytes((long)memoryOverhead, 1000).Bytes; + parameters.HostConfig.MemoryReservation = ByteConverter.FromMegaBytes(memoryLimit, 1000).Bytes; + parameters.HostConfig.MemorySwap = swapLimit == -1 ? swapLimit : ByteConverter.FromMegaBytes(swapLimit, 1000).Bytes; + + // -- Other limits + parameters.HostConfig.BlkioWeight = 100; + //container.HostConfig.PidsLimit = configuration.Limits.PidsLimit; + parameters.HostConfig.OomKillDisable = true; //!configuration.Limits.EnableOomKill; + + // -- DNS + parameters.HostConfig.DNS = /*config.Docker.DnsServers.Any() ? config.Docker.DnsServers :*/ new List() + { + "1.1.1.1", + "9.9.9.9" + }; + + // -- Tmpfs + parameters.HostConfig.Tmpfs = new Dictionary() + { + { "/tmp", $"rw,exec,nosuid,size=100M" } // TODO: Config + }; + + // -- Logging + parameters.HostConfig.LogConfig = new() + { + Type = "json-file", // We need to use this provider, as the GetLogs endpoint needs it + Config = new Dictionary() + }; + + // - Labels + parameters.Labels = new Dictionary(); + + parameters.Labels.Add("Software", "Moonlight-Panel"); + parameters.Labels.Add("ServerId", configuration.Id.ToString()); + } + + public static Dictionary ConstructEnv(ServerConfiguration configuration) + { + var result = new Dictionary(); + + // Default environment variables + //TODO: Add timezone, add server ip + result.Add("STARTUP", configuration.StartupCommand); + result.Add("SERVER_MEMORY", configuration.Memory.ToString()); + + if (configuration.Allocations.Length > 0) + { + var mainAllocation = configuration.Allocations.First(); + + result.Add("SERVER_IP", mainAllocation.IpAddress); + result.Add("SERVER_PORT", mainAllocation.Port.ToString()); + } + + // Handle additional allocation variables + var i = 1; + foreach (var additionalAllocation in configuration.Allocations) + { + result.Add($"ML_PORT_{i}", additionalAllocation.Port.ToString()); + i++; + } + + // Copy variables as env vars + foreach (var variable in configuration.Variables) + result.Add(variable.Key, variable.Value); + + return result; + } + + public static string GetRuntimeVolume(ServerConfiguration configuration, AppConfiguration appConfiguration) + { + var localPath = PathBuilder.Dir(appConfiguration.Storage.Volumes, configuration.Id.ToString()); + var absolutePath = Path.GetFullPath(localPath); + + return absolutePath; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Helpers/StateMachine.cs b/MoonlightServers.Daemon/Helpers/StateMachine.cs new file mode 100644 index 0000000..d3bfbcc --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/StateMachine.cs @@ -0,0 +1,69 @@ +namespace MoonlightServers.Daemon.Helpers; + +public class StateMachine where T : struct, Enum +{ + private readonly List Transitions = new(); + private readonly object Lock = new(); + + public T CurrentState { get; private set; } + public event Func OnTransitioned; + public event Action OnError; + + public StateMachine(T initialState) + { + CurrentState = initialState; + } + + public void AddTransition(T from, T to, T? onError, Func fun) + { + Transitions.Add(new() + { + From = from, + To = to, + OnError = onError, + OnTransitioning = fun + }); + } + + public void AddTransition(T from, T to, Func fun) => AddTransition(from, to, null, fun); + + public async Task TransitionTo(T to) + { + lock (Lock) + { + var transition = Transitions.FirstOrDefault(x => + x.From.Equals(CurrentState) && + x.To.Equals(to) + ); + + if (transition == null) + throw new InvalidOperationException("Unable to transition to the request state: No transition found"); + + try + { + transition.OnTransitioning.Invoke().Wait(); + } + catch (Exception e) + { + if(OnError != null) + OnError.Invoke(to, e); + + if (transition.OnError.HasValue) + CurrentState = transition.OnError.Value; + else + throw new AggregateException("An error occured while transitioning to a state", e); + } + } + + if(OnTransitioned != null) + await OnTransitioned.Invoke(CurrentState); + } + + public class StateMachineTransition + { + public T From { get; set; } + public T To { get; set; } + public T? OnError { get; set; } + public Func OnTransitioning { get; set; } + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs b/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs new file mode 100644 index 0000000..80dda6b --- /dev/null +++ b/MoonlightServers.Daemon/Http/Controllers/Servers/ServersController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using MoonCore.Exceptions; +using MoonlightServers.Daemon.Models; +using MoonlightServers.Daemon.Services; + +namespace MoonlightServers.Daemon.Http.Controllers.Servers; + +[ApiController] +[Route("api/servers")] +public class ServersController : Controller +{ + private readonly ServerService ServerService; + + public ServersController(ServerService serverService) + { + ServerService = serverService; + } + + [HttpPost("{serverId}/start")] + public async Task Start(int serverId) + { + var server = ServerService.GetServer(serverId); + + if (server == null) + throw new HttpApiException("No server with this id found", 404); + + await server.StateMachine.TransitionTo(ServerState.Starting); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/Allocation.cs b/MoonlightServers.Daemon/Models/Allocation.cs deleted file mode 100644 index 028303f..0000000 --- a/MoonlightServers.Daemon/Models/Allocation.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MoonlightServers.Daemon.Models; - -public class Allocation -{ - public string IpAddress { get; set; } - public int Port { get; set; } -} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/ServerData.cs b/MoonlightServers.Daemon/Models/Cache/ServerConfiguration.cs similarity index 57% rename from MoonlightServers.Daemon/Models/ServerData.cs rename to MoonlightServers.Daemon/Models/Cache/ServerConfiguration.cs index 7417fcc..2215286 100644 --- a/MoonlightServers.Daemon/Models/ServerData.cs +++ b/MoonlightServers.Daemon/Models/Cache/ServerConfiguration.cs @@ -1,22 +1,30 @@ -namespace MoonlightServers.Daemon.Models; +namespace MoonlightServers.Daemon.Models.Cache; -public class ServerData +public class ServerConfiguration { public int Id { get; set; } - public string StartupCommand { get; set; } - public string OnlineDetection { get; set; } - public string StopCommand { get; set; } - public string DockerImage { get; set; } - public bool PullDockerImage { get; set; } - public string ParseConiguration { get; set; } + // Limits public int Cpu { get; set; } public int Memory { get; set; } public int Disk { get; set; } - - public bool UseVirtualDisk { get; set; } public int Bandwidth { get; set; } + public bool UseVirtualDisk { get; set; } + + // Start, Stop & Status + public string StartupCommand { get; set; } + public string StopCommand { get; set; } + public string OnlineDetection { get; set; } + + // Container + public string DockerImage { get; set; } + public AllocationConfiguration[] Allocations { get; set; } - public List Allocations { get; set; } public Dictionary Variables { get; set; } + + public struct AllocationConfiguration + { + public string IpAddress { get; set; } + public int Port { get; set; } + } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/Server.cs b/MoonlightServers.Daemon/Models/Server.cs new file mode 100644 index 0000000..3081ee5 --- /dev/null +++ b/MoonlightServers.Daemon/Models/Server.cs @@ -0,0 +1,12 @@ +using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.Models.Cache; + +namespace MoonlightServers.Daemon.Models; + +public class Server +{ + public ServerState State => StateMachine.CurrentState; + public StateMachine StateMachine { get; set; } + public ServerConfiguration Configuration { get; set; } + public string? ContainerId { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/ServerState.cs b/MoonlightServers.Daemon/Models/ServerState.cs new file mode 100644 index 0000000..0646952 --- /dev/null +++ b/MoonlightServers.Daemon/Models/ServerState.cs @@ -0,0 +1,10 @@ +namespace MoonlightServers.Daemon.Models; + +public enum ServerState +{ + Offline = 0, + Starting = 1, + Online = 2, + Stopping = 3, + Installing = 4 +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Models/ServerTask.cs b/MoonlightServers.Daemon/Models/ServerTask.cs new file mode 100644 index 0000000..ba3ffe0 --- /dev/null +++ b/MoonlightServers.Daemon/Models/ServerTask.cs @@ -0,0 +1,11 @@ +namespace MoonlightServers.Daemon.Models; + +public enum ServerTask +{ + None = 0, + CreatingStorage = 1, + PullingDockerImage = 2, + RemovingContainer = 3, + CreatingContainer = 4, + StartingContainer = 5 +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj index 57507e1..d055152 100644 --- a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj +++ b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj @@ -14,6 +14,7 @@ + diff --git a/MoonlightServers.Daemon/Services/ServerService.cs b/MoonlightServers.Daemon/Services/ServerService.cs index 109ff73..a9898a6 100644 --- a/MoonlightServers.Daemon/Services/ServerService.cs +++ b/MoonlightServers.Daemon/Services/ServerService.cs @@ -1,6 +1,9 @@ using MoonCore.Attributes; +using MoonCore.Helpers; using MoonCore.Models; +using MoonlightServers.Daemon.Helpers; using MoonlightServers.Daemon.Models; +using MoonlightServers.Daemon.Models.Cache; using MoonlightServers.DaemonShared.PanelSide.Http.Responses; namespace MoonlightServers.Daemon.Services; @@ -8,21 +11,31 @@ namespace MoonlightServers.Daemon.Services; [Singleton] public class ServerService { - private readonly List Servers = new(); - private readonly RemoteService RemoteService; + private readonly List Servers = new(); private readonly ILogger Logger; + private readonly RemoteService RemoteService; + private readonly IServiceProvider ServiceProvider; + private bool IsInitialized = false; - public ServerService(RemoteService remoteService, ILogger logger) + public ServerService(RemoteService remoteService, ILogger logger, IServiceProvider serviceProvider) { RemoteService = remoteService; Logger = logger; + ServiceProvider = serviceProvider; } public async Task Initialize() //TODO: Add initialize call from panel { - //TODO: Handle block creating servers while initializing - Logger.LogInformation("Loading servers from panel"); + if (IsInitialized) + { + Logger.LogWarning("Ignoring initialize call: Already initialized"); + return; + } + IsInitialized = true; + + // Loading models and converting them + Logger.LogInformation("Fetching servers from panel"); var apiClient = await RemoteService.CreateHttpClient(); var servers = await PagedData.All(async (page, pageSize) => @@ -30,15 +43,63 @@ public class ServerService $"api/servers/remote/servers?page={page}&pageSize={pageSize}" ) ); - + + var configurations = servers.Select(x => new ServerConfiguration() + { + Id = x.Id, + StartupCommand = x.StartupCommand, + Allocations = x.Allocations.Select(y => new ServerConfiguration.AllocationConfiguration() + { + IpAddress = y.IpAddress, + Port = y.Port + }).ToArray(), + Variables = x.Variables, + OnlineDetection = x.OnlineDetection, + DockerImage = x.DockerImage, + UseVirtualDisk = x.UseVirtualDisk, + Bandwidth = x.Bandwidth, + Cpu = x.Cpu, + Disk = x.Disk, + Memory = x.Memory, + StopCommand = x.StopCommand + }).ToArray(); + Logger.LogInformation("Initializing {count} servers", servers.Length); + + foreach (var configuration in configurations) + await InitializeServer(configuration); } - public Task ImportServer(ServerData serverData) + private async Task InitializeServer(ServerConfiguration configuration) + { + Logger.LogInformation("Initializing server '{id}'", configuration.Id); + + var server = new Server() + { + Configuration = configuration, + StateMachine = new(ServerState.Offline) + }; + + server.StateMachine.OnError += (state, exception) => + { + Logger.LogError("Server {id} encountered an unhandled error while transitioning to {state}: {e}", + server.Configuration.Id, + state, + exception + ); + }; + + server.StateMachine.AddTransition(ServerState.Offline, ServerState.Starting, ServerState.Offline, async () => + await ServerActionHelper.Start(server, ServiceProvider) + ); + + lock (Servers) + Servers.Add(server); + } + + public Server? GetServer(int id) { lock (Servers) - Servers.Add(serverData); - - return Task.CompletedTask; + return Servers.FirstOrDefault(x => x.Configuration.Id == id); } } \ No newline at end of file