Toan Le

Build a CI/CD pipeline for a .NET application in GitHub Actions

2023-12-25

This pipeline will include steps for checking formatting, building the application, running unit and integration tests, building and pushing a Docker image, and deploying the application.
    image

Prerequisites

  • An Azure account with permissions to create an Azure AD tenant and register an application
  • .NET Core SDK installed
  • Terraform

Step 1: Create Azure environment with Terraform

1. Create Service Principle

az ad sp create-for-rbac --name terraform --role Contributor --role "Role Based Access Control Administrator" \
--scopes /subscriptions/{your_subscription_id}

Replace {your_subscription_id} with your subscription id

2. Export variable in Powershell from Service Principle

$env:ARM_SUBSCRIPTION_ID = "{your_subscription_id}"
$env:ARM_TENANT_ID = "{tenant_id}"
$env:ARM_CLIENT_ID = "{client_id}"
$env:ARM_CLIENT_SECRET = "{client_secret}"

Step 2: Define Workflow Structure

In your repository’s .github/workflows directory, create a YAML file (e.g., cicd.yml)

This is where you define the workflow that GitHub Actions will run. The on section specifies when the workflow should be triggered. In this case, it’s set to run whenever there’s a push or a pull request to the master branch. The jobs section is where you define the tasks that make up your workflow.

name: Build and Test

on:
  push:
    branches: [ master ]

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  ARM_CLIENT_ID: "${{ secrets.ARM_CLIENT_ID }}"
  ARM_SUBSCRIPTION_ID: "${{ secrets.ARM_SUBSCRIPTION_ID }}"
  ARM_TENANT_ID: "${{ secrets.ARM_TENANT_ID }}"
  ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
  Sentry__Environment: "dev"
jobs:

Step 3: Check Formatting

It applies a set of style rules to your code and can detect when code violates these rules. The –check option makes it return a non-zero exit code when it finds badly formatted code, causing the workflow to fail.

For more info on installation https://csharpier.com

  check-formatting:
    name: Check Formatting
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: |
          dotnet tool restore
          dotnet csharpier --check .

Step 4: Build the Application

The dotnet build command compiles your code into a runnable application. The –no-restore option tells it not to restore NuGet packages (since this is done in a separate step).

  build:
    name: 'Build'
    runs-on: ubuntu-latest
    needs: check-formatting
    steps:
      - uses: actions/checkout@v3

      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '^7.0'

      - name: Restore Dependencies
        run: dotnet restore

      - name: Build The Application
        run: dotnet build --no-restore

Step 5: Run Unit Tests

The dotnet test command runs your unit tests. The –no-restore option tells it not to restore NuGet packages, and the –verbosity normal option tells it to output detailed messages about what it’s doing.

  unit-tests:
    name: 'Unit Tests'
    runs-on: ubuntu-latest
    needs: build
    steps:
      - uses: actions/checkout@v3

      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '^7.0'

      - name: Run Unit Tests
        run: dotnet test --verbosity normal --filter "UnitTest"

Step 6: Run Integration Tests

Here is an example of using Terraform to set up infrastructure for integration tests, which is then destroyed upon completion of the tests.

integration-tests:
  name: 'Integration Tests'
  runs-on: ubuntu-latest
  needs: build
  env:
    #this is needed since we are running terraform with read-only permissions
    ARM_SKIP_PROVIDER_REGISTRATION: true
  outputs:
    tfplanExitCode: ${{ steps.tf-plan.outputs.exitcode }}

  steps:
  # Checkout the repository to the GitHub Actions runner
  - name: Checkout
    uses: actions/checkout@v3

  # Install the latest version of the Terraform CLI
  - name: Setup Terraform
    uses: hashicorp/setup-terraform@v2
    with:
      terraform_wrapper: false

  # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc.
  - name: Terraform Init
    run: terraform init
    working-directory: ./.github/terraform-intergration-test

  # Checks that all Terraform configuration files adhere to a canonical format
  # Will fail the build if not
  - name: Terraform Format
    run: terraform fmt -check
    working-directory: ./.github/terraform-intergration-test

  # Generates an execution plan for Terraform
  # An exit code of 0 indicated no changes, 1 a terraform failure, 2 there are pending changes.
  - name: Terraform Plan
    id: tf-plan
    run: |
      export exitcode=0

      terraform plan -detailed-exitcode -no-color -out tfplan || export exitcode=$?

      echo "exitcode=$exitcode" >> $GITHUB_OUTPUT

      if [ $exitcode -eq 1 ]; then
        echo Terraform Plan Failed!
        exit 1
      else
        exit 0
      fi
    working-directory: ./.github/terraform-intergration-test

  # Create string output of Terraform Plan
  - name: Create String Output
    id: tf-plan-string
    run: |
      TERRAFORM_PLAN=$(terraform show -no-color tfplan)

      delimiter="$(openssl rand -hex 8)"
      echo "summary<<${delimiter}" >> $GITHUB_OUTPUT
      echo "## Terraform Plan Output" >> $GITHUB_OUTPUT
      echo "<details><summary>Click to expand</summary>" >> $GITHUB_OUTPUT
      echo "" >> $GITHUB_OUTPUT
      echo '```terraform' >> $GITHUB_OUTPUT
      echo "$TERRAFORM_PLAN" >> $GITHUB_OUTPUT
      echo '```' >> $GITHUB_OUTPUT
      echo "</details>" >> $GITHUB_OUTPUT
      echo "${delimiter}" >> $GITHUB_OUTPUT
    working-directory: ./.github/terraform-intergration-test

  # Publish Terraform Plan as task summary
  - name: Publish Terraform Plan to Task Summary
    env:
      SUMMARY: ${{ steps.tf-plan-string.outputs.summary }}
    run: |
      echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY
    working-directory: ./.github/terraform-intergration-test

  # Terraform Apply
  - name: Terraform Apply
    run: |
      terraform apply -auto-approve tfplan

      host=$(terraform output -raw postgresql_host)
      connectionString=$(terraform output -raw postgresql_connection_string)

      keyvault_secret_name=$(terraform output -raw keyvault_secret_name)
      keyvault_url=$(terraform output -raw keyvault_url)

      example_function_keys=$(terraform output -raw example_function_keys)
      example_blob_url=$(terraform output -raw example_blob_url)
      example_function_uri=$(terraform output -raw example_function_uri)

      cicd=true

      echo "ConnectionStrings__ConnectionString=$connectionString" >> $GITHUB_ENV
      echo "example_DATABASE_HOST=$host" >> $GITHUB_ENV

      echo "AzureKeyVault__SecretName=$keyvault_secret_name" >> $GITHUB_ENV
      echo "AzureKeyVault__Url=$keyvault_url" >> $GITHUB_ENV

      echo "AzureFunction__Keys=$example_function_keys" >> $GITHUB_ENV
      echo "AzureFunction__exampleBlobUrl=$example_blob_url" >> $GITHUB_ENV
      echo "AzureFunction__Url=$example_function_uri" >> $GITHUB_ENV

      echo "cicd=$cicd" >> $GITHUB_ENV

    working-directory: ./.github/terraform-intergration-test

    # Integration tests
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '^7.0'

    - name: Run Integration Tests
      run: dotnet test --verbosity normal --filter "IntegrationTest"

    # Terraform Destroy
    - name: Terraform Destroy
      if: always()
      run: terraform destroy -auto-approve
      working-directory: ./.github/terraform-intergration-test

main.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=3.84.0"
    }
  }
}

provider "azurerm" {
  features {
    key_vault {
      purge_soft_deleted_secrets_on_destroy = true
      recover_soft_deleted_secrets          = true
    }

    resource_group {
      prevent_deletion_if_contains_resources = false
    }
  }
  subscription_id = var.subscription_id
}

resource "azurerm_resource_group" "example" {
  name     = var.resource_group_name
  location = var.resource_group_location
  tags = {
    environment = var.tag_environment
  }
}

resource "random_string" "random" {
  length  = 10
  lower   = true
  numeric = false
  special = false
  upper   = false
}

resource "azurerm_postgresql_flexible_server" "example" {
  name                   = "${var.postgresql_name}${lower(random_string.random.id)}"
  resource_group_name    = azurerm_resource_group.example.name
  location               = azurerm_resource_group.example.location
  version                = "14"
  administrator_login    = var.administrator_login
  administrator_password = var.administrator_password
  zone                   = "1"
  storage_mb             = 32768

  sku_name = "B_Standard_B1ms"
  tags = {
    environment = var.tag_environment
  }
}

resource "azurerm_postgresql_flexible_server_firewall_rule" "example" {
  name             = "${var.example_fw_name}${lower(random_string.random.id)}"
  server_id        = azurerm_postgresql_flexible_server.example.id
  start_ip_address = "0.0.0.0"
  end_ip_address   = "255.255.255.255"
}

resource "azurerm_storage_account" "example" {
  name                     = "${var.storage_account_name}${lower(random_string.random.id)}"
  resource_group_name      = azurerm_resource_group.example.name
  location                 = azurerm_resource_group.example.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  min_tls_version          = "TLS1_2"
}

resource "azurerm_storage_container" "example" {
  name                  = "example"
  storage_account_name  = azurerm_storage_account.example.name
  container_access_type = "blob"
}

resource "azurerm_service_plan" "example" {
  name                = "${var.example_app_service_plan_name}${lower(random_string.random.id)}"
  resource_group_name = azurerm_resource_group.example.name
  location            = azurerm_resource_group.example.location
  os_type             = "Linux"
  sku_name            = "B1"
}

resource "azurerm_application_insights" "example" {
  name                = "${var.example_appinsights_plan_name}${lower(random_string.random.id)}"
  resource_group_name = azurerm_resource_group.example.name
  location            = azurerm_resource_group.example.location
  application_type    = "other"
}

resource "azurerm_linux_function_app" "example" {
  name                = "${var.function_name}${lower(random_string.random.id)}"
  resource_group_name = azurerm_resource_group.example.name
  location            = azurerm_resource_group.example.location

  storage_account_name       = azurerm_storage_account.example.name
  storage_account_access_key = azurerm_storage_account.example.primary_access_key
  service_plan_id            = azurerm_service_plan.example.id

  site_config {
    application_insights_key               = azurerm_application_insights.example.instrumentation_key
    application_insights_connection_string = azurerm_application_insights.example.connection_string
    application_stack {
      python_version = "3.11"
    }
  }
  app_settings = {
    "ENABLE_ORYX_BUILD"              = true
    "SCM_DO_BUILD_DURING_DEPLOYMENT" = true
  }

  zip_deploy_file = "functions.zip"

  identity {
    type = "SystemAssigned"
  }
}

data "azurerm_client_config" "current" {}

resource "azurerm_key_vault" "example" {
  name                       = "${var.key_vault_name}${lower(random_string.random.id)}"
  location                   = azurerm_resource_group.example.location
  resource_group_name        = azurerm_resource_group.example.name
  tenant_id                  = data.azurerm_client_config.current.tenant_id
  sku_name                   = "standard"
  soft_delete_retention_days = 7
}

resource "azurerm_key_vault_access_policy" "example" {
  key_vault_id = azurerm_key_vault.example.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = data.azurerm_client_config.current.object_id


  key_permissions = [
    "Create",
    "Get",
    "List",
  ]

  secret_permissions = [
    "Set",
    "Get",
    "List",
    "Delete",
    "Purge",
    "Recover"
  ]
}

resource "azurerm_key_vault_access_policy" "example-function" {
  key_vault_id = azurerm_key_vault.example.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = azurerm_linux_function_app.example.identity[0].principal_id

  key_permissions = [
    "Get",
  ]

  secret_permissions = [
    "Get",
  ]

  depends_on = [
    azurerm_key_vault_access_policy.example
  ]
}

resource "azurerm_role_assignment" "example" {
  scope                = azurerm_key_vault.example.id
  role_definition_name = "Reader"
  principal_id         = azurerm_linux_function_app.example.identity[0].principal_id

  depends_on = [
    azurerm_key_vault_access_policy.example-function
  ]
}

resource "azurerm_key_vault_secret" "example" {
  name         = "${var.key_vault_secret_name}${lower(random_string.random.id)}"
  value        = azurerm_storage_account.example.primary_connection_string
  key_vault_id = azurerm_key_vault.example.id
  depends_on = [
    azurerm_key_vault_access_policy.example
  ]
}

data "azurerm_function_app_host_keys" "example" {
  name                = azurerm_linux_function_app.example.name
  resource_group_name = azurerm_resource_group.example.name
  depends_on = [
    azurerm_resource_group.example,
    azurerm_linux_function_app.example
  ]
}

output "example_function_keys" {
  value     = data.azurerm_function_app_host_keys.example.default_function_key
  sensitive = true
}

output "example_blob_url" {
  value = "https://${azurerm_storage_account.example.name}.blob.core.windows.net/example"
}

output "example_function_uri" {
  value = "https://${azurerm_linux_function_app.example.name}.azurewebsites.net"
}

output "postgresql_host" {
  value     = "${azurerm_postgresql_flexible_server.example.name}.postgres.database.azure.com"
  sensitive = true
}

output "postgresql_connection_string" {
  value     = "Host=${azurerm_postgresql_flexible_server.example.name}.postgres.database.azure.com;Database=example;
Username=${var.administrator_login};Password=${var.administrator_password};"
  sensitive = true
}

output "keyvault_secret_name" {
  value = azurerm_key_vault_secret.example.name
}

output "keyvault_url" {
  value = azurerm_key_vault.example.vault_uri
}

variables.tf

variable "subscription_id" {
  type    = string
  default = "8e53319d-b091-4158-b1b5-d74c44a1554c"
}

variable "resource_group_name" {
  type    = string
  default = "example-be-cicd-rg"
}

variable "resource_group_location" {
  type    = string
  default = "East Asia"
}

variable "tag_environment" {
  type    = string
  default = "dev"
}

variable "postgresql_name" {
  type    = string
  default = "examplepostgres"
}

variable "administrator_login" {
  type    = string
  default = "psqladmin"
}

variable "administrator_password" {
  type    = string
  default = "123456789"
}

variable "example_fw_name" {
  type    = string
  default = "examplefw"
}

variable "function_name" {
  type    = string
  default = "examplefunction"
}

variable "storage_account_name" {
  type    = string
  default = "examplestorage"
}

variable "example_app_service_plan_name" {
  type    = string
  default = "exampleappserviceplan"
}

variable "example_appinsights_plan_name" {
  type    = string
  default = "exampleappinsights"
}

variable "key_vault_name" {
  type    = string
  default = "examplekeyvault"
}

variable "key_vault_secret_name" {
  type    = string
  default = "example-storage-account-connection-string"
}

Step 7: Build and Push Docker Image

  build-docker-image:
    name: 'Build And Push Docker Image'
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: mydocker/example-api:v1.0

Step 8: Deploy

Remember to replace the placeholders with your actual values. Also, make sure to store sensitive information like your DockerHub username and token as secrets in your GitHub repository.

This is a basic example and might need to be adjusted based on your application’s specific needs. For more information, check out the GitHub Actions documentation.

  deploy:
    name: 'Deploy'
    runs-on: ubuntu-latest
    needs: [build-docker-image]
    steps:
    - name: 'Sign in via Azure CLI'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}

    - uses: azure/webapps-deploy@v2
      with:
        app-name: 'myapp'
        images: 'mydocker/example-api:v1.0'

Copyright (c) 2024