EPIC: Enterprise-Grade CI/CD for ASP.NET Core
1. Complete Project Structure & Configuration
Solution Structure:
src/
├── MyApp.API/
│   ├── Controllers/
│   ├── Program.cs
│   ├── MyApp.API.csproj
│   └── appsettings.json
├── MyApp.Core/
│   ├── Entities/
│   ├── Interfaces/
│   └── MyApp.Core.csproj
├── MyApp.Infrastructure/
│   ├── Data/
│   ├── Repositories/
│   └── MyApp.Infrastructure.csproj
├── MyApp.Tests.Unit/
│   ├── Controllers/
│   ├── Services/
│   └── MyApp.Tests.Unit.csproj
└── MyApp.Tests.Integration/
    ├── API/
    ├── Database/
    └── MyApp.Tests.Integration.csprojKey Configuration Files:
Directory.Build.props (for consistent build configuration):
<Project> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <CodeAnalysisRuleSet>../stylecop.ruleset</CodeAnalysisRuleSet> </PropertyGroup> <ItemGroup> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> </ItemGroup> </Project>
API Project File (MyApp.API.csproj):
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerfileContext>..\..</DockerfileContext> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0"> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" /> </ItemGroup> </Project>
2. Docker Configuration
Multi-stage Dockerfile:
# Build stage FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src # Copy project files COPY ["src/MyApp.API/MyApp.API.csproj", "src/MyApp.API/"] COPY ["src/MyApp.Core/MyApp.Core.csproj", "src/MyApp.Core/"] COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"] COPY ["src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj", "src/MyApp.Tests.Unit/"] COPY ["src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj", "src/MyApp.Tests.Integration/"] # Restore dependencies RUN dotnet restore "src/MyApp.API/MyApp.API.csproj" RUN dotnet restore "src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj" RUN dotnet restore "src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj" # Copy everything else COPY . . # Build and publish WORKDIR "/src/src/MyApp.API" RUN dotnet build "MyApp.API.csproj" -c Release -o /app/build RUN dotnet publish "MyApp.API.csproj" -c Release -o /app/publish /p:UseAppHost=false # Test stage FROM build AS test WORKDIR "/src/src/MyApp.Tests.Unit" RUN dotnet test --logger "trx;LogFileName=test-results.trx" --collect:"XPlat Code Coverage" # Runtime stage FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app # Install curl for health checks RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* # Create a non-root user RUN groupadd -r appuser && useradd -r -g appuser appuser RUN chown -R appuser:appuser /app USER appuser EXPOSE 8080 EXPOSE 8081 COPY --from=build /app/publish . # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 ENTRYPOINT ["dotnet", "MyApp.API.dll"]
docker-compose.yml for local development:
version: '3.8' services: myapp.api: build: context: . dockerfile: src/MyApp.API/Dockerfile target: final ports: - "8080:8080" - "8081:8081" environment: - ASPNETCORE_ENVIRONMENT=Development - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=MyAppDb;User Id=sa;Password=YourPassword123!;TrustServerCertificate=true; depends_on: sqlserver: condition: service_healthy networks: - myapp-network sqlserver: image: mcr.microsoft.com/mssql/server:2022-latest environment: SA_PASSWORD: "YourPassword123!" ACCEPT_EULA: "Y" MSSQL_PID: "Express" ports: - "1433:1433" networks: - myapp-network healthcheck: test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "YourPassword123!" -Q "SELECT 1" || exit 1 interval: 10s retries: 10 start_period: 30s timeout: 3s seq: image: datalust/seq:latest environment: - ACCEPT_EULA=Y ports: - "5341:5341" - "8081:80" networks: - myapp-network networks: myapp-network: driver: bridge
3. Comprehensive Test Suite
Unit Test Example (MyApp.Tests.Unit/Services/UserServiceTests.cs):
using Moq; using FluentAssertions; using MyApp.Core.Entities; using MyApp.Core.Interfaces; using MyApp.Infrastructure.Services; namespace MyApp.Tests.Unit.Services; public class UserServiceTests { private readonly Mock<IUserRepository> _mockUserRepo; private readonly UserService _userService; public UserServiceTests() { _mockUserRepo = new Mock<IUserRepository>(); _userService = new UserService(_mockUserRepo.Object); } [Fact] public async Task GetUserById_WithValidId_ReturnsUser() { // Arrange var expectedUser = new User { Id = 1, Email = "test@example.com", Name = "Test User" }; _mockUserRepo.Setup(repo => repo.GetByIdAsync(1)).ReturnsAsync(expectedUser); // Act var result = await _userService.GetUserById(1); // Assert result.Should().NotBeNull(); result.Email.Should().Be("test@example.com"); result.Name.Should().Be("Test User"); _mockUserRepo.Verify(repo => repo.GetByIdAsync(1), Times.Once); } [Theory] [InlineData("")] [InlineData(null)] [InlineData("invalid-email")] public async Task CreateUser_WithInvalidEmail_ThrowsArgumentException(string invalidEmail) { // Arrange var user = new User { Email = invalidEmail, Name = "Test User" }; // Act & Assert await Assert.ThrowsAsync<ArgumentException>(() => _userService.CreateUser(user)); } }
Integration Test Example (MyApp.Tests.Integration/API/UsersControllerIntegrationTests.cs):
using System.Net; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using MyApp.API; using MyApp.Core.Entities; namespace MyApp.Tests.Integration.API; public class UsersControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime { private readonly WebApplicationFactory<Program> _factory; private readonly HttpClient _client; private readonly string _connectionString; public UsersControllerIntegrationTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real database with test database services.AddScoped<IConnectionFactory, TestConnectionFactory>(); }); }); _client = _factory.CreateClient(); _connectionString = "TestConnectionString"; } [Fact] public async Task GetUsers_ReturnsSuccessStatusCode() { // Act var response = await _client.GetAsync("/api/users"); // Assert response.EnsureSuccessStatusCode(); response.StatusCode.Should().Be(HttpStatusCode.OK); } [Fact] public async Task CreateUser_WithValidData_ReturnsCreatedUser() { // Arrange var user = new { Name = "Integration Test User", Email = "integration@test.com" }; var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json"); // Act var response = await _client.PostAsync("/api/users", content); // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); var responseContent = await response.Content.ReadAsStringAsync(); var createdUser = JsonSerializer.Deserialize<User>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); createdUser.Should().NotBeNull(); createdUser.Name.Should().Be("Integration Test User"); } public async Task InitializeAsync() { await ResetTestDatabase(); } public Task DisposeAsync() { _client.Dispose(); return Task.CompletedTask; } private async Task ResetTestDatabase() { // Implementation to reset test database await Task.CompletedTask; } }
4. GitHub Actions CI/CD Pipeline
.github/workflows/ci-cd.yml:
name: .NET CI/CD Pipeline on: push: branches: [ main, develop, feature/* ] pull_request: branches: [ main, develop ] env: DOTNET_VERSION: '8.0.x' REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: # JOB 1: Code Quality & Security code-analysis: name: Code Analysis & Security Scan runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Cache SonarCloud packages uses: actions/cache@v3 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache NuGet packages uses: actions/cache@v3 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: ${{ runner.os }}-nuget- - name: Install SonarCloud scanner run: | dotnet tool install --global dotnet-sonarscanner - name: SonarCloud Analysis env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | dotnet sonarscanner begin /k:"your-org_${{ github.event.repository.name }}" /o:"your-org" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" dotnet build --configuration Release dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - name: Run Security Scan uses: shiftleftsecurity/scan-action@master with: output: reports type: dotnet env: SCAN_AUTO_BUILD: true SCAN_AUDIT: true # JOB 2: Build & Test build-and-test: name: Build, Test & Analyze runs-on: ubuntu-latest needs: code-analysis strategy: matrix: configuration: [Debug, Release] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Cache NuGet packages uses: actions/cache@v3 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} restore-keys: ${{ runner.os }}-nuget- - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration ${{ matrix.configuration }} --no-restore --verbosity minimal - name: Run unit tests with coverage run: | dotnet test src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj \ --configuration ${{ matrix.configuration }} \ --no-build \ --verbosity normal \ --logger "trx;LogFileName=test-results.trx" \ --collect:"XPlat Code Coverage" \ --results-directory ./TestResults - name: Run integration tests run: | dotnet test src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj \ --configuration ${{ matrix.configuration }} \ --no-build \ --verbosity normal \ --logger "trx;LogFileName=integration-test-results.trx" - name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: test-results-${{ matrix.configuration }} path: | **/test-results.trx **/integration-test-results.trx retention-days: 30 # JOB 3: Docker Build & Security Scan docker-build: name: Build & Scan Docker Image runs-on: ubuntu-latest needs: build-and-test if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Log in to Container Registry uses: docker/login-action@v2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix={{branch}}- - name: Build and push Docker image uses: docker/build-push-action@v4 with: context: . file: ./src/MyApp.API/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} format: 'sarif' output: 'trivy-results.sarif' - name: Upload Trivy scan results uses: github/codeql-action/upload-sarif@v2 if: always() with: sarif_file: 'trivy-results.sarif' # JOB 4: Deployment to Environments deploy: name: Deploy to Environment runs-on: ubuntu-latest needs: [build-and-test, docker-build] if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' environment: name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} url: ${{ github.ref == 'refs/heads/main' && 'https://myapp.prod.com' || 'https://myapp.staging.com' }} strategy: matrix: include: - environment: staging condition: github.ref == 'refs/heads/develop' - environment: production condition: github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 - name: Deploy to Azure Container Instances if: matrix.condition uses: azure/aci-deploy@v1 with: resource-group: myapp-${{ matrix.environment }}-rg dns-name-label: myapp-${{ matrix.environment }}-${{ github.sha }} image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} registry-login-server: ${{ env.REGISTRY }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} name: myapp-${{ matrix.environment }} location: 'eastus' cpu: 1 memory: 1 - name: Run smoke tests run: | echo "Running smoke tests against ${{ matrix.environment }} environment" # Implement actual smoke tests using curl or HttpClient curl -f https://myapp-${{ matrix.environment }}.azurecontainer.io/health || exit 1 - name: Slack Notification uses: 8398a7/action-slack@v3 with: status: ${{ job.status }} channel: '#deployments' text: |- Deployment to ${{ matrix.environment }} ${{ job.status }} Commit: ${{ github.event.head_commit.message }} By: ${{ github.actor }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} if: always() # JOB 5: Database Migrations database-migrations: name: Run Database Migrations runs-on: ubuntu-latest needs: build-and-test if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v3 with: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Run EF Core Migrations run: | dotnet tool install --global dotnet-ef dotnet ef database update --project src/MyApp.Infrastructure --startup-project src/MyApp.API --connection "${{ secrets.STAGING_DB_CONNECTION }}" env: ASPNETCORE_ENVIRONMENT: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Staging' }}
5. Infrastructure as Code (Terraform)
main.tf for Azure:
terraform { required_version = ">= 1.0" required_providers { azurerm = { source = "hashicorp/azurerm" version = "~>3.0" } } backend "azurerm" { resource_group_name = "tfstate" storage_account_name = "mytfstatestorage" container_name = "tfstate" key = "myapp.terraform.tfstate" } } provider "azurerm" { features {} } resource "azurerm_resource_group" "main" { name = "myapp-${var.environment}-rg" location = var.location } resource "azurerm_container_group" "main" { name = "myapp-${var.environment}-ci" location = azurerm_resource_group.main.location resource_group_name = azurerm_resource_group.main.name ip_address_type = "Public" dns_name_label = "myapp-${var.environment}-${replace(substr(sha1(timestamp()), 0, 8), "/[^a-z0-9]/", "")}" os_type = "Linux" container { name = "myapp" image = var.container_image cpu = "1" memory = "1" ports { port = 8080 protocol = "TCP" } environment_variables = { ASPNETCORE_ENVIRONMENT = var.environment } secure_environment_variables = { ConnectionStrings__DefaultConnection = var.database_connection_string } } tags = { environment = var.environment version = var.app_version } }
6. Advanced Health Checks & Monitoring
Custom Health Checks:
public class DatabaseHealthCheck : IHealthCheck { private readonly IConfiguration _configuration; public DatabaseHealthCheck(IConfiguration configuration) { _configuration = configuration; } public async Task<HealthCheckResult> CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { try { using var connection = new SqlConnection(_configuration.GetConnectionString("DefaultConnection")); await connection.OpenAsync(cancellationToken); var command = connection.CreateCommand(); command.CommandText = "SELECT 1"; var result = await command.ExecuteScalarAsync(cancellationToken); return result?.ToString() == "1" ? HealthCheckResult.Healthy("Database is responsive") : HealthCheckResult.Unhealthy("Database check failed"); } catch (Exception ex) { return HealthCheckResult.Unhealthy("Database connection failed", ex); } } } // Program.cs configuration builder.Services.AddHealthChecks() .AddCheck<DatabaseHealthCheck>("database") .AddUrlGroup(new Uri("https://api.example.com/external"), "external_api") .AddApplicationInsightsPublisher(); builder.Services.Configure<HealthCheckPublisherOptions>(options => { options.Delay = TimeSpan.FromSeconds(5); options.Period = TimeSpan.FromSeconds(30); });
This comprehensive setup provides enterprise-grade CI/CD with security scanning, multi-environment deployment, infrastructure as code, and robust monitoring.
.png)
0 Comments
thanks for your comments!