Category Archives: Miscellaneous

Taking ADUser validation a step further…

β€˜Sup PSHomies,

This is me every single time I sit down to use Pester…

PesterOVF.PNG

Hehe…

While I do enjoy using Pester for operational validation, what do you do with the ones that fail? Most of the time you’re the one doing the validation in the chain of process and everything goes right! Because you’re that good… πŸ˜‰ Hehe…Β  I’ve been recently asked to update some users where some attributes didn’t get populated during a migration… Are you thinking what I’m thinking? πŸ˜‰

The first thing I did was export the attributes they needed, easy enough. Next was to change the necessary attributes to the correct value. The ones that differ will fail (Of course they will Urv, not a total retard here… get on with it… )

Here’s where having proper descriptions can go a long way.

Breadcrumbing

This is what a Test ErrorRecord looks like when you capture the results:PesterError

At first I thought of using regex on the ErrorRecord. Some of theΒ  attributes aren’t set which gave me some issues, so I decided to breadcrumb the name. First do a split on the colon ‘:’ grab the last part and do a split again using ‘/’ to get the attribute name and value. Don’t forget to trim() πŸ™‚

There were some other Attributes that weren’t part of the default parameter set of the Set-ADUser cmdlet. To change those attributes you need to use the DisplayName and the Replace Operator. For the attributes “not set” that need to be cleared, use the Clear operator. Just don’t use both the parameter and the DisplayName! I had EmailAddress and mail in the CSV file, one passed and the other failed… I got rid of mail…

Ok here’s the code to get things done:

First get failed Pester tests.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Validate AD User attributes
#>
[CmdletBinding(SupportsShouldProcess = $True)]
Param(
$csvFile = 'd:\scripts\ps1\source\csv\SetUser.csv'
)
#region Import Csv file
$csvUsers = Import-Csv -Path $csvFile -Delimiter "`t" -Encoding UTF8
$userProperties = ($csvUsers | Get-Member -MemberType NoteProperty).Name |
Where-Object {$_ -ne 'OU'}
#endregion
#region Main
$csvUsers |
Foreach-Object {
$Expected = $_
Describe "Processing User: $($Expected.SamAccountName)" {
Context "Verifying AD User properties for $($Expected.DisplayName)" {
#Get AD user properties
$Actual = Get-ADUser -Identity $Expected.SamAccountName -Properties $userProperties
ForEach( $property in $userProperties){
if (([string]::isNullOrEmpty($Expected.$property))) {
$Expected.$property = $null
$lableExpected = '<not set>'
}
else{
$lableExpected = $Expected.$property
}
it "Verifying user property: $($property) / $($lableExpected)"{
$Actual.$property | Should be $Expected.$property
}
}
}
}
}
#endregion
#Run PesterTest and save results
$resultsTest = Invoke-Pester D:\scripts\ps1\dsa\ADUser.Properties.Tests.ps1 -PassThru
#Get All failed tests
$failedTests = $resultsTest.TestResult.where{$_.Passed -eq $false}
$failedTests |
ForEach-Object{
$result = $_.Name.Split(':')[-1]
$arrResult = $result.Split('/')
[PSCustomObject]@{
SamAccountName = ($_.Describe.split(':').Trim())[-1]
Property = $arrResult[0].Trim()
Expected = $arrResult[1].Trim()
}
} -OutVariable failedObjects
#Export Failed objects
$failedObjects |
Export-Csv -Path D:\Scripts\ps1\source\csv\FailedTests.csv -Delimiter "`t" -NoTypeInformation -Encoding UTF8

This will give me a csv file with the following Columns, SamAccountName, Property & Expected (Value).

The set failed Pester tests will either set or clear the attribute depending on it’s value. If it’s $null it will be cleared.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Set ADUser attributes of failed tests
#>
[CmdletBinding(SupportsShouldProcess = $True)]
Param(
$csvFile = 'D:\Scripts\ps1\source\csv\FailedTests.csv'
)
$FailedTests = Import-Csv -Path $csvFile -Delimiter "`t" -Encoding UTF8
#Get Set-ADUser Parameters
$setADUserParameters = (Get-Command Set-ADUser).ParameterSets.Parameters.Where{ $_.IsDynamic -eq $true} | Select-Object -ExpandProperty Name
#Get User Property
$FailedTests |
Foreach-object{
#Set Expected to null if <not set>
$Expected = @{$true = $null; $false = $_.Expected}[$_.Expected -eq '<not set>']
If ($setADUserParameters.Contains($_.Property)){
$paramSetADUser = @{
Identity = $_.SamAccountName
$_.Property = $Expected
}
Set-ADUser @paramSetADUser
}
else{
if($Expected){
Set-ADUser -Identity $($_.SamAccountName) -Replace @{$_.Property = $Expected}
}
else{
Set-ADUser -Identity $($_.SamAccountName) -Clear $($_.Property)
}
}
}

Here are some screenshots to give you an idea what to expect.

Ideally you’d only have a few failed tests. I wouldn’t use this to reset entire User attributes. Moving and/or renaming an object isn’t supported… yet! πŸ˜‰

So there you have it, taking failed Pester tests values and setting them accordingly!

Hope it’s worth something to you,

Ttyl,

Urv

 

 

Microsoft Teams MessageCards

β€˜Sup PSHomies,

I was excited to give the MS Teams module a go! I was secretly hoping for a cmdlet to send messages to channels. Unfortunately no such luck… yet! If push comes to shove you can always take the Graph route! πŸ˜‰

I posted the blog on social media and that generated some interesting ideas. Emotions and opinions varied quite a bit.Β Prateek raised an interesting question on whether slack would become obsolete in the near future. MS Teams is gaining momentum and it integrates nicely in Microsoft’s eco system, so why wouldn’t you use it? It doesn’t have to be black or white, use whatever adds value to your business I’d say!

This is where social media can be a treasure trove at times, got an interesting tip from Brett MillerΒ on posting messages to channels using webhooks! Ah! the missing cmdlet I was hoping for! “So basically it’s just an Invoke-RestMethod?” Cool! I gave it a spin:

New-TeamMessage

Nice! Hmmm… Webhooks eh? Sounds familiar… Where did I read about that? Ah yes! Stefan Stranger did a blog about that. To be honest, I did read his blog, but at that time I didn’t have access to Teams… πŸ˜‰ Just follow his instructions and you’re golden!

Here’s when the fun really started! Come to find out that there’s a lot more you can post using the message card reference! There’s even a card playground where you can try out your message cards. It’s all JSON, no problem, we’ve got cmdlets for that.

There’s a whole design guide on Messagecards. Best of all it supports MarkDown format! I tried a sending a table but that didn’t work as planned.

Just imagine the automation possibilities here… I understand now why ChatOps is really taking off…

My first attempt at DSL

Looking at the MessageCard format I thought: “This would be interesting as a DSL (Domain Specific Language) implementation… Hmmm…” I’ve been meaning to look into DSL after reading Kevin Marquette’s blog series on the subject. Seems now is a good time as any to start! This is what I came up with so far… It’s a work in progress ( sharing is caring) , but it works!

#region MessageCard function helpers
function MessageCard {
param([scriptblock]$ScriptBlock)
$newScript = "@{$($ScriptBlock.ToString())}"
$newScriptBlock = [scriptblock]::Create($newScript)
& $newScriptBlock
}
function section {
param([scriptblock]$ScriptBlock)
$newScript = "[Ordered]@{$($ScriptBlock.ToString())}"
$newScriptBlock = [scriptblock]::Create($newScript)
& $newScriptBlock
}
function fact {
param([scriptblock]$ScriptBlock)
$Invoked = $ScriptBlock.Invoke()
$Invoked.Keys |
ForEach-Object {
@{
Name = $_
Value = $Invoked.$_
}
}
}
#endregion
#region Main
$NewMessage = MessageCard {
summary = 'This is the summary property'
title = "This is the card's title property"
text = 'This is the cards text property.'
sections = @(
section {
activityTitle = 'Activity Title'
activitySubtitle = 'Activity **Sub**Title'
images = @(
@{
image = "http://connectorsdemo.azurewebsites.net/images/WIN12_Scene_01.jpg&quot;
title = "This is the image alternate text Pic 01"
}
@{
image = "http://connectorsdemo.azurewebsites.net/images/WIN12_Anthony_02.jpg&quot;
title = "This is the image alternate text Pic 02"
}
)
PotentialAction = @(
@{
'@type' = 'Actioncard'
Name = 'Comment'
Inputs = @(
@{
'@type' = 'TextInput'
Id = 'Comment'
isMultiLine = $true
Title = 'Input title property'
}
)
Actions = @(
@{
'@type' = 'HttpPOST'
Name = 'Save'
Target = 'http://...'
}
)
}
@{
'@type' = 'Actioncard'
Name = 'Due Date'
Inputs = @(
@{
'@type' = 'DateInput'
Id = 'dueDate'
Title = 'Input due date'
}
)
Actions = @(
@{
'@type' = 'HttpPOST'
Name = 'Save'
Target = 'http://...'
}
)
}
)
}
section {
title = 'Details:'
facts = fact {
@{
GivenName = 'Irwin'
SurName = 'Strachan'
}
}
}
section {
activityTitle = 'Default MD Support'
activitySubtitle = 'Activity **Subtitle**'
facts = fact {
@{
Email = 'Irwin@DosSantos.ca'
WebSite = '[pshirwins](https://pshirwin.wordpress.com)'
}
}
}
)
}
$restparams = @{
Uri = "Your WebHook Uri"
Method = 'POST'
Body = $($NewMessage | ConvertTo-Json -Depth 6)
ContentType = 'application/JSON'
}
Invoke-RestMethod @restparams
#endregion

And here’s the result:

New-MessageCard

I used the full card format as reference.Β  I realize that others in the community could easily knock this out the park. Maybe we can make this a community project? I would love to see what the possibilities are here…

Turns out not having a Send-TeamMessage wasn’t as painful as I thought it would be. Many ways to Rome…

Shout out to Brett & Stefan! Gotta love the PowerShell community!!!

Hope it’s worth something to you,

Ttyl,

Urv

PSConfAsia 2017

I recently had the pleasure of presenting at PSConf.asiaΒ in Singapore. This was my very first conference as a speaker! As a first time speaker a great way to get started.

Jaap Brasser approached me at PSConf.EuΒ about doing a session (or two) at PSConf.asia. At first I was hesitant…Β  me, a speaker… Hmmm… I need to think about… Let me get back to you on that… Jaap was clever enough to appease my ego, now how could I say no?

I decided to get out of my comfort zone and said yes, much to Jaap’s delight! Alright! Another PSConf, this time in Singapore, how cool is that!

So after I said yes I did some googling… Let’s see Singapore… Wait what? 12 Hours??? Oh boy… Flying isn’t really my favorite passtime…

ba plane

Hehe… Fortunately I had night flights so…

Singapore has my kinda weather, tropical! I came well prepared. Poor Rob Sewell was melting in Singapore. Transportation is awesome in Singapore. From and to the conference was about 1 SGD.

The organizers did an awesome job! Great location! I met up with old acquaintances and made some new ones!

Finally got to interact with David das Neves. I wanted to attend his session but I opted for Steve Hosking session on Graph. In case you missed it Graph is going to interconnect everything. Definitely have a look at it. I liked David’s approach on having a Class for different types of log format. So I asked him “Hey noΒ  love for Robocopy?” Next thing I knew it was in it! You can read all about here.Β Raimund AndrΓ©eΒ was also there. I told him how NTFSSecurity module saved me on quite a few occasions. He has some serious plans for the module on PowerShell core, so stay tuned!

Rick Taylor won a book on Windows PowerShell for Developers by Doug Finke! Rick was like: “Wait I know this guy!” So I said we need to take a pic featuring Doug’s book! :-). Max Trinidad is a great guy! Been in IT for some time and has loads and loads of skills and it definitely shows!

The keynote is always the highlight of the Conference. Angel Calvo delivered the keynote like a boss! How can I do the key note justice? It’s all about Digital Transformation.

Digital Transformation

Digital tranformation

This is something we all need to consider. It’s not a question of if but when and how. If you’re content with where you’re at right now then you will be obsolete in the near future. What I really appreciated, was the fact that Angel acknowledged that this change can be overwhelming, but it isn’t an all-or-nothing situation. Start where you can, with what you can and take it from there. This digital transformation may take you places you never thought possible. I spoke to Amanda DeblerΒ , her transformation has her working more with kurbernetes, how cool is that?

Azure is about adding value to your business

If you’re still under the impression that Azure is just for offloading servers to the cloud, you’re sorely mistaken. We got some excellent demos by Micheal Greene andΒ Ravi Kiran Chintalapudi on Azure Management Services. Azure is about delivering value to your business. And what has value? Data! Lots and lots of it! Having your servers, application, services just to name a few in Azure, will give you the chance to transform metric and monitoring data into added value for your business. As an OPS guy I always thought of monitoring as a necessary evil. Ravi showed us how to manipulate Azure data that is at your deposition and make it valuable! That one server that isn’t patched adequately or that server that’s missing a configuration, the data is there, it’s up to you to turn that into valuable information.

ChatOps

Michael Greene’s demo on ChatOps blew my mind!

ChatOps

I’ve seen chatbots in action but this will take things to the next level! Here are a few links Michael shared to give you an idea where they’re heading:

PowerShell and the future

When Jeffrey Snover said that PowerShell is finished, I died a little inside. Fortunately I was better prepared this time around when Angel said the same πŸ™‚ . PowerShell has gained critical mass. Moving forward, PowerShell Core is where all attention will be placed. Steve Lee had some interesting charts and number he pulled from GitHub (It’s all about data). Mark Kraus is the nr 1 contributor on GitHub! Michael Greene’s go-to PowerShell version is Core! Joey even had a demo on cross-platform administration starting a session on a Windows and Linux. Unfortunately Joey forget about the Timezone difference, so his servers were down.

Keynote conclusion

Conclusion

Pretty much sums it up, no need to reiterate… πŸ™‚

Networking event

Conferences are a great wayΒ  to interact with the Microsoft Team and delegates. It was awesome meeting Michael Greene and Steve lee in the flesh! I decided on a different approach when it came to mingling. I wanted to connect on a personal level. I can always reach out when I’m working on something. So here I am sitting in an Irish pub in Singapore knocking over a few with Michael, Amanda, Jason and Max! I asked MichaelΒ  what his thoughts were on running Windows for Workgroup 3.11 in Azure… Hypothetically that is :-P. What followed was a lively discussion! Jason had some real great stories to tell! Max told us about his first job in IT back in ’78 when debugging was literally ‘debugging’. Amanda was like: “I wasn’t even born yet…”

Benjamin had Joey finish his demo in the pub. Joey’s demo didn’t go through because of timezone difference (His Azure Servers shutdown automatically after-hours), but Benjamin wasn’t haven’t that. Joey complied and did the demo in the pub, with all attending cheering him on like proper hooligans! For outsiders we may as well have been watching a football game.

I even have my own fan club!

Suresh

Suresh follows my blog and was pretty excited to meet me! Lil’ ol’ me! Suresh made the 12 hour flight worth it! I enjoy meeting my PowerShell heroes in the flesh, I just never considered myself special… Thanks for the support!!! Appreciate it!

Slides & Code

Before I forget here’s the GitHubΒ link to all my presentation slides and code. So Fabian Dibot was at my Infrastructure session and I almost didn’t recognize him! Both him and Mathias JessenΒ had fun sending twitter messages during my session πŸ˜› . Tip: make sure you disable browser notification before starting you presentation… I know, a rookie mistake, you caught me guys… πŸ˜›

Bartek Bielawski was at my Dependencies session! That was a big honor for me! Bartek is the guy you go to when you’re really stuck! While doing my session I was trying to get a read on Bartek’s facial expression. At some point I could see him scanning the code… was that a smile? Ah! no comment whew! I couldn’t wait to ask him for feedback. Getting a compliment from Bartek on presentation preparationΒ  & code definitely gave me a confidence boost! IMHO I enjoyed presenting this session the most. I did this as a flash session at one of our DuPSUG gatherings. I took a different route using AzureAD instead of plain ol’ AD and came across some fun stuff. AD and AzureAD have different parametersets πŸ˜‰

I’m really glad I did the PSConf.asia sessions. Milton GohΒ was hinting on a surprise next year? πŸ™‚ If I’m fortunate to be asked next year, I won’t hesitate!

Thanks for a wonderful experience PSConf.asia! I can’t wait to see where your Digital Transformation takes you!

PSConfAsia

Ttyl,

Urv

 

 

ACLs Folder class

‘Sup PSHomies,

I recently had to make a quick Backup & Restore of ACLs three levels deep. Nothing fancy, just two functions, but that got me thinking…

Why not make a class of this?

And so I did! Here’s the framework for the class:

classACLFolders

Here’s a list of the methods:

  • Backup. Backup SDDL from the property $Folder of the class
  • Restore. Restore SDDL to the property $Folder of the class
  • Clone. Clone will take a target Folder and clone the SDDL of $Folder to it
  • ConvertSDDLToAccess. This will enumerate what the SDDL stands for

Default Constructor

classACLDefConstructor

The default constructor will evaluate the folder used to instantiate the object. If successful, the SDDL, Owner and Access is retrieved using  the Backup() method. All actions are registered for future reference.

Instantiating is pretty straightforward:

instantiateClass

Backup()

classACLBackup

This will retrieve the SDDL for the folder and enumerate the Access.

Restore()

classACLRestore

Restore is a bit tricky. For one you need to make sure it isn’t empty. Set-Acl has no problem setting an empty SDDL, blowing everything open (worst case scenario, and that’s why you should test this in a lab first!). The other challenge is having a valid SDDL string. You can change the SDDL string if you want to something gibberish, hence the typecast as a precaution.

Clone()

classACLDefClone

The same goes for cloning. In this case we need to test the target path. Alternatively, you could  also change the Folder to a new path… It works, you’d just have misleading ActionHistory entries… I wonder if there’s a way to make this read-only,  just thinking out loud here… (note to self)

ConvertSDDLToAccess()

This is just a lil’ something extra. Like I said in a previous blog SDDL really gives more information. For one, the SID will let you know which domain this object belongs to. One thing I ran into with ReACL is that SIDHistory will resolve to the current NTAccount. This had me puzzled for a while until I saw that the SIDs in SDDL where different.

Here’s what the ouput looks like:

convertSDDLToAccess

Now for those of you that are wondering just what is this AccessMask, wonder no more! πŸ™‚

Remember the RoboCopy ExitCodes blog I did a while back? Well it’s the same principal πŸ™‚ This is why classes & everything related should be on your radar…

enumAccessmask

Here’s how this works…

Say I wanted to evaluate the AccessMask of a SDDL entry

AccessMaskEnum
classACLConvertSDDLToAccess

Here I have the SID & the NTAccount. This is the builtin administrators account but it also works for Domain accounts.

There’s a private function that will translate the SID accordingly.

To see what the account can actually do we can enumerate the AccessMask

AccessMaskEnum1
AdvancedPermissions

This is what we’d see using the advanced Security GUI tab of a folder.

Not bad… Not bad at all…

I can’t state this enough, SDDL is NOT to be be trifled with. Yes you need admin rights and please try this is a testlab first.  SDDL is very potent, if used with caution, it could do a whole bit of good!

So finally, here’s the code…

[Flags()] Enum AccessMask{
Read = 1
Write = 2
Append = 4
ReadExtendedAttributes = 8
WriteExtendedAttributes = 16
Execute = 32
DeleteDirectory = 64
ReadAttributes = 128
WriteAttributes = 256
Delete = 65536
ReadSecurity = 131072
WriteACL = 262144
WriteOwner = 524288
Synchronize = 1048576
}
class aclsFolder{
[String]$Folder
[String]$SDDL
[String]$Owner
[PSObject[]]$Access
[PSObject[]]$ActionHistory
#Default Constructor
aclsFolder($fldr){
if(Test-Path -Path $fldr){
$this.Folder = $fldr
$Tags = @('Default','Constructor','valid')
$MessageData = "Path $($fldr) found"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
$this.Backup()
}
else{
$Tags = @('Default','Constructor','invalid')
$MessageData = "Path $($fldr) not found"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
#Methods
Backup(){
if(Test-Path $this.Folder){
$result = Get-Acl $this.Folder
$this.SDDL = $result.Sddl
$this.Owner = $result.Owner
$this.Access = $($result.Access | Select-Object File*,Access*,Identity*,IsInherited,*Flags)
$Tags = @('Backup','Success')
$MessageData = "Backup SDDL of $($this.Folder) was successful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
else{
Write-Warning "Invalid Path $($this.Folder)"
$Tags = @('Backup','Failed')
$MessageData = "Backup SDDL of $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
Restore(){
if((Test-Path $this.Folder) -and
![string]::isNullOrEmpty($this.SDDL) -and
[Security.AccessControl.RawSecurityDescriptor]$this.SDDL){
$acl = Get-Acl -Path $this.Folder
$acl.SetSecurityDescriptorSddlForm($this.SDDL)
Set-Acl -Path $($this.Folder) -AclObject $($acl)
$Tags = @('Restore', 'Success')
$MessageData = "Restoring SDDL on $($this.Folder) was succesful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
#Reset values
$this.Backup()
}
else{
Write-Warning "Invalid Path $($this.Folder) or SDDL is invalid"
$Tags = @('Restore', 'Failed')
$MessageData = "Restoring SDDL on $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
Clone($tgt){
if((Test-Path -Path $tgt) -and
![string]::isNullOrEmpty($this.SDDL) -and
[Security.AccessControl.RawSecurityDescriptor]$this.SDDL){
$acl = Get-Acl -Path $tgt
$acl.SetSecurityDescriptorSddlForm($this.SDDL)
Set-Acl -Path $($tgt) -AclObject $($acl)
$Tags = @('Clone', 'Success')
$MessageData = "Cloning SDDL on $($this.Folder) was succesful"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
else{
Write-Warning "Invalid Path $($this.Folder) or SDDL is invalid"
$Tags = @('Clone', 'Failed')
$MessageData = "Cloning SDDL on $($this.Folder) has failed"
$this.ActionHistory += Write-Information -MessageData $MessageData 6>&1 -Tags $Tags | Select-Object *
}
}
[PSObject]ConvertSDDLToAccess(){
Function Convert-SID2NTAccount{
param(
$SID
)
$objSID = New-Object System.Security.Principal.SecurityIdentifier($SID)
$objUser = $objSID.Translate( [System.Security.Principal.NTAccount])
$objUser
}
$accessSDDL = ([Security.AccessControl.RawSecurityDescriptor]$this.SDDL).DiscretionaryAcl |
ForEach-Object{
[PSCustomObject]@{
SID = $_.SecurityIdentifier
NTAccount = (Convert-SID2NTAccount -SID $_.SecurityIdentifier)
AceQualifier = $_.AceQualifier
AccessMask = $_.AccessMask
AceType = $_.AceType
AceFlags = $_.AceFlags
IsInherited = $_.IsInherited
InheritanceFlags = $_.InheritanceFlags
}
}
return $accessSDDL
}
}

Hope it’s worth something to you…

Ttyl,

Urv

RoboCopy class

β€˜Sup PSHomies,

It all started a year ago… Always wanting to learn anything PowerShell related, classes caught my eye ever since it was introduced in v5.  I wanted to try my hand at classes with a real life application… So I got on twitter for some tips…

powershell-class-tweet-2
powershell-class-tweet-3

Doug was kind enough to reach out and point me in the right direction, for which I owe him a great debt! Appreciate it Doug!!!

Like I said, I wanted to try my hand at classes with a real life application… If you’ve read my blogs then you’ll know that I’m a fan of robocopy, seriously, huge fan! . Did I mention how awesome robocopy is? πŸ˜› I think I found my real life application πŸ˜‰

When I started out with my Robocopy class, it was just about logging initially, but it could be so much more! Classes are native to v5. Now that v5 is mainstream I decided to finish the class. Richard Siddaway’s article  was just the spark I needed to get me going (again)!

Here’s what the Robocopy class looks like:

robocopy-class

Here a quick rundown on the properties:

The source/destination properties of the class are self explanatory (if you’re familiar with robocopy). The property logdir and JobID will be used to define a valid logfile name (with extension .log). Robocopy has quite a bit of options. I wanted to keep it as generic as possible. The property $this.Options is still a work in progress. The property $this.WildCards  is where you’ll define what will be filtered.  I’ll get back to rcCMDs and rcExitCodes later on…

These are the methods I came up with (so far, still a work in progress)

  • Mirror(). Mirrors $this.Source to $this.Destination with some default options
  • Move(). Moves this.Source to $this.Destination with some default options
  • RollBack(). Rollback $this.Destination to $this.Source with some default options
  • Sync(). Sync will synchronize the delta’s from $this.Source to $this.Destination using any additional $this.Options defined (at least that’s the idea). I’ve added a few options by default, mostly to exclude files and folders, think recycle.bin “System Volume Information” and the likes.
  • VerifyPaths(). This let’s you know if the $this.Source, $this.Destination and $this.LogDir are valid.
  • GetlistSource(). This will list the content of the $this.Source
  • GetListDestionation(). This will list the content of $this.Destination
  • GetLogSummary. This will return a summary of the log file (Hehe). The method is static so that you don’t have to instantiate the class in order to use it. (Thanks again Doug!)

The two methods: StartRCProcess and ExecuteRCCMD are actually helper methods. I just haven’t figured out how that works in classes. Ideally I’d like to have them hidden or as a function if that even makes sense. So here’s where they come in. At first I just executed robocopy with the necessary arguments. If you’re not interested in the exitcode then using ExecuteRCCMD is what you need. I wrote a blog about enumerating RoboCopy Exitcodes. Using $LastExitCode isn’t going to cut it if you decide to run robocopy jobs parallel. That’s where StartRCProcess comes in.Using Start-Process comes with an overhead of say 20 MB, which could add up in the long run. You do need to wait until the process has finished to retrieve the exitcode. If you really need the exitcode then StartRCProcess is what you need. The property $this.rcExitCodes will only be populated if StartRCProcess is used. Both will populate the $this.rcCMDs property.

Ok I think I’ve covered the basics, time to show some code! πŸ˜‰

[Flags()] Enum RoboCopyExitCodes{
NoChange = 0
OKCopy = 1
ExtraFiles = 2
MismatchedFilesFolders = 4
FailedCopyAttempts = 8
FatalError = 16
}
Class RoboCopy{
[String]$Source
[String]$Destination
[String]$LogDir
[String]$JobID
[String]$Options
[String[]]$WildCards
[PSObject[]]$rcCMDs
[PSObject[]]$rcExitCodes
#default constructor
RoboCopy(){}
#constructor
RoboCopy([String]$src,[String]$des,[String]$jid,[String]$ld,[string[]]$wc){
$this.Source = $src
$this.Destination = $des
$this.JobID = $jid
$this.LogDir = $ld
$this.WildCards = $wc
}
[PSCustomObject]VerifyPaths(){
return [PSCustomObject]@{
Source = $(Test-Path -Path $this.Source)
Destination = $(Test-Path -Path $this.Destination)
LogDirectory = $(Test-Path -Path $this.LogDir)
}
}
StartRCProcess($source,$target,$params,$logFile,$tag){
#Save RoboCopy Command
$MessageData = $('robocopy "{0}" "{1}" {2} {3}' -f $source,$target,$($params -join ' '),$logFile)
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Process
$rcArgs = "`"$($source)`" `"$($target)`" $params $logFile"
$rcResult = Start-Process robocopy -ArgumentList $rcArgs -WindowStyle Hidden -PassThru -Wait
#Save ExitCode
$this.rcExitCodes += [PSCustomObject]@{
ExitCode = [RoboCopyExitCodes]$rcResult.ExitCode
StartTime = $rcResult.StartTime
ExitTime = $rcResult.ExitTime
rcCMD = $MessageData
}
}
ExecuteRCCMD($source,$target,$params,$logFile,$tag){
#Save RoboCopy Command
$MessageData = $('robocopy "{0}" "{1}" {2} {3}' -f $source,$target,$($params -join ' '),$logFile)
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Robocopy CMD
$rcArgs = @("$($source)","$($target)",$params,$logFile)
robocopy @rcArgs
}
Mirror(){
$tag = @('Mirror',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/MIR','/E','/BYTES','/NP','/NDL','/R:1','/W:1'))
$logFile = '/LOG:{0}\mirror-{1}.log' -f $this.LogDir,$this.JobID
$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
#$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
Move(){
$tag = @('Move',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/COPY:DATS','/DCOPY:T','/SECFIX','/BYTES','/S','/E','/NP','/NDL','/R:1','/W:1','/MOVE'))
$logFile = '/LOG:{0}\move-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
RollBack(){
$tag = @('RollBack',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@('/COPY:DATS','/DCOPY:T','/SECFIX','/BYTES','/S','/E','/NP','/NDL','/R:1','/W:1','/MOVE'))
$logFile = '/LOG:{0}\rollback-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($$this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Destination,$this.Source,$params,$logFile,$tag)
}
Sync(){
$tag = @('Sync',$this.JobID)
#Excluded Files & Directories
$XF = @(
'/XF'
'thumbs.db'
'~*.*'
'*.pst'
'desktop.ini'
'*.lnk'
)
$XD = @(
'/XD'
"`$Recycle.bin"
'Boot Recycler'
'IECompatCache'
'IEDownloadHistory'
'Cookies'
'WINDOWS'
'PrivacIE'
"`"System Volume Information`""
)
$Excluded = "$($XF -join ' ') $($XD -join ' ')"
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/COPY:DATS','/SECFIX','/BYTES','/S','/PURGE','/E','/NP','/NDL','/R:1','/W:1'))
$logFile = '/LOG:{0}\sync-{1}.log' -f $this.LogDir,$this.JobID
#Save RoboCopy Command
$arrMessageDataInput = @(
$this.Source
$this.Destination
$($params -join ' ')
$($this.Options -join ' ')
$Excluded
$logFile
)
$MessageData = $('robocopy "{0}" "{1}" {2} {3} {4} {5}' -f $arrMessageDataInput )
$this.rcCMDs += Write-Information -MessageData $MessageData 6>&1 -Tags $tag | Select-Object *
#Execute Robocopy CMD
$rcArgs = @("$($this.Source)","$($this.Destination)",$params,$XF,$XD,$logFile)
robocopy @rcArgs
}
GetListSource(){
$tag = @('ListSRC',$this.JobID)
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/L','/S','/E', '/BYTES','/FP','/NC','/NDL','/TS','/R:0','/W:0'))
$logFile = '/LOG:{0}\listSRC-{1}.log' -f $this.LogDir,$this.JobID
$this.StartRCProcess($this.Source,'NULL',$params,$logFile,$tag)
#$this.ExecuteRCCMD($this.Source,$this.Destination,$params,$logFile,$tag)
}
GetListDestination(){
$tag = 'ListDES'
$params = New-Object System.Collections.Arraylist
$params.AddRange(@($($this.WildCards),'/L','/S','/E','/BYTES','/FP','/NC','/NDL','/TS','/R:0','/W:0'))
$logFile = '/LOG:{0}\listDES-{1}.log' -f $this.LogDir,$this.JobID
#$this.StartRCProcess($this.Source,$this.Destination,$params,$logFile,$tag)
$this.ExecuteRCCMD($this.Destination,'NULL',$params,$logFile,$tag)
}
static [PSCustomObject]GetLogSummary([String]$rcLogFile){
filter Get-CapacitySize {
'{0:N2} {1}' -f $(
if ($_ -lt 1kb) { $_, 'Bytes' }
elseif ($_ -lt 1mb) { ($_/1kb), 'KB' }
elseif ($_ -lt 1gb) { ($_/1mb), 'MB' }
elseif ($_ -lt 1tb) { ($_/1gb), 'GB' }
elseif ($_ -lt 1pb) { ($_/1tb), 'TB' }
else { ($_/1pb), 'PB' }
)
}
$rcLogSummary = [PSCustomObject]@{
Start = $null
End = $null
LogFile = $null
Source = $null
Destination = $null
TotalDirs = $null
CopiedDirs = $null
FailedDirs = $null
TotalFiles = $null
CopiedFiles = $null
FailedFiles = $null
TotalBytes = $null
CopiedBytes = $null
FailedBytes = $null
TotalTimes = $null
Speed = $null
}
$rcLogSummary.LogFile = $rcLogFile.Split('\')[-1].ToLower()
$logFileContent = Get-Content $rcLogFile -Raw
[regex]$regex_Start = 'Started\s:\s+(?<StartTime>.+[^\n\r])'
if ($logFileContent -match $regex_Start){
$rcLogSummary.Start = $Matches['StartTime']
}
[regex]$regex_End = 'Ended\s:\s+(?<EndTime>.+[^\n\r])'
if ($logFileContent -match $regex_End){
$rcLogSummary.End = $Matches['EndTime']
}
[regex]$regex_Source = 'Source\s:\s+(?<Source>.+[^\n\r])'
if($logFileContent -match $regex_Source){
$rcLogSummary.Source = $Matches['Source'].Tolower()
}
[regex]$regex_Target = 'Dest\s:\s+(?<Target>.+[^\n\r])'
if($logFileContent -match $regex_Target){
$rcLogSummary.Destination = $Matches['Target'].ToLower()
}
[regex]$regex_Dirs = 'Dirs\s:\s+(?<TotalDirs>\d+)\s+(?<CopiedDirs>\d+)(?:\s+\d+){2}\s+(?<FailedDirs>\d+)\s+\d+'
if ($logFileContent -match $regex_Dirs){
$rcLogSummary.TotalDirs = [int]$Matches['TotalDirs']
$rcLogSummary.CopiedDirs = [int]$Matches['CopiedDirs']
$rcLogSummary.FailedDirs = [int]$Matches['FailedDirs']
}
[regex]$regex_Files = 'Files\s:\s+(?<TotalFiles>\d+)\s+(?<CopiedFiles>\d+)(?:\s+\d+){2}\s+(?<FailedFiles>\d+)\s+\d+'
if ($logFileContent -match $regex_Files){
$rcLogSummary.TotalFiles = [int]$Matches['TotalFiles']
$rcLogSummary.CopiedFiles = [int]$Matches['CopiedFiles']
$rcLogSummary.FailedFiles = [int]$Matches['FailedFiles']
}
[regex]$regex_Speed = 'Speed\s:\s+(?<Speed>.+\/min)'
if ($logFileContent -match $regex_Speed){
$rcLogSummary.Speed = $Matches['Speed']
}
$arrBytes = @(
'Bytes\s:\s+(?<TotalBytes>(\d+\.\d+\s)[bmg]|\d+)\s+' #TotalBytes
'(?<CopiedBytes>\d+.\d+\s[bmg]|\d+)\s+' #CopiedBytes
'(?:(\d+.\d+\s[bmg]|\d+)\s+){2}' #Skip two
'(?<FailedBytes>\d+.\d+\s[bmg]|\d+)' #FailedBytes
)
[regex]$regex_Bytes = -join $arrBytes
if ($logFileContent -match $regex_Bytes){
$rcLogSummary.TotalBytes = [int64]$Matches['TotalBytes'] | Get-CapacitySize
$rcLogSummary.CopiedBytes = [int64]$Matches['CopiedBytes'] | Get-CapacitySize
$rcLogSummary.FailedBytes = [int64]$Matches['FailedBytes'] | Get-CapacitySize
}
[regex]$regex_Times = 'Times\s:\s+(?<TotalTimes>\d+:\d+:\d+)'
if ($logFileContent -match $regex_Times){
$rcLogSummary.TotalTimes = $Matches['TotalTimes']
}
return $rcLogSummary
}
[String]GetRoboCopyCMD(){
$paramRoboCopy = @(
$this.Source
$this.Destination
$($this.WildCards -join ' ')
$this.Options
$('/LOG:{0}\{1}.log' -f $this.LogDir,$this.JobID)
)
return $('robocopy "{0}" "{1}" {2} {3} {4}' -f $paramRoboCopy)
}
}

Here what’s happening in the List methods:

methods-lists

GetListSource() is using $this.StartRCProcess to generate a list of $this.Source using some default option. While writing I noticed that I forgot to add the wildcards to the parameter. All I had to do was add it!. I added it at the beginning so it lines up accordingly… Robocopy is fickle like that…  GetListDestination does the same only it uses ExecuteRCCMD instead.

Here’s what’s going on in StartRCProcess and ExcuteRCCMD

startexecuterc

Both StartRCProcess and ExcuteRCCMD will save the robocopy command using Write-Information. I’m loving Write-Information more and more! StartRCProcess saves the exitcode with some extra information. Here’s where the robocopy exitcode enumeration came in handy! ExecuteRCCMD will run robocopy with the specified arguments. Truth be told I’m more partial to the ExecuteRCCMD method. I added the StartRCProcess more for demo purposes and finally getting to use my Robocopy exitcode enumeration!

For Mirror(),Move() and RollBack(), I omitted the Wildcards. These methods all or nothing in my opinion. If omitted, . will be the default.

Sync() had me going for a while. I still have some issues with Options. For now Sync() uses some default switches. Like I said work in progress…

Quite a bit of code, so does it work? Here’s some code to play with. be sure to edit the source,destination and logdir to your liking. Just remember that robocopy is unforgiving so make sure not to use it of production folders!

#region Main
$rc = [RoboCopy]::New('C:\scripts\move','C:\temp\move','rc-0001','c:\scripts\log',@('*.*'))

#Run RoboCopy methods
$rc.Sync()
$rc.GetListSource()
$rc.GetListDestination()

#Get RoboCopy LogFile Summary
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\listSRC-rc-0001.log")
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\listDES-rc-0001.log")
[RoboCopy]::GetLogSummary("$env:HOMEDRIVE\scripts\log\sync-rc-0001.log")

#Get RoboCopy executed CMDs
$rc.rcCMDs
$rc.rcExitCodes
#endregion

First I instantiate the class with arguments. I then run the methods Sync(),GetListSource() and GetListDestination(). Up next is retrieve the LogSummaries from the methods. Here’s a screenshot of the Sync LogSummary

synclogfile

I did a select of $rc.rcCMDs to give you an idea what is being stored

rc-rccmds

Only want ListDES?

$rc.rcCMDs |
Where-Object{$_.Tags -contains 'ListDes'} |
Select-Object -Property Time*,Tag*,Mess*
rc-rccmdswhereobject

The information stream is quite handy! The tags will definitely come in handy when you need to filter on action verb or job ID.

The methods GetListSource() & Mirror() both make use of StartRCProcess(), so let’s see what $rc.rcExitcode has shall we?

rcexitcodes

Nice!

This is by far my longest blog, if you made this far then… Congratulations! There’s still so much to discover when it comes to classes.

Classes definitely  makes your code look and feel more like a developer πŸ˜‰ . I feel more comfortable giving a colleague this class than a bunch of scripts. In Richard’s article he’s using both classes and modules. There are sure to be some gotcha’s… Do you go all in with classes or only partial?

I’m hoping that the community can shed some light on the subject. I’d love to hear from you guys on how to improve on this… Let’s make this year, a year of PowerShell Classes! πŸ˜›

Hope it’s worth something to you…

Ttyl,

Urv

SMB shares Operational readiness

Sup’ PSHomies!

In migration mode! It’s been awhileΒ since I’ve done post-configuration of File Servers. I needed to recreate a DFSn structure in our Datacenter. A great opportunity to try out the new dfsn en smb cmdlets! πŸ˜‰

The SMB & DFSn cmdlet work with CIMSessions. Β Before I can create the DFS structure I need to create the shares… Here’s an impression of what the csv file looks like:

Server	ShareName	Path	FullAccess	ChangeAccess	ReadAccess
AZR-FS-01	FS_ARC001$	E:\FS_ARC001	NT AUTHORITY\Authenticated Users
AZR-FS-01	FS_ARC002$	E:\FS_ARC002	NT AUTHORITY\Authenticated Users
AZR-FS-01	FS_ARC003$	E:\FS_ARC003	NT AUTHORITY\Authenticated Users
AZR-FS-01	FS_ARC004$	E:\FS_ARC004	NT AUTHORITY\Authenticated Users
AZR-FS-01	FS_ARC005$	E:\FS_ARC005	NT AUTHORITY\Authenticated Users
AZR-FS-01	FS_ARC006$	E:\FS_ARC006	NT AUTHORITY\Authenticated Users

I’ll be using the servername to retain the cimsession amongst other things. Instead of giving everyone FullAccess to the share, we’re giving ‘Authenticates users’ FullAccess. To complete the list I’ve added Change- and ReadAccess as well even though I won’t be using them.

<#
Author: I. Strachan
Version:
Version History:
Purpose: Create new SMB shares
#>
#region import saved credential and csv File
$Hash = Import-CliXml -Path "${env:\userprofile}\Hash.Cred"
$cred = $Hash.'admstrachan'
$exportDate = Get-Date -Format ddMMyyyy
$csvShares = Import-Csv .\sources\csv\DFSnShares.csv -Delimiter "`t" -Encoding UTF8
#endregion
#region Main
$csvShares |
Foreach-object{
#Get CIMSession
$CIMSession = Get-CimSession -Name $_.Server
#If it doesn't exist create one
if(!$CIMSession){
$CIMSession = New-CimSession -ComputerName $_.Server -Name $_.Server -Credential $cred
}
#Test if Share is available
$Share = Get-SmbShare -Name $_.ShareName -CimSession $CIMSession -ErrorAction SilentlyContinue
if($Share){
#Test if Share path is equal to CSV Share Path
if($_.Path -eq $share.Path){
[PSCustomObject]@{
Server = $_.Server
ShareName = $_.ShareName
Path = $_.Path
Exists = $true
PathCorrect = $true
}
}
else{
[PSCustomObject]@{
Server = $_.Server
ShareName = $_.ShareName
Path = $_.Path
Exists = $true
PathCorrect = $false
}
}
}
else{
#Create folder via UNCPath
$UNCPath = '\\{0}\{1}' -f $_.Server,($_.Path).Replace(':','$')
New-Item -Path $UNCPath -ItemType Directory -Force
New-SmbShare -CimSession $CIMSession -FullAccess $_.FullAccess -Name $_.ShareName -Path $_.Path
[PSCustomObject]@{
Server = $_.Server
ShareName = $_.ShareName
Path = $_.Path
Exists = 'Created'
PathCorrect = $true
}
}
} |
Export-Csv .\export\dfsn\DFSnShares-Created-$($exportDate).csv -Encoding UTF8 -Delimiter "`t" -NoTypeInformation
#endregion
#Remove all CimSessions
Get-CimSession | Remove-CimSession

Here’s a quick rundown of the script.Β I’ve created quite a few checkpoints. First, see if the share exists then verify if the paths are identical. You never know if the share already exists and is pointing to another folder, I’ve learned that from past migration. If it doesn’t exist create it!

The reason for this setup is that I want to be able to re-run this script in the future. Without the check we’d end up with quite some errors… Incidentally, this is how I did my validation pre Pester… πŸ˜‰

Ok now for the fun part!

<#
Author: I. Strachan
Version:
Version History:
Purpose: OVF for SMBShares
#>
[CmdletBinding()]
Param(
$csvFile = 'DFSnShares.csv'
)
#region Import CSV and saved Credentials
$csvDFSnShares = Import-Csv .\sources\csv\$csvFile -Encoding UTF8 -Delimiter "`t"
$savedCreds = Import-CliXml -Path "${env:\userprofile}\Hash.Cred"
$cred = $savedCreds.'admstrachan'
#endregion
#region
$csvDFSnShares |
ForEach-Object{
#If CIMSession doesn't exist create one
if(!$( Get-CimSession -Name $_.Server -ErrorAction SilentlyContinue)){
$CIMSession = New-CimSession -ComputerName $_.Server -Name $_.Server -Credential $cred
}
else{
#Get CIMSession
$CIMSession = Get-CimSession -Name $_.Server
}
#Test if Share is available
$Share = Get-SmbShare -Name $_.ShareName -CimSession $CIMSession -ErrorAction SilentlyContinue
Describe "SMBShare Operation validation share $($_.ShareName) on $($_.Server)" -Tags 'DFSnShares' {
Context "Verifying share properties on $($_.Server)" {
It "Share Name $($_.ShareName) exists" {
$_.ShareName | Should be $Share.Name
}
It "Share Path $($_.Path) exists"{
$_.Path | Should Be $Share.Path
}
It "$($_.FullAccess) has full Access" {
$FullAccess = Get-SmbShareAccess -CimSession $CIMSession -Name $share.Name | Where-Object{$_.AccessRight -eq 'Full'}
$FullAccess.AccountName.Contains($_.FullAccess) | Should be $true
}
}
}
}
#endregion
#region Remove CimSessions
Get-CimSession | Remove-CimSession
#endregion

This time I didn’t make use of snapshots and went straight to the source! Using the csv file I can verify that the shares have been create using the right path and that ‘Authenticated Users’ have Full access.

Screen Shot 2016-07-26 at 12.40.06

Looking at the creation script you might wonder why bother with an OV script (Operation Validation)? As chance would have it, one of the File Server’s Β volume needed to be recreated. Which meant shares had to be recreated (and validated…) Now I wasn’t part of the recreation process, but I could validate that the shares were available as intended once the recreation took place! πŸ˜‰ It’s all about having peace of mind… I just ran the test before posting this blog, everything is as it should be… πŸ™‚

Hope it’s worth something to you

Ttyl,

Urv

 

 

 

 

 

 

Operational Readiness validation gotchas

Sup’ PSHomies,

Back from the PSConfEu 2016 in Hannover! It was awesome!!! It was great meeting so many in person! I highly recommend attending a conference if ever given the chance! Tobias did a great job organizing PSConfEU 2016!

The presentations were top notch! Two presentations I definitely wanted to follow were June Blenders’ Real world Pester TDD tests & Ravikanth Chaganti Operations Validation Framework. I enjoyed Ravikanth’s approach: a simplistic and a comprehensive test. June’s presentationΒ was insightful! There were definitely a few aha moments for me! SoΒ I decided to re-evaluate what I had learned so far now that I’ve seen how it should be done! πŸ˜‰ .

Simplistic tests

Think of simplistic testsΒ as kicking the tires. Here’s where the obvious tests go:

  • Are the sets aligned?
  • Did the object count meet your expectation?

Things of that nature. Here are some tests to help you understand some gotchaΒ moments.

Validating the count seems pretty straight forward right? Well, not always… I’ll explain…

When the sets to validate are identical validation is pretty straight forward. This is the best case scenario. I did one validation with and without Group-Object (I’ll explain later on).

Simplistic Identical sets - Count

Simplistic Indentical sets

By using Group-Object I can get the “real” count of a set. Group-Object will gauge the uniqueness of the set, but I also found a test where that might not always be useful… So my next test was to omit an entry from the Verify set

Simplistic Missing entry sets - Count

Simplistic missing an entry in Verify

The test failed as it should. Now for some fun, let’s add a double entry to the Verify set.

 

Simplistic Double entry sets - CountThis is an odd test, it could be a typo. Imagine having a list where double entries aren’t that obvious, this should catch it. Now ideally you’d use your source code that you used during deployment. I’m assuming you did automate your process eh? πŸ˜‰ Without the Group-Object the count is identical. With Group-Object you only have one entry! This could explain an exception happened during deployment… Accidents will happen…

The last test is a fun one: different sets.

Simplistic Different sets - Count

Different sets

Surprise! Both validation count test passed, but the sets are totally different!

Bonus Test!

Simplistic Different sets double entry - Count

Different set with a double entry

This one almost got away. While doing the comprehensive test, it dawned on me that I should be testing both counts, with and without Group-Object. I updated the screenshot accordingly.

Take away Simplistic test:

Don’t only rely theΒ count of a set. By using Group-Object you can gauge a set’s uniqueness. The bonus test showed that exceptions may happen. It’s totally valid as a starting point, that’s why it’s aΒ simplistic tests. Here’s the code for the Simplistic test for count validation:

$savedADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

$verifyADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

#region Example Operational validation Simplistic test
Describe 'Active Directory configuration operational readiness' {
   Context 'Verifying GlobalCatalogs count without Group-Object'{
      it 'Total GlobalCatalogs match' {
         @($savedADConfig.GlobalCatalogs).Count |
         Should be @($verifyADConfig.GlobalCatalogs).Count
      }
   }

   Context 'Verifying GlobalCatalogs count with Group-Object'{
      it 'Total GlobalCatalogs match' {
         @($savedADConfig.GlobalCatalogs  | Group-Object).Count |
         Should be @($verifyADConfig.GlobalCatalogs | Group-Object).Count
      }
   }
}
#endregion

If you’d like to try out the simplistic tests just add/remove entries to the saved-/verifyADConfig sets. Now for the Comprehensive tests!

Comprehensive tests

Here’s where in-depth analysis goes. When I did the AD Operational Readiness test, I had a feeling I was missing something. I saw June using sort-object in one of her validations. That triggered me to re-valuate this test.

Comprehensive Identical sets

Comprehensive Identical sets

Depending on which set you used for your enumeration you could end up with different results. When the sets are identical, all goes well. Next test, omit an entry in Verify set.

Comprehensive Missing an entry

Comprehensive missing an entry

Enumerating from the saved test caught the missing entry, enumerating from verify didn’t. Both found ‘DC-DSC-01.pshirwin.local’. The simplistic test caught this, that is why you need both! Next up: Double entry in Verify.

Comprehensive Double entry

Comprehensive double entry in verify

Enumerating from the saved set caught the double entry. Enumerating from the verify set just enumerated the entry twice. If you’re visually inclined, you might miss this.

Hey all my tests results are green and purple! Yeah…

Last test: Different sets.

Comprehensive Different sets

Different sets

At this point you’re comparing apple with oranges. This should fail.

Take away comprehensive tests:

The set you’re enumerating from matters! To cover validation, best bet is to do both! Here’s the code for the Comprehensive Test:

#region Example Operational validation Comprehensive test
$savedADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

$verifyADConfig= @{
   GlobalCatalogs = @(
      'DC-DSC-01.pshirwin.local'
      'DC-DSC-02.pshirwin.local'
   )
}

Describe 'Active Directory configuration operational readiness' {
   Context 'Verifying GlobalCatalogs enumerating from saved configuration'{
      $savedADConfig.GlobalCatalogs |
      ForEach-Object{
         it "Server $($_) is a GlobalCatalog"{
            $verifyADConfig.GlobalCatalogs.Contains($_) |
            Should be $true
         }
      }
   }
   Context 'Verifying GlobalCatalogs enumerating from verify configuration'{
      $verifyADConfig.GlobalCatalogs |
      ForEach-Object{
         it "Server $($_) is a GlobalCatalog"{
            $savedADConfig.GlobalCatalogs.Contains($_) |
            Should be $true
         }
      }
   }
}
#endregion

Validation is great, but you need to make sure your validating with the right set in the right order!

Summary

  • Create Simplistic & Comprehensive tests.
  • Simplistic tests should take care of the obvious.
  • Comprehensive tests is where in-depth analysis takes place.
  • Validate by enumerating from both sets!
  • Never trust a test that doesn’t fail πŸ˜‰

I’m glad I visited both presentations! Now it’s time to update my Operational readiness tests accordingly! πŸ™‚

Hope it’s worth something to you

Ttyl,

Urv

Active Directory configuration report

‘Sup PSHomies,

Last blogΒ I talked about how to create a AD configuration snapshot. I saved the AD Configuration using the Export-Clixml cmdlet. As promised here’s the follow up: How to create a report from the saved snapshot.

I’m a fan of HTML for reporting purposes. In the past I’ve dabbled in creating reports using XML in combination with CSS. The challenge was creating a well-defined XML file. If you’ve ever had the idea of using Export-Clixml to combine with CSS then you’re in for a disappointment! ConvertTo-HTMLΒ is a better fit for reporting. Having said that, creating a well-defined XML file can also be a challenge. As luck would have it there’s a mini-series on the subject, check it out if you want to go down that route.

My favorite way of creating HTML reports these days is using PScribo,Β brought to usΒ Β by Iain Brighton. I saw him demonstrate the module at the PowerShell Conference in Stockholm on youtube. PScribo sure makes creating reports easier!Β PScribo supports different output formats:

  • HTML
  • Word
  • Text
  • XML

You can also edit the style of your document. To get a better impression of all the possibilities have a look at the video.Β The module has enough examples to help you get started. Before you know it you’ll be hooked!

Here’s the script:

<#
Author: I.C.A. Strachan
Version:
Version History:
Purpose: Create PScribo Report of saved AD Configuration
#>
[CmdletBinding()]
Param(
$snapshotDate= '29032016'
)
#PScribo link: https://github.com/iainbrighton/PScribo
Import-Module PScribo -Verbose:$false
#Get ADSnapshot
$SavedADSnapshot = Import-Clixml .\export\dsa\ADReport-$($snapshotDate).xml
$reportAD = Document "AD snapshot report - $($snapshotDate)" {
GlobalOption -ForceUppercaseSection -EnableSectionNumbering -PageSize A4 -Margin 24
BlankLine -Count 20
Paragraph "Active Directory snapshot report - $($snapshotDate)" -Style Title
BlankLine -Count 20
PageBreak
TOC -Name 'Table of Contents'
PageBreak
Section -Style Heading1 'Forest Information' {
$ADForest = [Ordered]@{
Name = $($SavedADSnapshot.ForestInformation.Name)
RootDomain = $($SavedADSnapshot.ForestInformation.RootDomain)
ForestMode = $($SavedADSnapshot.ForestInformation.ForestMode.ToString())
Domains = $($SavedADSnapshot.ForestInformation.Domains)
}
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0 -Hashtable $ADForest
}
Section -Style Heading1 'Domain Information' {
$ADDomain = [Ordered]@{
NetBIOSName = $($SavedADSnapshot.DomainInformation.NetBIOSName)
DomainMode = $($SavedADSnapshot.DomainInformation.DomainMode.ToString())
DistinguishedName = $($SavedADSnapshot.DomainInformation.DistinguishedName)
DomainSID = $($SavedADSnapshot.DomainInformation.DomainSID)
}
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0 -Hashtable $ADDomain
}
Section -Style Heading1 'FSMO Roles' {
$SavedADSnapshot.ForestInformation |
Select-Object DomainNamingMaster,SchemaMaster |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0
Blankline
$SavedADSnapshot.DomainInformation |
Select-Object PDCEmulator,InfrastructureMaster,RIDMaster |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0
}
Section -Style Heading1 'Global Catalogs' {
$GCs = $SavedADSnapshot.ForestInformation |
Select-Object -ExpandProperty GlobalCatalogs
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0 -InputObject $GCs
}
Section -Style Heading1 'Domain Controllers' {
$SavedADSnapshot.DomainControllers |
Select-Object Name,OperatingSystem,IPv4Address,Site |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0
}
Section -Style Heading1 'Default Domain Password Policy' {
$SavedADSnapshot.DefaultPassWordPoLicy |
Select-Object ComplexityEnabled,LockoutDuration,LockoutObservationWindow,LockoutThreshold,
MaxPasswordAge,MinPasswordAge,MinPasswordLength,PasswordHistoryCount,ReversibleEncryptionEnabled |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0
}
Section -Style Heading1 'Domain Administrators' {
$SavedADSnapshot.DomainAdministrators |
Select-Object Name,DistinguishedName |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -Width 0
}
Section -Style Heading1 'Organizational Units' {
$SavedADSnapshot.OrganizationalUnits |
Select-Object Name,DistinguishedName |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -Width 0
}
Section -Style Heading1 'Sites & Subnets' {
Section -Style Heading2 'Sites' {
$SavedADSnapshot.Sites |
Select-Object Name,Description,DistinguishedName |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -List -Width 0
}
Section -Style Heading2 'Sitelinks' {
$SavedADSnapshot.SiteLinks |
Select-Object Name,Cost,ReplicationFrequencyInMinutes |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -Width 0
}
Section -Style Heading2 'Subnets' {
$SavedADSnapshot.Subnets |
Select-Object Name,Site |
Table -Name 'AutofitWidth-AutofitCell-NoHighlighting' -Width 0
}
}
}
#Render report in HTML format
$reportAD | Export-Document -Path .\export\dsa -Format Html

I’ve recently discovered markdown. If you’re comfortable creating HTML documents, then MD shouldn’t be much of a challenge ;-). BTWΒ if you’re looking for a good MD reader, vscodeΒ has you covered. VSCode is gaining momentum in the PowerShell community. I’ll admit to being hooked on ISESteroids, still, Β Tobias Weltner said there will be a major update pretty soon…Β So who knows what this might mean?

I decided to give it a try in MD Format as well!

<#
Author: I.C.A. Strachan
Version:
Version History:
Purpose: Create MD Report of saved AD Configuration
#>
[CmdletBinding()]
Param(
$snapshotDate= '29032016'
)
#Get ADSnapshot
$SavedADSnapshot = Import-Clixml .\export\dsa\ADReport-$($snapshotDate).xml
#MDReport Title
$MDReport = "# Active Directory snapshot report - $($snapshotDate)`n"
#region Get Forest Information
$ADForest = @"
# Forest Information
| | |
|---|---|
|Name | $($SavedADSnapshot.ForestInformation.Name) |
|RootDomain | $($SavedADSnapshot.ForestInformation.RootDomain) |
|ForestMode | $($SavedADSnapshot.ForestInformation.ForestMode.ToString()) |
|Domains | $($SavedADSnapshot.ForestInformation.Domains) |
"@
$MDReport += $ADForest
#endregion
#region Domain Information
$ADDomain = @"
# Domain Information
| | |
|---|---|
|NetBIOSName | $($SavedADSnapshot.DomainInformation.NetBIOSName) |
|DomainMode | $($SavedADSnapshot.DomainInformation.DomainMode.ToString()) |
|DistinguishedName | $($SavedADSnapshot.DomainInformation.DistinguishedName) |
|DomainSID | $($SavedADSnapshot.DomainInformation.DomainSID) |
"@
$MDReport += $ADDomain
#endregion
#region Default Password Policy
$ADPasswordPolicy = @"
# Default Password Policy
| | |
|---|---|
|ComplexityEnabled | $($SavedADSnapshot.DefaultPassWordPoLicy.ComplexityEnabled) |
|DistinguishedName | $($SavedADSnapshot.DefaultPassWordPoLicy.DistinguishedName) |
|LockoutDuration | $($SavedADSnapshot.DefaultPassWordPoLicy.LockoutDuration) |
|LockoutObservationWindow | $($SavedADSnapshot.DefaultPassWordPoLicy.LockoutObservationWindow) |
|LockoutThreshold | $($SavedADSnapshot.DefaultPassWordPoLicy.LockoutThreshold) |
|MaxPasswordAge | $($SavedADSnapshot.DefaultPassWordPoLicy.MaxPasswordAge) |
|MinPasswordAge | $($SavedADSnapshot.DefaultPassWordPoLicy.MinPasswordAge) |
|MinPasswordLength | $($SavedADSnapshot.DefaultPassWordPoLicy.MinPasswordLength) |
|PasswordHistoryCount | $($SavedADSnapshot.DefaultPassWordPoLicy.PasswordHistoryCount) |
|ReversibleEncryptionEnabled | $($SavedADSnapshot.DefaultPassWordPoLicy.ReversibleEncryptionEnabled) |
"@
$MDReport += $ADPasswordPolicy
#endregion
#region FSMO Roles
$ADFSMORoles = @"
# FSMO Roles
| | |
|---|---|
|DomainNamingMaster| $($SavedADSnapshot.ForestInformation.DomainNamingMaster) |
|SchemaMaster | $($SavedADSnapshot.ForestInformation.SchemaMaster) |
|PDCEmulator | $($SavedADSnapshot.DomainInformation.PDCEmulator) |
|RIDMaster | $($SavedADSnapshot.DomainInformation.RIDMaster) |
|InfrastructureMaster | $($SavedADSnapshot.DomainInformation.InfrastructureMaster) |
"@
$MDReport += $ADFSMORoles
#endregion
#region Global Catalogs
$ADGCs = @"
# Global Catalogs
|Global Catalogs |
|---|
$(
$SavedADSnapshot.ForestInformation.GlobalCatalogs |
ForEach-Object {
"|$_|`n"
}
)
"@
$MDReport += $ADGCs
#endregion
#region Domain Controllers
$ADDCs = @"
# Domain Controllers
|Name|OperatingSystem|IPv4Address|Site|
|---|---|---|---|
$(
$SavedADSnapshot.DomainControllers |
ForEach-Object {
"|$($_.Name)|$($_.OperatingSystem)|$($_.IPv4Address)|$($_.Site)|`n"
}
)
"@
$MDReport += $ADDCs
#endregion
#region Domain Administrators
$ADDomainAdmins = @"
# Domain Administrators
|Name|DistinguishedName|
|---|---|
$(
$SavedADSnapshot.DomainAdministrators |
ForEach-Object {
"|$($_.Name)|$($_.DistinguishedName)|`n"
}
)
"@
$MDReport += $ADDomainAdmins
#endregion
#region Sites & Subnets
$ADSitesSubnets = @"
# Sites & Subnets
## Sites
|Name|Description|DistinguishedName|
|---|---|---|
$(
$SavedADSnapshot.Sites |
ForEach-Object {
"|$($_.Name)|$($_.Description)|$($_.DistinguishedName)|`n"
}
)
## Sitelinks
|Name|Cost|ReplicationFrequencyInMinutes |
|---|---|---|
$(
$SavedADSnapshot.SiteLinks |
ForEach-Object {
"|$($_.Name)|$($_.Cost)|$($_.ReplicationFrequencyInMinutes)|`n"
}
)
## Subnets
|Name|Site|
|---|---|
$(
$SavedADSnapshot.Subnets |
ForEach-Object {
"|$($_.Name)|$($_.Site)|`n"
}
)
"@
$MDReport += $ADSitesSubnets
#endregion
#region Organizational Units
$ADOUs = @"
# Organizational Units
|Name|DistinguishedName|
|---|---|
$(
$SavedADSnapshot.OrganizationalUnits |
ForEach-Object {
"|$($_.Name)|$($_.DistinguishedName)|`n"
}
)
"@
#$MDReport += $ADOUs
#endregion
#Save MDReport
$MDReport | Out-File ".\export\dsa\AD snapshot report - $($snapshotDate).md" -Encoding utf8 -Force

Not bad…

MD Format ADConfiguration

MD format has a small footprint which could be interesting. MD files can be converted to other formats. Doug Finke has an excellent vscode extensionΒ to render md files in pdf, word or html, using PanDoc.

PScribo is definitely worth a try! MD is also an option. Generating reports in Powershell just got easier thanks to Iain & Doug! Great addition guys! Keep up the good work!

Hope it’s worth something to you

Ttyl,

Urv

Pester to validate ADUser properties

‘Sup PSHomies,

See if you can relate. You’re in the middleΒ of a migration, the users need to be created asap. You get a xlsx file with all the necessary properties. A quick Copy/Paste to csv file, Import-Csv Β user.csv -Delimiter "`t" | New-ADUser and presto! Whew! Glad we got that out of the way πŸ˜‰

FeelsΒ pretty awesome right? 15 minutes after,Β your project manager comes asking: “Say, which file did you use?” The one you sent me last week, why?Β “Uh, there’s a new version on sharepoint, did you use that one?” Well I did ask which file I should use (in my defense I did, that’s why I always email, written proof!). “Well there’s an updated version, could you make sure the users get updated? Thanks!!!” Sigh, here we go again…

At this point I can do two things:

  1. Just delete and recreate. Thing is you’ll loose SIDs and access to homedirectory etc etc. Not exactly ideal.
  2. Update the user properties. Definitely a better option. Still tricky especially using the Set-ADUser cmdlet, but that’s another story.

But before you go off to update the user settings, how about validating what has been changed? Maybe the damage isn’t that bad. I mean if it’s under five changes, I just might Β do it manually… Oh who am I kidding? Wait, gimme a minute to catch my breathe from laughing! πŸ˜›

Enter Pester for ADUser validation!

WithΒ a Pester script to validate your ADUser settings, you’ll never have to second guess if the settings are as they should.


<#
Author: I.C.A. Strachan
Version: 1.0
Version History:
Purpose: Pester script to validate ADUser properties.
#>
[CmdletBinding()]
Param(
[string]
$csvFile = 'Users.csv',
[Microsoft.PowerShell.Commands.FileSystemCmdletProviderEncoding]
$Encoding = 'UTF8'
)
$csvParam = @{
Path = ".\source\csv\$csvFile"
Delimiter = "`t"
Encoding = $Encoding
}
$csvADUsers = Import-Csv @csvParam
$ADPropertiesToVerify = ($csvADUsers | Get-Member | Where-Object {$_.membertype -eq 'noteproperty'}).name
Foreach ($user in $csvADUsers){
#Get AD User attirbutes
try{
$verify = Get-ADUser -Identity $user.SamAccountName -Properties *
if ($verify) {
Describe "AD User operational readiness for $($user.DisplayName)" {
Context 'Verifying ADUser Attributes'{
ForEach($attribute in $ADPropertiesToVerify){
if (([string]::isNullOrEmpty($user.$attribute))) {
$user.$attribute = $null
}
if($attribute -eq 'Path'){
it "User is located in $($user.$attribute)" {
$verify.DistinguishedName.Contains($user.$attribute)
}
}
else{
it "User property $($attribute) value is $($verify.$attribute)" {
$user.$attribute | Should be $verify.$attribute
}
}
}
}
Context 'Verifying ADUser HomeDirectory Security'{
it 'User HomeDirectory attribute is not empty'{
$user.HomeDirectory | Should not be $null
}
It "Homedirectory $($user.HomeDirectory) exists"{
Test-Path $user.HomeDirectory | Should be $true
}
It "User is owner of $($user.HomeDirectory)"{
(Get-Acl $user.HomeDirectory).Owner| Should be "$($env:USERDOMAIN)\$($user.sAMAccountName)"
}
}
}
}
}
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]{
Write-Error -Message "User $($user.SamAccountName) account NOT present"
}
catch {
Write-Error -Message "Unhandled exception looking up $($user.SamAccountName)) account."
throw $_
}
}

Here’s the result:ADPesterResults

Here’s a quick rundown of the script:

First I’ll just get all the user settings using $verify = Get-ADUser -Identity $user.SamAccountName -Properties *.

$ADPropertiesToVerify = Β ($csvADUsers | Get-Member | Where-Object {$_.membertype -eq 'noteproperty'}).name will get me all the properties in the csv file. No need to map properties manually. Now I can loop through any amount of properties!

Next up, making sure empty properties get $null

if (([string]::isNullOrEmpty($user.$attribute))) {
   $user.$attribute = $null
}

$null isn’t equal to empty (Ofcourse you already knew that!)

Now compare what’s in the csv to what Get-ADUser found:

if($attribute -eq 'Path'){
   it &quot;User is located in $($user.$attribute)&quot; {
      $verify.DistinguishedName.Contains($user.$attribute)
   }
}
else{
   it &quot;User property $($attribute) value is $($verify.$attribute)&quot; {
      $user.$attribute | Should be $verify.$attribute
   }
}

Quick note: I used Path to create the user in a specific OU. There’s no Path property in Get-ADUser. So I did the next best thing, just verify that path is part ofΒ the user’s distinguishedname πŸ˜‰

I also added a little bonus to verify the user’s homedirectory exists and that the user is also the owner.

Being able to validate will definitely give you peace of mind…

Hope it’s worth something to you

Ttyl,

Urv

Custom Intellisense for AD cmdlets with SearchBase parameter

Sup’ PSHomies!

You gotta love the PowerShell community! Found this little gem in my twitter feed (again) :-). Trevor Sullivan demonstrates how we can create custom intellisense for cmdlets if they haven’t been provided as yet. Great video! Trevor really does a great job explaining this.

The first thing that came to mind was Active Directory! I can’t tell you how often I needed the DistinguishedName of an OU. Now imagine having a dynamic list generated for you! No more errors, Β just select and you’re good to go! Excited??? I sure am!

Sometimes you need to limit your searchbase depending on you AD size. Let’s say I want to retrieve all users starting from a specific point

Get-ADUser -Filter * -SearchBase 'OU=Users,OU=IT,DC=pshirwin,DC=local'

A simple typo will generate an error. Distinguished names are notorious for being lengthy…

Now the obvious AD cmdlets would be Get-ADUser,Get-ADGroup & Get-ADComputer. So that got me thinking , just how many AD cmdlets have SearchBase as a parameter?

Get-Command -Module ActiveDirectory |
ForEach-Object{
   $psItem.Name |
   Where-Object {
        (Get-Command $psItem).ParameterSets.Parameters.Name -eq 'SearchBase'
   }
}

Turns out there are quite a few using SearchBase

  • Get-ADComputer
  • Get-ADFineGrainedPasswordPolicy
  • Get-ADGroup
  • Get-ADObject
  • Get-ADOptionalFeature
  • Get-ADOrganizationalUnit
  • Get-ADServiceAccount
  • Get-ADUser
  • Search-ADAccount

So I can have Intellisense on all these cmdlets? Awesome!!!


<#
Author: I.C.A. Strachan
Version:
Version History:
Purpose: Custom Intellisense completion for AD cmdlets with SearchBase parameter
ActiveDirectory & TabExpansion++ module is required.
Link to Trevor Sullivan's video demonstration: https://goo.gl/0TdWuv
#>
#region Get AD cmdlets with SearchBase parameter
$ADCmdlestWithSearchBase = Get-Command -Module ActiveDirectory |
ForEach-Object{
$psItem.Name |
Where-Object {
(Get-Command $psItem).ParameterSets.Parameters.Name -eq 'SearchBase'
}
}
#endregion
#region Configure custom intellisense for AD cmdlets with SearchBase
$sbADSearchBase= {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
$ADPaths = ActiveDirectory\Get-ADOrganizationalUnit -filter *
foreach ($ADPath in $ADPaths){
$completionResult =@{
CompletionText = $ADPath.DistinguishedName
ToolTip = ('The organization unit DistinguishedName {0}' -f $ADPath.DistinguishedName)
ListItemText = $ADPath.Name
CompletionResultType = 'ParameterValue'
}
New-CompletionResult @completionResult
}
}
$tabExpansion = @{
CommandName = $ADCmdlestWithSearchBase
ParameterName = 'SearchBase'
ScriptBlock = $sbADSearchBase
}
TabExpansion++\Register-ArgumentCompleter @tabExpansion
#endregion

Intellisense completed the DistinguisedName on -SearchBase for me. No need to type it in, no errors, just select and go!

TabExpansionSearchBase

Here’s the result:

TabExpansionSearchBase-result

I’m sure you guys will find your own use for this… Thanks again Trevor for bring this to our attention! Good looking out for the community! Be sure to watch Trevor’s video for in depth explanation.

Hope it’s worth something to you…

Ttyl,

Urv