Posts Tagged ‘Powershell’

Improving Notifications in System Center Operations Manager 2012

Anyone who depends on System Center Operations Manager 2012 (or any earlier version of SCOM, back to MOM) likely has noticed that notifications are a bit of a weak spot in the product.

To address this, we have use the “command channel” to improve the quality of messages coming out of SCOM.  Building on the backs of giants, we implemented a script that takes an AlertID from SCOM, and generated nicely formatted email and alpha-numeric pager messages with relevant alert details.

More recently, we have identified the need to generate follow-up notifications when an initial alert does not get addressed.  I went back to our original script, and updated it to use a new, custom Alert ResolutionState (“Notified”), and I have added logic to update the Alert CustomField1 and CustomField2 with data that is useful in determining whether or not an alert should get a new notification, and how many times follow-up notifications have been sent.

Heart-felt appreciation goes out to Tao Yang for his awesome work on his “SCOMEnhancedEmailNotification.ps1″ script, which served as the core for my work here.

Here is my version… I don’t have a lot of time to explain it, but hopefully the comments give you enough to go on. Apologies for the rather bad munging of quotation marks… wordpress hates me this month. If you want to use this code, search for ampersand-quot-semicolon, replace with actual quotation marks.

#=====================================================================================================
# AUTHOR:	J. Greg Mackinnon, Adapted from 1.1 release by Tao Yang 
# DATE:		2013-05-21
# Name:		SCOMEnhancedEmailNotification.PS1
# Version:	3.0
# COMMENT:	SCOM Enhanced Email notification which includes detailed alert information
# Update:	2.0 - 2012-06-30	- Major revision for compatibility with SCOM 2012
#								- Cmdlets updated to use 2012 names
#								- "Notified" Resolution Status logic removed
#								- Snapin Loading and PSDrive Mappings removed (replaced with Module load)
#								- HTML Email reformatted for readability
#								- Added '-format' parameter to allow for alphanumeric pager support
#								- Added '-diag' boolean parameter to create options AlertID-based diagnostic logs
# Update:   2.2 - 2013-05-16    - Added logic to update "CustomField1" alert data to reflect that notification has been sent for new alerts.
#								- Added logic to update "CustomField2" alert data to reflect the repeat count for new alert notification sends.
#								- Added support for specifying alerts with resolution state "acknowledged"
#                               - Did some minor adjustments to improve execution time and reduce memory overhead.
# Update:	3.0 - 2013-05-20	- Updated to reduce volume of PowerShell instance spawned by SCOM.  Added "mailTo" and "pageTo" paramerters to allow sending of both short
#                                         and long messages from a single script instance.
#								- Converted portions of script to subroutine-like functions to allow repetition (buildHeaders, buildPage, buildMail)
#								- Restored "Notified" resolution state logic.
#								- Renamed several variables for my own sanity.
#								- Added article lookup updates from Tao Yang 2.0 script.
# Usage:	.\SCOMEnhancedEmailNotification.ps1 -alertID xxxxx -mailTo @('John Doe;jdoe@mail.com','Richard Roe;rroe@provider.net') -pageTo @('Team Pager;teampage@page.provider.com')
#=====================================================================================================
#In OpsMgr 2012, the AlertID parameter passed in is '$Data/Context/DataItem/AlertId$' (single quote)
#Quotation marks are required otherwise the AlertID parameter will not be treated as a string.
param(
	[string]$alertID = $(throw 'A valid, quote-delimited, SCOM AlertID must be provided for -AlertID.'),
	[string[]]$mailto,
	[string[]]$pageto,
	[switch]$diag
)
Set-PSDebug -Strict

#### Setup Error Handling: ####
$error.clear()
#$erroractionpreference = "SilentlyContinue"
$erroractionpreference = "Inquire"

#### Setup local option variables: ####
## Logging: 
#Remove '$alertID' from the following two log file names to prevent the drive from filling up with diag logs:
$errorLogFile = 'C:\local\logs\SCOMNotifyErr-' + $alertID + '.log'
$diagLogFile = 'C:\local\logs\SCOMNotifyDiag-' + $alertID + '.log'
#$errorLogFile = 'C:\local\logs\SCOMNotifyErr.log'
#$diagLogFile = 'C:\local\logs\SCOMNotifyDiag.log'
## Mail: 
$SMTPHost = "smtp.uvm.edu"
$SMTPPort = 25
$Sender = New-Object System.Net.Mail.MailAddress("OpsMgr@lifeboat.campus.ad.uvm.edu", "Lifeboat OpsMgr Notification")
#If error occured while excuting the script, the recipient for error notification email.
$ErrRecipient = New-Object System.Net.Mail.MailAddress("saa-ad@uvm.edu", "SAA Windows Administration Team")
##Set Culture Info (for knowledgebase article language selection):
$cultureInfo = [System.Globalization.CultureInfo]'en-US'
##Get the FQDN of the local computer (where the script is run)...
$RMS = $env:computername

#### Initialize Global Variables and Objects: ####
## Mail Message Object:
[string] $threadID = ''
$SMTPClient = New-Object System.Net.Mail.smtpClient
$SMTPClient.host = $SMTPHost
$SMTPClient.port = $SMTPPort
##Load SCOM PS Module
if ((get-module | ? {$_.name -eq 'OperationsManager'}) -eq $null) {
	Import-Module OperationsManager -ErrorAction SilentlyContinue -ErrorVariable Err | Out-Null
}
## Management Group Object:
$mg = get-SCOMManagementGroup
##Get Web Console URL
$WebConsoleBaseURL = (get-scomwebaddresssetting | Select-Object -Property WebConsoleUrl).webconsoleurl
#### End Initialize ####


#### Begin Parse Input Parameters: ####
##Get recipients names and email addresses from "-to" array parameter: ##
if ((!$mailTo) -and (!$pageTo)) {
	write-host "An array of name/email address pairs must be provided in either the -mailTo or -pageTo parameter, in the format `@(`'me;my@mail.com`',`'you;you@mail.net`')"
	exit
}
$mailRecips = @()
Foreach ($item in $mailTo) {
	$to = New-Object psobject
	$name = ($item.split(";"))[0]
	$email = ($item.split(";"))[1]
	Add-Member -InputObject $to -MemberType NoteProperty -Name Name -Value $name
	Add-Member -InputObject $to -MemberType NoteProperty -Name Email -Value $email
	$mailRecips += $to
	Remove-Variable to
	Remove-Variable name
	Remove-Variable email
}
$pageRecips = @()
Foreach ($item in $pageTo) {
	$to = New-Object psobject
	$name = ($item.split(";"))[0]
	$email = ($item.split(";"))[1]
	Add-Member -InputObject $to -MemberType NoteProperty -Name Name -Value $name
	Add-Member -InputObject $to -MemberType NoteProperty -Name Email -Value $email
	$pageRecips += $to
	Remove-Variable to
	Remove-Variable name
	Remove-Variable email
}
if ($diag -eq $true) {
	[string] $("mailRecipients:") | Out-File $diagLogFile -Append 
	$mailRecips | Out-File $diagLogFile -Append
	[string] $("pageRecipients:") | Out-File $diagLogFile -Append 
	$pageRecips | Out-File $diagLogFile -Append
}
## Parse "-AlertID" input parameter: ##
$alertID = $alertID.toString()
#remove "{" and "}" around the $alertID if exist
if ($alertID.substring(0,1) -match "{") {
	$alertID = $alertID.substring(1, ( $alertID.length -1 ))
}
if ($alertID.substring(($alertID.length -1), 1) -match "}") {
	$alertID = $alertID.substring(0, ( $alertID.length -1 ))
}
#### End Parse input parameters ####


#### Function Library: ####
function getResStateName($resStateNumber){
	[string] $resStateName = $(get-ScomAlertResolutionState -resolutionStateCode $resStateNumber).name
	$resStateName
}
function setResStateColor($resStateNumber) {
	switch($resStateNumber){
		"0" { $sevColor = "FF0000" }	#Color is Red
		"1" { $sevColor = "FF0000" }	#Color is Red
		"255" { $sevColor = "3300CC" }	#Color is Blue
		default { $sevColor = "FFF00" }	#Color is Yellow
	}
	$sevColor
}
function stripCruft($cruft) {
	#Removes "cruft" data from messages. 
	#Intended to make subject lines and alphanumeric pages easier to read
	$cruft = $cruft.replace("®","")
	$cruft = $cruft.replace("(R)","")
	$cruft = $cruft.replace("Microsoftr ","")
	$cruft = $cruft.replace("Microsoft ","")
	$cruft = $cruft.replace("Microsoft.","")
	$cruft = $cruft.replace("Windows ","")
	$cruft = $cruft.replace(" without Hyper-V","")
	$cruft = $cruft.replace("Serverr","Server")
	$cruft = $cruft.replace(" Standard","")
	$cruft = $cruft.replace(" Enterprise","")
	$cruft = $cruft.replace(" Edition","")
	$cruft = $cruft.replace(".campus","")
	$cruft = $cruft.replace(".CAMPUS","")	
	$cruft = $cruft.replace(".ad.uvm.edu","")
	$cruft = $cruft.replace(".AD.UVM.EDU","")
	$cruft = $cruft.trim()
	return $cruft
}
function fnMamlToHTML($MAMLText){
	$HTMLText = "";
	$HTMLText = $MAMLText -replace ('xmlns:maml="http://schemas.microsoft.com/maml/2004/10"');
	$HTMLText = $HTMLText -replace ("maml:para", "p");
	$HTMLText = $HTMLText -replace ("maml:");
	$HTMLText = $HTMLText -replace (&quot;</section>&quot;);
	$HTMLText = $HTMLText -replace (&quot;<section>&quot;);
	$HTMLText = $HTMLText -replace (&quot;<section>&quot;);
	$HTMLText = $HTMLText -replace (&quot;<title>&quot;, &quot;<h3>&quot;);
	$HTMLText = $HTMLText -replace (&quot;</title>&quot;, &quot;</h3>&quot;);
	$HTMLText = $HTMLText -replace (&quot;&quot;, &quot;<li>&quot;);
	$HTMLText = $HTMLText -replace (&quot;&quot;, &quot;</li>&quot;);
	$HTMLText;
}
function fnTrimHTML($HTMLText){
	$TrimedText = &quot;&quot;;
	$TrimedText = $HTMLText -replace (&quot;&lt;&quot;, &quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;&quot;)
	$TrimedText = $TrimedText -replace (&quot;<h1>&quot;, &quot;<h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;</h1>&quot;, &quot;</h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;<h2>&quot;, &quot;<h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;</h2>&quot;, &quot;</h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;<H1>&quot;, &quot;<h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;</H1>&quot;, &quot;</h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;<H2>&quot;, &quot;<h3>&quot;)
	$TrimedText = $TrimedText -replace (&quot;</H2>&quot;, &quot;</h3>&quot;)
	$TrimedText;
}
function buildEmail {
	## Format the message for full-HTML email
	[string] $escTxt = &quot;&quot;
	if ($resState -eq '1') {$escTxt = '- Repeat Count ' + $escLev.ToString()}
	[string] $script:mailSubj = &quot;SCOM - $resStateName $escTxt - $alertSev | $moPath | $alertName&quot;
	$mailSubj = stripCruft($mailSubj)
	[string] $script:mailErrSubj = &quot;Error emailing SCOM Notification for Alert ID $alertID&quot;
	[string] $webConsoleURL = $WebConsoleBaseURL+&quot;?DisplayMode=Pivot&amp;AlertID=%7b$alertID%7d&quot;
	[string] $psCmd = &quot;Get-SCOMAlert -Id `&quot;$alertID`&quot; | format-list *&quot;
	# Format the Mail Message Body (do not indent this block!)
	$script:MailMessage.isBodyHtml = $true
	$script:mailBody = @&quot;



<p><b>Alert Resolution State:<Font color='$sevColor'> $resStateName </Font></b><br />
<b>Alert Severity:<Font color='$sevColor'> $alertSev</Font></b><br />
<b>Object Source (Display Name):</b> $moSource <br />
<b>Object Path:</b> $moPath <br />
</p>
<p>
<p><b>Alert Name:</b> $alertName <br />
<b>Alert Description:</b> <br />
$alertDesc <br>
&quot;@
	if (($resState -eq 0) -or ($resState -eq 1)) {
		if ($isMonitorAlert -eq $true) {
$script:mailBody = $mailBody + @&quot;
<b>Alert Monitor Name:</b> $MonitorName <br />
<b>Alert Monitor Description:</b> $MonitorDescription
</p>
&quot;@
		}elseif ($isMonitorAlert -eq $false) {
			$script:mailBody = $mailBody + @&quot;
<b>Alert Rule Name:</b> $RuleName <br />
<b>Alert Rule Description:</b> $RuleDescription <br />
&quot;@
		}
	}
$script:mailBody = $mailBody + @&quot;
<b>Alert Context Properties:</b><br /> 
$alertCX <br />
<b>Time Raised:</b> $timeRaised <br />
<b>Alert ID:</b> $alertID <br />
<b>Notification Status:</b> $($alert.CustomField1) </br>
<b>Notification Repeat Count:</b> $($escLev.ToString()) </p>
<p>
<b>PowerShell Alert Retrieval:</b> $psCmd <br />
<b>Web Console Link:</b> <a href="&quot;$webConsoleURL&quot;">$webConsoleURL</a> </p>
&quot;@
	if (($resState -eq 0) -or ($resState -eq 1)) {
		foreach ($article in $arrArticles) {
		$articleContent = $article.content
$script:mailBody = $mailBody + @&quot;
<p>
<b>Knowledge Article / Company Knowledge `-$($article.Language):</b>
<hr>
<p> $articleContent
<hr>
<p>

&quot;@
		}
	}
$script:mailErrBody = @&quot;

<p>Error occurred when excuting script located at $RMS for alert ID $alertID.
<p>
<p>Alert Resolution State: $resStateName
<p>
<p>$error
<p>
<p><b>**Use below command to view the full details of this alert in SCOM Powershell console:</b>
<p>$psCmd
<p>
<p> SCOM link:<a href="&quot;$webConsoleURL&quot;"> $webConsoleURL </a>
 

&quot;@ 
}
function buildPage {
	## Format the message for primitive alpha-numeric pager
	$script:moPath = stripCruft($moPath)
	[string] $escTxt = ''
	if ($resState -eq '1') {$escTxt = '- Rep Count ' +$escLev.ToString()}
	[string] $script:mailSubj = &quot;SCOM - $resStateName $escTxt | $moPath&quot;
	[string] $script:mailErrSubj = &quot;Error emailing SCOM Notification for Alert ID $alertID&quot;
	#UFT8 makes the message body look like trash.  Use ASCII (the default) instead.
	#$mailMessage.BodyEncoding =  [System.Text.Encoding]::UTF8 
	$script:MailMessage.isBodyHtml = $false
	$script:moSource = stripCruft($moSource)
	$script:alertName = stripCruft($alertName)
	$script:mailBody = &quot;| $moSource | $alertName | $timeRaised&quot; 
	$script:mailBody = stripCruft($mailBody)
}
function buildHeaders {
	param(
		[array]$recips
	)
	## Complete the MailMessage object:
	$script:MailMessage.Sender = $Sender
	$script:MailMessage.From = $Sender
	$script:MailMessage.Headers.Add('references',$threadID)
	# Regular (non-error) format
	if ($error.count -eq &quot;0&quot;) { 				
		$script:MailMessage.Subject = $mailSubj
		Foreach ($item in $recips) {
			$to = New-Object System.Net.Mail.MailAddress($item.email, $item.name)
			$script:MailMessage.To.add($to)
			Remove-Variable to
		}
		$script:MailMessage.Body = $mailBody
	} 
	# Error format:
	else {									
		$script:MailMessage.Subject = $mailErrSubj
		$script:MailMessage.To.add($ErrRecipient)
		$script:MailMessage.Body = $mailErrBody
	}
	## Log the message if in diag mode:
	if ($diag -eq $true) {
		[string] $('Mail Message Object Content:') | Out-File $diagLogFile -Append
		$mailMessage | fl * | Out-File $diagLogFile -Append
	}
}
#### End Function Library ####


#### Clean up existing logs: ####
if (Test-Path $errorLogFile) {Remove-Item $errorLogFile -Force}
if (Test-Path $diagLogFile) {Remove-Item $diagLogFile -Force}
if ($diag -eq $true) {
	[string] $(&quot;AlertID : `t&quot; + $alertID) | Out-File $diagLogFile -Append
	[string] $(&quot;MailTo      : `t&quot; + $mailto) | Out-File $diagLogFile -Append
	[string] $(&quot;PageTo      : `t&quot; + $pageto) | Out-File $diagLogFile -Append
	#[string] $(&quot;Format  : `t&quot; + $format) | Out-File $diagLogFile -Append
}



#### Begin Alert Handling: ####
## Locate the specific alert:
$alert = Get-SCOMAlert -Id $alertID
if ($diag -eq $true) {
	[string] $('SCOM Alert Object Content:') | Out-File $diagLogFile -Append
	$alert | fl | Out-File $diagLogFile -Append
}
## Read Alert Informaiton:
[string] $alertName = $alert.Name
[string] $alertDesc = $alert.Description
#[string] $alertPN = $alert.principalName
[string] $moSource = $alert.monitoringObjectDisplayName 	# Display name is &quot;Path&quot; in OpsMgr Console.
[string] $moId = $alert.monitoringObjectID.tostring()
#[string] $moName = $alert.MonitoringObjectName 			# Formerly &quot;strAgentName&quot;
[string] $moPath = $alert.MonitoringObjectPath 				# Formerly &quot;pathName
#[string] $moFullName = $alert.MonitoringObjectFullName 	# Formerly &quot;alertFullName&quot;
[string] $ruleID = $alert.MonitoringRuleId.Tostring()
[string] $resState = ($alert.resolutionstate).ToString()
[string] $resStateName = getResStateName $resState
[string] $alertSev = $alert.Severity.ToString() 			# Formerly &quot;severity&quot;
if ($alertSev.ToLower() -match &quot;error&quot;) {
	$alertSev = &quot;Critical&quot; 									# Rename Severity to &quot;Critical&quot;
}
[string] $sevColor = setResStateColor $resState				# Assign color to alert severity
#$problemID = $alert.ProblemId
$alertCx = $(1($alert.Context)).DataItem.Property `
	| Select-Object -Property Name,'#text' `
	| ConvertTo-Html -Fragment								# Alert Context property data, in HTML
$localTimeRaised = ($alert.timeraised).tolocaltime()
[string] $timeRaised = get-date $localTimeRaised -Format &quot;MMM d, h:mm tt&quot;
[bool] $isMonitorAlert = $alert.IsMonitorAlert
$escLev = 1
if ($alert.CustomField2) {
	[int] $escLev = $alert.CustomField2
}
## Lookup available Knowledge articles, if new alert:
if (($resState -eq 0) -or ($resState -eq 1)) {
	$articles = $mg.Knowledge.GetKnowledgeArticles($ruleId)
	
	if (!$error) {	#no point retrieving the monitoring rule when there's error processing the alert
		#if failed to get knowledge article, remove the error from $error because not every rule and monitor will have knowledge articles.
		if ($isMonitorAlert -eq $false) {
			$rule = Get-SCOMRule -Id $ruleID		
			$ruleName = $rule.DisplayName
			$ruleDescription = $rule.Description
			if ($RuleDescription.Length -lt 1) {$RuleDescription = &quot;None&quot;}
		} elseif ($isMonitorAlert) {
			$monitor = Get-SCOMMonitor -Id $ruleID
			$monitorName = $monitor.DisplayName
			$monitorDescription = $monitor.Description
			if ($monitorDescription.Length -lt 1) {$monitorDescription = &quot;None&quot;}
		}
		#Convert Knowledge articles
		$arrArticles = @()
		Foreach ($article in $articles) {
			If ($article.Visible) {
				$LanguageCode = $article.LanguageCode
				#Retrieve and format article content
				$MamlText = $null
				$HtmlText = $null
				if ($article.MamlContent -ne $null) {
					$MamlText = $article.MamlContent
					$articleContent = fnMamlToHtml($MamlText)
				}
					
				if ($article.HtmlContent -ne $null) {
					$HtmlText = $article.HtmlContent
					$articleContent = fnTrimHTML($HtmlText)
				}
				$objArticle = New-Object psobject
				Add-Member -InputObject $objArticle -MemberType NoteProperty -Name Content -Value $articleContent
				Add-Member -InputObject $objArticle -MemberType NoteProperty -Name Language -Value $LanguageCode
				$arrArticles += $objArticle
				Remove-Variable LanguageCode, articleContent
			}
		}	
	}
	if ($Articles -eq $null) {
		$articleContent = &quot;No resolutions were found for this alert.&quot;
	}
}
## End Knowledge Article Lookup
#### End Alert Handling ####



#### Begin Mail Processes:
if ($mailto) {
	# For all alerts, send full HTML email:
	$MailMessage = New-Object System.Net.Mail.MailMessage
	buildEmail
	buildHeaders -recips $mailRecips
	invoke-command -ScriptBlock {$SMTPClient.Send($MailMessage)} -errorVariable smtpRet
}
if ($pageTo) {
	# For page-worthy alerts, format short message and send:
	$MailMessage = New-Object System.Net.Mail.MailMessage
	buildPage
	buildHeaders -recips $pageRecips
	invoke-command -ScriptBlock {$SMTPClient.Send($MailMessage)} -errorVariable smtpRet
}
#### End Mail Message Formatting #### 


# Populate CustomField1 and 2 to indicate that a notification has been sent, with repeat count.
if (!$smtpRet) { 							# IF the message was sent (apparently)...
	[string] $updateReason = &quot;Updated by Email notification script.&quot;
	[string] $custVal1 = &quot;notified&quot;
	if ($resState -eq &quot;0&quot;) { 				# . AND IF this is a &quot;new&quot; alert...
		$alert.ResolutionState = 1			# ..Set the resolution state to &quot;Notified&quot;
		$alert.CustomField2 = $escLev		# ..Set CustomField2 to the current notification retry count (presumably 1)
		if (!$alert.CustomField1) {			# ..AND if CustomField1 is not already defined...
			$alert.CustomField1 = $custVal1	# ... Set CustomField1.
		}
		$alert.Update($updateReason)
	} 
	elseif ($resState -eq &quot;1&quot;) {		# .Or,If this is a &quot;notified&quot; alert
		if ($alert.CustomField2) {		# ..and the notification retry count exists..
			$escLev += 1				# ...Increment by one.
		}
		$alert.CustomField2 = $escLev
		$alert.Update($updateReason)
	}
}



Write-Host $error
##Make sure the script is closed
if ($error.count -ne &quot;0&quot;) {
	[string]$('AlertID string: ' + $alertID) | Out-File $errorLogFile
	[string]$('Alert Object Content: ') | Out-File $errorLogFile
	$alert | Format-List * | Out-File $errorLogFile
	[string]$('Error Object contents:') | Out-File $errorLogFile
	$Error | Out-File $errorLogFile
}
#Remove-Variable alert
#Remove-Module OperationsManager

Coping with Renamed user Accounts in sharepoint

Yesterday I received a strange error report from a person trying to create a new SharePoint site collection.  Our front line guy went to investigate and found that she was getting a “User cannot be found” error out of SharePoint when attempting to complete the self-service site creation process.  This person reported that her last name changed recently, along with her user ID, yet SharePoint will still showing her as logged in under her old name.

Linking the “Correlation ID” up to the diagnostic logs was of no great help.  The diagnostic logs simply reported “User cannot be found” when executing the method “Microsoft.SharePoint.SPSite.SelfServiceCreateSite”.  We are able to see that “ownerLogin”, “ownerEmail”, and “ownerName” strings were being passed to this function, but not what the values of those strings were.  I guessed that the web form was passing the person’s old account login name to the function, and that since this data was no longer valid, an error was getting displayed.  But how to fix this?

SharePoint 2010 (and WSS 3.0 before it) keeps a list of Site Users that can be accessed using the SharePoint Web “SiteUsers” property. This list is updated every time a new user logs in to the site.  The list entries contain username, login identity, email address, and security ID (SID) data.  It also appears that Site User data is not updated when user data changes in Active Directory (as long as the SID stays the same, that is).  Additional user account data is stored in XML data in the SharePoint databases, and can be accessed using the SharePoint Web “SiteUserInfoList” property.  All of this data needs to be purged from the root web site so that our hapless user can once again pass valid data to the SelfServiceCreateSite method.

Presumably the Site Management tools could be forced to get the job done, but the default views under SharePoint 2010 are hiding all site users from me, even when I log in as a site administrator.  Let’s try PowerShell instead:

add-pssnapin microsoft.sharepoint.powershell 
$root = get-spweb -identity "https://sharepoint.uvm.edu/" 

# "Old ID" below should be all or part of the user's original login name: 
$oldAcc = $root.SiteUsers | ? {$_.userLogin -match "oldID"} 
#Let's see if we found something: 
$oldAcc.LoginName 

#Remove the user from the web's SiteUsers list: 
$root.SiteUsers.Remove($oldAcc.LoginName) 
$root.Update() 
#Let's see if it worked: 
$id = $oldAcc.ID 
$root = get-spweb -identity "https://sharepoint.uvm.edu/" 
$root.SiteUsers.GetByID($id) 
# (This should return a "User cannot be found" error.) 

#Now to see what is in SiteUserInfoList: 
$root.SiteUserInfoList.GetItemById($id) 
# (This data can be cleaned up in the browser by visiting:
# " /_catalogs/users/simple.aspx" 
# from your site collection page.)

Moving User Profiles with PowerShell

Something that comes up with some frequency on Terminal Servers (or “Remote Desktop Servers”), but perhaps sometimes in VDI, is “How to I move a user profile from one drive to another”. The traditional answers include the use of the user profile management GUI, or some expensive piece of software. But what if you need to automate the job? Or if you don’t have any money for the project?

Answer? PowerShell, of course… and robocopy.

Below is a code snippet that will set existing user profiles to load from “C:\Users” to “E:\Users”:

#Collect profile reg keys for regular users ("S-1-5-21" excludes local admin, network service, and system)
$profiles = gci -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList" `
	| ? {$_.name -match "S-1-5-21-"} 

foreach ($profile in $profiles) {
	#Set the registry path in a format that can be used by the annoyingly demanding "get-itemproperty" cmdlet:
	$regPath = $(
		$($profile.pspath.tostring().split("::") | Select-Object -Last 1).Replace("HKEY_LOCAL_MACHINE","HKLM:")
	)
	
	#Get the current filesystem path for the user profile, using get-ItemProperty"
	$oldPath = $(
		Get-ItemProperty -LiteralPath $regPath -name ProfileImagePath
	).ProfileImagePath.tostring()
	
	#Set a varialble for the new profile filesystem path:
	$newPath = $oldPath.Replace("C:\","E:\")
	
	#Set the new profile path using "set-itemproperty"
	Set-ItemProperty -LiteralPath $regPath -Name ProfileImagePath -Value $newPath
} 

#Now copy the profile filesystem directories using "robocopy".

But this code will not actually move the data. For that, we need robocopy. Make sure that your users are logged off before performing this operation, otherwise “NTUSER.DAT” will not get moved, and your users will get a new TEMP profile on next login:

robocopy /e /copyall /r:0 /mt:4 /b /nfl /xj /xjd /xjf C:\users e:\Users

Finally, be sure to set the default location for new profiles and the “Public” directory to your new drive as well. For that, run “Regedit”, then go to:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList
and set new paths for the registry strings “ProfilesDirectory” and “Public”. Moving the default user profile is optional.

Oh yeah… you might want to purge the old Recycle Bin cruft for your moved users as well:

rmdir /s /q C:\$Recycle.Bin

SharePoint 2010 – Email Alerts to Site Administrators

We are in the final stages of preparation for the long-overdue upgrade to SharePoint 2010.  I have set up a preview site with a copy of the production SharePoint content database, and I want to notify all site owners that they should check out their sites for major problems.  How to do?  PowerShell?  Absolutely!


Set-PSDebug -Strict
Add-PSSnapin -Name microsoft.SharePoint.PowerShell

[string] $waUrl = "https://sharepoint2010.uvm.edu"
[string] $SmtpServer = "smtp.uvm.edu"
[string] $From = "saa-ad@uvm.edu"

$allAdmins = @()

[string] $subjTemplate = 'Pending Upgrade for your site -siteURL-'
[string] $bodyTemplate = @"
Message Body Goes Here.
Use the string -siteURL- in the body where you want the user's site address to appear.
"@

$wa = Get-SPWebApplication -Identity $waUrl

foreach ($site in $wa.sites) {
	#Write-Host "Working with site: " + $site.url
	$siteAdmins = @()
	$siteAdmins = $site.RootWeb.SiteAdministrators
	ForEach ($admin in $siteAdmins) {
		#Write-Host "Adding Admin: " + $admin.UserLogin
		[string]$a = $($admin.UserLogin).Replace("CAMPUS\","")
		[string]$a = $a.replace(".adm","")
		[string]$a = $a.replace("-admin","")
		[string]$a = $a.replace("admin-","")
		if ($a -notmatch "sa_|\\system") { $allAdmins += , @($a; [string]$site.Url) }
	}
	$site.Dispose()
}

$allAdmins = $allAdmins | Sort-Object -Unique
#$allAdmins = $allAdmins | ? {$_[0] -match "jgm"} | Select-Object -Last 4

foreach ($admin in $allAdmins) {
	[string] $to = $admin[0] + "@uvm.edu"
	[string] $siteUrl = $admin[1]
	[string] $subj = $subjTemplate.Replace("-siteURL-",$siteUrl)
	[string] $body = $bodyTemplate.Replace("-siteURL-",$siteUrl)
	Send-MailMessage -To $to -From $From -SmtpServer $SmtpServer -Subject $subj -BodyAsHtml $body
}

vSphere 5.1 – Train Wreck in Slow Motion

vSphere 5.1 arrived this summer to no great fan-fare. We waited a few weeks, heard no sounds of howling pain (we did not listen very hard, I guess), and decided to proceed with upgrading vCenter.  I have been digging out of the wreckage ever since.

How do you know if upgrading to vSphere 5.1 is right for you?  Here are a few bullet points to help you decide:

  • Do you have CA-signed (externally trusted, or in-house Enterprise CA server) certificates in use in your current vSphere environment?
  • Are you using an external MS SQL Server to host your vCenter database?  Are you using mirrored SQL databases?
  • Is your environment currently stable and reliable?

Is you answered “yes” to any of these questions, do not upgrade to vSphere 5.1.  At least, not yet. Do deceive yourself that that the vSphere 5.1.0a release will be any help, either.

What is the big problem, you ask?  The major source of pain in this release is the new “Single Sign-On Service” that handles authentication and authorization for all of the other vSphere components.  This component of vSphere has twitchy SSL certificate requirements that are poorly documented by VMware.  The SSL requirements are so touchy that in our case, even the self-signed certs generated by the installer did not work.  Unlike all of the other current vSphere components, it does not support mirrored SQL databases.  It has new permissions requirements in AD that are not documented at all, and at the time of our installation, did not even have a KB entry.  The installer is very buggy, most notably in that it requests that you set and admin password for the SSO Service, and demands password complexity, but it does not inform you when your password is unacceptably long (i.e. longer than 32 characters) or when your password contains illegal characters (i.e. most regular expression special characters).

So, if you do upgrade, be prepared for an extended service outage.  Give yourself a long service window.  Have your VMware support contract numbers handy.  Familiarize yourself with the myriad of locations that are used to log vCenter data.  Learn to use PowerShell (get-childitem -recurse | select-string -pattern “configSettingThatThevCenterInstallerBorkedUp”) and keep this page bookmarked:

http://derek858.blogspot.com/2012/09/vmware-vcenter-51-installation-part-1.html

Here are UVM we are indebted to Derek Seaman for his thorough documentation of the vSphere 5.1 installation process and detailed SSL certificate generation instructions.

Following are some installation quirks that we encountered, presented mainly for my own reference, but maybe you will find them useful as well:

  1. “Performance Charts Experienced an Internal Error” seen in the vSphere client after the upgrade:
    This happened because vCenter Web Services did not read the database mirroring configuration from our defined ODBC data sources… it grabbed the primary database only, and not the mirror data.  The fix?  Edit:
    “%ProgramData%\VMware\VMware VirtualCenter\vcdb.properties”
    Find the “url=” line, and append:
    ;failoverPartner\=[mirrorServer]
    (Where [mirrorServer] the the actual DB mirror host name.  Don’t forget the “\” before the “=”.)
  2. Some users with permissions to vCenter 5.0 cannot log in after the upgrade.  In the vSphere web client, these users are marked as “disabled”:
    This occurred for use for two reasons:

    1. The SSO Service installer prompts us for a service account to use during install.  Following installation, the service is seen to be running as “SYSTEM”, and not the specified service account.  Change the Service to run with your planned service account using services.msc after the installation.  As an alternative, you could specify those credentials  in the vSphere Web Client -> Administration ->Sign-On and Discovery -> Configuration -> Identity Sources.  Edit your identity source, and under “Authentication Source” select “password”, then enter your service account credentials.
    2. The SSO Service needs to read account attributes that cannot be read by a standard user account (at least, not in an AD forest at a Server 2008 R2 functional level).  When we asked VMware support to define the required permissions, they replied: “an account has to have at least read-only permissions over the user and group Organization Units furthermore read permissions also on the properties of the users, such as UserAccessControl.”  After some experimentation, I just gave the SSO Service account “read all properties” rights to the account OU, and login abilities were restored.
  3. Our SSO Service broke when the mirrored database servers that we currently use for vCenter services had a failover event.  During install, I used the standard “failoverPartner=” JDBC connection string property to specify our failover database server.  Unfortunately, the SSO service ignores this property.  I could not identify an acceptable workaround for this problem. Ultimately, I installed a SQL Express instance on our vCenter server to house just the SSO database.  I tried:
    1. Using SQL Aliases, but this failed because the JDBC driver is not aware of SQL Aliases.
    2. Using a script that edits the local “hosts” file on a database failover event.  I then used this host name alias for the database connections.  This almost worked.  I edited the following files to use the host alias, instead of the actual database server host name:
      %ProgramFiles%\VMware\Infrastructure\SSOServer\webapps\ims\WEB-INF\classes\jndi.properties
      and:
      %ProgramFiles%\VMware\Infrastructure\SSOServer\webapps\lookupservice\WEB-INF\classes\config.properties
      Upon restart, the SSO Service was able to connect to the database, but it did not survive a failover.  Apparently the old database connection information was still in use somewhere, and VMware support was not helpful in identifying all of the database configuration locations for SSO.
    3. While VMware does have command line configuration tools that could have been used to script reconfiguration of the database connection strings, I have deemed that they are too fragile for production use.
  4. The option to authenticate using Windows session credentials in the vSphere Client (traditional version) stopped working after the 5.1 upgrade.  This is a bug that is fixed with the 5.1.0a release.  Unfortunately, the SSO installer for 5.1.0a does not work in upgrade mode.  Aargh!  I had to uninstall the SSO service to get the updated files into place.  Guess what the uninstaller does?  That’s right… it erases the SSO Service database (drops all tables!  Gah!), and deletes all configuration files for the service.  Before you upgrade, make sure that you have an SSO Service backup bundle.  I did, but it was outdated.  I had to re-register all of the vCenter components with SSO manually, which was a pain in the butt.
  5. vSphere Update Manager registered with vCenter using the wrong DNS name.  We could not scan ESXi hosts for updates, because vCenter was telling them to connect to an invalid URL.  To fix, I needed to search the registry for the incorrect host name, and replace with the correct one:
    “HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\VMware, Inc.\VMware Update Manager\VUMServer”
    For good measure I also edited:
    %Program Files(x86)%\VMware\Infrastructure\Update Manager\extension.xml
    To contain the correct host name.  Then we restart the Update Manager services, and we are back in business.
  6. Other fun related to VMware Update Manager… the SQL Account used by Update Manager cannot have a password that exceeds 24 characters in length. Special characters in the SQL Account password also may cause problems.

So, VMware is not my favorite company this month.  On to solve more problems.  We still cannot add new permissions to vCenter, and Performance Charts are loading like a slug in winter.

Windows Backup Performance Testing with PowerShell

While developing our new Windows file services infrastructure, we wanted to test our pre-production platform to see if there are any file server-side bottlenecks that will cause unacceptable delays in backup processing. Here are UVM we still are using EMC Networker for Enterprise backup (no comments on our satisfaction with EMC will be provided at this time). EMC provides a tool “uasm.exe” that is used at the core of the “save.exe” and “recover.exe” commands on the backup client. If we use “uasm.exe” to backup all of the file server data to “null”, it is possible that we will be able to detect disk, HBA, and other local I/O bottlenecks before they bite us in production.

Since Networker will break up our file server into multiple “save sets”, and run a user-definable number of save set backup processes in parallel, it also is important for us to determine the required number of parallel backup processes required to complete backup in a timely fashion. Thus, we want to run several parallel “uasm.exe” processes in our tests.

PowerShell, with the assistance of “cmd.exe”, and some finesses, can get this job done. Hurdles I ran into while scripting this test follow:

  1. During development, PowerShell consumed huge amounts of CPU while redirecting uasm.exe output to the PowerShell $null object. Interestingly, previous tests using uasm.exe with cmd.exe did not show this problem. To fix this, each uasm job is spawned from a one-line cmd.exe “bat” script, which is included below.
  2. Remember that PowerShell uses the null object “$null”, but that cmd.exe uses the handle “nul” (with one “L”). If you redirect to “null”, you will soon fill up your disk with a file named “null”.
  3. When wanted to examine running jobs, it was difficult to determine which directory a jobs was working on. This was because I initially created a scriptblock object and passed parameters to it when starting a job. For example:
    [scriptblock] $sb = {
    $uasmBlock = {
    	param ([string]$sPath)
    	[string[]] $argList = '/c','c:\local\scripts\uasm_cmd.bat',$sPath
    	& cmd.exe $argList
    }
    $jobs += start-job -Name $myJob -ScriptBlock $sb -ArgumentList $dir1
    

    However, when inspecting the job object’s “command” property, we see “$sPath” in the output. We want the variable expanded. How to do this? Create the scriptblock object in-line when starting the job:

    [string] $cmd = '& cmd.exe "/c","c:\local\scripts\uasm_cmd.bat",' + $dir
    $jobs += Start-Job -Name $jobName `
    	-ScriptBlock ([scriptblock]::create($cmd))
    

    This makes for more compact code, too.

  4. To check on jobs that have completed, I create an array named “$djs” (Done Jobs), populated by piping the $jobs array and filtering for “completed” jobs. I inspect $djs to see if jobs are present. In my first pass, I used the check:
    if ($djs.count -gt 0)

    Meaning, continue if there is anything in the array $djs. However, this check did not work well because output from the $jobs object would put a null item in $djs on creation, meaning that if there were no running jobs, $djs would still have a count of one! I fixed this by changing the test:

    if ($djs[0] -ne $null)

    Meaning, if the first entry in $djs is not a null object, then proceed.

The full script follows:

#uasm_jobQueue.ps1, 2011-09-30, author: J. Greg Mackinnon
#Tests performance of disk when accessed by Networker backup commands.
#   Creates a queue of directories to test ($q), then uses external command 
#   "uasm.exe" to backup these directories to null.
#Change the "$wp" variable to set the number of uasm 'worker processes' to be 
#   used during the test.
#Note: PowerShell $null object causes very high CPU utilization when used for
#   this purpose.  Instead, we call "uasm_cmd.bat" which uses the CMD.exe 'nul'
#   re-director.  'nul' does not have the same problems as $null.

set-psdebug -strict

[int] $wp = 4

# Initialize the log file:
[string] $logfile = "s:\uasm_test.log"
remove-item $logfile -Force
[datetime] $startTime = Get-Date
[string] "Start Time: " + $startTime | Out-File $logfile -Append

##Create work queue array:
# Add shared directories:
[String[]] $q = gci S:\shared | ? {$_.Attributes.tostring() -match "Directory"}`
	| sort-object -Property Name | % {$_.FullName}
# Add remaining targets to queue:
$q += 'H:\','I:\','J:\','K:\','L:\','M:\','S:\sis\','S:\software\','s:\r25\'
	
[int] $dc = 0			#Count of completed (done) jobs.
[int] $qc = $q.Count	#Initial count of jobs in the queue
[int] $qi = 0			#Queue Index - current location in queue
[int] $jc = 0			#Job count - number of running jobs
$jobs = @()				#Jobs array - intended to contain running PS jobs.
	
while ($dc -lt $qc) { # Completed jobs is less than total jobs in queue
	# Keep running jobs until completed jobs is less than total jobs in queue, 
	#  and our queue count is less than the current queue index.
	while (($jobs.count -lt $wp) -and ($qc -gt $qi)) { 
		[string] $jobName = 'qJob_' + $qi + '_';
		[string] $dir = '"' + $q[$qi] + '"'
		[string] $cmd = '& cmd.exe "/c","c:\local\scripts\uasm_cmd.bat",' + $dir
		#Start the job defined in $cmd string.  Use this rather than a pre-
		#  defined scriptblock object because this allows us to see the expanded
		#  job command string when debugging. (i.e. $jobs[0].command)
		$jobs += Start-Job -Name $jobName `
			-ScriptBlock ([scriptblock]::create($cmd))
		$qi++ #Increment the queue index.
	}
	$djs = @(); #Completed jobs array
	$djs += $jobs | ? {$_.State -eq "Completed"} ;
	# $djs array will always have a count of at least 1.  However, if the 
	#    first entry is not empty (null), then there must be completed jobs to
	#    be retrieved.
	if ($djs[0] -ne $null) { 
		$dc += $djs.count;
		$djs | Receive-Job | Out-File $logfile -Append; #Log completed jobs
		$djs | Remove-Job -Force;
		Remove-Variable djs;
		$jobs = @($jobs | ? {$_.State -eq "Running"}); #rebuild jobs array.
	}
	Start-Sleep -Seconds 3
}


# Complete logging:
[datetime] $endTime = Get-Date
[string] "End Time: " + $endTime | Out-File $logfile -Append 
$elapsedTime = $endTime - $startTime
[string] $outstr =  "Elapsed Time: " + [math]::floor($elapsedTime.TotalHours)`
	+ " hours, " + $elapsedTime.minutes + " minutes, " + $elapsedTime.seconds`
	+ " seconds."
$outstr | out-file -Append $logfile

The “uasm_cmd.bat” file called in the above code block contains the following one line:

"c:\program files\legato\nsr\bin\uasm.exe" -s %1 > nul

Migrating from NetApp to Windows File Servers with PowerShell – part 2

Previously we saw how PowerShell and RoboCopy can be used to sync multi-terabyte file shares from NetApp to Windows. What I did not tell you was that this script choked and died horribly on a single share in our infrastructure. You may have seen it commented out in the previous script? “#,’R25′”?

CollegeNet Resource25… my old enemy. These clowns worked around a bug in their product (an inability to read an open text column in an Oracle DB table) by copying every text row in the database to its own file on a file server, and to make matters worse they copy all of the files to the same directory. Why is this bad? Ever try to get a directory listing on a directory with 480,000 1k files? It’s bad news. Worse, it kills robocopy. Fortunately, we have a workaround.

The archive utility “7-zip” is able to wrap up the nasty directory into a single small file, which we then can unpack on the new file server. Not familiar with 7-Zip? For shame! Get it now, it’s free:
http://www.7-zip.org/

7-zip ignores most file attributes, which seems to speed up the copy process a bit. Using robocopy, ouy sync operation would either run for hours on this single directory, or just hang up forever. With 7-zip, we get the job done in 30 minutes. Still slow, but better than never.

Troublesome files are found in the R25 “text_comments” directory, a subdirectory of “text”. We have prod, pre-prod, and test environments, and so need to do a few separate 7-zip archives. Note that a little compresson goes a long way here. When using “tar” archives, my archive was several gb in size. With the lowest level of compression, we squeeze down to only about 14 Mb. How is this possible? Well, a lot of our text comment files were empty, but uncompressed they still take up one block of storage. Over 480,000 blocks, this really adds up.

Code snippet follows.

#Sync R25 problem dirs

Set-PSDebug -Strict

# Initialize the log file:
[string] $logfile = "s:\r25Sync.log"
remove-item $logfile -Force
[datetime] $startTime = Get-Date
[string] "Start Time: " + $startTime | Out-File $logfile -Append

function zipit {
	param ([string]$source)
	[string] $cmd = "c:\local\bin\7za.exe"
	[string] $arg1 = "a" #add (to archive) mode
	[string] $arg2 = join-path -Path $Env:TEMP -ChildPath $($($source | `
		Split-Path -Leaf) + ".7z") # filespec for archive
	[string] $arg3 = $source #spec for source directory
	[string] $arg4 = "-mx=1" #compression level... minimal for performance
	#[string] $arg4 = "-mtm=on" #timestamp preservation - commented out for perf.
	#[string] $arg5 = "-mtc=on"
	#[string] $arg6 = "-mta=on"
	#invoke command, route output to null for performance.
	& $cmd $arg1,$arg2,$arg3,$arg4 > $null 
}

function unzipit {
	param ([string]$dest)
	[string] $cmd = "c:\local\bin\7za.exe"
	[string] $arg1 = "x" #extract archive mode
	[string] $arg2 = join-path -Path $Env:TEMP -ChildPath $($($dest | `
		Split-Path -Leaf) + ".7z")
	[string] $arg3 = "-aoa" #overwrite existing files
	#destination directory specification:
	[string] $arg4 = '-o"' + $(split-path -Parent $dest) + '"' 
	#invoke command, route to null for performance:
	& $cmd $arg1,$arg2,$arg3,$arg4 > $null 
	Remove-Item $arg2 -Force # delete archive
}

[String[]] $zips = "V3.3","V3.3.1","PROD\WinXp\Text"
[string] $sourceD = "\\files\r25"
[string] $destD = "s:\r25"

foreach ($zip in $zips) {
	Get-Date | Out-File $logfile -Append 
	[string] "Compressing directory: " + $zip | Out-File $logfile -Append 
	zipIt $(join-path -Path $sourceD -ChildPath $zip)
	Get-Date | Out-File $logfile -Append 
	[string] "Uncompressing to:" + $destD | Out-File $logfile -Append
	unzipit $(Join-Path -Path $destD -ChildPath $zip)
}

Get-Date | Out-File $logfile -Append 
[string] "Syncing remaining files using Robocopy..." | Out-File $logfile -Append
$xd1 = "\\files\r25\V3.3" 
$xd2 = "\\files\r25\V3.3.1" 
$xd3 = "\\files\r25\PROD\WinXP\text"
$xd4 = "\\files\r25\~snapshot"
$roboArgs = @("/e","/copy:datso","/purge","/nfl","/ndl","/np","/r:0","/mt:4",`
	"/b",$sourceD,$destD,"/xd",$xd1,$xd2,$xd3,$xd4)

& robocopy.exe $roboArgs

Get-Date | Out-File $logfile -Append 
[string] "Done with Robocopy..." | Out-File $logfile -Append

# Complete logging:
[datetime] $endTime = Get-Date
[string] "End Time: " + $endTime | Out-File $logfile -Append 
$elapsedTime = $endTime - $startTime
[string] $outstr =  "Elapsed Time: " + [math]::floor($elapsedTime.TotalHours)`
	+ " hours, " + $elapsedTime.minutes + " minutes, " + $elapsedTime.seconds`
	+ " seconds."
$outstr | out-file -Append $logfile

Migrating from NetApp to Windows File Servers with PowerShell – part 1

We are retiring our NetApp filer this year. It was nice knowing you, NetApp. Thank you for the no-hassle performance, agile volume management, and excellent customer support. We will not miss your insane pricing, and subtle incompatibilities with modern Windows clients.

In this multi-part series, I will be sharing PowerShell code developed to assist with our migration. In part one, we will look at bulk copy operations with RoboCopy. In part 2, we will look at a situation where RoboCopy fails to get the job done. In future parts, we will look at automated share and quota management and migration.

Migrating large amounts of data off a NetApp is not particularly straightforward. The only real option we have is to copy data off of the filer CIFS shares to their Windows counterparts. Fortunately, with the multi-threading power utility “robocopy” we can move data between shares pretty quickly. Unfortunately, robocopy only multi-threads file copy operations, not directory search operations. So, while initial data transfers with robocopy take place really quickly, subsequent sync operations are slower than expected. MS also released a utility called “RichCopy” whish supports multi-thread directory searching, but this utility is not supported by MS, and has some significant bugs (i.e. it crashes all the time). What to do?

PowerShell to the rescue! Using PowerShell jobs, we can spawn off a separate robocopy job for each subdirectory of a source share, and run an arbitrary number of parallel directory copies. With some experimentation, I determined that I could run ten simultaneous robocopy operations without overwhelming CPU or disk channels on the filer. Under this arrangement, or file sync Window has been reduced from almost 48 hours to a mere 2.5 hours.

Some tricky bits in the development of this script where:

  • PowerShell jobs and job queuing are critical to completing this script in a timely fashion. Syntax for “start-job” is tricky. See my post on backup performance testing for more comments on working with jobs.
  • Robocopy fails top copy a number of source files. This is mitigated though the use of the “/b” switch (backup mode).
  • The PowerShell cmdlet “receive-jobs” fails to capture output from a variety of job commands unless you assign the job to an object. To reliably capture the output of commands within our jobs, I needed to assign the jobs to our $jobs array.
  • I needed to do some post processing on the log file. In doing so, I needed to find UNC paths for our source filer “\\files”. It is important to remember that, when using regular expressions, “\” is the escape character. So, to match for “\”, we need to enter “\\”. To match for “\\” we need to enter “\\\\”, as in:
     get-content $logfile | select-string -Pattern "\\\\files" | ...
  • Initially I allowed the script to process only one top level directory at a time (i.e. Start with \\files\software, and only proceed to \\files\shared when “software” completes). The problem with this was, I was preventing the script from running an optimal job count. Furthermore, a single hung job could bring the whole script to a halt. To combat this, I start the script by building a master queue array “$q”, which holds all of the directories for which I am going to start a job. The result of using a master queue is a considerable improvement in sustained throughput.
  • When building an array with a loop (i.e. while…) you may have trouble with the first item added to the array if you do not initialize the array before starting to loop. In my case, I needed to initialize “[array]$jobs = @()” before using the array to hold job objects in the “while” loop. Failing to do so caused “$jobs” to become a single job object when the number of jobs was equal to one. Bad news, if you are expecting to use array properties such as $jobs.count, or to call in index of the object (i.e. $jobs[0]).
  • ISE programs like the native PowerShell ISE, or Quest PowerGUI make script development much easier. However, production environments are not the same as the debug environment, so keep these tips in mind:
    1. Log your script actions! Use lots of out-file calls. If you are feeling slick, you can enclose these in “if ($debug)” clauses, and set the $debug variable as a script parameter (which I did no do here).
    2. When running in production, watch the log file in real-time using “get-content -wait”. I know it is not a cool as the Gnu command “tail”, but it is close.
  • Scoping… careful of the “global” scope. Initially I modified the $jobs and $dc variables in the global scope from within the “collectJobs” function. This worked fine in my ISE and at the PowerShell prompt. However, when running as a scheduled task, these calls failed miserably. I changed the calls to use the “script” scope, and the script now runs as a scheduled task successfully.

Below is the script I developed for this job… it contains paths specific to our infrastructure, but easily could be modified. Change the “while ($jobcount -lt 10)” loop to set the number of simultaneous robocopy processes to be used by the script…

# FilerSync_jobQueue.ps1
# JGM, 2011-09-29
# Copies all content of the paths specified in the $srcShares array to 
# corresponding paths on the local server.
# Keeps data on all copy jobs in an array "$q".
# We will use up to 10 simultaneous robocopy operations.

set-psdebug -strict

# Initialize the log file:
[string] $logfile = "s:\files_to_local.log"
remove-item $logfile -Force
[datetime] $startTime = Get-Date
[string] "Start Time: " + $startTime | Out-File $logfile -Append

# Initialize the Source file server root directories:
[String[]] $srcShares1 = "adfs$","JMP$","tsFlexConfig","software","mca","sis","shared"`
	#,"R25"
	#R25 removed from this sync process as the "text_comments" directory kills
	#robocopy.  We will sync this structure separately.
[String[]] $srcShares2 = "uvol_t1_1$\q-home","uvol_t1_2$\q-home","uvol_t1_3$\q-home",`
	"uvol_t1_4$\q-home","uvol_t1_5$\q-home","uvol_t2_1$\q-home",`
	"vol1$\qtree-home"
	
[String[]] $q = @() #queue array

function collectJobs { 
#Detects jobs with status of Completed or Stopped.
#Collects jobs output to log file, increments the "done jobs" count, 
#Then rebuilds the $jobs array to contain only running jobs.
#Modifies variables in the script scope.
	$djs = @(); #Completed jobs array
	$djs += $script:jobs | ? {$_.State -match "Completed|Stopped"} ;
	[string]$('$djs.count = ' + $djs.count + ' ; POssible number of jobs completed in this colletion cycle.') | Out-File $logfile -Append;
	if ($djs[0] -ne $null) { #First item in done jobs array should not be null.
		$script:dc += $djs.count; #increment job count
		[string]$('$script:dc = ' + $script:dc + ' ; Total number of completed jobs.') | Out-File $logfile -Append;
		$djs | Receive-Job | Out-File $logfile -Append; #log job output to file
		$djs | Remove-Job -Force;
		Remove-Variable djs;
		$script:jobs = @($script:jobs | ? {$_.State -eq "Running"}) ; #rebuild jobs arr
		[string]$('$script:jobs.count = ' + $script:jobs.Count + ' ; Exiting function...') | Out-File $logfile -Append
	} else {
		[string]$('$djs[0] is null.  No jobs completed in this cycle.') | Out-File $logfile -Append
	}
}
	
# Loop though the source directories:
foreach ($rootPath in $srcShares1) {
    [string] $srcPath = "\\files\" + $rootPath # Full Source Directory path.  
	#Switch maps the source directory to a destination volume stored in $target 
    switch ($rootPath) {
        shared {[string] $target = "S:\shared"}
        software {[string] $target = "S:\software"}
        mca {[string] $target = "S:\mca"}
        sis {[string] $target = "S:\sis"}
        adfs$ {[string] $target = "S:\adfs"}
        tsFlexConfig {[string] $target = "s:\tsFlexConfig"}
        JMP$ {[string] $target = "s:\JMP"}
        R25 {[string] $target = "S:\R25"}
    }
    #Enumerate directories to copy:
	$dirs1 = @()
	$dirs1 += gci $srcPath | sort-object -Property Name `
		| ? {$_.Attributes.tostring() -match "Directory"} `
		| ? {$_.Name -notmatch "~snapshot"}
	#Copy files in the root directory:
	[string] $sd = '"' + $srcPath + '"';
	[string] $dd = '"' + $target + '"';
	[Array[]] $q += ,@($sd,$dd,'"/COPY:DATSO"','"/LEV:1"' )
	# Add to queue:
	if ($dirs1[0] -ne $null) {
		foreach ($d in $dirs1) {
			[string] $sd = '"' + $d.FullName + '"';
	    	[string] $dd = '"' + $target + "\" + $d.Name + '"';
			$q += ,@($sd,$dd,'"/COPY:DATSO"','"/e"')
		}
	}
}
foreach ($rootPath in $srcShares2) {   
    [string] $srcPath = "\\files\" + $rootPath # Full Source Directory path.
	#Switch maps the source directory to a destination volume stored in $target 
    switch ($rootPath) {
        uvol_t1_1$\q-home {[string] $target = "H:\homes1"}
        uvol_t1_2$\q-home {[string] $target = "I:\homes1"}
        uvol_t1_3$\q-home {[string] $target = "J:\homes1"}
        uvol_t1_4$\q-home {[string] $target = "K:\homes1"}
        uvol_t1_5$\q-home {[string] $target = "L:\homes1"}
        uvol_t2_1$\q-home {[string] $target = "M:\homes1"}
        vol1$\qtree-home {[string] $target = "J:\homes2"}
    }
    #Enumerate directories to copy:
	[array]$dirs1 = gci -Force $srcPath | sort-object -Property Name `
		| ? {$_.Attributes.tostring() -match "Directory"}
	if ($dirs1[0] -ne $null) {
		foreach ($d in $dirs1) {
			[string] $sd = '"' + $d.FullName + '"'
			[string] $dd = '"' + $target + "\" + $d.Name + '"'
			$q += ,@($sd,$dd,'"/COPY:DAT"','"/e"')
		}
	}
}

[string] $queueFile = "s:\files_to_local_queue.csv"
Remove-Item -Force $queueFile
foreach ($i in $q) {[string]$($i[0]+", "+$i[1]+", "+$i[2]+", "+$i[3]) >> $queueFile }

New-Variable -Name dc -Option AllScope -Value 0
[int] $dc = 0			#Count of completed (done) jobs.
[int] $qc = $q.Count	#Initial count of jobs in the queue
[int] $qi = 0			#Queue Index - current location in queue
[int] $jc = 0			#Job count - number of running jobs
$jobs = @()

while ($qc -gt $qi) { # Problem here as some "done jobs" are not getting captured.
	while ($jobs.count -lt 10) {
		[string] $('In ($jobs.count -lt 10) loop...') | out-file -Append $logFile
		[string] $('$jobs.count is now: ' + $jobs.count) | out-file -Append $logFile
		[string] $jobName = 'qJob_' + $qi + '_';
		[string] $sd = $q[$qi][0]; [string]$dd = $q[$qi][1];
		[string] $cpo = $q[$qi][2]; [string] $lev = $q[$qi][3]; 
		[string]$cmd = "& robocopy.exe $lev,$cpo,`"/dcopy:t`",`"/purge`",`"/nfl`",`"/ndl`",`"/np`",`"/r:0`",`"/mt:4`",`"/b`",$sd,$dd";
		[string] $('Starting job with source: ' + $sd +' and destination: ' + $dd) | out-file -Append $logFile
		$jobs += Start-Job -Name $jobName -ScriptBlock ([scriptblock]::create($cmd))
		[string] $('Job started.  Incrementing $qi to: ' + [string]$($qi + 1)) | out-file -Append $logFile
		$qi++
	}
	[string] $("About to run collectJobs function...") | out-file -Append $logFile
	collectJobs
	[string] $('Function done.  $jobs.count is now: ' + $jobs.count)| out-file -Append $logFile
	[string] $('$jobs.count = '+$jobs.Count+' ; Sleeping for three seconds...') | out-file -Append $logFile
	Start-Sleep -Seconds 3
}
#Wait up to two hours for remaining jobs to complete:
[string] $('Started last job in queue. Waiting up to three hours for completion...') | out-file -Append $logFile
$jobs | Wait-Job -Timeout 7200 | Stop-Job
collectJobs

# Complete logging:
[datetime] $endTime = Get-Date
[string] "End Time: " + $endTime | Out-File $logfile -Append 
$elapsedTime = $endTime - $startTime
[string] $out =  "Elapsed Time: " + [math]::floor($elapsedTime.TotalHours)`
	+ " hours, " + $elapsedTime.minutes + " minutes, " + $elapsedTime.seconds`
	+ " seconds."
$out | out-file -Append $logfile

#Create an error log from the session log.  Convert error codes to descriptions:
[string] $errFile = 's:\files_to_local.err'
remove-item $errFile -force
[string] $out = "Failed jobs:"; $out | out-file -Append $logfile
$jobs | out-file -Append $errFile
$jobs | % {$jobs.command} | out-file -Append $errFile
[string] $out = "Failed files/directories:"; $out | out-file -Append $errFile
Get-Content $logfile | Select-String -Pattern "\\\\files"`
	| select-string -NotMatch -pattern "^   Source" `
	| % {
		$a = $_.toString(); 
		if ($a -match "ERROR 32 ")  {[string]$e = 'fileInUse:        '};
		if ($a -match "ERROR 267 ") {[string]$e = 'directoryInvalid: '};
		if ($a -match "ERROR 112 ") {[string]$e = 'notEnoughSpace:   '};
		if ($a -match "ERROR 5 ")   {[string]$e = 'accessDenied:     '};
		if ($a -match "ERROR 3 ")   {[string]$e = 'cannotFindPath:   '};
		$i = $a.IndexOf("\\f");
		$f = $a.substring($i);
		Write-Output "$e$f" | Out-File $errFile -Force -Append
	}

Discovering orphaned vmdk files in vSphere

On occasion we have found abandoned vmdk files in our vSphere infrastructure. I often have thought we needed to take some time to hunt down and exterminate these orphans. As is often the case, someone else already did the initial research required to make automation of this task possible, but I fou nd I needed to do some updating of the source scripts for improved accuracy, improved formatting, and compatibility with vSphere 4.1:

# getOrphanVMDK.ps1
# Purpose : List all orphaned vmdk on all datastores in all VC's
# Version : v2.0
# Author  : J. Greg Mackinnon, from original by HJA van Bokhoven
# Change  : v1.1  2009.02.14  DE  angepasst an ESX 3.5, Email versenden und Filegrösse ausgeben
# Change  : v1.2  2011.07.12 EN  Updated for ESX 4, collapsed if loops into single conditional
# Change  : v2.0  2011.07.22 EN: 
	# Changed vmdk search to use the VMware.Vim.VmDiskFileQuery object to improve search accuracy
	# Change vmdk matching logic as a result of VmDiskFileQuery usage
	# Pushed discovered orphans into an array of custom PS objects
	# Simplified logging and email output
			
Set-PSDebug -Strict

#Initialize the VIToolkit:
add-pssnapin VMware.VimAutomation.Core
[Reflection.Assembly]::LoadWithPartialName("VMware.Vim")

#Main

[string]$strVC = "myViServer.mydomain.org"								# Virtual Center Server name
[string]$logfile = "c:\local\temp\getOrphanVMDK.log"
[string]$SMTPServer = "mysmtp.mydomain.org"							# Change to a SMTP server in your environment
[string]$mailfrom = "GetOrphanVMDK@myViServer.mydomain.org"	# Change to email address you want emails to be coming from
[string]$mailto = "vmware@mydomain.org"							# Change to email address you would like to receive emails
[string]$mailreplyto = "vmware@mydomain.org"						# Change to email address you would like to reply emails

[int]$countOrphaned = 0
[int64]$orphanSize = 0

# vmWare Datastore Browser query parameters
# See http://pubs.vmware.com/vi3/sdk/ReferenceGuide/vim.host.DatastoreBrowser.SearchSpec.html
$fileQueryFlags = New-Object VMware.Vim.FileQueryFlags
$fileQueryFlags.FileSize = $true
$fileQueryFlags.FileType = $true
$fileQueryFlags.Modification = $true
$searchSpec = New-Object VMware.Vim.HostDatastoreBrowserSearchSpec
$searchSpec.details = $fileQueryFlags
#The .query property is used to scope the query to only active vmdk files (excluding snaps and change block tracking).
$searchSpec.Query = (New-Object VMware.Vim.VmDiskFileQuery)
#$searchSpec.matchPattern = "*.vmdk" # Alternative VMDK match method.
$searchSpec.sortFoldersFirst = $true

if ([System.IO.File]::Exists($logfile)) {
    Remove-Item $logfile
}

#Time stamp the log file
(Get-Date –f "yyyy-MM-dd HH:mm:ss") + "  Searching Orphaned VMDKs..." | Tee-Object -Variable logdata
$logdata | Out-File -FilePath $logfile -Append
#Connect to vCenter Server
Connect-VIServer $strVC

#Collect array of all VMDK hard disk files in use:
[array]$UsedDisks = Get-View -ViewType VirtualMachine | % {$_.Layout} | % {$_.Disk} | % {$_.DiskFile}
#The following three lines were used before adding the $searchSpec.query property.  We now want to exclude template and snapshot disks from the in-use-disks array.
# [array]$UsedDisks = Get-VM | Get-HardDisk | %{$_.filename}
# $UsedDisks += Get-VM | Get-Snapshot | Get-HardDisk | %{$_.filename}
# $UsedDisks += Get-Template | Get-HardDisk | %{$_.filename}

#Collect array of all Datastores:
#$arrDS is a list of datastores, filtered to exclude ESX local datastores (all of which end with "-local1" in our environment), and our ISO storage datastore.
[array]$allDS = Get-Datastore | select -property name,Id | ? {$_.name -notmatch "-local1"} | ? {$_.name -notmatch "-iso$"} | Sort-Object -Property Name

[array]$orphans = @()
Foreach ($ds in $allDS) {
	"Searching datastore: " + [string]$ds.Name | Tee-Object -Variable logdata
	$logdata | Out-File -FilePath $logfile -Append
	$dsView = Get-View $ds.Id
	$dsBrowser = Get-View $dsView.browser
	$rootPath = "["+$dsView.summary.Name+"]"
	$searchResult = $dsBrowser.SearchDatastoreSubFolders($rootPath, $searchSpec)
	foreach ($folder in $searchResult) {
	    foreach ($fileResult in $folder.File) {
			if ($UsedDisks -notcontains ($folder.FolderPath + $fileResult.Path) -and ($fileResult.Path.length -gt 0)) {
				$countOrphaned++
				IF ($countOrphaned -eq 1) {
					("Orphaned VMDKs Found: ") | Tee-Object -Variable logdata
					$logdata | Out-File -FilePath $logfile -Append
				}
				$orphan = New-Object System.Object
				$orphan | Add-Member -type NoteProperty -name Name -value ($folder.FolderPath + $fileResult.Path)
				$orphan | Add-Member -type NoteProperty -name SizeInGB -value ([Math]::Round($fileResult.FileSize/1gb,2))
				$orphan | Add-Member -type NoteProperty -name LastModified -value ([string]$fileResult.Modification.year + "-" + [string]$fileResult.Modification.month + "-" + [string]$fileResult.Modification.day)
				$orphans += $orphan
				$orphanSize += $fileResult.FileSize
				$orphan | ft -autosize | out-string | Tee-Object -Variable logdata
				$logdata | Out-File -FilePath $logfile -Append
				[string]("Total Size or orphaned files: " + ([Math]::Round($orphanSize/1gb,2)) + " GB") | Tee-Object -Variable logdata
				$logdata | Out-File -FilePath $logfile -Append
				Remove-Variable orphan
			}
		}
	}
}
(Get-Date –f "yyyy-MM-dd HH:mm:ss") + "  Finished (" + $countOrphaned + " Orphaned VMDKs Found.)" | Tee-Object -Variable logdata
$logdata | Out-File -FilePath $logfile -Append

if ($countOrphaned -gt 0) {
	[string]$body = "Orphaned VMDKs Found: `n"
	$body += $orphans | Sort-Object -Property LastModified| ft -AutoSize | out-string
	$body += [string]("Total Size or orphaned files: " + ([Math]::Round($orphanSize/1gb,2)) + "GB")
    $SmtpClient = New-Object system.net.mail.smtpClient
    $SmtpClient.host = $SMTPServer
    $MailMessage = New-Object system.net.mail.mailmessage
    $MailMessage.from = $mailfrom
    $MailMessage.To.add($mailto)
    $MailMessage.replyto = $mailreplyto
    $MailMessage.IsBodyHtml = 0
    $MailMessage.Subject = "Info: VMware orphaned VMDKs"
    $MailMessage.Body = $body
	"Mailing report... " | Tee-Object -Variable logdata
	$logdata | Out-File -FilePath $logfile -Append
    $SmtpClient.Send($MailMessage)
}
Disconnect-VIServer -Confirm:$False

WSUS Reporting with PowerShell

I have been trying to determine if our SCCM service has most of our domain clients registered, and have decided that the WSUS client database may be the best source of information on currently active domain members. As previously mentioned, WSUS is not pre-configured with a lot of useful infrastructure reports, but pulling data out with PowerShell is not overly difficult. Have a gander… this script generates a count of all current clients, counts by OS type, a count of Virtual Machine clients, and a few counts based of various source IP addresses.

#Get WSUS Computers script
# Finds and counts all registered computers matching various criteria specified in the script
# Optionally, the found computer names to the file defined in $outFile, forced to uppercase, trimmed of whitespace, and sorted.
# Generates an object $out, that is sent to the console at the end of the script.

set-psdebug -strict

#Initialize Variables
	#$outFile = [string] "\\files\shared\ets\SAA\jgm\WSUSXps.txt"

	$hwModel = "Virtual|vm"
	$ipMatch = "^132.198|^10.245" # specify your internal network ip ranges here, in RegEx format.
	$wsusParentGroup = [string] "All Computers"
	$wsusgroup = ""
	$WindowsUpdateServer= [string] "wsus.mydomain.com" #specify your WSUS server here
	$useSecureConnection = [bool] $true
	$portNumber = [int] "443" #required if you have added SSL protection to your WSUS (which you should do).

#Instantiate Objects:
	#Required WSUS Assembly – auto installed with WSUS Administration Tools
	[void][reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration")
	$wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::GetUpdateServer($WindowsUpdateServer,$useSecureConnection,$portNumber)
	$computerScope = new-object Microsoft.UpdateServices.Administration.ComputerTargetScope
	$computerScope.IncludedInstallationStates = [Microsoft.UpdateServices.Administration.UpdateInstallationStates]::All
	$computers = $wsus.GetComputerTargets($computerScope)
	$wsusData = new-object System.Object
	$out = @()

$wsusData | add-member -type NoteProperty -name Criteria -value ("Total comptuers")
$wsusData | add-member -type NoteProperty -name Count -value ($computers.count)
$out += $wsusData
remove-variable wsusData

$osType = "Windows 7"
$filtComps = $computers | ? {$_.OSDescription -match $osType}
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("Windows 7")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData

$osType = "Windows Vista"
$filtComps = $computers | ? {$_.OSDescription -match $osType} 
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("Windows Vista")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData

$osType = "Windows XP"
# final "select" in the pipeline if you want to generate a list of computer names matching the criteria.
$filtComps = $computers | ? {$_.OSDescription -match $osType} | select-object -Property FullDomainName
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("XP Professional")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData

#Filter for virtual machine models
$filtComps = $computers | ? {$_.Model -match $hwModel}
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("Virtual Machines")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData 

$filtComps = $computers | ? {$_.IPAddress -notmatch $ipMatch} | select-object -Property IPAddress
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("Non-UVM Addresses")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData

## Following section does not produce useful data... WSUS does not see NAT-based addresses, on the public IP in front of the NAT.
## However, it is a good regex... it matches any non-routable (private) IPv4 address.  Take note for future use.
#$ipMatch = "^10.|^192.168.|^72.[1-2][0-9].|^72.3[0-1]."
#$filtComps = $computers | ? {$_.IPAddress -match $ipMatch}
#$wsusData = new-object System.Object
#$wsusData | add-member -type NoteProperty -name Criteria -value ("NAT Addresses")
#$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
#$out += $wsusData
#remove-variable wsusData

$ipMatch = "^10.245." # Our Wi-Fi and VPN clients fall in this IP range.  Substitute your internal (non-routed) IPs here.
$filtComps = $computers | ? {$_.IPAddress -match $ipMatch} 
$wsusData = new-object System.Object
$wsusData | add-member -type NoteProperty -name Criteria -value ("UVM Wireless/VPN Addresses")
$wsusData | add-member -type NoteProperty -name Count -value ($filtComps.count)
$out += $wsusData
remove-variable wsusData

#Generate file output by: removing all but the RDN of the computer name, trimming any whitespace, forcing to uppercase, 
# sorting, suppressing headers, then writing to file.
#$filtComps | foreach {$_.FullDomainName.split('.')[0]} | foreach {$_.Trim()} | foreach {$_.ToUpper()} | `
	#sort-object | Format-Table -HideTableHeaders | Out-File -FilePath $outFile -Force
	
$out | Format-Table -AutoSize