ASP.NET Core  

Enterprise-Grade CI/CD for ASP.NET Core (Part-33 of 40)

Previous article: ASP.NET Core Docker Kubernetes Deployment Guide | Cloud-Native DevOps (Part-32 of 40)  

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.csproj
  

Key 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 = "[email protected]", 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("[email protected]");
        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 = "[email protected]" };
        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.