How to implement CI/CD for IaC in practice? — part 4: How to prepare the yaml pipeline for linting, validation, and pester testing of the Bicep code base.

How to implement CI/CD for IaC in practice? — part 4: How to prepare the yaml pipeline for linting, validation, and pester testing of the Bicep code base.

This blog post will prepare an Azure pipeline that automates the deployment of infrastructure resources in Azure using Bicep files (the one we prepared in the previous article ). The pipeline will consist of several stages, each of which has one or more jobs, and each job has one or more steps.

  1. Lint stage: This stage performs linting of the Bicep code in the "main.bicep" file.
  2. Validate stage: This stage performs preflight validation of the Bicep file using an Azure Resource Manager Template Deployment task.
  3. Preview stage: This stage performs a what-if deployment preview of the changes that will be made to the Azure environment.
  4. Deploy stage: This stage deploys the Bicep file to the Azure environment and saves the deployment outputs into variables.
  5. SmokeTest stage: This stage performs smoke tests on the deployed resources using a PowerShell script, and NUnit test results are published.

Each stage runs in a virtual machine (VM) using the "ubuntu-latest" image. The pipeline uses several variables, including the default deployment location, resource group name, and environment type. The pipeline is triggered only when changes are made to the "main" branch and runs in batches.

Stage 1: Lint

We need to start by creating a new pipeline for our project. Navigate to the section of the pipeline and select the 'New pipeline' button on the top right. Next, choose the Azure Repos Git option, pick your repository, and select the blank pipeline. This will result in the creation of a new azure-pipeline.yml file. Proceed with the first stage, review the code, and commit.

trigger:
  batch: true
  branches:
    include:
    - main

pool:
  vmImage: ubuntu-latest

stages:

- stage: Lint
  jobs:
  - job: LintCode
    displayName: Lint code
    steps:
      - script: |
          az bicep build --file deploy/main.bicep
        name: LintBicepCode
        displayName: Run Bicep linter
💡
Tip
YAML files are sensitive to indentation. If you type or paste this code, ensure your indentation is correct.

The first stage, "Lint," runs a linter on the Bicep file in the deploy directory to check for syntax errors or other issues.

Next, we can configure our linter. We need to add a new file bicepconfig.json

{
  "analyzers": {
    "core": {
      "enabled": true,
      "verbose": true,
      "rules": {
        "adminusername-should-not-be-literal": {
          "level": "error"
        },
        "max-outputs": {
          "level": "error"
        },
        "max-params": {
          "level": "error"
        },
        "max-resources": {
          "level": "error"
        },
        "max-variables": {
          "level": "error"
        },
        "no-hardcoded-env-urls": {
          "level": "error"
        },
        "no-unnecessary-dependson": {
          "level": "error"
        },
        "no-unused-params": {
          "level": "error"
        },
        "no-unused-vars": {
          "level": "error"
        },
        "outputs-should-not-contain-secrets": {
          "level": "error"
        },
        "prefer-interpolation": {
          "level": "error"
        },
        "secure-parameter-default": {
          "level": "error"
        },
        "simplify-interpolation": {
          "level": "error"
        },
        "use-protectedsettings-for-commandtoexecute-secrets": {
          "level": "error"
        },
        "use-stable-vm-image": {
          "level": "error"
        }
      }
    }
  }
}

Our repository should look like this.

Stage 2: Validate

 - stage: Validate
  jobs:
  - job: ValidateBicepCode
    displayName: Validate Bicep code
    steps:
      - task: AzureResourceManagerTemplateDeployment@3
        name: RunPreflightValidation
        displayName: Run preflight validation
        inputs:
          connectedServiceName: $(ServiceConnectionName)
          location: $(deploymentDefaultLocation)
          deploymentMode: Validation
          resourceGroupName: $(ResourceGroupName)
          csmFile: deploy/main.bicep
          overrideParameters: >
            -environmentType $(EnvironmentType)

This stage defines a single step that runs the preflight validation. Notice that this step includes a reference to your service connection because the preflight validation process requires communicating with Azure.

We need to add those variables to be accessible from the pipeline.

Click the 'Edit' edit button and next choose the variables section.

Next, Add three variables.

  • ServiceConnectionName => choose the service connection that we have previously created
  • ResourceGroupName => do the same for the resource group name
  • EnvironmentType => for now, we should go with 'Test' value

Stage 3: Preview

- stage: Preview
  jobs:
  - job: PreviewAzureChanges
    displayName: Preview Azure changes
    steps:
      - task: AzureCLI@2
        name: RunWhatIf
        displayName: Run what-if
        inputs:
          azureSubscription: $(ServiceConnectionName)
          scriptType: 'bash'
          scriptLocation: 'inlineScript'
          inlineScript: |
            az deployment group what-if \
              --resource-group $(ResourceGroupName) \
              --template-file deploy/main.bicep \
              --parameters environmentType=$(EnvironmentType)

This code defines a stage in an Azure DevOps pipeline called "Preview". It contains a single job called "PreviewAzureChanges" which displays what changes will be made to the Azure environment before deploying. The task uses the Azure CLI@2 and is named "RunWhatIf". The Azure CLI task runs a what-if command, previewing deployment changes. The "azureSubscription" input specifies the Azure service connection to be used, and the "inlineScript" input specifies the script to be executed, which runs the what-if command. This script specifies the Azure resource group, the Bicep template file, and the environment type parameters.

Stage 4: Deploy

- stage: Deploy
  jobs:
  - deployment: DeployWebsite
    displayName: Deploy website
    environment: Website
    strategy:
      runOnce:
        deploy:
          steps:
            - checkout: self
            - task: AzureResourceManagerTemplateDeployment@3
              name: DeployBicepFile
              displayName: Deploy Bicep file
              inputs:
                connectedServiceName: $(ServiceConnectionName)
                deploymentName: $(Build.BuildNumber)
                location: $(deploymentDefaultLocation)
                resourceGroupName: $(ResourceGroupName)
                csmFile: deploy/main.bicep
                overrideParameters: >
                  -environmentType $(EnvironmentType)
                deploymentOutputs: deploymentOutputs

            - bash: |
                echo "##vso[task.setvariable variable=appServiceAppHostName;isOutput=true]$(echo $DEPLOYMENT_OUTPUTS | jq -r '.appServiceAppHostName.value')"
              name: SaveDeploymentOutputs
              displayName: Save deployment outputs into variables
              env:
                DEPLOYMENT_OUTPUTS: $(deploymentOutputs)

This code defines a stage named "Deploy" in a pipeline. The stage contains a single job named "DeployWebsite" with a display name of "Deploy website". The job is associated with an environment called a "Website".

The deployment strategy for the job is set to "runOnce", meaning it will only run once and will not automatically redeploy after each new build. The deployment process consists of several steps:

  • Checking out the source code of the pipeline.
  • Deploying a Bicep file (located at "deploy/main.bicep") to Azure using the Azure Resource Manager Template Deployment task. The deployment is connected to an Azure service connection specified by the variable $(ServiceConnectionName) and is deployed to a resource group specified by the variable $(ResourceGroupName).
  • A bash script is run to save the deployment outputs as variables in the pipeline. The script parses the deployment outputs and saves the value of "appServiceAppHostName" into the variable "appServiceAppHostName".

We must navigate the pipeline section and click environments to create a new one. Next, click create new button o proceed with the name.

Stage 5: Smoke Test

we need to create a new file for our test cases. Lets co with 'Website.Tests.ps1'

Paste the below code in the new file.

param(
  [Parameter(Mandatory)]
  [ValidateNotNullOrEmpty()]
  [string] $HostName
)

Describe 'Website' {

    It 'Serves pages over HTTPS' {
      $request = [System.Net.WebRequest]::Create("https://$HostName/")
      $request.AllowAutoRedirect = $false
      $request.GetResponse().StatusCode |
        Should -Be 200 -Because "the website requires HTTPS"
    }

    It 'Does not serves pages over HTTP' {
      $request = [System.Net.WebRequest]::Create("http://$HostName/")
      $request.AllowAutoRedirect = $false
      $request.GetResponse().StatusCode | 
        Should -BeGreaterOrEqual 300 -Because "HTTP is not secure"
    }

}

This code is a PowerShell script that validates the behavior of a website by testing its ability to serve pages over HTTPS and not over HTTP. The script takes an input parameter $HostName, which is the website hostname to be tested.

In the script, there are two test cases defined under the Describe Website block, which are:

  1. 'Serves pages over HTTP' This test case creates a web request using the "System.Net.WebRequest" object and the URL "https://$HostName/". The test case then checks the response's status code and uses the "Should -Be 200" statement to assert that the status code should be 200 (Success) because the website requires HTTPS. The "AllowAutoRedirect" property is false to prevent automatic redirection to another URL.
  2. 'Does not serve pages over HTTP' This test case creates a web request using the "System.Net.WebRequest" object and the URL "http://$HostName/". The test case then checks the response's status code and uses the "Should -BeGreaterOrEqual 300" statement to assert that the status code should be greater than or equal to 300 (redirection) because HTTP is not secure. The "AllowAutoRedirect" property is false to prevent automatic redirection to another URL.

Next, we can adjust our pipeline.

- stage: SmokeTest
  jobs:
  - job: SmokeTest
    displayName: Smoke test
    variables:
      appServiceAppHostName: $[ stageDependencies.Deploy.DeployWebsite.outputs['DeployWebsite.SaveDeploymentOutputs.appServiceAppHostName'] ]
    steps:
      - task: PowerShell@2
        name: RunSmokeTests
        displayName: Run smoke tests
        inputs:
          targetType: inline
          script: |
            $container = New-PesterContainer `
              -Path 'deploy/Website.Tests.ps1' `
              -Data @{ HostName = '$(appServiceAppHostName)' }
            Invoke-Pester `
              -Container $container `
              -CI

      - task: PublishTestResults@2
        name: PublishTestResults
        displayName: Publish test results
        condition: always()
        inputs:
          testResultsFormat: NUnit
          testResultsFiles: 'testResults.xml'

The code defines a stage  "SmokeTest" in a pipeline. The stage has one job, also named "SmokeTest", with a display name of "Smoke test". The job has a variable called "appServiceAppHostName" which is assigned a value from the previous stage's output. The job has two steps:

  1. Running smoke tests using PowerShell task: The step runs a script that creates a Pester container using a file "deploy/Website.Tests.ps1" and passes a data object containing the hostname to the container. The script then invokes Pester in Continuous Integration (CI) mode.
  2. Publishing test results: The step is to publish the test results in NUnit format. The input file is "testResults.xml". The condition is set to "always()" meaning the step will always run regardless of the previous steps' status.

Summary

We now have 5 stages declared, configured lantern, and prepared test case for after deployment smoke test. ideally, our pipeline should go like this.

In reality, we will receive some errors regarding linting or test results. Also, a good idea is not to deploy anything into production environments before verification. Azure Pipelines allows linking deployments to settings, inheriting checks and approvals defined by environment owners. However, pipeline definitions should still be reviewed through established practices like pull request reviews on changes to the main branch. Service connections can also have checks and approvals, which affects the pipeline's ability to run preflight validation and what-if operations. To avoid this, a separate service connection with its service principal can be used for the what-if stage. The service principal for the preflight and validation stages should have a custom Azure role with minimum permissions.

In the last entry of this mini-series, we will address all the issues and refactor our code to be more modular.

Subscribe to EngEX

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe