Building automated feature test environment on hybrid infrastructure

Nowadays a hybrid approach in building infrastructure is become a standard which commonly used. We are placing more and more elements of infrastructure, to a cloud. Like automation, CI/CD environment and web servers. No need to host and maintain on-premise integration servers any more. But having in-place servers are still critical for many companies. Saving critical applications and data in own data center is important for compliance and security reasons. This is why it becomes vital having simple and reproducible approach to delivery code to end customer. And very often, it is more important to expose new feature for internal testing first as soon as it was developed. I would like to present an architecture approach for automated feature test environment based on hybrid Azure DevOps Ci/CD servers infrastructure and on-premise Windows-based application servers.

First, starting from definition of the problem. A simple diagram below:

automated feature test environment

The essentials parts here are:

  • continuous integration and continuous delivery systems are hosted on Azure DevOps. It contains Azure Repos with source code, build agent pool hosted in Azure and Azure Artifact feed to safely store application package and share them across different feature test environments;
  • and on premise application Windows server with IIS application pool installed. The server will host a number of application slots to be used by testers checking specific feature at any time they wish to.

The idea under the picture is following. A developer working on feature branch commits a pull request with latest changes. It triggers simple CI pipeline which basically should contains only restoration process, building source code and unit testing. These are everything we need and the process does not take a lot of time. Why is time so important here? It is because of the number of parallel features developed may vary depends on project scale and might be even a few commits per a few minutes. So, we do not want to stuck process of code delivery in push hours.

Build process

So, here is a build pipeline in YAML:

name: "$(Build.BuildId)$(rev:.r)"

parameters:
- name: fastBuild
  displayName: Skip Unit Tests
  type: boolean
  values:
  - true
  - false
  default: false
- name: buildConfiguration
  displayName: Build Configuration
  type: string
  values:
  - 'Release'
  - 'Debug'
  default: 'Release'
- name: appName
  displayName: Application Name
  type: string
  default: $(app-name)
- name: solutionName
  displayName: Solution Name
  type: string
  default: $(solution-name)
- name: testRunnerConfiguration
  displayName: Test Runner Configuration
  type: string
  values:
  - 'DotNetCore'
  - 'PowerShell'
  - 'VSTest'
  default: 'PowerShell'
- name: runInParallel
  displayName: Run Tests Projects In Parallel
  type: boolean
  values:
  - true
  - false
  default: true

variables:
- group: Pipeline Secrets

trigger: none

pool: 
  name: Hosted Windows 2019 with VS2019

jobs:
- job: CI_ON_PullRequest
  displayName: CI pipeline on Pull Request
  steps:
  ##########################################
  ### Preparing and Build solution
  ##########################################
  - task: UseDotNet@2
    displayName: 'Use .NET Core sdk '
    inputs:
      useGlobalJson: true
      performMultiLevelLookup: true

  - task: NuGetAuthenticate@0
    displayName: 'NuGet Authenticate'

  - task: DotNetCoreCLI@2
    displayName: Build
    inputs:
      command: 'build'
      projects: |
        **/${{ parameters.solutionName }}.sln
      arguments: '-c ${{ parameters.buildConfiguration }} /p:DebugType=embedded /p:InformationalVersion=$(Build.BuildNumber) /p:DefineConstants=${{ variables.buildConstants }} /p:NuGetRestorePackages=false'

  ##########################################
  ### Tests
  ##########################################
  - ${{ if eq(parameters.testRunnerConfiguration, 'DotNetCore') }}:
    - template: ${{ variables.templateFolder }}/dotnet-core-test-runner.yml
      parameters:
        fastBuild: ${{ parameters.fastBuild}}
        buildConfiguration: ${{ parameters.buildConfiguration }}
        sourceBranch: ${{ variables['Build.SourceBranchName'] }}
  - ${{ if eq(parameters.testRunnerConfiguration, 'PowerShell') }}:
    - template: ${{ variables.templateFolder }}/powershell-test-runner.yml
      parameters:
        fastBuild: ${{ parameters.fastBuild}}
        buildConfiguration: ${{ parameters.buildConfiguration }}
        sourceBranch: ${{ variables['Build.SourceBranchName'] }}
        solutionName: ${{ parameters.solutionName }}
        runInParallel: ${{ parameters.runInParallel }}
  - ${{ if eq(parameters.testRunnerConfiguration, 'VSTest') }}:
    - template: ${{ variables.templateFolder }}/vs-test-runner.yml
      parameters:
        fastBuild: ${{ parameters.fastBuild}}
        buildConfiguration: ${{ parameters.buildConfiguration }}
        sourceBranch: ${{ variables['Build.SourceBranchName'] }}
        runInParallel: ${{ parameters.runInParallel }}

  ##########################################
  ### Creating Artifacts
  ##########################################
  - template: ${{ variables.templateFolder }}/dotnet-core-publish.yml
    parameters:
      buildConfiguration: ${{ parameters.buildConfiguration }}
      artifactName: '${{ parameters.appName }}'
      additionalParameters: '/p:CopyOutputSymbolsToPublishDirectory=false'
      listOfProjectToPublish:
      - ${{ parameters.appName }}

It contains a configuration which allows to enable/disable fast build by switching on and off unit tests. As you can noticed, I have added a few options for test runners. I did it because of execution time may significantly varies depends on execution methods.

Using VSTest, for example, it is able to run tests in parallel in very straightforward way:

- task: VSTest@2
   displayName: Unit Tests
   inputs:
       testSelector: testAssemblies
       testAssemblyVer2: |
         **\*.Tests.Unit.dll 
         !\obj*
      codeCoverageEnabled: False
      runOnlyImpactedTests: True
      runInParallel: ${{ parameters.runInParallel }}

Deployment to available slot

After the artifact was created, a tester or developer deploys a code at one of the free slot available on IIS server. Below is a main delivery pipeline YAML:

"Release-$(rev:r)"

trigger: none

resources:
  pipelines:
  - pipeline: build
    source: CI_ON_PullRequest
  repositories:
  - repository: REPOSITORY
    type: git
    name: REPOSITORY

appendCommitMessageToRunName: false

parameters:

### Reservation
- name: CreateReservation
  displayName: Create Reservation
  type: boolean
  values:
  - true
  - false
  default: false
- name: ReservationFrom
  displayName: Reservation Day From
  type: string
  default: yyyy-MM-dd
- name: ReservationTo
  displayName: Reservation Day To
  type: string
  default: yyyy-MM-dd


- name: DeployLeg1
  displayName: Deploy Leg 1
  type: boolean
  values:
  - true
  - false
  default: false
...
- name: DeployLegN
  displayName: Deploy Leg N
  type: boolean
  values:
  - true
  - false
  default: false

variables:
- group: PIPELINESECRETS
- group: Project Configuration
- group: Reservation Configuration

stages:
- template: ${{ variables.TemplateFolder }}/deploy-slot.yml
  parameters: 
    DeploymentLeg: 1
    Condition: ${{ and(eq(variables['Build.Reason'], 'Manual'), eq(parameters.DeployLeg1, true)) }}
    HealthCheckEnabled: ${{ parameters.HealthCheckEnabled }}
    CreateReservation: ${{ parameters.CreateReservation }}
    ReservationFrom: ${{ parameters.ReservationFrom }}
    ReservationTo: ${{ parameters.ReservationTo }}

...

- template: ${{ variables.TemplateFolder }}/deploy-slot.yml
  parameters: 
    DeploymentLeg: N
    Condition: ${{ and(eq(variables['Build.Reason'], 'Manual'), eq(parameters.DeployLeg19, true)) }}
    HealthCheckEnabled: ${{ parameters.HealthCheckEnabled }}
    CreateReservation: ${{ parameters.CreateReservation }}
    ReservationFrom: ${{ parameters.ReservationFrom }}
    ReservationTo: ${{ parameters.ReservationTo }}

The main idea is having a 1 of N slots in the IIS pool ready to host feature branch. Deploying the pipeline, a testes specify a slot and optionally reserve it for further usage. The workflow might be automated using pipeline chain trigger:

trigger: none

resources:
  pipelines:
  - pipeline: build
    source: CI_ON_PullRequest
    trigger: true # enable the trigger

Reservation system

To make things even more convenient I created a reservation system. Any developer may books a specific slot for a some amount time to prevent the slot to be used by another person and loose changes.

I leverage Azure CosmoDB to store reservation information and Azure API for retrieving data. This is an awesome way to lock specific feature slot preventing another deployment to overwrite the test environment (deploy-slot.yml);

parameters:
- name: DeploymentLeg
  type: number
- name: CreateReservation
  type: boolean
- name: ReservationFrom
  type: string
- name: ReservationTo
  type: string
- name: Condition
  type: string
  default: ''
- name: DependsOn
  type: object
  default: []

stages:
- stage: FeatureTestDeploy${{ parameters.DeploymentLeg }}
  displayName: Deploy to FeatureTest ${{ parameters.DeploymentLeg }}
  ${{ if eq(length(parameters.DependsOn), 0) }}:
    dependsOn: []
  ${{ else }}:
    dependsOn: ${{ parameters.DependsOn }}
  ${{ if ne(parameters.Condition, '') }}:
    condition: ${{ parameters.Condition }}
 ...

  jobs:
  #############################
  #### Create Reservation #####
  #############################
  - job: CreateReservation${{ parameters.DeploymentLeg }}
    displayName: Create Reservation
    pool: Hosted Agent Pool
    steps:
    - checkout: REPOSITORY
    - powershell: |
        IF ('${{ parameters.CreateReservation }}' -eq 'true') {
          $reservationFrom = ([DateTime]'${{ parameters.ReservationFrom }}').toString('yyyy-MM-ddT00:00:00')
          $reservationTo = ([DateTime]'${{ parameters.ReservationTo }}').toString('yyyy-MM-ddT23:59:59')
        }
        ELSE {
          $reservationFrom = $(Get-Date -Format 'yyyy-MM-ddT00:00:00')
          $reservationTo = $(Get-Date -Format 'yyyy-MM-ddT23:59:59')
        }
        Write-Host ReservationFrom: $reservationFrom
        Write-Host ReservationTo: $reservationTo
        Write-Host StorageUri: https://$(StorageAccount).table.core.windows.net/$(TableName)
        IF ([DateTime]$reservationFrom -gt [DateTime]$reservationTo) {
          Write-Host "##[error] reservation date From > reservation date To"
          exit 1
        }
        IF ([DateTime]$reservationFrom -lt [DateTime]((Get-Date).ToString('yyyy-MM-ddT00:00:00'))) {
          Write-Host "##[error] reservation date From < Today"
          exit 1
        }
        IF ([DateTime]$reservationFrom -gt [DateTime]((Get-Date).AddDays($(MaxForDays)).ToString('yyyy-MM-ddT00:00:00'))) {
          Write-Host "##[error] reservation date From > Today + $(MaxForDays) days"
          exit 1
        }
        IF ((([DateTime]$reservationFrom).AddDays($(MaxForDays))) -lt [DateTime]$reservationTo) {
          Write-Host "##[error] reservation for more than $(MaxForDays) days not allowed"
          exit 1
        }
      
        . .\Tools\AzdoRestApi\storage\azure-tables-crud.ps1 -StorageAccount $(StorageAccount) -AccessKey $(Accesskey)
        $now = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss')
        $currectReservations = GetTableEntity -TableName $(TableName) -Filter "PartitionKey%20eq%20'${{ parameters.DeploymentLeg }}'%20and%20From%20lt%20datetime'$now'%20and%20To%20gt%20datetime'$now'"
        $reservations = GetTableEntity -TableName $(TableName) -Filter "PartitionKey%20eq%20'${{ parameters.DeploymentLeg }}'%20and%20From%20le%20datetime'$reservationTo'%20and%20To%20ge%20datetime'$reservationFrom'"
        IF (($currectReservations -eq $null) -and ($reservations -eq $null)) {
          Write-Host "##[section] No reservation from $reservationFrom to $reservationTo exists on FT ${{ parameters.DeploymentLeg }}"
          IF ('${{ parameters.CreateReservation }}' -eq 'true') {
            $body = @{
                RowKey = "$(Build.BuildId)"
                PartitionKey = "${{ parameters.DeploymentLeg }}"
                "From@odata.type" = "Edm.DateTime" 
                From = $reservationFrom
                "To@odata.type" = "Edm.DateTime"
                To = $reservationTo
                Author = "$(Build.RequestedFor)" 
            }
            PutTableEntity -TableName $(TableName) -Entity $body
            Write-Host "Reservation created successfully" 
            $body
          }
        }
        ELSE {
          IF ($currectReservations[0].Author -eq "$(Build.RequestedFor)") {
            Write-Host "##[section] Reservation for $($currectReservations[0].Author) already exists from $($currectReservations[0].From) to $($currectReservations[0].To) on FT ${{ parameters.DeploymentLeg }}"
            $currectReservations
          }
          ELSE {
            Write-Host "##[error] Reservation for $($currectReservations[0].Author) already exists from $($currectReservations[0].From) to $($currectReservations[0].To) on FT ${{ parameters.DeploymentLeg }}"
            $currectReservations
            exit 1
          }
        }
      displayName: Check/Create Reservation
Azure Rest API tools, I mentioned above, are available in my GitHub repository here, among another interesting stuff.

It must be mentioned, the Azure VMs pools might be replaced with on premise virtual machines to decrease latency.

All of this stuff configured to be monitored with Azure AppInsights installing SDK agents to provide health check functionality and alerting. Data collected inside Azure log workspace for monitoring and alerting in real time.

So, it was a quick presentation of building automated feature test environment on hybrid infrastructure with Azure, Azure DevOps server and on premise infrastructure. If you like the post, please subscribe to my newsletter and like the page below and go to the link for more interesting content.

Be an ethical, save your privacy!

subscribe to newsletter

and receive weekly update from our blog

By submitting your information, you're giving us permission to email you. You may unsubscribe at any time.

Leave a Comment

Discover more from #cybertechtalk

Subscribe now to keep reading and get access to the full archive.

Continue reading