1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-05-10 17:34:13 +02:00

Add PostgreSQL database support via Entity Framework

This commit is contained in:
chylex 2022-10-06 15:02:06 +02:00
parent 23a4cf69dd
commit 57073b1bd0
Signed by: chylex
GPG Key ID: 4DE42C8F19A80548
18 changed files with 260 additions and 9 deletions

12
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "7.0.0-rc.1.22426.7",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@ -6,6 +6,11 @@
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="PG_DATABASE" value="postgres" />
<env name="PG_HOST" value="localhost" />
<env name="PG_PASS" value="development" />
<env name="PG_PORT" value="9402" />
<env name="PG_USER" value="postgres" />
<env name="RPC_SERVER_HOST" value="localhost" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs>

8
AddMigration.bat Normal file
View File

@ -0,0 +1,8 @@
@echo off
if [%1]==[] (
echo "Usage: AddMigration.bat <migration-name>"
exit
)
dotnet ef migrations add %~1 --project Server/Phantom.Server.Database.Postgres

6
AddMigration.sh Normal file
View File

@ -0,0 +1,6 @@
if [ -z "$1" ]; then
echo "Usage: AddMigration.sh <migration-name>"
exit 1
fi
dotnet ef migrations add "$1" --project Server/Phantom.Server.Database.Postgres

View File

@ -24,6 +24,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server", "Server\Phantom.Server\Phantom.Server.csproj", "{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Database", "Server\Phantom.Server.Database\Phantom.Server.Database.csproj", "{E3AD566F-384A-489A-A3BB-EA3BA400C18C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Database.Postgres", "Server\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj", "{81625B4A-3DB6-48BD-A739-D23DA02107D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Rpc", "Server\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj", "{79312D72-44E0-431D-96A4-4C0A066B9671}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Server.Services", "Server\Phantom.Server.Services\Phantom.Server.Services.csproj", "{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9}"
@ -78,6 +82,14 @@ Global
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Release|Any CPU.Build.0 = Release|Any CPU
{E3AD566F-384A-489A-A3BB-EA3BA400C18C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3AD566F-384A-489A-A3BB-EA3BA400C18C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3AD566F-384A-489A-A3BB-EA3BA400C18C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3AD566F-384A-489A-A3BB-EA3BA400C18C}.Release|Any CPU.Build.0 = Release|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81625B4A-3DB6-48BD-A739-D23DA02107D1}.Release|Any CPU.Build.0 = Release|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79312D72-44E0-431D-96A4-4C0A066B9671}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -123,6 +135,8 @@ Global
{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{81625B4A-3DB6-48BD-A739-D23DA02107D1} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{79312D72-44E0-431D-96A4-4C0A066B9671} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{90F0F1B1-EB0A-49C9-8DF0-1153A87F77C9} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}
{7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {8AC8FB6C-033A-4626-820F-ED0F908756B2}

View File

@ -0,0 +1,15 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Phantom.Server.Database.Postgres;
public sealed class ApplicationDbContextDesignFactory : IDesignTimeDbContextFactory<ApplicationDbContext> {
public ApplicationDbContext CreateDbContext(string[] args) {
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(static options => options.MigrationsAssembly(Assembly.GetExecutingAssembly().FullName))
.Options;
return new ApplicationDbContext(options);
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0-rc.1.22426.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.0-rc.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,7 @@
using Microsoft.EntityFrameworkCore;
namespace Phantom.Server.Database;
public class ApplicationDbContext : DbContext {
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) {}
}

View File

@ -0,0 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
namespace Phantom.Server.Database;
public sealed class DatabaseProvider {
private readonly IServiceScopeFactory serviceScopeFactory;
public DatabaseProvider(IServiceScopeFactory serviceScopeFactory) {
this.serviceScopeFactory = serviceScopeFactory;
}
public Scope CreateScope() {
return new Scope(serviceScopeFactory.CreateScope());
}
public readonly struct Scope : IDisposable {
private readonly IServiceScope scope;
public ApplicationDbContext Ctx { get; }
internal Scope(IServiceScope scope) {
this.scope = scope;
this.Ctx = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
public void Dispose() {
scope.Dispose();
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
namespace Phantom.Server.Database.Factories;
public abstract class AbstractUpsertHelper<T> where T : class {
private protected readonly ApplicationDbContext Ctx;
internal AbstractUpsertHelper(ApplicationDbContext ctx) {
this.Ctx = ctx;
}
private protected abstract DbSet<T> Set { get; }
private protected abstract T Construct(Guid guid);
public T Fetch(Guid guid) {
DbSet<T> set = Set;
T? entity = set.Find(guid);
if (entity == null) {
entity = Construct(guid);
set.Add(entity);
}
else {
set.Update(entity);
}
return entity;
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.0-rc.1.22427.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0-rc.1.22426.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,31 @@
namespace Phantom.Server.Web.Components.Utils;
public sealed class Throttler {
private readonly TimeSpan interval;
private DateTime lastInvocation;
public Throttler(TimeSpan interval) {
this.interval = interval;
this.lastInvocation = DateTime.Now;
}
public bool Check() {
var now = DateTime.Now;
if (now - lastInvocation >= interval) {
lastInvocation = now;
return true;
}
return false;
}
public async Task Wait() {
var now = DateTime.Now;
var waitTime = lastInvocation + interval - now;
if (waitTime > TimeSpan.Zero) {
await Task.Delay(waitTime);
}
lastInvocation = DateTime.Now;
}
}

View File

@ -1,9 +1,12 @@
using Serilog;
using Microsoft.EntityFrameworkCore;
using Phantom.Server.Database;
using Phantom.Server.Web.Components.Utils;
using Serilog;
namespace Phantom.Server.Web;
public static class Launcher {
public static WebApplication CreateApplication(Configuration config, IConfigurator configurator) {
public static async Task<WebApplication> CreateApplication(Configuration config, IConfigurator configurator, Action<DbContextOptionsBuilder> dbOptionsBuilder) {
var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
ApplicationName = typeof(Launcher).Assembly.GetName().Name
});
@ -21,10 +24,18 @@ public static class Launcher {
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
builder.Services.AddDbContextPool<ApplicationDbContext>(dbOptionsBuilder, poolSize: 64);
builder.Services.AddSingleton<DatabaseProvider>();
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
builder.Services.AddServerSideBlazor();
return builder.Build();
var application = builder.Build();
await MigrateDatabase(config, application.Services.GetRequiredService<DatabaseProvider>());
await configurator.LoadFromDatabase(application.Services);
return application;
}
public static async Task Launch(Configuration config, WebApplication application) {
@ -59,7 +70,26 @@ public static class Launcher {
}
}
private static async Task MigrateDatabase(Configuration config, DatabaseProvider databaseProvider) {
var logger = config.Logger;
using var scope = databaseProvider.CreateScope();
var database = scope.Ctx.Database;
logger.Information("Connecting to database...");
var retryConnection = new Throttler(TimeSpan.FromSeconds(10));
while (!await database.CanConnectAsync(config.CancellationToken)) {
logger.Warning("Cannot connect to database, retrying...");
await retryConnection.Wait();
}
logger.Information("Running database migrations...");
await database.MigrateAsync(); // Do not allow cancellation.
}
public interface IConfigurator {
void ConfigureServices(IServiceCollection services);
Task LoadFromDatabase(IServiceProvider serviceProvider);
}
}

View File

@ -22,6 +22,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Phantom.Server.Database\Phantom.Server.Database.csproj" />
<ProjectReference Include="..\Phantom.Server.Web.Components\Phantom.Server.Web.Components.csproj" />
</ItemGroup>

View File

@ -15,6 +15,7 @@
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Server.Database.Postgres\Phantom.Server.Database.Postgres.csproj" />
<ProjectReference Include="..\Phantom.Server.Rpc\Phantom.Server.Rpc.csproj" />
<ProjectReference Include="..\Phantom.Server.Services\Phantom.Server.Services.csproj" />
<ProjectReference Include="..\Phantom.Server.Web\Phantom.Server.Web.csproj" />

View File

@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Phantom.Common.Logging;
using Phantom.Server;
using Phantom.Server.Database.Postgres;
using Phantom.Server.Rpc;
using Phantom.Server.Services.Rpc;
using Phantom.Utils.IO;
@ -18,7 +20,7 @@ PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
try {
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel server...");
var (webServerHost, webServerPort, rpcServerHost, rpcServerPort) = Variables.LoadOrExit();
var (webServerHost, webServerPort, rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrExit();
string secretsPath = Path.GetFullPath("./secrets");
if (!Directory.Exists(secretsPath)) {
@ -46,7 +48,9 @@ try {
PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
var webConfigurator = new WebConfigurator(agentToken, cancellationTokenSource.Token);
var webApplication = WebLauncher.CreateApplication(webConfiguration, webConfigurator);
var webApplication = await WebLauncher.CreateApplication(webConfiguration, webConfigurator, options => options.UseNpgsql(sqlConnectionString, static options => {
options.CommandTimeout(10).MigrationsAssembly(typeof(ApplicationDbContextDesignFactory).Assembly.FullName);
}));
await Task.WhenAll(
RpcLauncher.Launch(rpcConfiguration, webApplication.Services.GetRequiredService<MessageToServerListenerFactory>().CreateListener),

View File

@ -1,4 +1,5 @@
using Phantom.Common.Logging;
using Npgsql;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
namespace Phantom.Server;
@ -7,14 +8,24 @@ sealed record Variables(
string WebServerHost,
ushort WebServerPort,
string RpcServerHost,
ushort RpcServerPort
ushort RpcServerPort,
string SqlConnectionString
) {
private static Variables LoadOrThrow() {
var connectionStringBuilder = new NpgsqlConnectionStringBuilder {
Host = EnvironmentVariables.GetString("PG_HOST").OrThrow,
Port = EnvironmentVariables.GetPortNumber("PG_PORT").OrThrow,
Username = EnvironmentVariables.GetString("PG_USER").OrThrow,
Password = EnvironmentVariables.GetString("PG_PASS").OrThrow,
Database = EnvironmentVariables.GetString("PG_DATABASE").OrThrow
};
return new Variables(
EnvironmentVariables.GetString("WEB_SERVER_HOST").OrDefault("0.0.0.0"),
EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").OrDefault(9400),
EnvironmentVariables.GetString("RPC_SERVER_HOST").OrDefault("0.0.0.0"),
EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").OrDefault(9401)
EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").OrDefault(9401),
connectionStringBuilder.ToString()
);
}

View File

@ -22,4 +22,8 @@ sealed class WebConfigurator : WebLauncher.IConfigurator {
services.AddSingleton<AgentManager>();
services.AddSingleton<MessageToServerListenerFactory>();
}
public Task LoadFromDatabase(IServiceProvider serviceProvider) {
return Task.CompletedTask;
}
}