diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 6fff877..26eedfc 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -35,15 +35,36 @@ services: DOTNET_RUNNING_IN_CONTAINER: "true" DB_CONNECTION_STRING: "${DB_CONNECTION_STRING}" MASTER_DB_CONNECTION_STRING: "${MASTER_DB_CONNECTION_STRING}" + CLEAR_DATABASE: "true" restart: "no" networks: - devnet + + database.seed: + image: database.seed + container_name: dev-env-database-seed + depends_on: + database.migrations: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: Database/Database.Seed/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_CONNECTION_STRING: "${DB_CONNECTION_STRING}" + restart: "no" + networks: + - devnet + api.core: image: api.core container_name: dev-env-api-core depends_on: - sqlserver: - condition: service_healthy + database.seed: + condition: service_completed_successfully build: context: ./src/Core dockerfile: API/API.Core/Dockerfile @@ -58,6 +79,7 @@ services: ASPNETCORE_URLS: "http://0.0.0.0:8080" DOTNET_RUNNING_IN_CONTAINER: "true" DB_CONNECTION_STRING: "${DB_CONNECTION_STRING}" + JWT_SECRET: "${JWT_SECRET}" restart: unless-stopped networks: - devnet diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 7efaaba..eccbf98 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -59,6 +59,7 @@ services: DOTNET_RUNNING_IN_CONTAINER: "true" MASTER_DB_CONNECTION_STRING: "${MASTER_DB_CONNECTION_STRING}" DB_CONNECTION_STRING: "${DB_CONNECTION_STRING}" + JWT_SECRET: "${JWT_SECRET}" restart: unless-stopped networks: - prodnet diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml new file mode 100644 index 0000000..08e93b7 --- /dev/null +++ b/docker-compose.test.yaml @@ -0,0 +1,110 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + container_name: test-env-sqlserver + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "${SA_PASSWORD}" + MSSQL_PID: "Express" + DOTNET_RUNNING_IN_CONTAINER: "true" + volumes: + - sqlserverdata-test:/var/opt/mssql + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${SA_PASSWORD}' -C -Q 'SELECT 1' || exit 1"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + networks: + - testnet + + database.migrations: + image: database.migrations + container_name: test-env-database-migrations + depends_on: + sqlserver: + condition: service_healthy + build: + context: ./src/Core/Database + dockerfile: Database.Migrations/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_CONNECTION_STRING: "${TEST_DB_CONNECTION_STRING}" + MASTER_DB_CONNECTION_STRING: "${TEST_MASTER_DB_CONNECTION_STRING}" + CLEAR_DATABASE: "true" + restart: "no" + networks: + - testnet + + database.seed: + image: database.seed + container_name: test-env-database-seed + depends_on: + database.migrations: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: Database/Database.Seed/Dockerfile + args: + BUILD_CONFIGURATION: Release + APP_UID: 1000 + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_CONNECTION_STRING: "${TEST_DB_CONNECTION_STRING}" + restart: "no" + networks: + - testnet + + api.specs: + image: api.specs + container_name: test-env-api-specs + depends_on: + database.seed: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: API/API.Specs/Dockerfile + args: + BUILD_CONFIGURATION: Release + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_CONNECTION_STRING: "${TEST_DB_CONNECTION_STRING}" + JWT_SECRET: "${JWT_SECRET}" + volumes: + - ./test-results:/app/test-results + restart: "no" + networks: + - testnet + + repository.tests: + image: repository.tests + container_name: test-env-repository-tests + depends_on: + database.seed: + condition: service_completed_successfully + build: + context: ./src/Core + dockerfile: Repository/Repository.Tests/Dockerfile + args: + BUILD_CONFIGURATION: Release + environment: + DOTNET_RUNNING_IN_CONTAINER: "true" + DB_CONNECTION_STRING: "${TEST_DB_CONNECTION_STRING}" + JWT_SECRET: "${JWT_SECRET}" + volumes: + - ./test-results:/app/test-results + restart: "no" + networks: + - testnet + +volumes: + sqlserverdata-test: + driver: local + +networks: + testnet: + driver: bridge diff --git a/src/Core/API/API.Specs/API.Specs.csproj b/src/Core/API/API.Specs/API.Specs.csproj index 0791bcb..45ce21a 100644 --- a/src/Core/API/API.Specs/API.Specs.csproj +++ b/src/Core/API/API.Specs/API.Specs.csproj @@ -34,6 +34,5 @@ - diff --git a/src/Core/API/API.Specs/Dockerfile b/src/Core/API/API.Specs/Dockerfile new file mode 100644 index 0000000..7929db8 --- /dev/null +++ b/src/Core/API/API.Specs/Dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] +COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] +COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"] +COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"] +RUN dotnet restore "API/API.Specs/API.Specs.csproj" +COPY . . +WORKDIR "/src/API/API.Specs" +RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS final +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /app/test-results +WORKDIR /src/API/API.Specs +ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--no-build", "--no-restore", "--logger", "trx;LogFileName=/app/test-results/test-results.trx"] diff --git a/src/Core/Database/Database.Migrations/Database.Migrations.csproj b/src/Core/Database/Database.Migrations/Database.Migrations.csproj index 3488889..ab0b5d7 100644 --- a/src/Core/Database/Database.Migrations/Database.Migrations.csproj +++ b/src/Core/Database/Database.Migrations/Database.Migrations.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - DataLayer + Database.Migrations Linux diff --git a/src/Core/Database/Database.Migrations/Program.cs b/src/Core/Database/Database.Migrations/Program.cs index 1fa4b3a..86281cb 100644 --- a/src/Core/Database/Database.Migrations/Program.cs +++ b/src/Core/Database/Database.Migrations/Program.cs @@ -3,7 +3,7 @@ using System.Reflection; using DbUp; using Microsoft.Data.SqlClient; -namespace DataLayer; +namespace Database.Migrations; public static class Program { @@ -22,6 +22,57 @@ public static class Program return result.Successful; } + private static bool ClearDatabase() + { + var myConn = new SqlConnection(masterConnectionString); + + try + { + myConn.Open(); + + // First, set the database to single user mode to close all connections + var setModeCommand = new SqlCommand( + "IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " + + "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", + myConn); + try + { + setModeCommand.ExecuteNonQuery(); + Console.WriteLine("Database set to single user mode."); + } + catch (System.Exception ex) + { + Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}"); + } + + // Then drop the database + var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn); + try + { + dropCommand.ExecuteNonQuery(); + Console.WriteLine("Database cleared successfully."); + } + catch (System.Exception ex) + { + Console.WriteLine($"Error dropping database: {ex}"); + return false; + } + } + catch (System.Exception ex) + { + Console.WriteLine($"Error clearing database: {ex}"); + return false; + } + finally + { + if (myConn.State == ConnectionState.Open) + { + myConn.Close(); + } + } + return true; + } + private static bool CreateDatabaseIfNotExists() { var myConn = new SqlConnection(masterConnectionString); @@ -58,6 +109,13 @@ public static class Program try { + var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE"); + if (clearDatabase == "true") + { + Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database..."); + ClearDatabase(); + } + CreateDatabaseIfNotExists(); var success = DeployMigrations(); diff --git a/src/Core/Database/Database.Seed/Database.Seed.csproj b/src/Core/Database/Database.Seed/Database.Seed.csproj index 656d3be..4c68909 100644 --- a/src/Core/Database/Database.Seed/Database.Seed.csproj +++ b/src/Core/Database/Database.Seed/Database.Seed.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - DBSeed + Database.Seed @@ -18,7 +18,6 @@ - diff --git a/src/Core/Database/Database.Seed/Dockerfile b/src/Core/Database/Database.Seed/Dockerfile new file mode 100644 index 0000000..1bfaf89 --- /dev/null +++ b/src/Core/Database/Database.Seed/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +# Copy everything from the context (src/Core) +COPY . . +RUN dotnet restore "./Database/Database.Seed/Database.Seed.csproj" +RUN dotnet build "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Database.Seed.dll"] diff --git a/src/Core/Database/Database.Seed/Program.cs b/src/Core/Database/Database.Seed/Program.cs index 720005b..0fb1138 100644 --- a/src/Core/Database/Database.Seed/Program.cs +++ b/src/Core/Database/Database.Seed/Program.cs @@ -5,73 +5,60 @@ using System.Reflection; try { - var connectionString = Environment.GetEnvironmentVariable( - "DB_CONNECTION_STRING" - ); - if (string.IsNullOrWhiteSpace(connectionString)) - throw new InvalidOperationException( - "Environment variable DB_CONNECTION_STRING is not set or is empty." - ); + var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING"); - await using var connection = new SqlConnection(connectionString); - await connection.OpenAsync(); + Console.WriteLine("Attempting to connect to database..."); - // drop and recreate the database - var useMaster = connection.CreateCommand(); - useMaster.CommandText = "USE master;"; - await useMaster.ExecuteNonQueryAsync(); + // Retry logic for database connection + SqlConnection? connection = null; + int maxRetries = 10; + int retryDelayMs = 2000; - var dbName = "Biergarten"; - var dropDb = connection.CreateCommand(); - dropDb.CommandText = $@" - IF DB_ID(N'{dbName}') IS NOT NULL - BEGIN - ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; - DROP DATABASE [{dbName}]; - END"; - await dropDb.ExecuteNonQueryAsync(); - - var createDb = connection.CreateCommand(); - createDb.CommandText = $@"CREATE DATABASE [{dbName}];"; - await createDb.ExecuteNonQueryAsync(); - await connection.CloseAsync(); - await connection.OpenAsync(); - - - Console.WriteLine("Connected to database."); - - Console.WriteLine("Starting migrations..."); - - // Run Database.Core migrations (embedded resources) via DbUp - var migrationAssembly = Assembly.Load("Database.Core"); - var upgrader = DeployChanges - .To.SqlDatabase(connectionString) - .WithScriptsEmbeddedInAssembly(migrationAssembly) - .LogToConsole() - .Build(); - - var upgradeResult = upgrader.PerformUpgrade(); - if (!upgradeResult.Successful) - throw upgradeResult.Error; - - Console.WriteLine("Migrations completed."); - - - ISeeder[] seeders = - [ - new LocationSeeder(), - new UserSeeder(), - ]; - - foreach (var seeder in seeders) + for (int attempt = 1; attempt <= maxRetries; attempt++) { - Console.WriteLine($"Seeding {seeder.GetType().Name}..."); - await seeder.SeedAsync(connection); - Console.WriteLine($"{seeder.GetType().Name} seeded."); + try + { + connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + Console.WriteLine($"Connected to database successfully on attempt {attempt}."); + break; + } + catch (SqlException ex) when (attempt < maxRetries) + { + Console.WriteLine($"Connection attempt {attempt}/{maxRetries} failed: {ex.Message}"); + Console.WriteLine($"Retrying in {retryDelayMs}ms..."); + await Task.Delay(retryDelayMs); + connection?.Dispose(); + connection = null; + } + } + + if (connection == null) + { + throw new Exception($"Failed to connect to database after {maxRetries} attempts."); + } + + Console.WriteLine("Starting seeding..."); + + using (connection) + { + + ISeeder[] seeders = + [ + new LocationSeeder(), + new UserSeeder(), + ]; + + foreach (var seeder in seeders) + { + Console.WriteLine($"Seeding {seeder.GetType().Name}..."); + await seeder.SeedAsync(connection); + Console.WriteLine($"{seeder.GetType().Name} seeded."); + } + + Console.WriteLine("Seed completed successfully."); } - Console.WriteLine("Seed completed successfully."); - await connection.CloseAsync(); return 0; } catch (Exception ex) diff --git a/src/Core/Repository/Repository.Tests/Dockerfile b/src/Core/Repository/Repository.Tests/Dockerfile new file mode 100644 index 0000000..6d5db48 --- /dev/null +++ b/src/Core/Repository/Repository.Tests/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"] +COPY ["Repository/Repository.Tests/Repository.Tests.csproj", "Repository/Repository.Tests/"] +RUN dotnet restore "Repository/Repository.Tests/Repository.Tests.csproj" +COPY . . +WORKDIR "/src/Repository/Repository.Tests" +RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS final +RUN mkdir -p /app/test-results +WORKDIR /src/Repository/Repository.Tests +ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--no-build", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"] diff --git a/src/Core/Service/Service.Core/Services/JwtService.cs b/src/Core/Service/Service.Core/Services/JwtService.cs index c881e70..197dd21 100644 --- a/src/Core/Service/Service.Core/Services/JwtService.cs +++ b/src/Core/Service/Service.Core/Services/JwtService.cs @@ -1,3 +1,4 @@ +using System; using System.Security.Claims; using System.Text; using Microsoft.Extensions.Configuration; @@ -6,14 +7,13 @@ using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; namespace ServiceCore.Services; -public class JwtService(IConfiguration config) : IJwtService +public class JwtService : IJwtService { - // private readonly string? _secret = config["Jwt:Secret"]; - private readonly string? _secret = "128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR!@#$fdsafjdslajfl"; + private readonly string? _secret = Environment.GetEnvironmentVariable("JWT_SECRET"); public string GenerateJwt(Guid userId, string username, DateTime expiry) { var handler = new JsonWebTokenHandler(); - + var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set")); // Base claims (always present) @@ -35,4 +35,4 @@ public class JwtService(IConfiguration config) : IJwtService return handler.CreateToken(tokenDescriptor); } -} \ No newline at end of file +}