Implemented restorer, wired up for basic testing. Improved abstractions and fixed observer pattern issues

This commit is contained in:
2025-07-26 23:19:57 +02:00
parent 84b3d1caf6
commit b546a168d2
17 changed files with 355 additions and 97 deletions

View File

@@ -13,10 +13,17 @@ public class CompositeServiceProvider : IServiceProvider
{
foreach (var provider in ServiceProviders)
{
var service = provider.GetService(serviceType);
if (service != null)
return service;
try
{
var service = provider.GetService(serviceType);
if (service != null)
return service;
}
catch (InvalidOperationException)
{
// Ignored
}
}
return null;

View File

@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="MoonCore" Version="1.9.2" />
<PackageReference Include="MoonCore" Version="1.9.3" />
<PackageReference Include="MoonCore.Extended" Version="1.3.6" />
<PackageReference Include="MoonCore.Unix" Version="1.0.8" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />

View File

@@ -2,8 +2,8 @@ namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IConsole : IServerComponent
{
public IAsyncObservable<string> OnOutput { get; }
public IAsyncObservable<string> OnInput { get; }
public IObservable<string> OnOutput { get; }
public IObservable<string> OnInput { get; }
public Task AttachToRuntime();
public Task AttachToInstallation();

View File

@@ -2,7 +2,8 @@ namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IInstaller : IServerComponent
{
public IAsyncObservable<object> OnExited { get; set; }
public IObservable<object> OnExited { get; }
public bool IsRunning { get; }
public Task Start();
public Task Abort();

View File

@@ -2,7 +2,8 @@ namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IProvisioner : IServerComponent
{
public IAsyncObservable<object> OnExited { get; set; }
public IObservable<object> OnExited { get; }
public bool IsProvisioned { get; }
public Task Provision();
public Task Start();

View File

@@ -2,7 +2,7 @@ namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public interface IStatistics : IServerComponent
{
public IAsyncObservable<ServerStats> Stats { get; }
public IAsyncObservable<ServerStats> OnStats { get; }
public Task SubscribeToRuntime();
public Task SubscribeToInstallation();

View File

@@ -1,3 +1,4 @@
using System.Reactive.Subjects;
using MoonlightServers.Daemon.ServerSystem;
using Stateless;
@@ -12,11 +13,14 @@ public class Server : IAsyncDisposable
public IRestorer Restorer { get; private set; }
public IStatistics Statistics { get; private set; }
public StateMachine<ServerState, ServerTrigger> StateMachine { get; private set; }
public ServerContext Context { get; private set; }
public IObservable<ServerState> OnState => OnStateSubject;
private readonly Subject<ServerState> OnStateSubject = new();
private readonly ILogger<Server> Logger;
private IAsyncDisposable? ProvisionExitSubscription;
private IAsyncDisposable? InstallerExitSubscription;
private IDisposable? ProvisionExitSubscription;
private IDisposable? InstallerExitSubscription;
public Server(
ILogger<Server> logger,
@@ -26,7 +30,7 @@ public class Server : IAsyncDisposable
IProvisioner provisioner,
IRestorer restorer,
IStatistics statistics,
StateMachine<ServerState, ServerTrigger> stateMachine
ServerContext context
)
{
Logger = logger;
@@ -36,7 +40,7 @@ public class Server : IAsyncDisposable
Provisioner = provisioner;
Restorer = restorer;
Statistics = statistics;
StateMachine = stateMachine;
Context = context;
}
public async Task Initialize()
@@ -74,20 +78,22 @@ public class Server : IAsyncDisposable
CreateStateMachine(restoredState);
// Setup event handling
ProvisionExitSubscription = await Provisioner.OnExited.SubscribeAsync(async o =>
ProvisionExitSubscription = Provisioner.OnExited.Subscribe(o =>
{
await StateMachine.FireAsync(ServerTrigger.Exited);
StateMachine.Fire(ServerTrigger.Exited);
});
InstallerExitSubscription = await Installer.OnExited.SubscribeAsync(async o =>
InstallerExitSubscription = Installer.OnExited.Subscribe(o =>
{
await StateMachine.FireAsync(ServerTrigger.Exited);
StateMachine.Fire(ServerTrigger.Exited);
});
}
private void CreateStateMachine(ServerState initialState)
{
StateMachine = new StateMachine<ServerState, ServerTrigger>(initialState, FiringMode.Queued);
StateMachine.OnTransitioned(transition => OnStateSubject.OnNext(transition.Destination));
// Configure basic state machine flow
@@ -120,7 +126,10 @@ public class Server : IAsyncDisposable
// Handle transitions
StateMachine.Configure(ServerState.Starting)
.OnActivateAsync(HandleStart);
.OnEntryAsync(HandleStart);
StateMachine.Configure(ServerState.Stopping)
.OnEntryFromAsync(ServerTrigger.Stop, HandleStop);
}
#region State machine handlers
@@ -136,7 +145,7 @@ public class Server : IAsyncDisposable
// 4. Provision the container
// 5. Attach console to container
// 6. Start the container
// 1. Fetch latest configuration from panel
// TODO: Implement
@@ -153,14 +162,14 @@ public class Server : IAsyncDisposable
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();
}
@@ -169,16 +178,21 @@ public class Server : IAsyncDisposable
Logger.LogError(e, "An error occured while starting the server");
}
}
private async Task HandleStop()
{
await Provisioner.Stop();
}
#endregion
public async ValueTask DisposeAsync()
{
if (ProvisionExitSubscription != null)
await ProvisionExitSubscription.DisposeAsync();
ProvisionExitSubscription.Dispose();
if (InstallerExitSubscription != null)
await InstallerExitSubscription.DisposeAsync();
InstallerExitSubscription.Dispose();
await Console.DisposeAsync();
await FileSystem.DisposeAsync();

View File

@@ -2,9 +2,8 @@ using MoonlightServers.Daemon.Models.Cache;
namespace MoonlightServers.Daemon.ServerSys.Abstractions;
public record ServerMeta
public record ServerContext
{
public ServerConfiguration Configuration { get; set; }
public IServiceCollection ServiceCollection { get; set; }
public IServiceProvider ServiceProvider { get; set; }
public AsyncServiceScope ServiceScope { get; set; }
}

View File

@@ -0,0 +1,56 @@
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSystem;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DefaultRestorer : IRestorer
{
private readonly ILogger<DefaultRestorer> Logger;
private readonly IConsole Console;
private readonly IProvisioner Provisioner;
private readonly IStatistics Statistics;
public DefaultRestorer(
ILogger<DefaultRestorer> logger,
IConsole console,
IProvisioner provisioner,
IStatistics statistics
)
{
Logger = logger;
Console = console;
Provisioner = provisioner;
Statistics = statistics;
}
public Task Initialize()
=> Task.CompletedTask;
public Task Sync()
=> Task.CompletedTask;
public async Task<ServerState> Restore()
{
Logger.LogDebug("Restoring server state");
if (Provisioner.IsProvisioned)
{
Logger.LogDebug("Detected runtime to restore");
await Console.AttachToRuntime();
await Statistics.SubscribeToRuntime();
// TODO: Read out existing container log in order to search if the server is online
return ServerState.Online;
}
else
{
Logger.LogDebug("Nothing found to restore");
return ServerState.Offline;
}
}
public ValueTask DisposeAsync()
=> ValueTask.CompletedTask;
}

View File

@@ -11,23 +11,29 @@ namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerConsole : IConsole
{
public IAsyncObservable<string> OnOutput => OnOutputSubject.ToAsyncObservable();
public IAsyncObservable<string> OnInput => OnInputSubject.ToAsyncObservable();
public IObservable<string> OnOutput => OnOutputSubject;
public IObservable<string> OnInput => OnInputSubject;
private readonly AsyncSubject<string> OnOutputSubject = new();
private readonly AsyncSubject<string> OnInputSubject = new();
private readonly Subject<string> OnOutputSubject = new();
private readonly Subject<string> OnInputSubject = new();
private readonly ConcurrentList<string> OutputCache = new();
private readonly DockerClient DockerClient;
private readonly ILogger<DockerConsole> Logger;
private readonly ServerMeta Meta;
private readonly ServerContext Context;
private MultiplexedStream? CurrentStream;
private CancellationTokenSource Cts = new();
public DockerConsole(ServerMeta meta)
public DockerConsole(
ServerContext context,
DockerClient dockerClient,
ILogger<DockerConsole> logger
)
{
Meta = meta;
Context = context;
DockerClient = dockerClient;
Logger = logger;
}
public Task Initialize()
@@ -38,13 +44,13 @@ public class DockerConsole : IConsole
public async Task AttachToRuntime()
{
var containerName = $"moonlight-runtime-{Meta.Configuration.Id}";
var containerName = $"moonlight-runtime-{Context.Configuration.Id}";
await AttachStream(containerName);
}
public async Task AttachToInstallation()
{
var containerName = $"moonlight-install-{Meta.Configuration.Id}";
var containerName = $"moonlight-install-{Context.Configuration.Id}";
await AttachStream(containerName);
}
@@ -108,7 +114,7 @@ public class DockerConsole : IConsole
}
catch (Exception e)
{
Logger.LogWarning("An unhandled error occured while reading from container stream: {e}", e);
Logger.LogWarning(e, "An unhandled error occured while reading from container stream");
}
finally
{
@@ -121,7 +127,7 @@ public class DockerConsole : IConsole
}
catch (Exception e)
{
Logger.LogError("An error occured while attaching to container: {e}", e);
Logger.LogError(e, "An error occured while attaching to container");
}
}
@@ -131,7 +137,7 @@ public class DockerConsole : IConsole
Logger.LogDebug("Disconnected from container stream");
}, Cts.Token);
return Task.CompletedTask;
}
@@ -168,7 +174,8 @@ public class DockerConsole : IConsole
}
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");
=> 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()
{

View File

@@ -0,0 +1,55 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using MoonlightServers.Daemon.ServerSys.Abstractions;
namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerInstaller : IInstaller
{
public IObservable<object> OnExited => OnExitedSubject;
public bool IsRunning { get; private set; } = false;
private readonly Subject<string> OnExitedSubject = new();
private readonly ILogger<DockerInstaller> Logger;
public DockerInstaller(ILogger<DockerInstaller> logger)
{
Logger = logger;
}
public Task Initialize()
{
return Task.CompletedTask;
}
public Task Sync()
{
throw new NotImplementedException();
}
public Task Start()
{
throw new NotImplementedException();
}
public Task Abort()
{
throw new NotImplementedException();
}
public Task Cleanup()
{
throw new NotImplementedException();
}
public Task<ServerCrash?> SearchForCrash()
{
throw new NotImplementedException();
}
public async ValueTask DisposeAsync()
{
OnExitedSubject.Dispose();
}
}

View File

@@ -1,3 +1,5 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Docker.DotNet;
using Docker.DotNet.Models;
@@ -9,28 +11,29 @@ namespace MoonlightServers.Daemon.ServerSys.Implementations;
public class DockerProvisioner : IProvisioner
{
public IAsyncObservable<object> OnExited { get; set; }
public IObservable<object> OnExited => OnExitedSubject;
public bool IsProvisioned { get; private set; }
private readonly DockerClient DockerClient;
private readonly ILogger<DockerProvisioner> Logger;
private readonly DockerEventService EventService;
private readonly ServerMeta Meta;
private readonly ServerContext Context;
private readonly IConsole Console;
private readonly DockerImageService ImageService;
private readonly ServerConfigurationMapper Mapper;
private readonly IFileSystem FileSystem;
private AsyncSubject<object> OnExitedSubject = new();
private Subject<object> OnExitedSubject = new();
private string? ContainerId;
private string ContainerName;
private IAsyncDisposable? ContainerEventSubscription;
private IDisposable? ContainerEventSubscription;
public DockerProvisioner(
DockerClient dockerClient,
ILogger<DockerProvisioner> logger,
DockerEventService eventService,
ServerMeta meta,
ServerContext context,
IConsole console,
DockerImageService imageService,
ServerConfigurationMapper mapper,
@@ -40,7 +43,7 @@ public class DockerProvisioner : IProvisioner
DockerClient = dockerClient;
Logger = logger;
EventService = eventService;
Meta = meta;
Context = context;
Console = console;
ImageService = imageService;
Mapper = mapper;
@@ -49,30 +52,39 @@ public class DockerProvisioner : IProvisioner
public async Task Initialize()
{
ContainerName = $"moonlight-runtime-{Meta.Configuration.Id}";
ContainerName = $"moonlight-runtime-{Context.Configuration.Id}";
ContainerEventSubscription = await EventService
ContainerEventSubscription = EventService
.OnContainerEvent
.SubscribeAsync(HandleContainerEvent);
.Subscribe(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
// Check for any already existing runtime container to reclaim
Logger.LogDebug("Searching for orphan container to reclaim");
try
{
var container = await DockerClient.Containers.InspectContainerAsync(ContainerName);
ContainerId = container.ID;
IsProvisioned = container.State.Running;
}
catch (DockerContainerNotFoundException)
{
// Ignored
}
}
private ValueTask HandleContainerEvent(Message message)
private void HandleContainerEvent(Message message)
{
// Only handle events for our own container
if (message.ID != ContainerId)
return ValueTask.CompletedTask;
return;
// Only handle die events
if (message.Action != "die")
return ValueTask.CompletedTask;
return;
OnExitedSubject.OnNext(message);
return ValueTask.CompletedTask;
}
public Task Sync()
@@ -111,7 +123,7 @@ public class DockerProvisioner : IProvisioner
// 2. Ensure the docker image has been downloaded
await Console.WriteToMoonlight("Downloading docker image");
await ImageService.Download(Meta.Configuration.DockerImage, async message =>
await ImageService.Download(Context.Configuration.DockerImage, async message =>
{
try
{
@@ -127,7 +139,7 @@ public class DockerProvisioner : IProvisioner
var hostFsPath = FileSystem.GetExternalPath();
var parameters = Mapper.ToRuntimeParameters(
Meta.Configuration,
Context.Configuration,
hostFsPath,
ContainerName
);
@@ -151,15 +163,15 @@ public class DockerProvisioner : IProvisioner
public async Task Stop()
{
if (Meta.Configuration.StopCommand.StartsWith('^'))
if (Context.Configuration.StopCommand.StartsWith('^'))
{
await DockerClient.Containers.KillContainerAsync(ContainerId, new()
{
Signal = Meta.Configuration.StopCommand.Substring(1)
Signal = Context.Configuration.StopCommand.Substring(1)
});
}
else
await Console.WriteToInput(Meta.Configuration.StopCommand);
await Console.WriteToInput(Context.Configuration.StopCommand + "\n\r");
}
public async Task Kill()
@@ -241,6 +253,6 @@ public class DockerProvisioner : IProvisioner
OnExitedSubject.Dispose();
if (ContainerEventSubscription != null)
await ContainerEventSubscription.DisposeAsync();
ContainerEventSubscription.Dispose();
}
}

View File

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

View File

@@ -8,24 +8,29 @@ public class RawFileSystem : IFileSystem
public bool IsMounted { get; private set; }
public bool Exists { get; private set; }
private readonly ServerMeta Meta;
private readonly ServerContext Context;
private readonly AppConfiguration Configuration;
private string HostPath => Path.Combine(Configuration.Storage.Volumes, Meta.Configuration.Id.ToString());
private string HostPath;
public RawFileSystem(ServerMeta meta, AppConfiguration configuration)
public RawFileSystem(ServerContext context, AppConfiguration configuration)
{
Meta = meta;
Context = context;
Configuration = configuration;
}
public Task Initialize()
=> Task.CompletedTask;
{
HostPath = Path.Combine(Directory.GetCurrentDirectory(), Configuration.Storage.Volumes, Context.Configuration.Id.ToString());
return Task.CompletedTask;
}
public Task Sync()
=> Task.CompletedTask;
public Task Create()
{
Directory.CreateDirectory(HostPath);
return Task.CompletedTask;
}

View File

@@ -1,7 +1,5 @@
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSys.Implementations;
namespace MoonlightServers.Daemon.ServerSys;
@@ -16,24 +14,13 @@ public class ServerFactory
public Server CreateServer(ServerConfiguration configuration)
{
var serverMeta = new ServerMeta();
serverMeta.Configuration = configuration;
// Build service collection
serverMeta.ServiceCollection = new ServiceCollection();
var scope = ServiceProvider.CreateAsyncScope();
// Configure service pipeline for the server components
serverMeta.ServiceCollection.AddSingleton<IConsole, DockerConsole>();
serverMeta.ServiceCollection.AddSingleton<IFileSystem, RawFileSystem>();
var meta = scope.ServiceProvider.GetRequiredService<ServerContext>();
// 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
]);
meta.Configuration = configuration;
meta.ServiceScope = scope;
return serverMeta.ServiceProvider.GetRequiredService<Server>();
return scope.ServiceProvider.GetRequiredService<Server>();
}
}

View File

@@ -10,13 +10,13 @@ public class DockerEventService : BackgroundService
private readonly ILogger<DockerEventService> Logger;
private readonly DockerClient DockerClient;
public IAsyncObservable<Message> OnContainerEvent => OnContainerSubject.ToAsyncObservable();
public IAsyncObservable<Message> OnImageEvent => OnImageSubject.ToAsyncObservable();
public IAsyncObservable<Message> OnNetworkEvent => OnNetworkSubject.ToAsyncObservable();
public IObservable<Message> OnContainerEvent => OnContainerSubject;
public IObservable<Message> OnImageEvent => OnImageSubject;
public IObservable<Message> OnNetworkEvent => OnNetworkSubject;
private readonly AsyncSubject<Message> OnContainerSubject = new();
private readonly AsyncSubject<Message> OnImageSubject = new();
private readonly AsyncSubject<Message> OnNetworkSubject = new();
private readonly Subject<Message> OnContainerSubject = new();
private readonly Subject<Message> OnImageSubject = new();
private readonly Subject<Message> OnNetworkSubject = new();
public DockerEventService(
ILogger<DockerEventService> logger,

View File

@@ -1,3 +1,5 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Text;
using System.Text.Json;
using Docker.DotNet;
@@ -13,9 +15,13 @@ using MoonlightServers.Daemon.Configuration;
using MoonlightServers.Daemon.Helpers;
using MoonlightServers.Daemon.Http.Hubs;
using MoonlightServers.Daemon.Mappers;
using MoonlightServers.Daemon.Models.Cache;
using MoonlightServers.Daemon.ServerSys;
using MoonlightServers.Daemon.ServerSys.Abstractions;
using MoonlightServers.Daemon.ServerSys.Implementations;
using MoonlightServers.Daemon.ServerSystem;
using MoonlightServers.Daemon.Services;
using Server = MoonlightServers.Daemon.ServerSystem.Server;
namespace MoonlightServers.Daemon;
@@ -65,6 +71,70 @@ public class Startup
await MapBase();
await MapHubs();
Task.Run(async () =>
{
try
{
Console.WriteLine("Press enter to create server instance");
Console.ReadLine();
var config = new ServerConfiguration()
{
Allocations = [
new ServerConfiguration.AllocationConfiguration()
{
IpAddress = "0.0.0.0",
Port = 25565
}
],
Cpu = 400,
Disk = 10240,
DockerImage = "ghcr.io/parkervcp/yolks:java_21",
Id = 69,
Memory = 4096,
OnlineDetection = "\\)! For help, type ",
StartupCommand = "java -Xms128M -Xmx{{SERVER_MEMORY}}M -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}",
StopCommand = "stop",
Variables = new()
{
{
"SERVER_JARFILE",
"server.jar"
}
}
};
var factory = WebApplication.Services.GetRequiredService<ServerFactory>();
var server = factory.CreateServer(config);
using var consoleSub = server.Console.OnOutput
.Subscribe(Console.Write);
using var stateSub = server.OnState.Subscribe(state =>
{
Console.WriteLine($"State: {state}");
});
await server.Initialize();
Console.ReadLine();
if(server.StateMachine.State == ServerState.Offline)
await server.StateMachine.FireAsync(ServerTrigger.Start);
else
await server.StateMachine.FireAsync(ServerTrigger.Stop);
Console.ReadLine();
await server.Context.ServiceScope.DisposeAsync();
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
});
await WebApplication.RunAsync();
}
@@ -72,7 +142,6 @@ public class Startup
{
Directory.CreateDirectory("storage");
Directory.CreateDirectory(Path.Combine("storage", "logs"));
Directory.CreateDirectory(Path.Combine("storage", "plugins"));
return Task.CompletedTask;
}
@@ -261,6 +330,17 @@ public class Startup
WebApplicationBuilder.Services.AddSingleton<ServerFactory>();
// Server scoped stuff
WebApplicationBuilder.Services.AddScoped<IConsole, DockerConsole>();
WebApplicationBuilder.Services.AddScoped<IFileSystem, RawFileSystem>();
WebApplicationBuilder.Services.AddScoped<IRestorer, DefaultRestorer>();
WebApplicationBuilder.Services.AddScoped<IInstaller, DockerInstaller>();
WebApplicationBuilder.Services.AddScoped<IProvisioner, DockerProvisioner>();
WebApplicationBuilder.Services.AddScoped<IStatistics, DockerStatistics>();
WebApplicationBuilder.Services.AddScoped<ServerContext>();
WebApplicationBuilder.Services.AddScoped<ServerSys.Abstractions.Server>();
return Task.CompletedTask;
}