I develop C# based web apps and integration solutions (APIs) for years and nowadays they are hosted in Azure. Most of the time I also create the infrastructure to deploy these kind of applications. In accordance with the Infrastructure as Code principles that I use, I do this using ARM templates supplemented with PowerShell (DSC) scripts. As a developer, I am used to writing unit tests to test my applications. Testing infrastructure is a new area for me. The traditional unit test approach does not work here because you cannot test an infrastructure until it has been rolled out.
Recently I have gained experience with the use of the Pester framework for performing (pre) deployment verification testing during the rollout of an IIS website environment. In this blog article I share some experiences I have gained during this project.
In terms of rollout, we have various pain points. Does the Azure VM meet the installation requirements? Is there enough disk space and internal memory available. Has the DNS name been created by the other team and is the required service account present and not disabled? In terms of connectivity, it can also be important that your API can call other APIs and databases. These are all things that can make the deployment fail or cause our own API not to work. These points must be verified beforehand, otherwise it makes no sense to allow the deployment to continue. All these checks fall into the category of pre deployment verification testing.
After installation, it is also important to check that our own API is installed properly. We must therefore check whether our API is running and whether it is responding. These kind of checks fall into the category of deployment verification testing.
The deployment order will be:
Next we will create a PowerShell DSC file called
This Pester file will use the PowerShell data file we will use to create the website of the WCF API as its input source.
Recently I have gained experience with the use of the Pester framework for performing (pre) deployment verification testing during the rollout of an IIS website environment. In this blog article I share some experiences I have gained during this project.
Case
The case in this case is the rollout of a WCF API hosted on an Azure VM on which no IIS web server is installed yet. This web service will run under a service account. The creation of the Azure VM, the DNS name and the service account are set up by another Dev/Ops team and therefore fall outside the responsibility of my CI/CD pipeline.In terms of rollout, we have various pain points. Does the Azure VM meet the installation requirements? Is there enough disk space and internal memory available. Has the DNS name been created by the other team and is the required service account present and not disabled? In terms of connectivity, it can also be important that your API can call other APIs and databases. These are all things that can make the deployment fail or cause our own API not to work. These points must be verified beforehand, otherwise it makes no sense to allow the deployment to continue. All these checks fall into the category of pre deployment verification testing.
After installation, it is also important to check that our own API is installed properly. We must therefore check whether our API is running and whether it is responding. These kind of checks fall into the category of deployment verification testing.
Approach
Since we receive a bare Azure VM, we first have to apply our prerequisites. In this case, that is the configuration of the IIS server with a website that meets the application requirements and the installation of some PowerShell modules. Once the requirements are present, we can install the WCF API. For the installation we use PowerShell DSC which is executed by an Azure DevOps CD pipeline. The results of our (pre) depoyment verification tests will be published to our pipeline in the form of NUnit (or JUnit) reports.The deployment order will be:
- General phase
- Pre-deployment verification
- Check available storage
- Check memory requirements
- Check connectivity
- Configure IIS and deploy PowerShell modules
- Deployment verification
- Check IIS settings
- Check presence of PowerShell modules
- Application specific phase
- Pre-deployment verification
- Check presence of DNS name
- Check presence and state of Windows service account
- Configure IIS website
- Deployment verification
- Check IIS website settings
- Install website
- Deployment verification
- Check whether our API is running and whether it is responding.
Install-Module -Name Pester -Force -SkipPublisherCheckFor general information about Pester read their Wiki page.
General phase
Pre-deployment verification
To execute these tests we create a PowerShell file with the nameGeneral.PreDeploymentVerification.Tests.ps1
. The .Tests.ps1
part is manditory by the Pester framework. In this file we add our three tests in the Behavior-Driven Development (BDD) based syntax of Pester.Describe -Name "General pre-deployment verification checks" -Fixture { Context -Name "Hardware" -Fixture { It -Name "Has at least 4 GB of total RAM" -Test { (Get-CimInstance Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB | Should -Not -BeLessThan 4 } It -Name "Has at least 5 GB of free disk space on the C: drive" -Test { $disk = Get-WMIObject -Class Win32_LogicalDisk | Where-Object DeviceID -eq "C:" | Select-Object DeviceID, @{'Name'='Freespace'; 'Expression'={ [Math]::Truncate($_.Freespace/1GB) }} $disk.Freespace | Should -Not -BeLessThan 5 } } Context -Name "Networking" -Fixture { It -Name "Has internet connectivity" -Test { (Test-NetConnection -CommonTCPPort HTTP).TcpTestSucceeded | Should -BeTrue } # Add other more specific checks below using commands like 'Test-NetConnection -ComputerName www.google.com -Port 443'. } }
Configure IIS and deploy PowerShell modules
First we install some necessary PowerShell modules. One of the modules that we will soon need is thexWebAdministration
module for configuring our IIS website. For the real project I created a PowerShell module which contains commandlets for deploying all of the individual modules this project needed. Below I will give a sample for the installation of the xWebAdministration PowerShell DSC module.function Install-xWebAdministration { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory=$true)] [string] $Version ) if (-not (Get-Module -Name "xWebAdministration" -ListAvailable -ErrorAction SilentlyContinue | Where-Object { $_.Version -eq $Version })) { $Versions = (Get-InstalledModule "xWebAdministration" -AllVersions -ErrorAction SilentlyContinue | Select-Object Version) $Versions | ForEach { Uninstall-AllModules -TargetModule "xWebAdministration" -Version ($_.Version) -Force } Install-Module -Name "xWebAdministration" -RequiredVersion $Version -Scope AllUsers -Force -AllowClobber Write-Verbose "xWebAdministration module version '$Version' installed on machine." } }This function checks if the supplied version of xWebAdministration is installed on the computer. If not it will install it, if an older version is found it will remove that version and install the requested version. The code of the Uninstall-AllModules commandlet you can find on the Uninstall Azure PowerShell page of Microsoft. With the command
Install-xWebAdministration -Version "3.0.0.0"
we install the requested version we wil use in the rest of this blog post.Next we will create a PowerShell DSC file called
General.IISConfigurationDsc.ps1
. In this file we define the desired state of our basic IIS configuration.Configuration WebConfiguration { Import-DscResource -ModuleName "PSDesiredStateConfiguration" Node $AllNodes.NodeName { WindowsFeature "Web-Server" { Ensure = "Present" Name = "Web-Server" } WindowsFeature "Web-Dyn-Compression" { Ensure = "Present" Name = "Web-Dyn-Compression" } WindowsFeature "Web-Windows-Auth" { Ensure = "Present" Name = "Web-Windows-Auth" } # Disable ASP.NET 3.5. WindowsFeature "Web-Asp-Net" { Ensure = "Absent" Name = "Web-Asp-Net" } WindowsFeature "NET-HTTP-Activation" { Ensure = "Absent" Name = "NET-HTTP-Activation" } # Enable ASP.Net 4.x. WindowsFeature "Web-Asp-Net45" { Ensure = "Present" Name = "Web-Asp-Net45" } WindowsFeature "NET-WCF-HTTP-Activation45" { Ensure = "Present" Name = "NET-WCF-HTTP-Activation45" } } } $exportPath = Join-Path $PSScriptRoot "IISConfigurationDsc" WebConfiguration -OutputPath $exportPath -ConfigurationData @{ AllNodes = @( @{ NodeName = $env:COMPUTERNAME; PSDscAllowPlainTextPassword = $true ; PSDscAllowDomainUser = $true } ) } Start-DscConfiguration -Path $exportPath -Wait -Verbose:$VerbosePreference -Force # Cleanup mof files Remove-Item -Path $exportPath -Recurse
Deployment verification
To execute these tests we create a PowerShell file with the nameGeneral.DeploymentVerification.Tests.ps1
. We are going to check that the PowerShell modules we need are available and that IIS is available and configured correctly.Describe -Name "General deployment verification checks" -Fixture { Context -Name "PowerShell" -Fixture { It -Name "Has PowerShell version 4.0 or later" -Test { $PSVersionTable.PSVersion -ge [System.Version] "4.0" | Should -BeTrue } It -Name "Has the xWebAdministration module installed" -Test { Get-Module -Name "xWebAdministration" -ListAvailable | Should -Not -BeNullOrEmpty } } Context -Name "IIS" -Fixture { It -Name "World Wide Web Publishing Service (W3SVC) is running" -Test { (Get-Service -Name W3SVC).Status | Should -Be Running } It -Name "ASP.NET 3.5 is not installed" -Test { (Get-WindowsFeature -Name "Web-Asp-Net").InstallState | Should -Be Available } It -Name "ASP.NET 4.x is installed" -Test { (Get-WindowsFeature -Name "Web-Asp-Net45").InstallState | Should -Be Installed } } }If none of these tests fail we can continue the installation of our application.
Application specific phase
Pre-deployment verification
To execute these tests we create a PowerShell file with the nameApplication.PreDeploymentVerification.Tests.ps1
. With these tests we are going to test if the DNS name is configured by the other team and that the service account we need is created and enabled.This Pester file will use the PowerShell data file we will use to create the website of the WCF API as its input source.
[CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationFile ) $ConfigurationFileFolder = Split-Path -Path (Resolve-Path $ConfigurationFile) # Import configuration information $node = (Import-PowerShellDataFile -Path $ConfigurationFile).AllNodes Describe -Name "IIS prerequisites" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { $testCase = @{} $testCase.Add("AccountName", $Site.Pool.AccountName) $testCase.Add("HostName", $Site.BindingInformation.HostName) $testCases += $testCase } It -Name "The AD account '<AccountName>' exist in the AD domain." -TestCases $testCases { param ($AccountName) Get-ADUser -Filter { UserPrincipalName -eq $AccountName } | Should -Not -BeNullOrEmpty } It -Name "The AD account '<AccountName>' is enabled." -TestCases $testCases { param ($AccountName) (Get-ADUser -Filter { UserPrincipalName -eq $AccountName }).Enabled | Should -BeTrue } It -Name "The DNS name '<HostName>' can be resolved." -TestCases $testCases { param ($HostName) Resolve-DnsName $HostName | Should -Not -BeNullOrEmpty } }If none of these tests fail the creation of the website will succeed.
Configure IIS website
I use a PowerShell data file to store the definition of the IIS website. Create a PowerShell file with the nameApplication.IISWebsite.psd1
. Below a sample of the definition of the IIS configuration of our sample WCF API.@{ AllNodes = @( @{ # Node name NodeName = "localhost" # See: https://blogs.technet.microsoft.com/ashleymcglone/2015/12/18/using-credentials-with-psdscallowplaintextpassword-and-psdscallowdomainuser-in-powershell-dsc-configuration-data/ PSDscAllowPlainTextPassword = $true PSDscAllowDomainUser = $true # Define root path for IIS Sites RootPath = "C:\inetpub\wwwroot" # Define IIS Sites Sites = @( @{ Ensure = "Present" Name = "WCFSample" State = "Started" BindingInformation = @( @{ Port = "443" Protocol = "HTTPS" HostName = "wcfsample.acme.org" CertificateSubject = "wcfsample.acme.org" CertificateStoreName = "My" SslFlags = 1 } ) Pool = @{ Name = "WCFSample" Pipeline = "Integrated" RuntimeVersion = "v4.0" State = "Started" IdentityType = "SpecificUser" AccountName = "WCFSample@acme.org" } Authentication = @{ Windows = $true Anonymous = $false } TestUris = @( @{ Uri = "https://wcfsample.acme.org/CalculatorService.svc" StatusCode = 200 } ) } ) } ) }For the actual configuration we create a PowerShell DSC file called
Application.IISWebsiteDsc.ps1
. The sample below is a stripped down version of the one I used for the project. The script is expecting that the HTTPS certificate is present on the Azure VM. Install it scripted (from Azure Key Vault for example) within the general phase.[CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationDataFileName ) Configuration WebConfigurationWebsite { # Import the module that contains the resources we're using. Import-DscResource -ModuleName "PSDesiredStateConfiguration" Import-DscResource -ModuleName "xWebAdministration" Node $AllNodes.NodeName { ForEach ($Site in $Node.Sites) { # Create the folder. File $Site.Name { Ensure = $(if ($Site.Ensure) { $Site.Ensure } else { "Present" }) Type = "Directory" Force = $true DestinationPath = "$($Node.RootPath)\$($Site.Name)" } if ($Site.Pool.AccountName) { $password = ... Retrieve password from Azure Key Vault here based on the account name ... $credential = New-Object System.Management.Automation.PSCredential -ArgumentList $Site.Pool.AccountName, $password.SecretValue } xWebAppPool $Site.Name { Ensure = $(if ($Site.Ensure) { $Site.Ensure } else { "Present" }) Name = $(if ($Site.Pool.Name) { $Site.Pool.Name } else { $Site.Name }) State = $Site.Pool.State # General managedPipelineMode = "$($Site.Pool.Pipeline)" managedRuntimeVersion = "$($Site.Pool.RuntimeVersion)" # Process Model identityType = $(if ($Site.Pool.IdentityType) { $Site.Pool.IdentityType } else { "ApplicationPoolIdentity" }) Credential = $(if ($Site.Pool.AccountName) { $credential } else { $null } ) } # Create the IIS site. xWebSite $Site.Name { Ensure = $(if ($Site.Ensure) { $Site.Ensure } else { "Present" }) Name = $Site.Name PhysicalPath = Join-Path $Node.Rootpath $Site.Name State = $Site.State # Configure the site bindings. BindingInfo = @( ForEach ($BindingInfo in $Site.BindingInformation) { if ($BindingInfo.CertificateSubject) { MSFT_xWebBindingInformation { Protocol = $BindingInfo.Protocol IPAddress = $BindingInfo.IPAddress Port = $BindingInfo.Port HostName = $BindingInfo.HostName CertificateSubject = $BindingInfo.CertificateSubject CertificateStoreName = $BindingInfo.CertificateStoreName SslFlags = $BindingInfo.SslFlags } } else { MSFT_xWebBindingInformation { Protocol = $BindingInfo.Protocol IPAddress = $BindingInfo.IPAddress Port = $BindingInfo.Port HostName = $BindingInfo.HostName } } } ) ApplicationPool = $(if ($Site.Pool.Name) { $Site.Pool.Name } else { $Site.Name }) # Set Authentication mechanisms. AuthenticationInfo = MSFT_xWebAuthenticationInformation { Anonymous = $Site.Authentication.Anonymous Windows = $Site.Authentication.Windows } } } } } $configurationDataPath = Join-Path $PSScriptRoot $ConfigurationDataFileName $exportPath = Join-Path $PSScriptRoot (Get-Item $configurationDataPath ).Basename WebConfigurationWebsite -OutputPath $exportPath -ConfigurationData $PSScriptRoot/$ConfigurationDataFileName Start-DscConfiguration -Path $exportPath -Wait -Verbose:$VerbosePreference -Force # Cleanup mof files Remove-Item -Path $exportPath -Recurse
Deployment verification
To execute these tests we create a PowerShell file with the nameApplication.IISWebsiteDsc.DeploymentVerification.Tests.ps1
. We are going to check if the application pool and the website are configured correctly.[CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationFile ) $ConfigurationFileFolder = Split-Path -Path (Resolve-Path $ConfigurationFile) # Import module(s) Import-Module (Join-Path -Path $ConfigurationFileFolder -ChildPath "Functions-WebDeploy.psm1") -Verbose:$VerbosePreference # Import configuration information $node = (Import-PowerShellDataFile -Path $ConfigurationFile).AllNodes Describe -Name "IIS" -Fixture { Context -Name "Application pool" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { $testCase = @{} $testCase.Add("Name", $Site.Pool.Name) $testCase.Add("ManagedPipelineMode", $Site.Pool.Pipeline) $testCase.Add("State", $Site.Pool.State) $testCases += $testCase } It -Name "The application pool '<name>' should exist." -TestCases $testCases { param ($Name, $State) Get-IISAppPool -Name $Name | Should -Not -BeNullOrEmpty } It -Name "The application pool '<name>' managed pipeline mode is configured correctly." -TestCases $testCases { param ($Name, $ManagedPipelineMode) (Get-IISAppPool -Name $Name).ManagedPipelineMode | Should -Be $ManagedPipelineMode } } Context -Name "Sites" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { $testCase = @{} $testCase.Add("Name", $Site.Name) $testCase.Add("PhysicalPath", Join-Path $node.Rootpath $Site.Name) $testCase.Add("AuthenticationWindows", $(if ($Site.Authentication.Windows) { $Site.Authentication.Windows } else { $false } )) $testCases += $testCase } It -Name "The site '<name>' should exist." -TestCases $testCases { param ($Name, $State) Get-IISSite -Name $Name | Should -Not -BeNullOrEmpty } It -Name "The physical path of site '<name>' is configured correctly." -TestCases $testCases { param ($Name, $PhysicalPath) (Get-IISSite -Name $Name).Applications["/"].VirtualDirectories["/"].PhysicalPath | Should -Be $PhysicalPath } It -Name "The authentication setting of site '<name>' for windows authentication is configured correctly." -TestCases $testCases { param ($Name, $AuthenticationWindows) (Get-WebConfigurationProperty -Filter system.webServer/security/authentication/windowsAuthentication -PSPath IIS:\Sites\$Name -Name Enabled).Value | Should -Be $AuthenticationWindows } } Context -Name "Bindings" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { ForEach ($Binding in $Site.BindingInformation) { $testCase = @{} $testCase.Add("Name", $Site.Name) $testCase.Add("BindingInformation", "*:" + $Binding.Port + ":" + $Binding.HostName) $testCase.Add("CertificateSubject", $Binding.CertificateSubject) $flag = switch ($Binding.SslFlags) { 0 { "None"; break} 1 { "Sni"; break} 2 { "CentralCertStore"; break} 3 { "Sni, CentralCertStore"; break} default { "Unknown"; break} } $testCase.Add("SslFlags", $flag) $testCases += $testCase } } It -Name "The binding '<bindinginformation>' exists for site '<name>'." -TestCases $testCases { param ($Name, $BindingInformation) Get-IISSiteBinding -Name $Name -BindingInformation $BindingInformation | Should -Not -BeNullOrEmpty } It -Name "The host name for binding '<bindinginformation>' of site '<name>' is configured correctly." -TestCases $testCases { param ($Name, $BindingInformation, $HostName) (Get-IISSiteBinding -Name $Name -BindingInformation $BindingInformation).Host | Should -Be $HostName } It -Name "The correct client certificate for binding '<bindinginformation>' of site '<name>' is used." -TestCases $testCases { param ($Name, $BindingInformation, $CertificateSubject) if ($CertificateSubject) { $Certifcate = Get-ClientCertificate -CommonName $CertificateSubject (Get-IISSiteBinding -Name $Name -BindingInformation $BindingInformation).CertificateHash | Should -Be $Certifcate.GetCertHash() } else { Set-ItResult -Skipped -Because "it's no HTTPS binding" } } } }If these tests of our settings are not failing we can start the deployment of our WCF API.
Install website
We can now deploy the WCF API with a web deployment ZIP file in our Azure DevOps CD pipeline.Deployment verification
To execute these tests we create a PowerShell file with the nameApplication.DeploymentVerification.Tests.ps1
. We are going to check if the endpoint of our WCF API is responding.[CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ConfigurationFile ) # Import configuration information $node = (Import-PowerShellDataFile -Path $ConfigurationFile).AllNodes Describe -Name "IIS" -Fixture { Context -Name "Ports" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { ForEach ($Binding in $Site.BindingInformation) { $testCase = @{} $testCase.Add("Protocol", $Binding.Protocol) $testCase.Add("Port", $Binding.Port) $testCase.Add("HostName", $(if ($Binding.HostName) { $Binding.HostName } else { "localhost" })) $testCases += $testCase } } if ($testCases.Count -gt 0) { It -Name "Given the hostname '<HostName>' the port <Port> (<Protocol>) should be active" -TestCases $testCases { param ($HostName, $Protocol, $Port) (Test-NetConnection -ComputerName $HostName -Port $Port).TcpTestSucceeded | Should -BeTrue } } } Context -Name "Uris" -Fixture { $testCases = @() ForEach ($Site in $node.Sites) { ForEach ($TestUri in $Site.TestUris) { $testCase = @{} $testCase.Add("Uri", $TestUri.Uri) $testCase.Add("StatusCode", $TestUri.StatusCode) switch ($TestUri.StatusCode) { 200 { $description = "OK"; break} 401 { $description = "Unauthorized"; break} 403 { $description = "Forbidden"; break} 404 { $description = "Not Found"; break} 500 { $description = "Internal Server Error"; break} default { $description = ""; break} } $testCase.Add("StatusCodeDescription", $description) $testCases += $testCase } } It -Name "Given the uri '<Uri>' the returned status code should be <StatusCode> (<StatusCodeDescription>)" -TestCases $testCases { param ($Uri, $StatusCode, $StatusCodeDescription) try { $response = Invoke-WebRequest -Uri $Uri -Method Get -MaximumRedirection 1 -ErrorAction Stop -UseDefaultCredentials -TimeoutSec 120 -UseBasicParsing # This will only execute if the Invoke-WebRequest is successful. $actualStatusCode = $response.StatusCode } catch { $actualStatusCode = $_.Exception.Response.StatusCode.value__ if ($actualStatusCode -eq $null) { $actualStatusCode = $_.Exception } } $actualStatusCode | Should -Be $StatusCode } } }If our endpoint is responding we know that the installation of the WCF API is done succesfully.
CD pipeline
All these individual PowerShell (DSC) scripts can be executed from an Azure DevOps pipeline by using the (Azure) PowerShell tasks. Publishing from the Pester test results can be done by uploading the report which is generated by commands likeInvoke-Pester -OutputFile "./TestResults.xml" -OutputFormat NUnitXml
with the Publish Test Results task.
Comments
Post a Comment