Splitting a long string into lines

This is a very simple exercise, probably why googling it didn’t get any hits as most people are interested in splitting text read in from a file. I wrote an audit script that would write
to the ARS scheduled task ‘lastrunmessage’ attribute so that I could quickly see if there were any errors in the last run.

I then wrote an audit script to audit the audit script, as I like to be sure my scripts are running error free.

I’d like to say I don’t have any bugs in my production scripts but we both know that’s not true! Plus things can change so a script that’s has run for years might suddenly stop. I had this exact issue with my scripts sending to an SMTP relay but that’s another post to come next.

Sometimes the script would generate multiple error messages so I stick them all together on the last run message but I discovered that the email alert generated was a little messy so I
wanted to split the looooooooooooooooooooooooooong lastrunmessage into a number of lines.

A quick function later and it was done. I didn’t even have to write a recursive function. The function has two parameters: stringLength, this is the length of the lines I want produce and string, which is the string I want to split up into lines.

I could have course added CRLF into the string and reconstituted the string but here all I wanted was an array of stings where the length didn’t exceed the stringLength parameter. I split the input sting using the ‘space’ as the delimiter.

$words = $string.split(" ")

Then I iterate through the words keeping any eye on the string length

ForEach ( $word in $words ) {
  if ( ( $newString.length + $word.length ) -gt $stringLength ) {

and rebuild the string as I go

  $newString += " $word "

until I hit the ‘stringLength’ limit when I add the current ‘newString’ to the array of ‘strings’ that the function will return.

  if ( ( $newString.length + $word.length ) -gt $stringLength ) {
   $strings += $newString
   $newString = $word
  }

the function will always return an array even if the input string is under the stringLength limit.

Here’s the function and an example command line to make use of the function.

Function Create-StringArray {
 param (
  $stringLength = 100,
  $string
 )
 $strings = @()
 if ( $string.Length -le $stringLength ) {
  $strings += $string 
  Return ,$strings # string is not big enough to other splitting so just return it as an array of 1 string
 }
 $words = $string.split(" ")
 $newString = ""
 ForEach ( $word in $words ) {
  if ( ( $newString.length + $word.length ) -gt $stringLength ) {
   $strings += $newString
   $newString = $word
  }
  elseif ( $newString -eq '' ) {
   $newString = $word 
  }
  else { 
   $newString += " $word "
  }
 }
 if ( $newString -ne '' ) {
  $strings += $newString
 }
 Return ,$strings 
}

$inputString = "this is a long string that is made up of words that would make it too long to display in a file without bad word wrapping happening"
ForEach ( $line in $(Create-StringArray -string $inputString) ) {
 $line 
}
Advertisements

How to check a GPO drive mapping

Rather than use logon scripts you can configure drive mappings in a User Preference GPO.  As with logon scripts the issue here is that over time you add to the drive mappings and never get around to checking if the drive mappings are still relevant.    If you have a large number of mappings this can be a big problem.  My solution is to the GrpupPolicy modules and extract an XML report from the Drive Mapping GPO and then parse the XML to confirm that the filters being used and the file paths are still valid.  Exporting the output to a CSV file is an easy way to document the GPO settings and also to highlight potential issues.

A HTML report could be used but in my book at least a CSV file can be opened in Excel and then sorted to show the drive mapping info required.  This is much better than the HTML report.

You might think its easy to examine an XML file and to some extent it is, but if you don’t know the structure then just dumping out the information is about all you can do and this is no better than jst reading the XML file which you probably know if not very much fun.

Using the GPO commandlets you can extract the drive mappings like this – I used Powergui to navigate trhough the XML to find the required nodes I was interested in and the HTML reports to help me work out what information I’d need.

$([xml]$(Get-GPOReport -Name $GPOName -ReportType Xml)).GPO.User.extensionData[0].Extension.DriveMapSettings

The drive mappings are returned as an array of mappings so you can walk through them one at a time using a For loop and for each one you can extract the filters and collections.  Collections are groups of filters that are evaluated together and you can AND or OR the collections too if the mapping has more than one collection.  Mosy in my scenario had only one collection but I wrote code to handle the one GPO that had two collections.

Having got the drive mappings and when looping through them I grabbed the filters and collections.  You can have one or both of these by the way

$driveMapSetting = $driveMapSettings.Drive[$index]
$driveMapSettingFilters = $driveMapSetting.Filters
$NoOfCollections = Count-Filters $driveMapSettingFilters.FilterCollection

There are four branches of the XML I’m interested in , users , groups, OUs and sites.  These are filters based on the object type so if a drive mapping were set if the users is a member of a group then there would be a FilterGroup element in the XML.

$userFilters = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterUser)
$groupFilters = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterGroup)
$OUfilters = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterOrgUnit)
$siteFilters = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterSite)

If there is no matching branch then the ‘count’ is set to 0 and I can use this to conditionally explore the XML node more or not.

if ( $groupfilters -gt 0 ) {
$filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection.FilterGroup “Group”
}

I wrote functions to simplify things and given I need to do the same thing over and over functions are definitely the way to go.

Now that I have located a filter all I need to do is store it in a variable but before I can do that I need to figure out the attributes I need.  The Powergui variable window is great for parsing the xml and working out what you need.

Now the last bit is the real labour saving bit, when I discover a group, user, OU or site in a filter I can check in AD if it exists.  If not then I can write an error message to the file.  I found lots of errors in my GPO so the time writing the script to automate the check paid off and that was a one time run.  Using it going forward will ensure the GPO does not get stale ever or at minimum I have no excuse.

#region User Configurable Parameters
$GPOName          = "GPOName"
$exportFolder     = "c:\temp"
$exportFileName   = "$exportFolder\$GPOName - Report.csv" 
$padright         = 110  # used to manage the oputput display length 
$limitDriveMapsTo = 0 # limits the number fo drive maps for debug only set to 0 for no limit
#endregion User Configurable Parameters
#region Define message coulour variables 
$script:Green     = @{"ForegroundColor"="green" ;"BackgroundColor"="Black"}
$script:Gray      = @{"ForegroundColor"="Gray"  ;"BackgroundColor"="Black"}
$script:Yellow    = @{"ForegroundColor"="Yellow";"BackgroundColor"="Black"}
$script:Cyan      = @{"ForegroundColor"="Cyan"  ;"BackgroundColor"="Black"}
#endregion Define message coulour variables  
#region Helper Functions
function Get-ModuleStatus { # http://www.ehloworld.com/938
	[CmdletBinding(SupportsShouldProcess=$true)]
	param (
		[parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Mandatory=$true, HelpMessage="Enter the Module Name, e.g. Lync")]
		[string]$name
	)
	if(!(Get-Module -name "$name")) {
		if( Get-Module -ListAvailable | ? {$_.name -eq "$name"} ) {
			try { 
				Import-Module -Name "$name" 
				return $true
			}
			catch { return $false	}
		} 
		else { return $false }
	}
	else { return $true	}
} # end function Get-ModuleStatus
function Get-SnapinStatus { 
 [CmdletBinding(SupportsShouldProcess=$true)]
 param	(
  [parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Mandatory=$true, HelpMessage="Enter the snapin name, e.g. Microsoft.Exchange.Management.PowerShell.Admin")]
  [string]$name
 )
 if(!(Get-PSSnapin -name "$name" -ErrorAction SilentlyContinue)) {
  if(Get-PSSnapin -Registered | Where-Object {$_.name -eq "$name"}) {
   try { 
    Add-PSSnapin -Name "$name" -ErrorAction SilentlyContinue 
    return $true
   }
   catch {	return $false }
  } 
  else { return $false }
 }
 else { return $true	}
}                    # Get-SnapinStatus and optionally load it into memory
Function Convert-OutputForCSV {
    <#
        .SYNOPSIS
            Provides a way to expand collections in an object property prior
            to being sent to Export-Csv.

        .DESCRIPTION
            Provides a way to expand collections in an object property prior
            to being sent to Export-Csv. This helps to avoid the object type
            from being shown such as system.object[] in a spreadsheet.

        .PARAMETER InputObject
            The object that will be sent to Export-Csv

        .PARAMETER OutPropertyType
            This determines whether the property that has the collection will be
            shown in the CSV as a comma delimmited string or as a stacked string.

            Possible values:
            Stack
            Comma

            Default value is: Stack

        .NOTES
            Name: Convert-OutputForCSV
            Author: Boe Prox
            Created: 24 Jan 2014
            Version History:
                1.1 - 02 Feb 2014
                    -Removed OutputOrder parameter as it is no longer needed; inputobject order is now respected 
                    in the output object
                1.0 - 24 Jan 2014
                    -Initial Creation

        .EXAMPLE
            $Output = 'PSComputername','IPAddress','DNSServerSearchOrder'

            Get-WMIObject -Class Win32_NetworkAdapterConfiguration -Filter "IPEnabled='True'" |
            Select-Object $Output | Convert-OutputForCSV | 
            Export-Csv -NoTypeInformation -Path NIC.csv    
            
            Description
            -----------
            Using a predefined set of properties to display ($Output), data is collected from the 
            Win32_NetworkAdapterConfiguration class and then passed to the Convert-OutputForCSV
            funtion which expands any property with a collection so it can be read properly prior
            to being sent to Export-Csv. Properties that had a collection will be viewed as a stack
            in the spreadsheet.        
            
    #>
    #Requires -Version 3.0
    [cmdletbinding()]
    Param (
        [parameter(ValueFromPipeline)]
        [psobject]$InputObject,
        [parameter()]
        [ValidateSet('Stack','Comma')]
        [string]$OutputPropertyType = 'Stack'
    )
    Begin {
        $PSBoundParameters.GetEnumerator() | ForEach {
            Write-Verbose "$($_)"
        }
        $FirstRun = $True
    }
    Process {
        If ($FirstRun) {
            $OutputOrder = $InputObject.psobject.properties.name
            Write-Verbose "Output Order:`n $($OutputOrder -join ', ' )"
            $FirstRun = $False
            #Get properties to process
            $Properties = Get-Member -InputObject $InputObject -MemberType *Property
            #Get properties that hold a collection
            $Properties_Collection = @(($Properties | Where-Object {
                $_.Definition -match "Collection|\[\]"
            }).Name)
            #Get properties that do not hold a collection
            $Properties_NoCollection = @(($Properties | Where-Object {
                $_.Definition -notmatch "Collection|\[\]"
            }).Name)
            Write-Verbose "Properties Found that have collections:`n $(($Properties_Collection) -join ', ')"
            Write-Verbose "Properties Found that have no collections:`n $(($Properties_NoCollection) -join ', ')"
        }
 
        $InputObject | ForEach {
            $Line = $_
            $stringBuilder = New-Object Text.StringBuilder
            $Null = $stringBuilder.AppendLine("[pscustomobject] @{")

            $OutputOrder | ForEach {
                If ($OutputPropertyType -eq 'Stack') {
                    $Null = $stringBuilder.AppendLine("`"$($_)`" = `"$(($line.$($_) | Out-String).Trim())`"")
                } ElseIf ($OutputPropertyType -eq "Comma") {
                    $Null = $stringBuilder.AppendLine("`"$($_)`" = `"$($line.$($_) -join ', ')`"")                   
                }
            }
            $Null = $stringBuilder.AppendLine("}")
 
            try { Invoke-Expression $stringBuilder.ToString() }
            catch { 
             $bp = 0 
            }
        }
    }
    End {}
}
Function Write-DriveMapGeneralProperties {
 param ( $driveMapSetting )
 Write-Host "".padright($padright,"=") @script:Green
 Write-Host "Order                : $($driveMapSetting.GPOSettingOrder)".padright($padright) @Gray
 Write-Host "Name                 : $($driveMapSetting.name)".padright($padright)  @Gray
 switch ( $($driveMapSetting.Properties.action) ) {
  "C"     { $action = "Create"    }
  "R"     { $action = "Reconnect" }
  "D"     { $action = "Delete"    }
  Default { $action = $($driveMapSetting.Properties.action) }
 }
 Write-host "Action               : $action".padright($padright) @Gray
 Write-Host "Hide/Show this drive : $($driveMapSetting.Properties.thisDrive)".padright($padright) @Gray
 Write-Host "Hide/Show all drives : $($driveMapSetting.Properties.allDrives)".padright($padright) @Gray
 if ($($driveMapSetting.Properties.persistent) -eq 0 ) {
  $persistent = "DISABLED"
 }
 else {
  $persistent = "ENABLED"
 }
  if ($($driveMapSetting.Properties.useLetter) -eq 0 ) {
  $useLetter = "DISABLED"
 }
 else {
  $useLetter = "ENABLED"
 }
 Write-Host "Reconnect/Persistent : $persistent".padright($padright) @Gray
 Write-host "UserName             : $($driveMapSetting.Properties.userName)".padright($padright) @Gray
 $path = $driveMapSetting.Properties.path
 $path = $path.replace("\%username%","") 
 $Error.Clear()
 $pathConnection = ""
 try { $pathAvailable = Test-Path $path }
 catch { }
 if ( $Error ) {
  $colours = $script:Yellow 
  if ( $Error.Exception.message -eq "Access is denied" ) {
   $pathConnection = " - ERROR: Access is denied"
  }
  else {
   $pathConnection = " - ERROR: Path not reachable"
  }
 }
 else {
  if ( $pathAvailable ) {
   $colours = $script:Gray
  }
  else {
   $pathConnection = " - ERROR: Path not reachable"
   $colours = $script:Yellow
  }
 }
 write-host "Path                 : $($driveMapSetting.Properties.path)$pathConnection".padright($padright) @colours
 Write-host "Label as             : $($driveMapSetting.Properties.label)".padright($padright) @Gray
 Write-host "useLetter            : $userLetter".padright($padright) @Gray
 Write-host "Letter               : $($driveMapSetting.Properties.letter)".padright($padright) @Gray
 Write-Host "".padright($padright,"-") @script:Green 
 Return $pathConnection
}
Function Count-Filters {
 param ($filters)
 $noOfFilters = 0 
 if ( $filters -ne $null ) {
  $noOfFilters = $filters.count
  if ( $noOfFilters -eq $null ) {
   $noOfFilters = 1
  }
 }
 $noOfFilters
}
Function Get-DriveMappings {
 param ( $GPOName )
 $([xml]$(Get-GPOReport -Name $GPOName -ReportType Xml)).GPO.User.extensionData[0].Extension.DriveMapSettings
 #$driveMaps.GPO.User.extensionData[0].Extension.DriveMapSettings.Drive # - will list all the drive maps in the GPO 
}
Function Count-DriveMappings {
 param ( $driveMappings ) 
 $NoOfMappings = $driveMappings.Drive.count
 if ( $NoOfMappings -eq $null ) {
  if ( $driveMappings.Drive -ne $null ) {
   $NoOfMappings = 1
  }
 }
 $NoOfMappings
}
Function Get-Filters {
 param ( $filters,$filterType)
 $filterSettings = @()
 ForEach ( $filter in $filters ) {
  $objectMissing = "'ERROR: Object Not found'"
  $objectType = $filterType
  switch ( $filterType ) {
   "Site" {
    if ( $script:Sites -contains $($filter.name) ) {
     $objectMissing = ""
    }
    Break
   }
   "User" {
    if ( Get-QADUser -SamAccountName $($filter.name) -Enabled -SearchRoot $script:searchRoot  ) {
     $objectMissing = ""
    }
    Break 
   }
   "Group" {
    if ( Get-QADGroup $($filter.name) -SearchRoot $script:searchRoot  ) {
     $objectMissing = ""
    }
    Break 
   }
   Default {
    if ( Get-QADObject $($filter.name) -SearchRoot $script:searchRoot -Type organizationalUnit  ) {
     $objectMissing = ""
    }
    Break 
   }
  }
  if ( ( $filter.userContext ) -and ( $filter.userContext -ne 1 ) ) {
   $bp = 0 
  }
  <# other settings that may be of interest 
   userContext,primaryGroup,localGroup - but would require a way of reporting this
  #>
  $filterSetting = "$($filter.bool) $(if ( $filter.not -eq 1 ){'NOT'}) $filterType = $($filter.name) $objectMissing "
  if ( $objectMissing -ne "" ) {
   $colours = $script:Yellow
  }
  else {
   $colours = $script:gray
  }
  Write-Host $filterSetting.padright($padright) @colours 
  $filterSettings += $filterSetting 
 }
 if ( $filterSettings.Count -ge 1 ) {
  Write-Host "".padright($padright,"-") -ForegroundColor Green -BackgroundColor Black
 }
 Return ,$filterSettings
}
Function Set-DriveMapping {
 param ( $driveMapSetting,$filterSettings,$pathConnection)
 $driveMapping = "" | select order,name,Action,useLetter,Letter,path,label,condition,'Hide/Show this drive','Hide/Show all drives','Reconnect/Persistent',UserName
 $driveMapping.order = $($driveMapSetting.GPOSettingOrder) 
 $driveMapping.name = $($driveMapSetting.name)
 switch ( $($driveMapSetting.Properties.action) ) {
  "C"     { $action = "Create" }
  Default { $action = $($driveMapSetting.Properties.action) }
 }
 $driveMapping.Action = $action
 $driveMapping.Letter = $($driveMapSetting.Properties.Letter)
 $driveMapping.path = "$($driveMapSetting.Properties.path) $pathConnection".trim() 
 $driveMapping.label = $($driveMapSetting.Properties.label)
 $driveMapping.condition = $filterSettings
 $driveMapping.'Hide/Show this drive' = $($driveMapSetting.Properties.thisDrive)
 $driveMapping.'Hide/Show all drives' = $($driveMapSetting.Properties.allDrives)
 if ($($driveMapSetting.Properties.persistent) -eq 0 ) {
  $persistent = "DISABLED"
 }
 else {
  $persistent = "ENABLED"
 }
  if ($($driveMapSetting.Properties.useLetter) -eq 0 ) {
  $useLetter = "DISABLED"
 }
 else {
  $useLetter = "ENABLED"
 } 
 $driveMapping.'Reconnect/Persistent' = $($driveMapSetting.Properties.persistent)
 $driveMapping.useLetter = $useLetter
 $driveMapping.UserName = $($driveMapSetting.Properties.userName)
 $driveMapping 
}
#endregion Helper Functions
#region load required powershell modules
if ( ( Get-ModuleStatus "ActiveRolesManagementShell" ) -eq $false ) { # load the quest cmdlets
 Write-Host "You need to have the Quest ARS 6 or 7 commandlets installed for this script to work".padright($padright) @yellow 
 Write-host "Trying to load VARS 6 commandlets".padright($padright) @green
} # throw if we are unable to load the Quest cmdlets
elseif ( $(Get-SnapinStatus "Quest.ActiveRoles.ADManagement") -eq $false ) { # load the quest cmdlets
 	Throw "Quest.ActiveRoles.ADManagement could not be loaded SCRIPT HALTING - Please investigate"
} # throw if we are unable to load the Quest cmdlets
if ( ( Get-ModuleStatus "GroupPolicy" ) -eq $false ) { # load the quest cmdlets
 Throw "FATAL ERROR - Unable to Load GroupPolicy COMMANDLETS"
} # throw if we are unable to load the Quest cmdlets
#endregion load required powershell modules
#region get AD Sites
$script:searchRoot = "DC=myDomain,DC=com"
$connection        = Connect-QADService "mydomain.com"
$configNCDN        = (Get-QADRootDSE -Connection $connection).ConfigurationNamingContext
$siteContainerDN   = ("CN=Sites," + $configNCDN)
$script:Sites      = Get-QADObject `
                     -SearchRoot $siteContainerDN `
                     -LdapFilter "(objectClass=site)" `
                     -IncludedProperties siteObjectBL,location,description | 
                     select -ExpandProperty Name #,Location,Description,siteObjectBL 
#endregion get AD Sites
#region Process Drive Maps 
cls
$driveMapSettings      = Get-DriveMappings $GPOName
if ( ! ( $NoOfMappings = Count-DriveMappings $driveMapSettings ) ) {
 Write-Host "No Drive Mapping Found - Quitting Script".padright($padright) @yellow 
 exit
}
if ( $limitDriveMapsTo -gt 0 ) {
 $NoOfMappings = $limitDriveMapsTo # used to limit the bnumber of drive maps processed for debug 
}
$driveMappings = @() 
for ($index = 0; $index -lt $NoOfMappings; $index++) {
 #region Initialise loop variables and print drive map info 
 $filterSettings         = @()
 $driveMapSetting        = $driveMapSettings.Drive[$index] 
 $driveMapSettingFilters = $driveMapSetting.Filters
 $NoOfCollections        =  Count-Filters $driveMapSettingFilters.FilterCollection 
 $pathConnection = Write-DriveMapGeneralProperties $driveMapSetting  
 #endregion Initialise loop variables and print drive map info 
 #region Check if there are multiple collections defined 
 if ( $NoOFCollections -gt 1 ) {
  $collectionFilters = @()
  for ($indexColection = 0; $indexColection -lt $NoOfCollections; $indexColection++) {
   Write-Host "==========< $($driveMapSettingFilters.FilterCollection[$indexColection].bool) $(if ( $driveMapSettingFilters.FilterCollection[$indexColection].not -eq 1 ) { 'NOT' }) FilterCollection: $indexColection>==========".padright($padright) @cyan
   $filterSettings +=  "==========< $($driveMapSettingFilters.FilterCollection[$indexColection].bool) $(if ( $driveMapSettingFilters.FilterCollection[$indexColection].not -eq 1 ) { 'NOT' }) FilterCollection: $indexColection>=========="
   $userFilters  = $(Count-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterUser)
   $groupFilters = $(Count-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterGroup)
   $OUfilters    = $(Count-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterOrgUnit)
   $siteFilters  = $(Count-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterSite) 
   if ( $userfilters -gt 0 ) {
    $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterUser "User" 
   }  
   if ( $groupfilters -gt 0 ) {
    $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterGroup "Group" 
   }  
   if ( $OUfilters -gt 0 ) {
    $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterOrgUnit "OU"
   }
   if ( $siteFilters -gt 0 ) {
    $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection[$indexColection].FilterSite "Site"
   }
   $bp = 0 
   $filterSettings += "===========<End FilterCollection: $indexColection>==========="
  }
 } #if ( $NoOFCollections -gt 1 ) {
 #endregion Check if there are multiple collections defined 
 #region Check if there was a single collection defined 
 elseif ( $NoOfCollections -eq 1 ) {
  Write-Host "==========< $($driveMapSettingFilters.FilterCollection.bool) $(if ( $driveMapSettingFilters.FilterCollection.not -eq 1 ) { 'NOT' }) FilterCollection: 0>==========".padright($padright) @cyan
  $filterSettings +=  "==========< $($driveMapSettingFilters.FilterCollection.bool) $(if ( $driveMapSettingFilters.FilterCollection.not -eq 1 ) { 'NOT' }) FilterCollection: 0>=========="
  $userFilters  = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterUser)
  $groupFilters = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterGroup)
  $OUfilters    = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterOrgUnit)
  $siteFilters  = $(Count-Filters $driveMapSettingFilters.FilterCollection.FilterSite) 
  if ( $userfilters -gt 0 ) {
   $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection.FilterUser "User" 
  }  
  if ( $groupfilters -gt 0 ) {
   $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection.FilterGroup "Group" 
  }  
  if ( $OUfilters -gt 0 ) {
   $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection.FilterOrgUnit "OU"
  }
  if ( $siteFilters -gt 0 ) {
   $filterSettings += Get-Filters $driveMapSettingFilters.FilterCollection.FilterSite "Site"
  }
  $bp = 0 
  $filterSettings += "===========<End FilterCollection: 0>==========="
 } # elseif ( $NoOfCollections -eq 1 ) {
 #endregion Check if there was a single collection defined 
 #region process any other drive maps 
 $groupFilters = $(Count-Filters driveMapSettingFilters.FilterGroup)
 $OUfilters    = $(Count-Filters driveMapSettingFilters.FilterOrgUnit)
 $siteFilters  = $(Count-Filters $driveMapSettingFilters.FilterSite) 
 if ( $groupfilters -gt 0 ) {
  $filterSettings += Get-Filters $driveMapSettingFilters.FilterGroup "Group"
 }  
 if ( $OUfilters -gt 0 ) {
  $filterSettings += Get-Filters $driveMapSettingFilters.FilterOrgUnit "OU"
 }
 if ( $siteFilters -gt 0 ) {
  $filterSettings += Get-Filters $driveMapSettingFilters.FilterSite "Site"
 }
 $driveMappings +=  Set-DriveMapping $driveMapSetting $filterSettings $pathConnection 
 #endregion process any other drive maps
} # for ($index = 0; $index -lt $NoOfMappings; $index++) {
#endregion Process Drive Maps 
#region ExportResults 
$driveMappings | select order,name,Action,Letter,path,label,condition,'Hide/Show this drive','Hide/Show all drives','Reconnect/Persistent',UserName,UseLetter | Convert-OutputForCSV | Export-Csv $exportFileName -NoTypeInformation 
#endregion ExportResults 

Group Nesting Strategy – stop the madness.

Talk about getting side tracked looking to confirm the syntax of a PowerShell command I came across this post on the Quest One Identity forum and started to answer it, then realised that I should really be posting here not in the forum and then use a shameless plug on the One Identity forum.

This was the question:

“We use the lousy nested structure for shared folder ntfs permissions where a domain local group contains a universal which contains a global and the global has the users.  I want to find a way to create the 3 groups required when a new folder is setup, then add users to the global group”

Woah! 3 groups for every file share!!! I want to scream stop the madness now!

I’ve been trying to explain this for years and I really want to enlist a few people in the guido school of administration

“Our Guido School of Admin Training is a very informal school held outside in the nice, fresh air in the alley between two office buildings. There are no formal registration procedures, however you do have to be nominated to attend.

What to expect: The instructor, Guido, will shake your hand and then gently haul you over to the nearest wall by your collar. Then it becomes really exciting and lots of fun. With a jaunty smile, Guido grabs you securely by the back of the neck and smacks your face against the wall while saying in a firm tone of voice:

Do {smack} not {smack} nest {smack} security {smack} groups {smack} into {smack} protected {smack} groups.”

Only in my case its do {smack} not {smack} just create groups {smack} for the sake of nesting them {smack}!

I’ve never heard anyone recommend a 3 level group nesting strategy suggested in the post 2 is already one too many and possibly two too many.

Use the appropriate groups for the job and NEVER create a group unless you have a good reason to.

The standard Microsoft teaching is to create 2 groups a domain local or universal and a global group nesting the global in the local.

That’s not the idea at all and is why almost every AD I’ve ever seen is a complete mess

Often an Active Directory will have more groups than the company has employees by several multiples which should tell you something is wrong.  Do a quick count in your environment and see how deep you are into this bad practice.

Let’s take a quick look at this.  You create a local group or a universal group,  well which is it ?  A local group can only be used to secure resources in the domain in which it is created.  A universal group is available in all domains in the forest.   Both groups can contain users and groups from all trusted domains.  So which group depends if you have resources in multiple domains or just one.

Now we come to the crux of the problem here.  You create a global group, add the users to the global group and then nest the global group in the local group.

Why not just add the users to the local group?

Good question.  Why not?  There is no real reason but then whats the point of a global group at all? Another good question.

Did you know that a global group takes up less room in the security token so you can be a member of more global groups than you can local groups before you get authentication issues because you have run out of allocated memory space for the security token.

So why not just use global groups then?

If you don’t have any trusted domains then you could just use global groups but we are getting away from the point here.

Why do Microsoft tell us to so the nesting?

Well the short answer is because this is the most flexible way of doing things.

So whats wrong with it then – what are they not telling you on the training courses?

The problem is that if you are creating a local group and a global group and there is a one to one relationship every time, then as shown above you could just use the local or global group.  This happens because when you designed the delegation model you did this in isolation from everything else and your solution is actually quite sound but if you step back a little and perhaps look at the wider picture you might see whats wrong.

What was it Microsoft said the global groups were for?

You add the users to the global groups, right?  Whats the global group called?  The group name should reflect the group of users you just added to it.   If for example we were controlling access to a finance file share the global group could reasonably be expected to be called ‘Finance Users’ now what about the local group, whats that called and what’s it for?  Well the local or universal group should be named after what it’s controlling access to.   I’ve adopted the term ‘capability group’ from a book which I promise I’ll look up and post the name as it’s an excellent book explaining how to delegate properly.  The local group name might be ‘Finance Share’  ( I’m not getting into naming conventions this is simplified just for this post).  Lets also suppose there are some applications the ‘Finance Users’ need installed on their PCs, this could be set up using a local group called ‘Finance Applications’ and we could nest the ‘Finance Users’ group in the two local groups.

A self documenting solution

If I look at a user and I see he is a member of the ‘Finance Users’  group, even if I don’t add HR data to my user objects, e.g. set the department attribute to ‘Finance’ I can see that the user is a finance user.   If I did populate the AD users department value then the ‘Finance Users’ group could be managed automatically by creating an ARS dynamic group or you could write a custom script to manage the group as a scheduled task.  Also when I look at the ‘Finance Users’ group I can see it is a member of two local groups, ‘Finance Applications’ and ‘Finance Share’ so I can now see just from looking at the groups what the ‘Finance Users’ have access to.

Now lets say you build another file server and want to share this out to Sales and Finance.  Create a ‘capability’ group called ‘Sales and Finance Share’, create a global group and add all the sales employees to the group and then nest BOTH the ‘Sales User’ and ‘Finance Users’ in the ‘Sales and Finance Share’ group.

What no one is pointing out is that we want to REUSE the global groups – maybe they just think it’s obvious but trust me in my experience it’s not.

The global groups should be reused and they could be considered role groups, although this is where there possibly is a case for for a 3 group nesting strategy but be careful in a large environment you will hit the group limit.  When you hit the limit the access token will be truncated causing access issues when the group you need to access the resource cannot fit in the allocated memory.  This will cause an ‘access denied’ or WORSE will get access because the DENY group wouldn’t fit in the memory which is another reason to NOT use DENY anywhere just don’t give them access in the first place.

In an ideal world…..

Your ideal is that when a new employee starts they are added to a single group, e.g. the ‘Finance Users’ group, automatically and this group give access to everything they need to do their job either by adding the global group directly to the ACL or by nesting it in a local group that has been applied to the ACL of the resource.  If they move departments, e.g. from ‘Sales’ to ‘Finance’ their group membership automatically gets updated.

 

Checking your ARS Scheduled tasks ran OK

How do you know that all of your ARS scheduled tasks have completed successfully?

Well the only way provided by Quest is to look at the scheduled task information provided in the MMC.   You’ll notice there are three columns

  • name
  • Last Run Time
  • Last Run Message

Sadly the Last Run Message will generally be empty as it only displays errors so perhaps this should have been named Last Error Message!

The Last Run Time is useful but do you know when it was last supposed to run? To find that out you need to open the properties and read the schedule.

All of that aside though you have to remember to check!

For my ARS scheduled tasks I have always used the Last Run Message as a way of easily telling that the task ran without errors.

“All of my scheduled task scripts THROW an error on purpose!

This might seem a little odd but I force errors using “throw” so that I can write a message on the Last Run Message column.

This has worked well, but I still had to check that the script ran on schedule so I decided to write an audit script that will read the Last Run Messages, the last run time and understand the next run time and email me if it finds an unexpected error or that the script missed the last scheduled run time.

Getting the Last Run Message is simple, a task object has a property called ‘edsaLastActionMessage’ which contains the droid we are looking for.  ( A very sad reference to Star Wars – sorry couldn’t resist )

It would therefore be very easy to code an audit script to look for specific text to confirm the task had completed successfuly.

If I hard code the messages though I’ll need to update the audit script every time
I modify the scheduled task script or added a new scheduled task script.

I also didn’t want to be limited to specific text I could THROW in my scheduled task script so I decided to use a new parameter that I add to each scheduled task I want to audit, called LastRunLine.  This is dynamically updated by the script when it runs so is always correct even when I edit the script, i.e. I don’t need to remember to update it manually.

The first task is to get all of the scheduled tasks like this :

$taskParameterList = @(
 'name'
 'edsaDisableSchedule'
 'edsaXMLSchedule'
 'edsaLastRunTime'
 'edsvaNextRunTime'
 'edsaLastActionMessage'
 'edsaTaskState'
 'edsvaIsReadyToTerminate'
 'edsvaServerNameToExecute'
 'edsvaTaskStateString'
 'DN'
 'ParentContainerDN'
)
$tasksOU  = "CN=Clan8,CN=Scheduled Tasks,CN=Server Configuration,CN=Configuration" ' this is actually a parameter
$tasks     = Get-QADObject -SearchRoot $tasksOU -IncludedProperties $taskParameterList -Type edsScheduledTask | select $taskParameterList

We can then loop around the tasks we found like this : foreach ( $taskInfo in $tasks ) {

In each loop we can check if the task is currently running and it it is we have to ignore it as the last run message will be blank

if ( $taskInfo.edsvaTaskStateString -eq 'Task started its execution' ) {
Continue
}

We get the parameters of the task like this:

$strParameters = Get-ScheduledTaskParameters $taskInfo.DN
$lastRunTime = $taskInfo.edsaLastRunTime
$nextRunTime = $taskInfo.edsvaNextRunTime

I’m only interested in a task if I have a parameter called lastrunLine
if ( ! ( $lastRunLineNo = [string]$xmlParameters.parameters.lastRunLine ) ) {
Continue
}

Now we can check if we got a last run message and compare this with the expected string lastRunLineNo
Based on this outcome I can raise an alert or not.

An example last run message is: ‘At Line:425 Char:1 Found 60 users’ The first part of this string is automatically created by ARS.

I check the Line number the script ‘failed’ on with the LastRunLine parameter. If it matches then the task completed succesfully.

When getting the task details I also get the last run time and the next run time, ‘edsaLastRunTime’ and ‘edsvaNextRunTime’

This allows me to check if the task has missed it’s run time and raise an alert.

Lastly because I want my audit script to run every hour it would repeatedly report the same errors and ideally I only want this to be reported once.
To solve this issue I manually update the last run message prefixing it with ‘ERROR:’ using:

Set-QADObject $taskInfo.DN -ObjectAttributes @{edsalastactionmessage="ERROR: '$($taskInfo.edsaLastActionMessage.Trim())'"} -proxy

Here’s the full script. If you run this in an editor it will automatically set the debug parameters and display progress messages to screen as it goes.
This prevents you accidentily uploading the script to ARS with the debug parameter still set to true

#region Script Header : Audit-ScheduledTasks 
$script:scriptVersion = "2.00" 
$script:scriptName    = "Audit-ScheduledTasks_v$script:scriptVersion"
$script:Owner         = "lee.Andrews@mydomain.com" # used to send script specific error messages
$script:taskDN        = "CN=Audit-ScheduledTasks,CN=Audit,CN=Clan8,CN=Scheduled Tasks,CN=Server Configuration,CN=Configuration"
$thisLastRLNo         = 441 # update this line when you update the script
$errorInRun           = $true 
if ( $Task.DirObj ) {
 $script:ShowDebug = $false
}
else {
 $script:ShowDebug = $true
 cls 
 $padright = 100 
}
#endregion Script Header : Audit-ScheduledTasks
#region Set default SMTP variables
$emailParameters =@{
 "smtpServer"="smtp.,mydomain.com"
 "To"=$script:Owner
 "From"="NoReply@mydomain.com"
}
#endregion Set default SMTP variables
#region Version Control Information
<# 
  Version 1.00 Original
  Version 1.01 Automatically sets debug variable using the task object 
  Version 1.02 Adds Report Header and simplifies audit check 
  Version 1.03 Updated debug routines and auto update of the last run message if script is in Debug mode
  Version 2.00 Now uses the ARS 7.0 commandlets 
#>
#endregion Version Control Information
#region Helper functions
function Get-CurrentLineNumber {
 $MyInvocation.ScriptLineNumber
}
function Get-ModuleStatus { # http://www.ehloworld.com/938
	[CmdletBinding(SupportsShouldProcess=$true)]
	param (
		[parameter(ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, Mandatory=$true, HelpMessage="Enter the Module Name, e.g. Lync")]
		[string]$name
	)
	if(!(Get-Module -name "$name")) {
		if( Get-Module -ListAvailable | ? {$_.name -eq "$name"} ) {
			try { 
				Import-Module -Name "$name" 
				return $true
			}
			catch { return $false	}
		} 
		else { return $false }
	}
	else { return $true	}
} # end function Get-ModuleStatus
function Get-TaskParameters {
 function Convert-OctetStringToGuid {
  param (
   [Parameter(Mandatory=$True,Position=1)][string]$Guid
  )
  if(32 -eq $guid.Length) {
   [UInt32]$a = [Convert]::ToUInt32(($guid.Substring(6, 2) + $guid.Substring(4, 2) + $guid.Substring(2, 2) + $guid.Substring(0, 2)), 16)
   [UInt16]$b = [Convert]::ToUInt16(($guid.Substring(10, 2) + $guid.Substring(8, 2)), 16)
   [UInt16]$c = [Convert]::ToUInt16(($guid.Substring(14, 2) + $guid.Substring(12, 2)), 16)

   [byte]$d = ([Convert]::ToUInt16($guid.Substring(16, 2), 16) -as [byte])
   [byte]$e = ([Convert]::ToUInt16($guid.Substring(18, 2), 16) -as [byte])
   [byte]$f = ([Convert]::ToUInt16($guid.Substring(20, 2), 16) -as [byte])
   [byte]$g = ([Convert]::ToUInt16($guid.Substring(22, 2), 16) -as [byte])
   [byte]$h = ([Convert]::ToUInt16($guid.Substring(24, 2), 16) -as [byte])
   [byte]$i = ([Convert]::ToUInt16($guid.Substring(26, 2), 16) -as [byte])
   [byte]$j = ([Convert]::ToUInt16($guid.Substring(28, 2), 16) -as [byte])
   [byte]$k = ([Convert]::ToUInt16($guid.Substring(30, 2), 16) -as [byte])

   [Guid]$g = New-Object Guid($a, $b, $c, $d, $e, $f, $g, $h, $i, $j, $k)
   return $g.Guid;
  }
  else {
   throw Exception("Input string is not a valid octet string GUID")
  }
 } # end function Convert-OctetStringToGuid 
 try {
  $Task.DirObj.GetInfo() 
  $Script:taskDN         = $($Task.DirObj.Get('distinguishedName')) # set to the actual task DN in case we trigger the catch clause
  $Task.DirObj.GetInfoEx(@('edsaParameters'),0)  #  if no parameters are specified this command will trigger the catch clause
  $strParameters         = $Task.DirObj.Get('edsaParameters')
  $Script:taskDNDefault  = "Scheduled Task...: $($Task.DirObj.Get('distinguishedName')). `r`n"  
  $byteString            = $Task.DirObj.Get('edsaModule')
  $byteString            = [BitConverter]::ToString($byteString)
  $OctetString           = $byteString.replace('-','')
  $taskModule            = Convert-OctetStringToGuid -Guid $OctetString 
  $script:scriptName     = $(Get-QADObject $taskModule -proxy  ).name 
 }
 catch {
  try {
   $Task                 = Get-QADObject -Identity $script:TaskDN  -proxy  -IncludedProperties edsaParameters,edsaModule 
   $strParameters        = $Task.edsaParameters
   $Script:taskDNDefault = "Scheduled Task...: $script:taskDN`r`n"  
   $taskModule           = Convert-OctetStringToGuid $Task.edsaModule 
   $script:scriptName    = $(Get-QADObject $taskModule -proxy  ).name 
  }
  catch {
   return 'Unable to get task parameters'
  }
 }
 $strParameters = '<parameters>' + $strParameters + '</parameters>' 
 if ( Test-Path variable:Task        ) { Remove-Variable -Name Task        -ErrorAction SilentlyContinue }
 if ( Test-Path variable:byteString  ) { Remove-Variable -Name byteString  -ErrorAction SilentlyContinue }
 if ( Test-Path variable:OctetString ) { Remove-Variable -Name OctetString -ErrorAction SilentlyContinue }
 return $strParameters
}
function Set-TaskParameters {
 param (
  [Parameter(Mandatory=$True,Position=1)]$htParameters,
  [Parameter(Mandatory=$True,Position=2)]$taskDN,
  [Parameter(Mandatory=$True,Position=3)]$connection 
 )
 $htParameterList = @()
 foreach ( $htParameter in $htParameters.GetEnumerator() ) {
  $htParameterList += $('<'+$htParameter.key+'>'+$htParameter.value+'</'+$htParameter.key+'>')
 }
 $null = Set-QADObject $taskDN -ObjectAttributes @{edsaParameters=$htParameterList} -proxy 
}
function Stop-ScriptRun {
 param (
  [Parameter(Mandatory=$True ,Position=1)][hashtable]$emailParameters,
  [Parameter(Mandatory=$True ,Position=2)][string]$throwMessage, 
  [Parameter(Position=3)][allowNull()][string]$logfile,
  [switch]$sendMail,
  [switch]$stop 
 )
 $emailBody = $emailParameters.item("body").split("`r`n")
 if ( ( $logfile ) -and ( Test-Path $logfile ) ) {
  foreach ( $line in $emailBody ) {
   $line | Out-File -FilePath $logfile -Append 
  }
 }
 try {
  if ($sendMail) {
   Send-MailMessage @emailParameters -Encoding ([Text.Encoding]::UTF8)
  }
  $returnMessage = "$throwMessage" + " " + $emailParameters.Item("Subject") + " :"
 }
 catch { $returnMessage += "$throwMessage UNABLE TO SEND EMAIL - $($emailParameters.Item('Subject')) :" }
 if ( $stop ) { 
  throw $returnMessage 
 }
 return $returnMessage
}
function Get-ScheduledTaskParameters {
 param ( $taskDN ) 
 try {
  $Task                 = Get-QADObject -Identity $taskDN  -proxy  -IncludedProperties edsaParameters,edsaModule 
 }
 catch {
  return 'Unable to get task parameters'
 }
 $strParameters        = $Task.edsaParameters
 $strParameters = '<parameters>' + $strParameters + '</parameters>' 
 if ( Test-Path variable:Task        ) { Remove-Variable -Name Task        -ErrorAction SilentlyContinue }
 return $strParameters
}
#endregion Helper functions
#region Connect to ARS service
if ( ( Get-ModuleStatus "ActiveRolesManagementShell" ) -eq $false ) { # load the quest cmdlets
 $message = "ActiveRolesManagementShell could not be loaded SCRIPT HALTING - Please investigate"
 $emailParameters.Add("body",$message)
 $emailParameters.Add("Subject","$scriptName Script FATAL ERROR - Unable to Load ARS COMMANDLETS")
 Stop-ScriptRun $emailParameters -stop -throwMessage "FATAL ERROR - Unable to Load ARS COMMANDLETS" -sendMail
} # throw if we are unable to load the Quest cmdlets
$script:hostname = $env:COMPUTERNAME
$script:hostCode = "$($script:hostName.substring(0,1))$($script:hostName.substring($($script:hostName.length-2),2))"
try { $proxy = Connect-QADService -Proxy -Service "PLONWBQSTS20.Clan8.ad.Clan8.com"  }
catch { 
 $subject = "FATAL ERROR: $script:hostname '$script:scriptName' Line No. $($error[0].InvocationInfo.ScriptLineNumber) - Unable to Connect to ARS server"
 $emailParameters.Add('body',"************* $script:hostname : Line $($error[0].InvocationInfo.ScriptLineNumber) - FATAL ERROR Unable to Connect to ARS server ******************" )
 $emailParameters.Add('Subject',$subject)
 Send-MailMessage @emailParameters -Encoding ([Text.Encoding]::UTF8)
 throw $subject
}
#endregion Connect to ARS service
#region Get Task Parameters
$strParameters      = Get-TaskParameters # returns an XML object containing all of the scheduled task parameters
if ( $strParameters -eq "Unable to get task parameters" ) {
 $emailParameters.Add("body","************* Line $($error[0].InvocationInfo.ScriptLineNumber) - FATAL ERROR: Failed to get script parameters ******************")
 $emailParameters.Add("Subject","$scriptName Script FATAL ERROR - Unable to get TASK PARAMETERS")
 Stop-ScriptRun $emailParameters -stop -throwMessage "FATAL ERROR - Unable to get TASK PARAMETERS" -sendMail 
}
$xmlParameters = [xml]$strParameters     
$emailAlert    = [string]$xmlParameters.parameters.emailAlert
$emailFrom     = [string]$xmlParameters.parameters.emailFrom 
$latestRunDate = [string]$xmlParameters.parameters.latestRunDate
$tasksOU       = [string]$xmlParameters.parameters.tasksOU
$smtpServer 	  = [string]$xmlParameters.parameters.smtpServer
$lastRunLine   = [string]$xmlParameters.parameters.lastRunLine
#endregion Get Task Parameters
#region set defaults if any paramters are missing
$defaultUsed = $false 
if ( $emailAlert  -eq '' ) { $defaultUsed = $true ; $emailAlert  = $script:Owner } else { $emailAlert = $emailAlert.split(",") }
if ( $emailFrom   -eq '' ) { $defaultUsed = $true ; $emailFrom   = $script:Owner } 
if ( $latestRunDate -eq '' ) { $defaultUsed = $true ; $latestRunDate = (Get-Date -Hour 0 -Minute 00 -Second 00).addDays(-1) } else { $latestRunDate = $(Get-Date $latestRunDate -Format "dd/MM/yyyy HH:mm:ss") }
if ( $lastRunLine -eq '' ) { $defaultUsed = $true ; $lastRunLine = $thisLastRLNo } 
if ( $tasksOU     -eq '' ) { $defaultUsed = $true ; $tasksOU     = "CN=Clan8,CN=Scheduled Tasks,CN=Server Configuration,CN=Configuration" } 
if ( $smtpserver  -eq '' ) { $defaultUsed = $true ; $smtpserver  = "smtp.Clan8.com" }
if ( [string]$xmlParameters.parameters.scriptOwner -eq "" ) {
 $defaultUsed = $true 
}
else {
 if ( $script:Owner -ne [string]$xmlParameters.parameters.scriptOwner ) {
  $script:Owner = [string]$xmlParameters.parameters.scriptOwner
 }
}
#endregion set defaults if any paramters are missing
#region Upload any missing default parameters to the calling scheduled task
if ( $defaultUsed ) {
 $taskParameters = @{
  'emailAlert'  = $($emailAlert -join ",")
  'emailFrom'   = $emailFrom
  'lastRunLine' = $lastRunLine
  'latestRunDate' = $(Get-Date $latestRunDate -Format "dd/MM/yyyy HH:mm:ss")
  'ScriptOwner' = $script:Owner 
  'smtpserver'  = $smtpserver
  'tasksOU'     = $tasksOU 
 }
 $Error.Clear()
 Set-TaskParameters -htParameters $taskParameters -taskDN $script:taskDN -connection $proxy
 $emailParameters.Add("body","Missing Parameter - Scheduled Task Updated and script halted.  Please run again after checking the parameters are set correctly")
 $emailParameters.Add("Subject","$scriptName Script Missing Parameter")
 Stop-ScriptRun $emailParameters -stop -throwMessage "Missing Parameter - Please run again after checking the parameters are set correctly" -sendMail
}
#endregion Upload any missing default parameters to the calling scheduled task
#region Set Script Runtime Parameters
$taskParameterList = @(
 'name'
 'edsaDisableSchedule'
 'edsaXMLSchedule'
 'edsaLastRunTime'
 'edsvaNextRunTime'
 'edsaLastActionMessage'
 'edsaTaskState'
 'edsvaIsReadyToTerminate'          
 'edsvaServerNameToExecute'
 'edsvaTaskStateString'
 'DN'
 'ParentContainerDN'
)
$tasks  = Get-QADObject -SearchRoot $tasksOU -IncludedProperties $taskParameterList -Type edsScheduledTask | select $taskParameterList
$report = ""
$fullReport = ""
$updateRunDate = $false 
if ( $script:showDebug ) {	Write-Host "".padright($padright,"=") -ForegroundColor Green -BackgroundColor Black }
$fullReport  = "================================================================================================================= `n"
$fullReport += "Script Name.......: '$script:scriptName' `n"
$fullReport += "Script Version....: '$script:scriptVersion' `n"
$fullReport += "Host Server.......: '$($env:COMPUTERNAME)' `n" 
$fullReport += "================================================================================================================= `n"
foreach ( $taskInfo in $tasks ) {
 if ( $taskInfo.Name -eq "Update-ManagedDomains" ) {
  $bp = 0 
 }
 if ( $taskInfo.edsvaTaskStateString -eq 'Task started its execution' ) { 
  $skippingMsg = "Skipping task $($taskInfo.name) as it is currently running"
  $fullReport += "$skippingMsg `n"
  $fullReport += "----------------------------------------------------------------------------------------------------------------- `n"
  if ( $script:showDebug ) {
   Write-Host "$skippingMsg".padright($padright) -ForegroundColor Gray -BackgroundColor Black 
   Write-Host "".padright($padright,"-") -ForegroundColor Green -BackgroundColor Black 
  } # if ( $script:showDebug ) {
  continue 
 } # if ( $taskInfo.edsvaTaskStateString -eq 'Task started its execution' ) {
 #Region Reset loop variables 
 $loopReport = ""
 $NumberStart = $null
 $NumberEnd   = $null 
 $errorInLoop = $false 
 #EndRegion Reset loop variables 
 if ( $taskInfo.edsaDisableSchedule ) { 
  $skippingMsg = "Skipping $($taskInfo.name) as it's disabled" 
  $fullReport += "$skippingMsg `n"
  $fullReport += "----------------------------------------------------------------------------------------------------------------- `n"
  if ( $script:showDebug ) {	
   Write-Host "$skippingMsg".padright($padright) -ForegroundColor Gray -BackgroundColor Black 
   Write-Host "".padright($padright,"-") -ForegroundColor Green -BackgroundColor Black 
  }
  continue 
 }  
 #Region  Set task variables
 $xmlData           = [xml]$taskInfo.edsaXMLSchedule
 $lastRunTime       = $taskInfo.edsaLastRunTime 
 $nextRunTime       = $taskInfo.edsvaNextRunTime 
 #if ( ( Get-Date (Get-Date) -Format d ) -eq (get-date $nextRunTime -Format d ) ) {
 $strParameters     = Get-ScheduledTaskParameters $taskInfo.DN  # returns an XML object containing all of the scheduled task parameters
 $xmlParameters     = [xml]$strParameters     
 if ( ! ( $lastRunLineNo     = [string]$xmlParameters.parameters.lastRunLine ) ) { 
  $skippingMsg = "Skipping task $($taskInfo.name) as it does not have the lastRunNo parameter so is out of scope"
  $fullReport += "$skippingMsg `n"
  $fullReport += "----------------------------------------------------------------------------------------------------------------- `n"
  if ( $script:showDebug ) {	
   Write-Host "$skippingMsg".padright($padright) -ForegroundColor Gray -BackgroundColor Black 
   Write-Host "".padright($padright,"-") -ForegroundColor Green -BackgroundColor Black 
  }
  continue 
 }
 $taskInfoNameMsg  = "Task Name.........: '$($taskInfo.Name)'"
 $taskContainerMsg = "Task Folder.......: '$($taskInfo.ParentContainerDN.Replace("",$tasksOU"",'').substring(3))'"
 $taskServerMsg    = "Task Server.......: '$($taskInfo.edsvaServerNameToExecute)'"
 if ( $script:showDebug ) {	Write-Host "$taskInfoNameMsg".padright($padright)  -ForegroundColor Green -BackgroundColor Black }
 if ( $script:showDebug ) {	Write-Host "$taskContainerMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
 if ( $script:showDebug ) {	Write-Host "$taskServerMsg".padright($padright)    -ForegroundColor Green -BackgroundColor Black }
 $loopReport += "$taskInfoNameMsg `n"
 $loopReport += "$taskContainerMsg`n"
 $loopReport += "$taskServerMsg`n"
 try { $lastActionMessage = "'$($taskInfo.edsaLastActionMessage.Trim())'" } 
 catch { 
  $bp = 0 
 }
 #EndRegion  Set task variables
 #Region Get LastRunLineNo Position  
 if ( $lastActionMessage.length -gt 0  ) { 
  if ( ( $lastActionMessage.IndexOf("ERROR") -ge 0 ) -and ( $taskInfo.DN -ne $script:taskDN ) ) {
   $taskErrorMsg = "Task ERROR already reported so skipping"
   if ( $script:showDebug ) {	Write-Host "$taskErrorMsg".padright($padright) -ForegroundColor Yellow -BackgroundColor Black }
   if ( $script:showDebug ) {	Write-Host "".padright($padright,"-")          -ForegroundColor Green  -BackgroundColor Black }
   $fullReport += $loopReport
   $fullReport += $taskErrorMsg
   $fullReport += "----------------------------------------------------------------------------------------------------------------- `n"
   continue
  }
  $lastRunMessage = "Last Run Message..: $lastActionMessage"
  if ( $script:showDebug ) {	Write-Host "$lastRunMessage".padright($padright) -ForegroundColor Green -BackgroundColor Black }
  $loopReport += "$lastRunMessage `n"
  $NumberStart = $lastActionMessage.IndexOf(":")+2
  $NumberEnd   = $lastActionMessage.IndexOf("char:")-$($NumberStart+1)
  try   { $derivedLineMsg = "Derived last line.: '$($lastActionMessage.Substring($NumberStart,$NumberEnd))'" }
  catch { $derivedLineMsg = "Derived last line.: None" }
  if ( $script:showDebug ) {	Write-Host "$derivedLineMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
  $loopReport += "$derivedLineMsg `n" 
 } # if ( $lastActionMessage.length -gt 0  ) {
 else {
  $lastRunMessage = "Last Run Message..: 'ERROR: no last run message'"
  if ( $script:showDebug ) {	Write-Host "$lastRunMessage".padright($padright) -ForegroundColor Green -BackgroundColor Black }
  $loopReport += "$lastRunMessage `n"
 } # else if ( $lastActionMessage.length -gt 0  ) {
 $lastRunLineNoMsg = "LastRunLine was...: '$lastRunLineNo'"
 if ( $script:showDebug ) {	Write-Host "$lastRunLineNoMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
 $loopReport += "$lastRunLineNoMsg `n"
 #EndRegion Get LastRunLineNo Position  
 #Region Check if task ran as expected
 if ( $xmlData.Schedule.Daily ) { 
  if ( $xmlData.Schedule.Daily.every ) {
   $unit = $xmlData.Schedule.Daily.every
   $units = "'Every $unit day(s) @ $time'" 
  }
  if ( $xmlData.Schedule.Daily.Start ) {
   if ( $xmlData.Schedule.Daily.Start.time ) {
    $time = Get-Date ([datetime]($xmlData.Schedule.Daily.Start.time)) -f T  
   } # if ( $xmlData.Schedule.Daily.Start.time ) {
  } # if ( $xmlData.Schedule.Daily.Start ) {
 } # if ( $xmlData.Schedule.Daily ) { 
 elseif ( $xmlData.Schedule.Monthly ) { 
  $DayOfWeek = $xmlData.Schedule.Monthly.days.weekday.'#text'
  $which = $xmlData.Schedule.Monthly.days.weekday.which
  $units = "'The $which $DayOfWeek of each Month'"
 }
 $scheduledUnitsMsg = "Schedule..........: $units "
 if ( $script:showDebug ) {	Write-Host "$scheduledUnitsMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
 $loopReport += "$scheduledUnitsMsg `n"  
 try   { $lastRunMsg = "Last Run..........: '$(get-date $lastRunTime -Format 'ddd, MMM dd, yyyy @ HH:mm' )'" }
 catch { $lastRunMsg = "Last Run..........: Not Available" }
 $loopReport += "$lastRunMsg `n"   
 if ( $script:showDebug ) {	Write-Host "$lastRunMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
 try   { $nextRunMsg = "Next Run..........: '$(get-date $nextRunTime -Format 'ddd, MMM dd, yyyy @ HH:mm')'" }
 catch {$nextRunMsg = "Next Run..........: Not Available" }
 if ( $script:showDebug ) {	Write-Host "$nextRunMsg".padright($padright) -ForegroundColor Green -BackgroundColor Black }
 $loopReport += "$nextRunMsg `n"  
 #EndRegion Check if task ran as expected
 if ( ( (Get-Date) -lt $nextRunTime ) -and ( ( $taskInfo.DN -eq $script:taskDN  ) -or ( ( $lastActionMessage.Length -gt 0  ) -and ( $lastRunLineNo -eq $($lastActionMessage.Substring($NumberStart,$NumberEnd)) ) ) ) ) {
  if ( $script:showDebug ) {	Write-Host "Last Run..........: 'Succesfull'".padright($padright) -ForegroundColor Cyan -BackgroundColor Black }
  $loopReport += "Last Run..........: 'Succesfull' `n"
 } # if ( ( (get-date) -lt $nextRunTime ) -and ( ( $taskInfo.DN -eq $script:taskDN  ) -or ( ( $lastActionMessage.Length -gt 0  ) -and ( $lastRunLineNo -eq $($lastActionMessage.Substring($NumberStart,$NumberEnd)) ) ) ) ) {
 else {
  $errorInLoop = $true
  if ( (Get-Date) -gt $nextRunTime ) {
   try { Set-QADObject $taskInfo.DN -ObjectAttributes @{edsalastactionmessage="ERROR: Missed last runtime"} -proxy }
   catch {
    $bp = 0 
   }
  }
  elseif ( $lastActionMessage.Length -gt 0 ) {
   try { Set-QADObject $taskInfo.DN -ObjectAttributes @{edsalastactionmessage="ERROR: '$($taskInfo.edsaLastActionMessage.Trim())'"} -proxy }
   catch {
    $bp = 0 
   }
  } # if ( $lastActionMessage.Length -gt 0 ) {
  else {
   Set-QADObject $taskInfo.DN -ObjectAttributes @{edsalastactionmessage="ERROR: task does not appear to have run please check any relevant logs"} -proxy 
   if ( $script:showDebug ) {	Write-Host "ERROR: task does not appear to have run please check any relevant logs".padright($padright) -ForegroundColor Yellow -BackgroundColor Black }
   $loopReport += "ERROR: task does not appear to have run please check any relevant logs `n"
  }
 } # else if ( ( (get-date) -lt $nextRunTime ) -and ( ( $taskInfo.DN -eq $script:taskDN  ) -or ( ( $lastActionMessage.Length -gt 0  ) -and ( $lastRunLineNo -eq $($lastActionMessage.Substring($NumberStart,$NumberEnd)) ) ) ) ) {
 $loopReport += "----------------------------------------------------------------------------------------------------------------- `n"
 if ( $errorInLoop )  {
  $report += $loopReport
 }
 $fullReport += $loopReport 
 if ( $script:showDebug ) {	Write-Host "".padright($padright,"-") -ForegroundColor Green -BackgroundColor Black  } 
} # ForEach ( $taskInfo in $tasks ) {
if ( $report.Length -gt 0 ) {
 if ( $script:showDebug ) {	Write-Host "".padright($padright,"=") -ForegroundColor Green -BackgroundColor Black }
 $report = "=============================================================================================================================== `n" + $report  
 # send an error report 
 Send-MailMessage -To $emailAlert -From $emailFrom -Body $report -SmtpServer $smtpServer -Subject "ERROR in ARS Scheduled Task Please investigate - $env:COMPUTERNAME" 
}
else {
 $errorInRun = $false 
}
$thisLastRLNo =  (Get-CurrentLineNumber) + 25
if ( $script:showDebug ) {	 Write-Host  "Line No = $thisLastRLNo".padright($padright) -ForegroundColor  Yellow -BackgroundColor Black } 
if ( $thisLastRLNo -ne $lastRunLine ) {
 $taskParameters = @{
  'emailAlert'  = $($emailAlert -join ",")
  'emailFrom'   = $emailFrom
  'lastRunLine' = $thisLastRLNo  
  'latestRunDate' = $(Get-Date $latestRunDate -Format "dd/MM/yyyy HH:mm:ss")
  'ScriptOwner' = $script:Owner 
  'smtpserver'  = $smtpserver
  'tasksOU'     = $tasksOU 
 }
 $Error.Clear()
 Set-TaskParameters -htParameters $taskParameters -taskDN $script:taskDN -connection $proxy
}
if ( $errorInRun -eq $false ) {
 $throwMessage = "Completed succesfully - $env:COMPUTERNAME"  
}
else {
 $throwMessage = "One or more tasks failed to complete please investigate - $env:COMPUTERNAME"
}
if ( $script:ShowDebug ) {
 $msg = "At line: $thisLastRLNo char:1. $throwMessage"
 Set-QADObject $script:TaskDN  -ObjectAttributes @{edsalastactionmessage=$msg} -proxy | Out-Null 
}
throw $throwMessage

 

Using an ARS Script in a standard Windows Scheduler Task

It’s been ages since I last blogged as I’ve been busy regression testing all of my ARS 6.9 scripts on ARS 7.0 and while doing this bringing them all in line. The scripts have been written over the last 7 years and it’s unbelievable the changes in style that I have made from my first scripts to the latest.

I wrote an audit script that would alert me if an ARS scheduled task didn’t run or had an unexpected error, an oxymoron if I ever heard one, how could an error be anything but unexpected. This script worked well and emails me if any of the scheduled task has an issue.

But what if the audit script fails?

My solution was to run the same audit script on another server using the Windows Task Scheduler and of course this script also reports on the audit scheduled task and warns me if it didn’t run.

But what if I edit the ARS scheduled task script?

Last BUT I promise, I’m getting to the point.  I didn’t want to have to remember to update the script in multiple locations whenever a change was made.  My solution was to use the following two commands in a simple script hosted on the second windows server and run by the task scheduler that downloads the script from ARS and uses Invoke-Expression to run the script locally.

$scriptText = $(Get-QADObject “GUID of the ARS Script” -IncludedProperties edsaScriptText -connection $proxy).edsaScriptText

$returnMsg = Invoke-Expression $scriptText

You can even substitute lines of code using  $scriptText.replace  but be careful, debugging is not going to be easy.

A central store for scripts you could run on ARS or any other Windows sever using the same code

There are lots of ways of centralising your scripts ( Network Share, GPO ) but if you have to run them on both ARS and externally as in this case it’s ideal.

 

Listing the line management chain

It’s easy to get the direct reports of a line manager but what about the users who are in the line management chain but not direct reports of the line manager you are interested in.

Here’s a function that will take a DN of the line manager and then walk up the tree from there until it reaches the line manager you are interested in. You could query all of your users or as in this example, I’m interested in dividing up the users in a specific group into the line management chains of two specific line managers.

Function Get-ManagementChain {
 param ( $managerDN,$stopDNs)
 if ( $stopDNs -contains $managerDN ) { 
  Return $managerDN
 }
 $manager = Get-QADUser $managerDN -IncludedProperties manager | select -ExpandProperty manager 
 if ( $stopDNs -contains $manager ) {
  return $manager
 }
 elseif ( $manager ) {
  Get-ManagementChain $manager $stopDNs
 }
 else {
  return $null 
 }
}

# these are the attributes I want in my report include and output
$incAttrs = @(
 'manager'
)

$outAttrs = @(
 @{n="GroupName";e={$groupName}}
 'sAMAccountName'
 'NTAccountName'
 'DisplayName'
 'department'
 @{n="managementLine";e={Get-ManagementChain $_.manager $stopDNs}}
 'manager'
)

# Amazingly the array above is expanded for each user in the pipeline – You would think it would be set when the variable was instantiated but it’s not!

# This is the list of line managers I am interested in
$stopDNs = @(
 "CN=<MANAGEROFINTEREST1>,DC=<MYDOMAIN>,DC=com"
 "CN=<MANAGEROFINTEREST2>,DC=<MYDOMAIN>,DC=com"
)

$sizelimit = 0
$groupName = "MYGROUPOFINTEREST"

# and off we go almost a 1 liner powershell script 🙂

Get-QADGroupMember "MYDOMAIN\$groupName" -IncludedProperties $incAttrs -SizeLimit $sizelimit | 
 select $outAttrs |
 Export-Csv "c:\temp\UserList.csv" -NoTypeInformation