‘Sup PSHomies,
I’m like a dog with a bone… 😛
2016 was all about operation validation for me. I did a series on Active Directory snapshot, report and validation that was well received by the community! Classes will definitely make the user experience more pleasant! I decided to refactor the code to a class 😉 Got a lot of ground to cover so let’s dive in!
Here’s a screenshot of the ADInfrastructure (was what popped in my mind at the time) class properties and methods:
The focus will be on Active Directory’s forest, domain, sites, sitelinks, subnets and domaincontrollers.
These are the methods I’ve worked out so far (work in progress):
GetCurrentConfig() will populate the necessary properties using the right activedirectory cmdlets
ImportADSnapshot() will import a saved XML file to ADSnapshot property.
ExportADSnapshot() saves the class object as a XML file. This will generate a new XML file using the current $exportDate value
RunValidation() deserves it own section… 😉
RunValidation()
This is where the the operation validation will take place. This was a bit of a challenge getting the method right, but I think it worked out just fine… I should explain…
In my first attempt I ran the validation directly from the method. You can invoke the Describe block just like a function. That wasn’t the challenge, have a look at the It block
The test is hard coded to use $this as source and $this.ADSnapshot as target. I blogged about some possible validation gotchas a while back. To remedy this, I decided to use a scriptblock. You can also provide parameters to a scriptblock . In this case I provided ($Source, $Target) as parameters. This will make interchanging input easier of which I’ll explain the advantages later on.
Ok so the scriptblock was a good idea. One of the things I wanted to do was save the validation results. That’s where I ran into something interesting. The input has to be a *.tests.ps1 file(s). I tried using the scriptblock as input but that didn’t work. I visited the github page to see if scriptblock is supported as a feature, it isn’t. In order to save the results I would first have to save the test to a file. The scriptblock made that part a lot easier. As a workaround, this isn’t an issue.The file is generate every time RunValidation is executed, an inconvenience at most. A scriptblock feature would make for a cleaner approach.
Quick side step: There’s a poll on twitter for anyone interested in casting a vote 🙂 Only 5 more days left…
The test results are saved in ValidationResults. That’s RunValidation in a nutshell.
It’s quite a bit of code, so I’ll post that at the end of the blog. Here’s what you can expect if you try it out. First up, a simple verification of the current configuration against a saved snapshot
#region Verify Current Configuration against a snapshot $snapShotDate = '12012017' $ADInfra = [ADInfrastructure]::New() $ADInfra.GetCurrentConfig() $ADInfra.snapshotDate = $snapShotDate $ADInfra.ExportADSnapshot() $ADInfra.ImportADSnapshot() $ADInfra.ADSnapshot $ADInfra.RunValidation($ADInfra,$ADInfra.ADSnapshot,@('Forest','Domain')) $ADInfra.RunValidation($ADInfra.ADSnapshot,$ADInfra,@('Forest','Domain')) $ADInfra.ActionHistory | Select-Object -Property TimeGenerated,Tags,MessageData #endregion
Before you get startetd, you need to instantiate the class. GetCurrentConfig() will save the information to the properties. ExportADSnapshot() will create a ADSnapshot-($exportDate).xml file. ImportADSnapshot will import any existing snapshot file of a given $snapshotDate formatted as ‘ddMMyyyy’.
Because I’m verifying the current configuration with a snapshot without any changes all the tests will pass.
No surprises.
For the next example, I wanted to validate against a manual configuration. This is where the scriptblock really made a difference. I added a non-existent DC to $ADVerifyConfig for testing purposes.
#region verify a manual Configuration a against a snapshot | |
$primaryDC = 'DC-DSC-01.pshirwin.local' | |
$ADVerifyConfig = @{ | |
Forest = @{ | |
Name = 'pshirwin.local' | |
ForestMode = 'Windows2012R2Forest' | |
DomainNamingMaster = $primaryDC | |
SchemaMaster = $primaryDC | |
GlobalCatalogs = @( | |
$primaryDC | |
) | |
} | |
Domain = @{ | |
DistinguishedName = 'DC=pshirwin,DC=local' | |
InfrastructureMaster = $primaryDC | |
PDCEmulator = $primaryDC | |
RIDMaster = $primaryDC | |
} | |
Sites = @( | |
[PSCustomObject]@{ | |
Name = 'Default-First-Site-Name' | |
} | |
[PSCustomObject]@{ | |
Name = 'Branch01' | |
} | |
) | |
SiteLinks = @( | |
[PSCustomObject]@{ | |
Name = 'DEFAULTIPSITELINK' | |
Cost = 100 | |
ReplicationFrequencyInMinutes = 180 | |
} | |
) | |
Subnets = @( | |
[PSCustomObject]@{ | |
Name = '192.168.0.0/24' | |
Site = 'CN=Branch01,CN=Sites,CN=Configuration,DC=pshirwin,DC=local' | |
Location = $null | |
} | |
) | |
DomainControllers = @( | |
[PSCustomObject]@{ | |
Name = 'DC-DSC-01' | |
Enabled = $true | |
IsGlobalCatalog = $true | |
IsReadOnly = $false | |
IPv4Address = '10.15.75.250' | |
} | |
[PSCustomObject]@{ | |
Name = 'DC-DSC-02' | |
Enabled = $true | |
IsGlobalCatalog = $true | |
IsReadOnly = $false | |
IPv4Address = '10.15.75.251' | |
} | |
) | |
} | |
$ADVerifyInfra = [ADInfrastructure]::New( | |
$ADVerifyConfig.Forest, | |
$ADVerifyConfig.Domain, | |
$ADVerifyConfig.Sites, | |
$ADVerifyConfig.Subnets, | |
$ADVerifyConfig.SiteLinks, | |
$ADVerifyConfig.DomainControllers | |
) | |
$ADVerifyInfra.snapshotDate = $snapShotDate | |
$ADVerifyInfra.ImportADSnapshot() | |
$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('Forest','Domain')) | |
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('Forest','Domain')) | |
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('DomainControllers')) | |
$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('DomainControllers')) | |
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@()) | |
$ADVerifyInfra.ActionHistory | Select-Object -Property TimeGenerated,Tags,MessageData | |
#endregion |
The class is instantiated differently this time. The values are added externally. If you run GetCurrentConfig() at any point, it will rewrite the default values. Here are the results of the snapshot vs manual source first.
$ADVerifyInfra.RunValidation($ADVerifyInfra.ADSnapshot,$ADVerifyInfra,@('DomainControllers'))
The snapshot only has one DomainController, we never get to the second DomainController. Now if we switch parameters from positions…
$ADVerifyInfra.RunValidation($ADVerifyInfra,$ADVerifyInfra.ADSnapshot,@('DomainControllers'))
Ah! DC-DSC-02 doesn’t exist in the snapshot so it will fail! There are always two sides to consider. RunValidation() makes it easier to test and verify both sides…
Bonus round
ActionHistory
I recently discovered the Information stream in PowerShell v5. I decided to make use of Write-Information to log activities as I go along. This makes for easier troubleshooting of actions and/or sequences of methods being executed, couldn’t hurt… 😉
ValidationResults
Saving the validation test result enables you to process the results in different ways.
For starters you can use Format-Pester by Erwan Quélin to generate documentation of the results. Now because it’s an object you can just as easily run a query:
$ADVerifyInfra.validationResults.Results.TestResult.Where{$_.Passed -eq $false}
You can even send a high-level overview to Slack (It’s on my to-do list).
Whew! I think I’ve covered all the essentials… Ok as promised the code:
Class ADInfrastructure{ | |
[PSObject]$Forest | |
[PSObject]$Domain | |
[PSObject]$Sites | |
[PSObject]$Subnets | |
[PSObject]$Sitelinks | |
[PSObject]$DomainControllers | |
[String]$snapshotDate | |
[String]$exportFolder='C:\scripts\export\dsa' | |
[PSObject]$ADSnapshot | |
[PSObject[]]$validationResults | |
[PSObject[]]$ActionHistory | |
#Default Constructor | |
ADInfrastructure(){} | |
#Constructor | |
ADInfrastructure($frs,$dom,$sit,$sub,$stl,$dcs){ | |
$this.Forest = $frs | |
$this.Domain = $dom | |
$this.Sites = $sit | |
$this.Sitelinks = $stl | |
$this.Subnets = $sub | |
$this.DomainControllers = $dcs | |
} | |
GetCurrentConfig(){ | |
$MessageData = "Get current Active Directory configuration" | |
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','CurrentConfig','AD' | Select-Object * | |
$this.Forest = $(Get-ADForest) | |
$this.Domain = $(Get-ADDomain) | |
$this.DomainControllers = $(Get-ADDomainController -Filter *) | |
$this.Sites = $(Get-ADReplicationSite -Filter *) | |
$this.Subnets = $(Get-ADReplicationSubnet -Filter *) | |
$this.Sitelinks = $(Get-ADReplicationSiteLink -Filter *) | |
} | |
ImportADSnapshot(){ | |
if(Test-Path "$($this.exportFolder)\ADSnapshot-$($This.snapshotDate).xml"){ | |
$this.ADSnapshot = Import-Clixml "$($this.exportFolder)\ADSnapshot-$($This.snapshotDate).xml" | |
$MessageData = "Imported ADSnapshot from $($this.SnapshotDate)" | |
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','ADSnapshot','Found' | Select-Object * | |
} | |
Else{ | |
$MessageData = "ADSnapshot from $($this.SnapshotDate) not found" | |
Write-Warning -Message $MessageData | |
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Get','ADSnapshot','Missing' | Select-Object * | |
} | |
} | |
ExportADSnapshot(){ | |
$MessageData = "Saving ADSnapshot" | |
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Save','ADSnapshot' | Select-Object * | |
$exportDate = Get-Date -Format ddMMyyyy | |
$this | Export-Clixml "$($this.exportFolder)\ADSnapshot-$($exportDate).xml" -Encoding UTF8 | |
} | |
RunValidation($src,$tgt,$tag){ | |
#Something with Tags | |
$MessageData = "Validating AD Configuration against saved snapshot from $($this.SnapShotDate)" | |
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags 'Validation','ADSnapshot','CurrentConfig','AD' | Select-Object * | |
$sbValidation = { | |
Param($Source,$Target) | |
Describe 'AD Forest configuration operational readiness' -Tags Forest { | |
Context 'Verifying Forest Configuration'{ | |
it "Forest Name $($Source.Forest.Name)" { | |
$Source.Forest.Name | | |
Should be $Target.Forest.Name | |
} | |
it "Forest Mode $($Source.Forest.ForestMode)" { | |
$Source.Forest.ForestMode | | |
Should be $Target.Forest.ForestMode | |
} | |
it "$($Source.Forest.DomainNamingMaster) is DomainNamingMaster" { | |
$Source.Forest.DomainNamingMaster| | |
Should be $Target.Forest.DomainNamingMaster | |
} | |
it "$($Source.Forest.DomainNamingMaster) is SchemaMaster"{ | |
$Source.Forest.SchemaMaster | | |
Should be $Target.Forest.SchemaMaster | |
} | |
} | |
} | |
Describe 'AD GlobalCatalog configuration operational readiness' -Tags GlobalCatalog { | |
Context 'Verifying GlobalCatalogs'{ | |
$Source.Forest.GlobalCatalogs | | |
ForEach-Object{ | |
it "Server $($_) is a GlobalCatalog"{ | |
$Target.Forest.GlobalCatalogs.Contains($_) | | |
Should be $true | |
} | |
} | |
} | |
} | |
Describe 'AD Domain configuration operational readiness' -Tags Domain{ | |
Context 'Verifying Domain Configuration'{ | |
it "Domain DN is $($Source.Domain.DistinguishedName)" { | |
$Source.Domain.DistinguishedName | | |
Should be $Target.Domain.DistinguishedName | |
} | |
it "$($Source.Domain.InfrastructureMaster) is InfrastructureMaster"{ | |
$Source.Domain.InfrastructureMaster | | |
Should be $Target.Domain.InfrastructureMaster | |
} | |
it "$($Source.Domain.PDCEmulator) is PDCEmulator"{ | |
$Source.Domain.PDCEmulator | | |
Should be $Target.Domain.PDCEmulator | |
} | |
it "$($Source.Domain.RIDMaster) is RIDMaster"{ | |
$Source.Domain.RIDMaster | | |
Should be $Target.Domain.RIDMaster | |
} | |
} | |
} | |
Describe 'AD DomainControllers configuration operational readiness' -Tags DomainControllers { | |
$lookupDC = $Target.DomainControllers | Group-Object -AsHashTable -AsString -Property Name | |
ForEach($dc in $Source.DomainControllers){ | |
Context "Verifying DC $($dc.Name) Configuration"{ | |
it "Is enabled " { | |
$dc.Enabled | Should be $lookupDC.$($dc.Name).Enabled | |
} | |
it "Is GC " { | |
$dc.IsGlobalCatalog | Should be $lookupDC.$($dc.Name).IsGlobalCatalog | |
} | |
it "ReadOnly is $($dc.IsReadOnly) " { | |
$dc.IsReadOnly| Should be $lookupDC.$($dc.Name).IsReadOnly | |
} | |
it "IPv4Address is $($dc.IPv4Address)" { | |
$dc.IPv4Address | Should be $lookupDC.$($dc.Name).IPv4Address | |
} | |
} | |
} | |
} | |
Describe 'AD Sites operational readiness' -Tags Sites { | |
Context 'Verifying Sites'{ | |
$Source.Sites | | |
ForEach-Object{ | |
it "Site $($_.Name)"{ | |
$Target.Sites.Name.Contains($_.Name) | | |
Should be $true | |
} | |
} | |
} | |
} | |
Describe 'AD Subnets operational readiness' -Tags Subnets{ | |
$lookupSubnets = $Target.SubNets | Group-Object -AsHashTable -AsString -Property Name | |
ForEach($subnet in $Source.Subnets){ | |
Context "Verifying Subnet $($subnet.Name)"{ | |
it "Subnet name is $($subnet.Name)"{ | |
$subnet.Name | Should be $lookupSubnets.$($subnet.Name).Name | |
} | |
it "Subnet location is $($subnet.Location)"{ | |
$subnet.Location | Should be $lookupSubnets.$($subnet.Name).Location | |
} | |
it "Subnet associated site is $($subnet.Site)"{ | |
$subnet.Site | Should be $lookupSubnets.$($subnet.Name).Site | |
} | |
} | |
} | |
} | |
Describe 'AD Sitelinks operational readiness' -Tags SiteLinks { | |
$lookupSiteLinks = $Target.Sitelinks | Group-Object -AsHashTable -AsString -Property Name | |
ForEach($sitelink in $Source.Sitelinks){ | |
Context "Verifying Sitelink $($sitelink.Name)"{ | |
it "Sitelink name is $($sitelink.Name)"{ | |
$sitelink.Name | Should be $lookupSiteLinks.$($sitelink.Name).Name | |
} | |
it "Sitelink cost is $($sitelink.Cost)"{ | |
$sitelink.Cost | Should be $lookupSiteLinks.$($sitelink.Name).Cost | |
} | |
it "Sitelink replication frequency (min) is $($sitelink.ReplicationFrequencyInMinutes)"{ | |
$sitelink.ReplicationFrequencyInMinutes| Should be $lookupSiteLinks.$($sitelink.Name).ReplicationFrequencyInMinutes | |
} | |
} | |
} | |
} | |
} | |
$pesterFile = "$($this.exportFolder)\ADInfra.tests.ps1" | |
$sbValidation.ToString() | out-file -FilePath $pesterFile -Force | |
$testADInfra = @( | |
@{ | |
Path = $pesterFile | |
Parameters = @{ | |
Source = $src | |
Target = $tgt | |
} | |
} | |
) | |
$this.ValidationResults += [PSCustomObject]@{ | |
ValidationDate = $(Get-Date) | |
Results = Invoke-Pester -Path $testADInfra -PassThru -Tag $tag | |
} | |
} | |
} |
Classes will definitely enhance your end-user’s experience…
Hope it’s worth something to you…
Ttyl,
Urv