diff --git a/MoonlightServers.Daemon/Configuration/AppConfiguration.cs b/MoonlightServers.Daemon/Configuration/AppConfiguration.cs index 76d3e43..5a77082 100644 --- a/MoonlightServers.Daemon/Configuration/AppConfiguration.cs +++ b/MoonlightServers.Daemon/Configuration/AppConfiguration.cs @@ -44,10 +44,10 @@ public class AppConfiguration public class StorageData { - 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"); + public string Volumes { get; set; } = Path.Combine("storage", "volumes"); + public string VirtualDisks { get; set; } = Path.Combine("storage", "virtualDisks"); + public string Backups { get; set; } = Path.Combine("storage", "backups"); + public string Install { get; set; } =Path.Combine("storage", "install"); public VirtualDiskData VirtualDiskOptions { get; set; } = new(); } diff --git a/MoonlightServers.Daemon/Helpers/CompositeServiceProvider.cs b/MoonlightServers.Daemon/Helpers/CompositeServiceProvider.cs new file mode 100644 index 0000000..c960bcd --- /dev/null +++ b/MoonlightServers.Daemon/Helpers/CompositeServiceProvider.cs @@ -0,0 +1,24 @@ +namespace MoonlightServers.Daemon.Helpers; + +public class CompositeServiceProvider : IServiceProvider +{ + private readonly List ServiceProviders; + + public CompositeServiceProvider(params IServiceProvider[] serviceProviders) + { + ServiceProviders = new List(serviceProviders); + } + + public object? GetService(Type serviceType) + { + foreach (var provider in ServiceProviders) + { + var service = provider.GetService(serviceType); + + if (service != null) + return service; + } + + return null; + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs b/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs new file mode 100644 index 0000000..19fe774 --- /dev/null +++ b/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs @@ -0,0 +1,284 @@ +using Docker.DotNet.Models; +using Mono.Unix.Native; +using MoonCore.Helpers; +using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.Models.Cache; + +namespace MoonlightServers.Daemon.Mappers; + +public class ServerConfigurationMapper +{ + private readonly AppConfiguration AppConfiguration; + + public ServerConfigurationMapper(AppConfiguration appConfiguration) + { + AppConfiguration = appConfiguration; + } + + public CreateContainerParameters ToRuntimeParameters( + ServerConfiguration serverConfiguration, + string hostPath, + string containerName + ) + { + var parameters = ToSharedParameters(serverConfiguration); + + #region Security + + parameters.HostConfig.CapDrop = new List() + { + "setpcap", "mknod", "audit_write", "net_raw", "dac_override", + "fowner", "fsetid", "net_bind_service", "sys_chroot", "setfcap" + }; + + parameters.HostConfig.ReadonlyRootfs = true; + parameters.HostConfig.SecurityOpt = new List() + { + "no-new-privileges" + }; + + #endregion + + #region Name + + parameters.Name = containerName; + parameters.Hostname = containerName; + + #endregion + + #region Docker Image + + parameters.Image = serverConfiguration.DockerImage; + + #endregion + + #region Working Dir + + parameters.WorkingDir = "/home/container"; + + #endregion + + #region User + + // TODO: Extract this to an external service with config options and return a userspace user id and a install user id + // in order to know which permissions are required in order to run the container with the correct permissions + + var userId = Syscall.getuid(); + + if (userId == 0) + userId = 998; + + parameters.User = $"{userId}:{userId}"; + + /* + if (userId == 0) + { + // We are running as root, so we need to run the container as another user and chown the files when we make changes + parameters.User = $"998:998"; + } + else + { + // We are not running as root, so we start the container as the same user, + // as we are not able to chown the container content to a different user + parameters.User = $"{userId}:{userId}"; + }*/ + + #endregion + + #region Mounts + + parameters.HostConfig.Mounts = new List(); + + parameters.HostConfig.Mounts.Add(new() + { + Source = hostPath, + Target = "/home/container", + ReadOnly = false, + Type = "bind" + }); + + #endregion + + #region Port Bindings + + if (true) // TODO: Add network toggle + { + parameters.ExposedPorts = new Dictionary(); + parameters.HostConfig.PortBindings = new Dictionary>(); + + foreach (var allocation in serverConfiguration.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 + } + }); + } + } + + #endregion + + // TODO: Implement a way to directly startup a server without using the entrypoint.sh and parsing the startup command here + // in the daemon instead of letting it the entrypoint do. iirc pelican wants to do that as well so we need to do that + // sooner or later in order to stay compatible to pelican + // Possible flag name: LegacyEntrypointMode + + return parameters; + } + + public CreateContainerParameters ToSharedParameters(ServerConfiguration serverConfiguration) + { + var parameters = new CreateContainerParameters() + { + HostConfig = new() + }; + + #region Input, output & error streams and tty + + parameters.Tty = true; + parameters.AttachStderr = true; + parameters.AttachStdin = true; + parameters.AttachStdout = true; + parameters.OpenStdin = true; + + #endregion + + #region CPU + + parameters.HostConfig.CPUQuota = serverConfiguration.Cpu * 1000; + parameters.HostConfig.CPUPeriod = 100000; + parameters.HostConfig.CPUShares = 1024; + + #endregion + + #region Memory & Swap + + var memoryLimit = serverConfiguration.Memory; + + // The overhead multiplier gives the container a little bit more memory to prevent crashes + var memoryOverhead = memoryLimit + (memoryLimit * AppConfiguration.Server.MemoryOverheadMultiplier); + + 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; + + #endregion + + #region Misc Limits + + // -- Other limits + parameters.HostConfig.BlkioWeight = 100; + //container.HostConfig.PidsLimit = configuration.Limits.PidsLimit; + parameters.HostConfig.OomKillDisable = true; //!configuration.Limits.EnableOomKill; + + #endregion + + #region DNS + + // TODO: Read hosts dns settings? + + parameters.HostConfig.DNS = /*config.Docker.DnsServers.Any() ? config.Docker.DnsServers :*/ new List() + { + "1.1.1.1", + "9.9.9.9" + }; + + #endregion + + #region Tmpfs + + parameters.HostConfig.Tmpfs = new Dictionary() + { + { "/tmp", $"rw,exec,nosuid,size={AppConfiguration.Server.TmpFsSize}M" } + }; + + #endregion + + #region Logging + + parameters.HostConfig.LogConfig = new() + { + Type = "json-file", // We need to use this provider, as the GetLogs endpoint needs it + Config = new Dictionary() + }; + + #endregion + + #region Labels + + parameters.Labels = new Dictionary(); + + parameters.Labels.Add("Software", "Moonlight-Panel"); + parameters.Labels.Add("ServerId", serverConfiguration.Id.ToString()); + + #endregion + + #region Environment + + parameters.Env = CreateEnvironmentVariables(serverConfiguration); + + #endregion + + return parameters; + } + + private List CreateEnvironmentVariables(ServerConfiguration serverConfiguration) + { + var result = new Dictionary + { + //TODO: Add timezone, add server ip + { "STARTUP", serverConfiguration.StartupCommand }, + { "SERVER_MEMORY", serverConfiguration.Memory.ToString() } + }; + + if (serverConfiguration.Allocations.Length > 0) + { + for (var i = 0; i < serverConfiguration.Allocations.Length; i++) + { + var allocation = serverConfiguration.Allocations[i]; + + result.Add($"ML_PORT_{i}", allocation.Port.ToString()); + + if (i == 0) // TODO: Implement a way to set the default/main allocation + { + result.Add("SERVER_IP", allocation.IpAddress); + result.Add("SERVER_PORT", allocation.Port.ToString()); + } + } + } + + // Copy variables as env vars + foreach (var variable in serverConfiguration.Variables) + result.Add(variable.Key, variable.Value); + + // Convert to the format of the docker library + return result.Select(variable => $"{variable.Key}={variable.Value}").ToList(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/IConsole.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/IConsole.cs index 6905e31..7545189 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/IConsole.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/IConsole.cs @@ -10,6 +10,7 @@ public interface IConsole : IServerComponent public Task WriteToOutput(string content); public Task WriteToInput(string content); + public Task WriteToMoonlight(string content); public Task ClearOutput(); public string[] GetOutput(); diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/IProvisioner.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/IProvisioner.cs index ad9108b..52029d9 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/IProvisioner.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/IProvisioner.cs @@ -3,11 +3,12 @@ namespace MoonlightServers.Daemon.ServerSys.Abstractions; public interface IProvisioner : IServerComponent { public IAsyncObservable OnExited { get; set; } - + + public Task Provision(); public Task Start(); public Task Stop(); public Task Kill(); - public Task Cleanup(); + public Task Deprovision(); public Task SearchForCrash(); } \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs index 48a7ec1..9b398a3 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs @@ -14,7 +14,7 @@ public class Server : IAsyncDisposable public StateMachine StateMachine { get; private set; } private readonly ILogger Logger; - + private IAsyncDisposable? ProvisionExitSubscription; private IAsyncDisposable? InstallerExitSubscription; @@ -58,7 +58,7 @@ public class Server : IAsyncDisposable "Error initializing server component: {type}", serverComponent.GetType().Name.GetType().FullName ); - + throw; } } @@ -70,15 +70,15 @@ public class Server : IAsyncDisposable Logger.LogDebug("Restorer didnt find anything to restore. State is offline"); else Logger.LogDebug("Restored server to state: {state}", restoredState); - + CreateStateMachine(restoredState); - + // Setup event handling ProvisionExitSubscription = await Provisioner.OnExited.SubscribeAsync(async o => { await StateMachine.FireAsync(ServerTrigger.Exited); }); - + InstallerExitSubscription = await Installer.OnExited.SubscribeAsync(async o => { await StateMachine.FireAsync(ServerTrigger.Exited); @@ -88,7 +88,7 @@ public class Server : IAsyncDisposable private void CreateStateMachine(ServerState initialState) { StateMachine = new StateMachine(initialState, FiringMode.Queued); - + // Configure basic state machine flow StateMachine.Configure(ServerState.Offline) @@ -116,19 +116,70 @@ public class Server : IAsyncDisposable StateMachine.Configure(ServerState.Installing) .Permit(ServerTrigger.FailSafe, ServerState.Offline) .Permit(ServerTrigger.Exited, ServerState.Offline); - + // Handle transitions - + + StateMachine.Configure(ServerState.Starting) + .OnActivateAsync(HandleStart); } + #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 + // TODO: Implement + + // 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"); + } + } + + #endregion + public async ValueTask DisposeAsync() { if (ProvisionExitSubscription != null) await ProvisionExitSubscription.DisposeAsync(); - - if(InstallerExitSubscription != null) + + if (InstallerExitSubscription != null) await InstallerExitSubscription.DisposeAsync(); - + await Console.DisposeAsync(); await FileSystem.DisposeAsync(); await Installer.DisposeAsync(); diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs new file mode 100644 index 0000000..04721ca --- /dev/null +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs @@ -0,0 +1,10 @@ +using MoonlightServers.Daemon.Models.Cache; + +namespace MoonlightServers.Daemon.ServerSys.Abstractions; + +public record ServerMeta +{ + public ServerConfiguration Configuration { get; set; } + public IServiceCollection ServiceCollection { get; set; } + public IServiceProvider ServiceProvider { get; set; } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Implementations/DockerConsole.cs b/MoonlightServers.Daemon/ServerSys/Implementations/DockerConsole.cs index 5e3d140..0bc6d7c 100644 --- a/MoonlightServers.Daemon/ServerSys/Implementations/DockerConsole.cs +++ b/MoonlightServers.Daemon/ServerSys/Implementations/DockerConsole.cs @@ -1,6 +1,9 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using System.Text; using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Options; using MoonCore.Helpers; using MoonlightServers.Daemon.ServerSys.Abstractions; @@ -15,11 +18,17 @@ public class DockerConsole : IConsole private readonly AsyncSubject OnInputSubject = new(); private readonly ConcurrentList OutputCache = new(); - private readonly DockerClient DockerClient; private readonly ILogger Logger; + private readonly ServerMeta Meta; + private MultiplexedStream? CurrentStream; private CancellationTokenSource Cts = new(); + + public DockerConsole(ServerMeta meta) + { + Meta = meta; + } public Task Initialize() => Task.CompletedTask; @@ -29,12 +38,101 @@ public class DockerConsole : IConsole public async Task AttachToRuntime() { - throw new NotImplementedException(); + var containerName = $"moonlight-runtime-{Meta.Configuration.Id}"; + await AttachStream(containerName); } public async Task AttachToInstallation() { - throw new NotImplementedException(); + var containerName = $"moonlight-install-{Meta.Configuration.Id}"; + await AttachStream(containerName); + } + + private Task AttachStream(string containerName) + { + 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("An unhandled error occured while reading from container stream: {e}", e); + } + finally + { + CurrentStream.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 + CurrentStream = null; + + Logger.LogDebug("Disconnected from container stream"); + }, Cts.Token); + + return Task.CompletedTask; } public Task WriteToOutput(string content) @@ -56,9 +154,22 @@ public class DockerConsole : IConsole public async Task WriteToInput(string content) { - throw new NotImplementedException(); + if (CurrentStream == null) + return; + + var contentBuffer = Encoding.UTF8.GetBytes(content); + + await CurrentStream.WriteAsync( + contentBuffer, + 0, + contentBuffer.Length, + Cts.Token + ); } + public async Task WriteToMoonlight(string content) + => await WriteToOutput($"\x1b[0;38;2;255;255;255;48;2;124;28;230m Moonlight \x1b[0m\x1b[38;5;250m\x1b[3m {content}\x1b[0m\n\r"); + public Task ClearOutput() { OutputCache.Clear(); @@ -75,8 +186,8 @@ public class DockerConsole : IConsole await Cts.CancelAsync(); Cts.Dispose(); } - - if(CurrentStream != null) + + if (CurrentStream != null) CurrentStream.Dispose(); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Implementations/DockerProvisioner.cs b/MoonlightServers.Daemon/ServerSys/Implementations/DockerProvisioner.cs new file mode 100644 index 0000000..6f28633 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSys/Implementations/DockerProvisioner.cs @@ -0,0 +1,246 @@ +using System.Reactive.Subjects; +using Docker.DotNet; +using Docker.DotNet.Models; +using MoonlightServers.Daemon.Mappers; +using MoonlightServers.Daemon.ServerSys.Abstractions; +using MoonlightServers.Daemon.Services; + +namespace MoonlightServers.Daemon.ServerSys.Implementations; + +public class DockerProvisioner : IProvisioner +{ + public IAsyncObservable OnExited { get; set; } + + private readonly DockerClient DockerClient; + private readonly ILogger Logger; + private readonly DockerEventService EventService; + private readonly ServerMeta Meta; + private readonly IConsole Console; + private readonly DockerImageService ImageService; + private readonly ServerConfigurationMapper Mapper; + private readonly IFileSystem FileSystem; + + private AsyncSubject OnExitedSubject = new(); + + private string? ContainerId; + private string ContainerName; + private IAsyncDisposable? ContainerEventSubscription; + + public DockerProvisioner( + DockerClient dockerClient, + ILogger logger, + DockerEventService eventService, + ServerMeta meta, + IConsole console, + DockerImageService imageService, + ServerConfigurationMapper mapper, + IFileSystem fileSystem + ) + { + DockerClient = dockerClient; + Logger = logger; + EventService = eventService; + Meta = meta; + Console = console; + ImageService = imageService; + Mapper = mapper; + FileSystem = fileSystem; + } + + public async Task Initialize() + { + ContainerName = $"moonlight-runtime-{Meta.Configuration.Id}"; + + ContainerEventSubscription = await EventService + .OnContainerEvent + .SubscribeAsync(HandleContainerEvent); + + // Check for any already existing runtime container + // TODO: Implement a way for restoring the state + // Needs to be able to be consumed by the restorer + } + + private ValueTask HandleContainerEvent(Message message) + { + // Only handle events for our own container + if (message.ID != ContainerId) + return ValueTask.CompletedTask; + + // Only handle die events + if (message.Action != "die") + return ValueTask.CompletedTask; + + OnExitedSubject.OnNext(message); + + return ValueTask.CompletedTask; + } + + 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.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(Meta.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( + Meta.Configuration, + hostFsPath, + ContainerName + ); + + var createdContainer = await DockerClient.Containers.CreateContainerAsync(parameters); + + ContainerId = createdContainer.ID; + + Logger.LogInformation("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 (Meta.Configuration.StopCommand.StartsWith('^')) + { + await DockerClient.Containers.KillContainerAsync(ContainerId, new() + { + Signal = Meta.Configuration.StopCommand.Substring(1) + }); + } + else + await Console.WriteToInput(Meta.Configuration.StopCommand); + } + + 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.LogInformation("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 SearchForCrash() + { + throw new NotImplementedException(); + } + + public async ValueTask DisposeAsync() + { + OnExitedSubject.Dispose(); + + if (ContainerEventSubscription != null) + await ContainerEventSubscription.DisposeAsync(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Implementations/RawFileSystem.cs b/MoonlightServers.Daemon/ServerSys/Implementations/RawFileSystem.cs new file mode 100644 index 0000000..aa2c9ea --- /dev/null +++ b/MoonlightServers.Daemon/ServerSys/Implementations/RawFileSystem.cs @@ -0,0 +1,55 @@ +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 ServerMeta Meta; + private readonly AppConfiguration Configuration; + private string HostPath => Path.Combine(Configuration.Storage.Volumes, Meta.Configuration.Id.ToString()); + + public RawFileSystem(ServerMeta meta, AppConfiguration configuration) + { + Meta = meta; + Configuration = configuration; + } + + public Task Initialize() + => Task.CompletedTask; + + public Task Sync() + => Task.CompletedTask; + + public Task Create() + { + 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; +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/ServerFactory.cs b/MoonlightServers.Daemon/ServerSys/ServerFactory.cs new file mode 100644 index 0000000..0f08e19 --- /dev/null +++ b/MoonlightServers.Daemon/ServerSys/ServerFactory.cs @@ -0,0 +1,39 @@ +using MoonlightServers.Daemon.Helpers; +using MoonlightServers.Daemon.Models.Cache; +using MoonlightServers.Daemon.ServerSys.Abstractions; +using MoonlightServers.Daemon.ServerSys.Implementations; + +namespace MoonlightServers.Daemon.ServerSys; + +public class ServerFactory +{ + private readonly IServiceProvider ServiceProvider; + + public ServerFactory(IServiceProvider serviceProvider) + { + ServiceProvider = serviceProvider; + } + + public Server CreateServer(ServerConfiguration configuration) + { + var serverMeta = new ServerMeta(); + serverMeta.Configuration = configuration; + + // Build service collection + serverMeta.ServiceCollection = new ServiceCollection(); + + // Configure service pipeline for the server components + serverMeta.ServiceCollection.AddSingleton(); + serverMeta.ServiceCollection.AddSingleton(); + + // TODO: Handle implementation configurations (e.g. virtual disk) here + + // Combine both app service provider and our server instance specific one + serverMeta.ServiceProvider = new CompositeServiceProvider([ + serverMeta.ServiceCollection.BuildServiceProvider(), + ServiceProvider + ]); + + return serverMeta.ServiceProvider.GetRequiredService(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Services/DockerEventService.cs b/MoonlightServers.Daemon/Services/DockerEventService.cs new file mode 100644 index 0000000..f712385 --- /dev/null +++ b/MoonlightServers.Daemon/Services/DockerEventService.cs @@ -0,0 +1,81 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Docker.DotNet; +using Docker.DotNet.Models; + +namespace MoonlightServers.Daemon.Services; + +public class DockerEventService : BackgroundService +{ + private readonly ILogger Logger; + private readonly DockerClient DockerClient; + + public IAsyncObservable OnContainerEvent => OnContainerSubject.ToAsyncObservable(); + public IAsyncObservable OnImageEvent => OnImageSubject.ToAsyncObservable(); + public IAsyncObservable OnNetworkEvent => OnNetworkSubject.ToAsyncObservable(); + + private readonly AsyncSubject OnContainerSubject = new(); + private readonly AsyncSubject OnImageSubject = new(); + private readonly AsyncSubject OnNetworkSubject = new(); + + public DockerEventService( + ILogger logger, + DockerClient dockerClient + ) + { + Logger = logger; + DockerClient = dockerClient; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Logger.LogInformation("Starting docker event service"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await DockerClient.System.MonitorEventsAsync( + new ContainerEventsParameters(), + new Progress(message => + { + switch (message.Type) + { + case "container": + OnContainerSubject.OnNext(message); + break; + + case "image": + OnImageSubject.OnNext(message); + break; + + case "network": + OnNetworkSubject.OnNext(message); + break; + } + }), + stoppingToken + ); + } + catch (TaskCanceledException) + { + // ignored + } + catch (Exception e) + { + Logger.LogError(e, "An error occured while listening for docker events: {message}", e.Message); + } + } + + Logger.LogInformation("Stopping docker event service"); + } + + public override void Dispose() + { + base.Dispose(); + + OnContainerSubject.Dispose(); + OnImageSubject.Dispose(); + OnNetworkSubject.Dispose(); + } +} \ No newline at end of file diff --git a/MoonlightServers.Daemon/Startup.cs b/MoonlightServers.Daemon/Startup.cs index ab7c369..9f914a2 100644 --- a/MoonlightServers.Daemon/Startup.cs +++ b/MoonlightServers.Daemon/Startup.cs @@ -8,9 +8,12 @@ using MoonCore.EnvConfiguration; using MoonCore.Extended.Extensions; using MoonCore.Extensions; using MoonCore.Helpers; +using MoonCore.Logging; using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Helpers; using MoonlightServers.Daemon.Http.Hubs; +using MoonlightServers.Daemon.Mappers; +using MoonlightServers.Daemon.ServerSys; using MoonlightServers.Daemon.ServerSystem; using MoonlightServers.Daemon.Services; @@ -68,8 +71,8 @@ public class Startup private Task SetupStorage() { Directory.CreateDirectory("storage"); - Directory.CreateDirectory(PathBuilder.Dir("storage", "logs")); - Directory.CreateDirectory(PathBuilder.Dir("storage", "plugins")); + Directory.CreateDirectory(Path.Combine("storage", "logs")); + Directory.CreateDirectory(Path.Combine("storage", "plugins")); return Task.CompletedTask; } @@ -100,7 +103,7 @@ public class Startup private Task UseBase() { WebApplication.UseRouting(); - WebApplication.UseApiExceptionHandler(); + WebApplication.UseExceptionHandler(); return Task.CompletedTask; } @@ -141,7 +144,7 @@ public class Startup var configurationBuilder = new ConfigurationBuilder(); // Ensure configuration file exists - var jsonFilePath = PathBuilder.File(Directory.GetCurrentDirectory(), "storage", "app.json"); + var jsonFilePath = Path.Combine(Directory.GetCurrentDirectory(), "storage", "app.json"); if (!File.Exists(jsonFilePath)) await File.WriteAllTextAsync(jsonFilePath, JsonSerializer.Serialize(new AppConfiguration())); @@ -187,20 +190,8 @@ public class Startup private Task SetupLogging() { - LoggerProviders = LoggerBuildHelper.BuildFromConfiguration(configuration => - { - configuration.Console.Enable = true; - configuration.Console.EnableAnsiMode = true; - - configuration.FileLogging.Enable = true; - configuration.FileLogging.EnableLogRotation = true; - configuration.FileLogging.Path = PathBuilder.File("storage", "logs", "WebAppTemplate.log"); - configuration.FileLogging.RotateLogNameTemplate = - PathBuilder.File("storage", "logs", "WebAppTemplate.log.{0}"); - }); - LoggerFactory = new LoggerFactory(); - LoggerFactory.AddProviders(LoggerProviders); + LoggerFactory.AddAnsiConsole(); Logger = LoggerFactory.CreateLogger(); @@ -211,31 +202,36 @@ public class Startup { // Configure application logging WebApplicationBuilder.Logging.ClearProviders(); - WebApplicationBuilder.Logging.AddProviders(LoggerProviders); + WebApplicationBuilder.Logging.AddAnsiConsole(); + WebApplicationBuilder.Logging.AddFile( + Path.Combine(Directory.GetCurrentDirectory(), "storage", "logs", "MoonlightServer.Daemon.log") + ); // Logging levels - var logConfigPath = PathBuilder.File("storage", "logConfig.json"); + var logConfigPath = Path.Combine("storage", "logConfig.json"); // Ensure logging config, add a default one is missing if (!File.Exists(logConfigPath)) { - var logLevels = new Dictionary + var defaultLogLevels = new Dictionary { { "Default", "Information" }, { "Microsoft.AspNetCore", "Warning" }, { "System.Net.Http.HttpClient", "Warning" } }; - var logLevelsJson = JsonSerializer.Serialize(logLevels); - var logConfig = "{\"LogLevel\":" + logLevelsJson + "}"; - await File.WriteAllTextAsync(logConfigPath, logConfig); + var json = JsonSerializer.Serialize(defaultLogLevels); + await File.WriteAllTextAsync(logConfigPath, json); } - // Configure logging configuration - WebApplicationBuilder.Logging.AddConfiguration( + var logLevels = JsonSerializer.Deserialize>( await File.ReadAllTextAsync(logConfigPath) - ); + )!; + // Configure logging configuration + foreach (var logLevel in logLevels) + WebApplicationBuilder.Logging.AddFilter(logLevel.Key, Enum.Parse(logLevel.Value)); + // Mute exception handler middleware // https://github.com/dotnet/aspnetcore/issues/19740 WebApplicationBuilder.Logging.AddFilter( @@ -255,10 +251,16 @@ public class Startup private Task RegisterServers() { - WebApplicationBuilder.Services.AddHostedService( - sp => sp.GetRequiredService() + WebApplicationBuilder.Services.AddHostedService(sp => sp.GetRequiredService() ); + WebApplicationBuilder.Services.AddSingleton(); + WebApplicationBuilder.Services.AddHostedService(sp => sp.GetRequiredService()); + + WebApplicationBuilder.Services.AddSingleton(); + + WebApplicationBuilder.Services.AddSingleton(); + return Task.CompletedTask; }