diff --git a/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs b/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs index 19fe774..a980c29 100644 --- a/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs +++ b/MoonlightServers.Daemon/Mappers/ServerConfigurationMapper.cs @@ -3,6 +3,7 @@ using Mono.Unix.Native; using MoonCore.Helpers; using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Models.Cache; +using MoonlightServers.DaemonShared.PanelSide.Http.Responses; namespace MoonlightServers.Daemon.Mappers; @@ -62,7 +63,7 @@ public class ServerConfigurationMapper // 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) @@ -137,7 +138,64 @@ public class ServerConfigurationMapper // 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 ToInstallParameters( + ServerConfiguration serverConfiguration, + ServerInstallDataResponse installData, + string runtimeHostPath, + string installationHostPath, + string containerName + ) + { + var parameters = ToSharedParameters(serverConfiguration); + + // - Name + parameters.Name = containerName; + parameters.Hostname = containerName; + + // - Image + parameters.Image = installData.DockerImage; + + // -- Working directory + parameters.WorkingDir = "/mnt/server"; + + // - User + // Note: Some images might not work if we set a user here + + var userId = Syscall.getuid(); + + // If we are root, we are able to change owner permissions after the installation + // so we run the installation as root, otherwise we need to run it as our current user, + // so we are able to access the files created by the installer + if (userId == 0) + parameters.User = "0:0"; + else + parameters.User = $"{userId}:{userId}"; + + // -- Mounts + parameters.HostConfig.Mounts = new List(); + + parameters.HostConfig.Mounts.Add(new() + { + Source = runtimeHostPath, + Target = "/mnt/server", + ReadOnly = false, + Type = "bind" + }); + + parameters.HostConfig.Mounts.Add(new() + { + Source = installationHostPath, + Target = "/mnt/install", + ReadOnly = false, + Type = "bind" + }); + + parameters.Cmd = [installData.Shell, "/mnt/install/install.sh"]; + return parameters; } @@ -263,7 +321,7 @@ public class ServerConfigurationMapper 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 @@ -273,7 +331,7 @@ public class ServerConfigurationMapper } } } - + // Copy variables as env vars foreach (var variable in serverConfiguration.Variables) result.Add(variable.Key, variable.Value); diff --git a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj index 3129e27..7e733f5 100644 --- a/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj +++ b/MoonlightServers.Daemon/MoonlightServers.Daemon.csproj @@ -57,6 +57,7 @@ <_ContentIncludedByDefault Remove="volumes\3\usercache.json" /> <_ContentIncludedByDefault Remove="volumes\3\version_history.json" /> <_ContentIncludedByDefault Remove="volumes\3\whitelist.json" /> + <_ContentIncludedByDefault Remove="storage\volumes\69\plugins\spark\config.json" /> diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/IInstaller.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/IInstaller.cs index 5dad421..db3bdf1 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/IInstaller.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/IInstaller.cs @@ -5,6 +5,7 @@ public interface IInstaller : IServerComponent public IAsyncObservable OnExited { get; } public bool IsRunning { get; } + public Task Setup(); public Task Start(); public Task Abort(); public Task Cleanup(); diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs index a734299..7da4052 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/Server.cs @@ -122,7 +122,7 @@ public class Server : IAsyncDisposable .Permit(ServerTrigger.Exited, ServerState.Offline); StateMachine.Configure(ServerState.Installing) - .Permit(ServerTrigger.FailSafe, ServerState.Offline) + .Permit(ServerTrigger.FailSafe, ServerState.Offline) // TODO: Add kill .Permit(ServerTrigger.Exited, ServerState.Offline); // Handle transitions @@ -130,6 +130,10 @@ public class Server : IAsyncDisposable 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); @@ -199,6 +203,40 @@ public class Server : IAsyncDisposable await Provisioner.Deprovision(); } + private async Task HandleInstall() + { + Logger.LogDebug("Installing"); + + Logger.LogDebug("Setting up"); + await Console.WriteToMoonlight("Setting up installation"); + + // TODO: Extract to service + + Context.InstallConfiguration = new() + { + Shell = "/bin/ash", + DockerImage = "ghcr.io/parkervcp/installers:alpine", + Script = + "#!/bin/ash\n# Paper Installation Script\n#\n# Server Files: /mnt/server\nPROJECT=paper\n\nif [ -n \"${DL_PATH}\" ]; then\n\techo -e \"Using supplied download url: ${DL_PATH}\"\n\tDOWNLOAD_URL=`eval echo $(echo ${DL_PATH} | sed -e 's/{{/${/g' -e 's/}}/}/g')`\nelse\n\tVER_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r --arg VERSION $MINECRAFT_VERSION '.versions[] | contains($VERSION)' | grep -m1 true`\n\tLATEST_VERSION=`curl -s https://api.papermc.io/v2/projects/${PROJECT} | jq -r '.versions' | jq -r '.[-1]'`\n\n\tif [ \"${VER_EXISTS}\" == \"true\" ]; then\n\t\techo -e \"Version is valid. Using version ${MINECRAFT_VERSION}\"\n\telse\n\t\techo -e \"Specified version not found. Defaulting to the latest ${PROJECT} version\"\n\t\tMINECRAFT_VERSION=${LATEST_VERSION}\n\tfi\n\n\tBUILD_EXISTS=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION} | jq -r --arg BUILD ${BUILD_NUMBER} '.builds[] | tostring | contains($BUILD)' | grep -m1 true`\n\tLATEST_BUILD=`curl -s https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION} | jq -r '.builds' | jq -r '.[-1]'`\n\n\tif [ \"${BUILD_EXISTS}\" == \"true\" ]; then\n\t\techo -e \"Build is valid for version ${MINECRAFT_VERSION}. Using build ${BUILD_NUMBER}\"\n\telse\n\t\techo -e \"Using the latest ${PROJECT} build for version ${MINECRAFT_VERSION}\"\n\t\tBUILD_NUMBER=${LATEST_BUILD}\n\tfi\n\n\tJAR_NAME=${PROJECT}-${MINECRAFT_VERSION}-${BUILD_NUMBER}.jar\n\n\techo \"Version being downloaded\"\n\techo -e \"MC Version: ${MINECRAFT_VERSION}\"\n\techo -e \"Build: ${BUILD_NUMBER}\"\n\techo -e \"JAR Name of Build: ${JAR_NAME}\"\n\tDOWNLOAD_URL=https://api.papermc.io/v2/projects/${PROJECT}/versions/${MINECRAFT_VERSION}/builds/${BUILD_NUMBER}/downloads/${JAR_NAME}\nfi\n\ncd /mnt/server\n\necho -e \"Running curl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\"\n\nif [ -f ${SERVER_JARFILE} ]; then\n\tmv ${SERVER_JARFILE} ${SERVER_JARFILE}.old\nfi\n\ncurl -o ${SERVER_JARFILE} ${DOWNLOAD_URL}\n\nif [ ! -f server.properties ]; then\n echo -e \"Downloading MC server.properties\"\n curl -o server.properties https://raw.githubusercontent.com/parkervcp/eggs/master/minecraft/java/server.properties\nfi" + }; + + await Installer.Setup(); + + await Console.AttachToInstallation(); + + await Installer.Start(); + } + + private async Task HandleInstallExit() + { + Logger.LogDebug("Installation done"); + await Console.WriteToMoonlight("Cleaning up"); + + await Installer.Cleanup(); + + await Console.WriteToMoonlight("Installation completed"); + } + #endregion public async ValueTask DisposeAsync() diff --git a/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs b/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs index 3c673bc..9d72949 100644 --- a/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs +++ b/MoonlightServers.Daemon/ServerSys/Abstractions/ServerMeta.cs @@ -1,4 +1,5 @@ using MoonlightServers.Daemon.Models.Cache; +using MoonlightServers.DaemonShared.PanelSide.Http.Responses; namespace MoonlightServers.Daemon.ServerSys.Abstractions; @@ -6,4 +7,5 @@ public record ServerContext { public ServerConfiguration Configuration { get; set; } public AsyncServiceScope ServiceScope { get; set; } + public ServerInstallDataResponse InstallConfiguration { get; set; } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/Implementations/DefaultRestorer.cs b/MoonlightServers.Daemon/ServerSys/Implementations/DefaultRestorer.cs index 8f64a7f..a0c890a 100644 --- a/MoonlightServers.Daemon/ServerSys/Implementations/DefaultRestorer.cs +++ b/MoonlightServers.Daemon/ServerSys/Implementations/DefaultRestorer.cs @@ -8,19 +8,20 @@ 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( ILogger logger, IConsole console, IProvisioner provisioner, - IStatistics statistics - ) + IStatistics statistics, IInstaller installer) { Logger = logger; Console = console; Provisioner = provisioner; Statistics = statistics; + Installer = installer; } public Task Initialize() @@ -44,11 +45,19 @@ public class DefaultRestorer : IRestorer return ServerState.Online; } - else + + if (Installer.IsRunning) { - Logger.LogDebug("Nothing found to restore"); - return ServerState.Offline; + Logger.LogDebug("Detected installation to restore"); + + await Console.AttachToInstallation(); + await Statistics.SubscribeToInstallation(); + + return ServerState.Installing; } + + Logger.LogDebug("Nothing found to restore"); + return ServerState.Offline; } public ValueTask DisposeAsync() diff --git a/MoonlightServers.Daemon/ServerSys/Implementations/DockerInstaller.cs b/MoonlightServers.Daemon/ServerSys/Implementations/DockerInstaller.cs index 6083a94..b2a00d9 100644 --- a/MoonlightServers.Daemon/ServerSys/Implementations/DockerInstaller.cs +++ b/MoonlightServers.Daemon/ServerSys/Implementations/DockerInstaller.cs @@ -1,5 +1,9 @@ using System.Reactive.Linq; using System.Reactive.Subjects; +using Docker.DotNet; +using Docker.DotNet.Models; +using MoonlightServers.Daemon.Configuration; +using MoonlightServers.Daemon.Mappers; using MoonlightServers.Daemon.ServerSys.Abstractions; using MoonlightServers.Daemon.Services; @@ -10,55 +14,264 @@ public class DockerInstaller : IInstaller public IAsyncObservable OnExited => OnExitedSubject.ToAsyncObservable(); public bool IsRunning { get; private set; } = false; - private readonly Subject OnExitedSubject = new(); + private readonly Subject 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 ContainerName; + + private string InstallHostPath; + + private IAsyncDisposable? ContainerEventSubscription; public DockerInstaller( ILogger logger, - DockerEventService eventService + DockerEventService eventService, + IConsole console, + DockerClient dockerClient, + ServerContext context, + DockerImageService imageService, + IFileSystem fileSystem, + AppConfiguration configuration, + ServerConfigurationMapper mapper ) { Logger = logger; EventService = eventService; + Console = console; + DockerClient = dockerClient; + Context = context; + ImageService = imageService; + FileSystem = fileSystem; + Configuration = configuration; + Mapper = mapper; } - public Task Initialize() + public async Task Initialize() { - return Task.CompletedTask; + ContainerName = $"moonlight-install-{Context.Configuration.Id}"; + InstallHostPath = + Path.GetFullPath(Path.Combine(Configuration.Storage.Install, Context.Configuration.Id.ToString())); + + ContainerEventSubscription = await EventService + .OnContainerEvent + .SubscribeAsync(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 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() + => Task.CompletedTask; + + public async Task Setup() { - throw new NotImplementedException(); + // 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.LogInformation("Created container"); + await Console.WriteToMoonlight("Created container"); } - public Task Start() + public async Task Start() { - throw new NotImplementedException(); + Logger.LogInformation("Starting container"); + await Console.WriteToMoonlight("Starting container"); + await DockerClient.Containers.StartContainerAsync(ContainerId, new()); } - public Task Abort() + public async Task Abort() { - throw new NotImplementedException(); + await EnsureContainerOffline(); } - public Task Cleanup() + public async Task Cleanup() { - throw new NotImplementedException(); + // 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 Task SearchForCrash() + public async Task SearchForCrash() { - throw new NotImplementedException(); + 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(); } } \ No newline at end of file diff --git a/MoonlightServers.Daemon/ServerSys/ServerFactory.cs b/MoonlightServers.Daemon/ServerSys/ServerFactory.cs index 1a0e735..c3c6c1a 100644 --- a/MoonlightServers.Daemon/ServerSys/ServerFactory.cs +++ b/MoonlightServers.Daemon/ServerSys/ServerFactory.cs @@ -16,10 +16,10 @@ public class ServerFactory { var scope = ServiceProvider.CreateAsyncScope(); - var meta = scope.ServiceProvider.GetRequiredService(); + var context = scope.ServiceProvider.GetRequiredService(); - meta.Configuration = configuration; - meta.ServiceScope = scope; + context.Configuration = configuration; + context.ServiceScope = scope; return scope.ServiceProvider.GetRequiredService(); } diff --git a/MoonlightServers.Daemon/Startup.cs b/MoonlightServers.Daemon/Startup.cs index cd44ddc..b85eaf3 100644 --- a/MoonlightServers.Daemon/Startup.cs +++ b/MoonlightServers.Daemon/Startup.cs @@ -126,6 +126,10 @@ public class Startup await server.StateMachine.FireAsync(ServerTrigger.Stop); Console.ReadLine(); + + await server.StateMachine.FireAsync(ServerTrigger.Install); + + Console.ReadLine(); await server.Context.ServiceScope.DisposeAsync(); }