diff --git a/Server/Components/Pages/GamePage.razor b/Server/Components/Pages/GamePage.razor index f649f07..0ad8be0 100644 --- a/Server/Components/Pages/GamePage.razor +++ b/Server/Components/Pages/GamePage.razor @@ -168,11 +168,11 @@ return; } - var user = await UsersRepository.GetUserAsync(username.Value!); + var user = await UsersRepository.GetUserAsync(username.Value); if (user is null) { - user = new User { Username = username.Value! }; - await UsersRepository.AddUserAsync(user); + user = new User { Username = username.Value, CreatedAt =DateTime.UtcNow}; + user = await UsersRepository.AddUserAsync(user); } _user = user; diff --git a/Server/Fracture.Server.csproj b/Server/Fracture.Server.csproj index 337a6dc..c00309a 100644 --- a/Server/Fracture.Server.csproj +++ b/Server/Fracture.Server.csproj @@ -12,15 +12,16 @@ <_ContentIncludedByDefault Remove="wwwroot\css\items.css" /> + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Server/Migrations/Initial.Designer.cs b/Server/Migrations/Initial.Designer.cs deleted file mode 100644 index ece270e..0000000 --- a/Server/Migrations/Initial.Designer.cs +++ /dev/null @@ -1,133 +0,0 @@ -// -using System; -using Fracture.Server.Modules.Database; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Fracture.Server.Migrations -{ - [DbContext(typeof(FractureDbContext))] - [Migration("20240520174514_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); - - modelBuilder.Entity("Fracture.Server.Modules.Items.Models.Item", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("CreatedById") - .HasColumnType("INTEGER"); - - b.Property("History") - .HasColumnType("TEXT"); - - b.Property("IsEquipped") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Rarity") - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("CreatedById"); - - b.ToTable("Items"); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Items.Models.ItemStatistics", b => - { - b.Property("ItemId") - .HasColumnType("INTEGER"); - - b.Property("Armor") - .HasColumnType("INTEGER"); - - b.Property("Health") - .HasColumnType("INTEGER"); - - b.Property("Luck") - .HasColumnType("INTEGER"); - - b.Property("Power") - .HasColumnType("INTEGER"); - - b.HasKey("ItemId"); - - b.ToTable("ItemStatistics"); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Users.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CreatedAt") - .HasColumnType("TEXT"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Items.Models.Item", b => - { - b.HasOne("Fracture.Server.Modules.Users.User", "CreatedBy") - .WithMany("Items") - .HasForeignKey("CreatedById") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CreatedBy"); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Items.Models.ItemStatistics", b => - { - b.HasOne("Fracture.Server.Modules.Items.Models.Item", "Item") - .WithOne("Statistics") - .HasForeignKey("Fracture.Server.Modules.Items.Models.ItemStatistics", "ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Items.Models.Item", b => - { - b.Navigation("Statistics") - .IsRequired(); - }); - - modelBuilder.Entity("Fracture.Server.Modules.Users.User", b => - { - b.Navigation("Items"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Server/Migrations/Initial.cs b/Server/Migrations/Initial.cs deleted file mode 100644 index 92777b2..0000000 --- a/Server/Migrations/Initial.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Fracture.Server.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table - .Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Username = table.Column(type: "TEXT", nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - } - ); - - migrationBuilder.CreateTable( - name: "Items", - columns: table => new - { - Id = table - .Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - Name = table.Column(type: "TEXT", nullable: false), - History = table.Column(type: "TEXT", nullable: true), - Rarity = table.Column(type: "INTEGER", nullable: false), - Type = table.Column(type: "INTEGER", nullable: false), - CreatedAt = table.Column(type: "TEXT", nullable: false), - IsEquipped = table.Column(type: "INTEGER", nullable: false), - CreatedById = table.Column(type: "INTEGER", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_Items", x => x.Id); - table.ForeignKey( - name: "FK_Items_Users_CreatedById", - column: x => x.CreatedById, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); - - migrationBuilder.CreateTable( - name: "ItemStatistics", - columns: table => new - { - ItemId = table.Column(type: "INTEGER", nullable: false), - Luck = table.Column(type: "INTEGER", nullable: false), - Health = table.Column(type: "INTEGER", nullable: false), - Armor = table.Column(type: "INTEGER", nullable: false), - Power = table.Column(type: "INTEGER", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_ItemStatistics", x => x.ItemId); - table.ForeignKey( - name: "FK_ItemStatistics_Items_ItemId", - column: x => x.ItemId, - principalTable: "Items", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_Items_CreatedById", - table: "Items", - column: "CreatedById" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ItemStatistics"); - - migrationBuilder.DropTable(name: "Items"); - - migrationBuilder.DropTable(name: "Users"); - } - } -} diff --git a/Server/Modules/Database/DockerHealthCheck.cs b/Server/Modules/Database/DockerHealthCheck.cs new file mode 100644 index 0000000..74ef89a --- /dev/null +++ b/Server/Modules/Database/DockerHealthCheck.cs @@ -0,0 +1,68 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Fracture.Server.Modules.Database; + +public class DockerHealthCheck : IHealthCheck +{ + private readonly DockerClient _dockerClient; + + public DockerHealthCheck(DockerClient dockerClient) + { + _dockerClient = dockerClient; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + try + { + var containers = await _dockerClient.Containers.ListContainersAsync( + new ContainersListParameters() + { + All = true, + Filters = new Dictionary> + { + { + "name", + new Dictionary { { "fracturePostgres", true } } + }, + }, + }, + cancellationToken + ); + + var container = containers.FirstOrDefault(); + if (container == null) + { + return HealthCheckResult.Unhealthy("PostgreSQL container not found"); + } + + var containerInfo = await _dockerClient.Containers.InspectContainerAsync( + container.ID, + cancellationToken + ); + + return containerInfo.State.Health?.Status switch + { + "healthy" => HealthCheckResult.Healthy(), + "starting" => HealthCheckResult.Degraded("PostgreSQL container is starting"), + "unhealthy" => HealthCheckResult.Unhealthy("PostgreSQL container is unhealthy"), + null when containerInfo.State.Running => HealthCheckResult.Healthy( + "PostgreSQL container is running, no healthcheck" + ), + null => HealthCheckResult.Unhealthy("PostgreSQL container is not running"), + _ => HealthCheckResult.Unhealthy( + $"Unknown status: {containerInfo.State.Health?.Status}" + ), + }; + } + catch (Exception e) + { + return HealthCheckResult.Unhealthy("Failed to check Docker container", e); + } + } +} diff --git a/Server/Modules/Database/DockerService.cs b/Server/Modules/Database/DockerService.cs new file mode 100644 index 0000000..7e226e8 --- /dev/null +++ b/Server/Modules/Database/DockerService.cs @@ -0,0 +1,228 @@ +using Docker.DotNet; +using Docker.DotNet.Models; + +namespace Fracture.Server.Modules.Database; + +public class DockerService +{ + private readonly DockerClient _dockerClient; + private readonly ILogger _logger; + private const string ContainerName = "fracturePostgres"; + private const string ImageName = "postgres:latest"; + private const string VolumeName = "fracturePostgresData"; + public int? AssignedHostPort; + + public DockerService(ILogger logger) + { + _dockerClient = new DockerClientConfiguration( + new Uri( + OperatingSystem.IsWindows() + ? "npipe://./pipe/docker_engine" + : "unix:/var/run/docker.sock" + ) + ).CreateClient(); + _logger = logger; + } + + public async Task EnsurePostgresRunningAsync() + { + try + { + var containers = await _dockerClient.Containers.ListContainersAsync( + new ContainersListParameters() + { + All = true, + Filters = new Dictionary> + { + { + "name", + new Dictionary { { ContainerName, true } } + }, + }, + } + ); + + var existingContainer = containers.FirstOrDefault(); + + if (existingContainer != null) + { + if (existingContainer.State == "running") + { + _logger.LogInformation("PostgreSQL container is already running"); + AssignedHostPort = GetHostPortFromContainer(existingContainer); + return; + } + + await _dockerClient.Containers.StartContainerAsync( + existingContainer.ID, + new ContainerStartParameters() + ); + _logger.LogInformation("Started existing PostgreSQL container"); + AssignedHostPort = GetHostPortFromContainer(existingContainer); + } + else + { + var volumes = await _dockerClient.Volumes.ListAsync(); + + if (!volumes.Volumes.Any(v => v.Name == VolumeName)) + { + await _dockerClient.Volumes.CreateAsync( + new VolumesCreateParameters { Name = VolumeName } + ); + } + + var portBindings = await TryGetPortBindings(); + + var response = await _dockerClient.Containers.CreateContainerAsync( + new CreateContainerParameters + { + Image = ImageName, + Name = ContainerName, + Env = new List + { + "POSTGRES_USER=postgres", + "POSTGRES_PASSWORD=postgres", + "POSTGRES_DB=fracturedb", + }, + + HostConfig = new HostConfig + { + PortBindings = portBindings, + Binds = new List { $"{VolumeName}:/var/lib/postgresql/data" }, + RestartPolicy = new RestartPolicy + { + Name = RestartPolicyKind.UnlessStopped, + }, + }, + + Healthcheck = new HealthConfig + { + Test = new List { "CMD-SHELL", "pg_isready -U postgres" }, + Interval = TimeSpan.FromSeconds(1), + Timeout = TimeSpan.FromMilliseconds(500), + Retries = 5, + }, + } + ); + + await _dockerClient.Containers.StartContainerAsync( + response.ID, + new ContainerStartParameters() + ); + _logger.LogInformation("Created and started PostgreSQL container"); + + var containerInfo = await _dockerClient.Containers.InspectContainerAsync( + response.ID + ); + + AssignedHostPort = GetHostPortFromContainer(containerInfo); + } + + await WaitForPostgresReady(); + } + catch (Exception ex) + { + _logger.LogError(ex, "PostgreSQL container failed"); + throw; + } + } + + private async Task>> TryGetPortBindings() + { + try + { + return new Dictionary> + { + { + "5432/tcp", + new List { new PortBinding { HostPort = "5432" } } + }, + }; + } + catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Conflict) + { + _logger.LogWarning(ex, "Port 5432 is occupied, using antoher avaiable port"); + return new Dictionary> + { + { + "5432/tcp", + new List { new PortBinding { HostPort = "0" } } + }, + }; + } + } + + private int GetHostPortFromContainer(ContainerListResponse container) + { + if (container.Ports != null && container.Ports.Any()) + { + return int.Parse(container.Ports[0].PublicPort.ToString()); + } + + return 5432; + } + + private int GetHostPortFromContainer(ContainerInspectResponse container) + { + var portBinding = container.NetworkSettings.Ports["5432/tcp"]?.FirstOrDefault(); + return portBinding != null ? int.Parse(portBinding.HostPort) : 5432; + } + + private async Task WaitForPostgresReady() + { + const int maxAttempts = 30; + var attempt = 0; + + while (attempt < maxAttempts) + { + attempt++; + _logger.LogInformation( + $"Waiting for PostgreSQL to be ready (attempt {attempt}/{maxAttempts})" + ); + + try + { + var containers = await _dockerClient.Containers.ListContainersAsync( + new ContainersListParameters() + { + Filters = new Dictionary> + { + { + "name", + new Dictionary { { ContainerName, true } } + }, + { + "health", + new Dictionary { { "healthy", true } } + }, + }, + } + ); + + if (containers.Any()) + { + _logger.LogInformation("PostgreSQL container is ready"); + return; + } + + var containerInfo = await _dockerClient.Containers.InspectContainerAsync( + containers.FirstOrDefault()?.ID ?? "" + ); + + if (containerInfo.State.Running && containerInfo.State.Health == null) + { + _logger.LogInformation("PostgreSQL container is running (no health check)"); + return; + } + } + catch (Exception e) + { + _logger.LogDebug(e, "Health check failed"); + } + + await Task.Delay(1000); + } + + throw new TimeoutException("PostgreSQL container timed out"); + } +} diff --git a/Server/Modules/Database/FractureDbContext.cs b/Server/Modules/Database/FractureDbContext.cs index 009e7ef..d551646 100644 --- a/Server/Modules/Database/FractureDbContext.cs +++ b/Server/Modules/Database/FractureDbContext.cs @@ -15,13 +15,39 @@ public FractureDbContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity().HasOne(i => i.Statistics).WithOne(s => s.Item); + modelBuilder.Entity(entity => + { + entity + .Property(u => u.Id) + .UseIdentityByDefaultColumn() + .HasIdentityOptions(startValue: 1, incrementBy: 1); + }); - modelBuilder.Entity().HasOne(i => i.CreatedBy).WithMany(u => u.Items); + modelBuilder.Entity(entity => + { + entity + .Property(i => i.Id) + .UseIdentityByDefaultColumn() + .HasIdentityOptions(startValue: 1, incrementBy: 1); + }); - modelBuilder.Entity().HasMany(u => u.Items).WithOne(i => i.CreatedBy); + modelBuilder + .Entity() + .HasOne(i => i.Statistics) + .WithOne(s => s.Item) + .HasForeignKey(s => s.ItemId); - modelBuilder.Entity().HasOne(s => s.Item).WithOne(i => i.Statistics); + modelBuilder + .Entity() + .HasOne(i => i.CreatedBy) + .WithMany(u => u.Items) + .HasForeignKey(i => i.CreatedById); + + modelBuilder + .Entity() + .HasOne(s => s.Item) + .WithOne(i => i.Statistics) + .HasForeignKey(s => s.ItemId); base.OnModelCreating(modelBuilder); } diff --git a/Server/Modules/Items/Models/Item.cs b/Server/Modules/Items/Models/Item.cs index 7e8fe55..f03600e 100644 --- a/Server/Modules/Items/Models/Item.cs +++ b/Server/Modules/Items/Models/Item.cs @@ -28,10 +28,10 @@ public class Item public int CreatedById { get; set; } [InverseProperty(nameof(ItemStatistics.Item))] - public virtual ItemStatistics Statistics { get; set; } = null!; + public virtual ItemStatistics Statistics { get; set; } [InverseProperty(nameof(User.Items))] [JsonIgnore] - public virtual User CreatedBy { get; set; } = null!; + public virtual User CreatedBy { get; set; } } } diff --git a/Server/Modules/Users/User.cs b/Server/Modules/Users/User.cs index f34b3e6..8197636 100644 --- a/Server/Modules/Users/User.cs +++ b/Server/Modules/Users/User.cs @@ -17,6 +17,6 @@ public class User [InverseProperty(nameof(Item.CreatedBy))] [JsonIgnore] - public virtual ICollection Items { get; set; } = null!; + public virtual ICollection Items { get; set; } } } diff --git a/Server/Program.cs b/Server/Program.cs index 5cb847a..8b1cd9f 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -14,6 +14,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.FeatureManagement; var builder = WebApplication.CreateBuilder(args); @@ -30,6 +31,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -50,12 +52,30 @@ .AddInteractiveWebAssemblyComponents(); builder.Services.AddControllers(); +builder.Services.AddHealthChecks(); -builder.Services.AddDbContext(options => +var dockerService = new DockerService( + LoggerFactory.Create(b => b.AddConsole()).CreateLogger() +); + +try +{ + await dockerService.EnsurePostgresRunningAsync(); + + var connectionString = builder + .Configuration.GetConnectionString("DefaultConnection") + ?.Replace("{DYNAMIC_PORT}", dockerService.AssignedHostPort.ToString() ?? "5432"); + + builder.Services.AddDbContext(options => + { + options.UseNpgsql(connectionString); + }); +} +catch (Exception e) { - options.UseSqlite("Data Source=fracture.db"); - options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); -}); + Console.WriteLine($"Failed to initialize database: {e.Message}"); + throw; +} // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -76,6 +96,19 @@ app.UseExceptionHandler("/Error", createScopeForErrors: true); } +try +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} +catch (Exception ex) +{ + var logger = app.Services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while migrating or seeding the database."); + throw; +} + app.UseStaticFiles(); app.UseAuthorization(); @@ -87,10 +120,4 @@ .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode(); -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); -} - app.Run(); diff --git a/Server/appsettings.Development.json b/Server/appsettings.Development.json index 73a8365..5cb82a8 100644 --- a/Server/appsettings.Development.json +++ b/Server/appsettings.Development.json @@ -1,12 +1,11 @@ { - "ConnectionStrings": { - "NonPlayerCharacterDbContext": "Server=localhost;Port=5432;Database=GameDb;User Id=root;Password=password;", - "RedisDialogue": "localhost,password=password" - }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port=5432;Database=fracturedb;User Id=postgres;Password=postgres;" } } diff --git a/Server/appsettings.json b/Server/appsettings.json index 27dfdc4..a52bb06 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -13,5 +13,8 @@ }, "FeatureFlags": { "UseAI": false + }, + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Port={DYNAMIC_PORT};Database=fracturedb;User Id=postgres;Password=postgres;" } } \ No newline at end of file