Implemented first iteration of the docker-based server installer. Added restore functionality for the installer. Wired up for basic installer testing

This commit is contained in:
2025-07-29 22:24:46 +02:00
parent f57d33cb1e
commit bb81ca9674
9 changed files with 353 additions and 27 deletions

View File

@@ -3,6 +3,7 @@ using Mono.Unix.Native;
using MoonCore.Helpers; using MoonCore.Helpers;
using MoonlightServers.Daemon.Configuration; using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Models.Cache; using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.DaemonShared.PanelSide.Http.Responses;
namespace MoonlightServers.Daemon.Mappers; namespace MoonlightServers.Daemon.Mappers;
@@ -141,6 +142,63 @@ public class ServerConfigurationMapper
return parameters; 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<Mount>();
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;
}
public CreateContainerParameters ToSharedParameters(ServerConfiguration serverConfiguration) public CreateContainerParameters ToSharedParameters(ServerConfiguration serverConfiguration)
{ {
var parameters = new CreateContainerParameters() var parameters = new CreateContainerParameters()

View File

@@ -57,6 +57,7 @@
<_ContentIncludedByDefault Remove="volumes\3\usercache.json" /> <_ContentIncludedByDefault Remove="volumes\3\usercache.json" />
<_ContentIncludedByDefault Remove="volumes\3\version_history.json" /> <_ContentIncludedByDefault Remove="volumes\3\version_history.json" />
<_ContentIncludedByDefault Remove="volumes\3\whitelist.json" /> <_ContentIncludedByDefault Remove="volumes\3\whitelist.json" />
<_ContentIncludedByDefault Remove="storage\volumes\69\plugins\spark\config.json" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -5,6 +5,7 @@ public interface IInstaller : IServerComponent
public IAsyncObservable<object> OnExited { get; } public IAsyncObservable<object> OnExited { get; }
public bool IsRunning { get; } public bool IsRunning { get; }
public Task Setup();
public Task Start(); public Task Start();
public Task Abort(); public Task Abort();
public Task Cleanup(); public Task Cleanup();

View File

@@ -122,7 +122,7 @@ public class Server : IAsyncDisposable
.Permit(ServerTrigger.Exited, ServerState.Offline); .Permit(ServerTrigger.Exited, ServerState.Offline);
StateMachine.Configure(ServerState.Installing) StateMachine.Configure(ServerState.Installing)
.Permit(ServerTrigger.FailSafe, ServerState.Offline) .Permit(ServerTrigger.FailSafe, ServerState.Offline) // TODO: Add kill
.Permit(ServerTrigger.Exited, ServerState.Offline); .Permit(ServerTrigger.Exited, ServerState.Offline);
// Handle transitions // Handle transitions
@@ -131,6 +131,10 @@ public class Server : IAsyncDisposable
.OnEntryAsync(HandleStart) .OnEntryAsync(HandleStart)
.OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit); .OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit);
StateMachine.Configure(ServerState.Installing)
.OnEntryAsync(HandleInstall)
.OnExitFromAsync(ServerTrigger.Exited, HandleInstallExit);
StateMachine.Configure(ServerState.Online) StateMachine.Configure(ServerState.Online)
.OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit); .OnExitFromAsync(ServerTrigger.Exited, HandleRuntimeExit);
@@ -199,6 +203,40 @@ public class Server : IAsyncDisposable
await Provisioner.Deprovision(); 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 #endregion
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()

View File

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

View File

@@ -8,19 +8,20 @@ public class DefaultRestorer : IRestorer
private readonly ILogger<DefaultRestorer> Logger; private readonly ILogger<DefaultRestorer> Logger;
private readonly IConsole Console; private readonly IConsole Console;
private readonly IProvisioner Provisioner; private readonly IProvisioner Provisioner;
private readonly IInstaller Installer;
private readonly IStatistics Statistics; private readonly IStatistics Statistics;
public DefaultRestorer( public DefaultRestorer(
ILogger<DefaultRestorer> logger, ILogger<DefaultRestorer> logger,
IConsole console, IConsole console,
IProvisioner provisioner, IProvisioner provisioner,
IStatistics statistics IStatistics statistics, IInstaller installer)
)
{ {
Logger = logger; Logger = logger;
Console = console; Console = console;
Provisioner = provisioner; Provisioner = provisioner;
Statistics = statistics; Statistics = statistics;
Installer = installer;
} }
public Task Initialize() public Task Initialize()
@@ -44,11 +45,19 @@ public class DefaultRestorer : IRestorer
return ServerState.Online; return ServerState.Online;
} }
else
if (Installer.IsRunning)
{ {
Logger.LogDebug("Nothing found to restore"); Logger.LogDebug("Detected installation to restore");
return ServerState.Offline;
await Console.AttachToInstallation();
await Statistics.SubscribeToInstallation();
return ServerState.Installing;
} }
Logger.LogDebug("Nothing found to restore");
return ServerState.Offline;
} }
public ValueTask DisposeAsync() public ValueTask DisposeAsync()

View File

@@ -1,5 +1,9 @@
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Reactive.Subjects; 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.ServerSys.Abstractions;
using MoonlightServers.Daemon.Services; using MoonlightServers.Daemon.Services;
@@ -10,55 +14,264 @@ public class DockerInstaller : IInstaller
public IAsyncObservable<object> OnExited => OnExitedSubject.ToAsyncObservable(); public IAsyncObservable<object> OnExited => OnExitedSubject.ToAsyncObservable();
public bool IsRunning { get; private set; } = false; public bool IsRunning { get; private set; } = false;
private readonly Subject<string> OnExitedSubject = new(); private readonly Subject<Message> OnExitedSubject = new();
private readonly ILogger<DockerInstaller> Logger; private readonly ILogger<DockerInstaller> Logger;
private readonly DockerEventService EventService; 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? ContainerId;
private string? ContainerName; private string ContainerName;
private string InstallHostPath;
private IAsyncDisposable? ContainerEventSubscription;
public DockerInstaller( public DockerInstaller(
ILogger<DockerInstaller> logger, ILogger<DockerInstaller> logger,
DockerEventService eventService DockerEventService eventService,
IConsole console,
DockerClient dockerClient,
ServerContext context,
DockerImageService imageService,
IFileSystem fileSystem,
AppConfiguration configuration,
ServerConfigurationMapper mapper
) )
{ {
Logger = logger; Logger = logger;
EventService = eventService; 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() 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<ServerCrash?> SearchForCrash() public async Task<ServerCrash?> 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() public async ValueTask DisposeAsync()
{ {
OnExitedSubject.Dispose(); OnExitedSubject.Dispose();
if (ContainerEventSubscription != null)
await ContainerEventSubscription.DisposeAsync();
} }
} }

View File

@@ -16,10 +16,10 @@ public class ServerFactory
{ {
var scope = ServiceProvider.CreateAsyncScope(); var scope = ServiceProvider.CreateAsyncScope();
var meta = scope.ServiceProvider.GetRequiredService<ServerContext>(); var context = scope.ServiceProvider.GetRequiredService<ServerContext>();
meta.Configuration = configuration; context.Configuration = configuration;
meta.ServiceScope = scope; context.ServiceScope = scope;
return scope.ServiceProvider.GetRequiredService<Server>(); return scope.ServiceProvider.GetRequiredService<Server>();
} }

View File

@@ -127,6 +127,10 @@ public class Startup
Console.ReadLine(); Console.ReadLine();
await server.StateMachine.FireAsync(ServerTrigger.Install);
Console.ReadLine();
await server.Context.ServiceScope.DisposeAsync(); await server.Context.ServiceScope.DisposeAsync();
} }
catch (Exception e) catch (Exception e)