Friday, October 4, 2013

Powershell SQL Agent Job Steps and the .Count Property

This has been frustrating me for a few days now and I finally cracked it, more or less.

I have a handful of SQL Agent jobs that run on our Central Management Server Instance. These jobs check certain settings across our environment and email me a report if any servers are out of compliance. I realize I could use Policy Based Management, but I've found this system works well, except for one thing...

The scripts populate an array in Powershell with the offending records. I then check the size of the array and if it has > 0 members, I email the results to an operator on the CMS instance. I realized recently that if the array has 1 member, I would not be notified. Only if there were two or more items in the array would I get an email. I had tested my script locally and it worked fine, the problem only seemed to occur when the script ran under the SQL Agent. Enter Google. Some searching turned up this article which describes how in versions of Powershell prior to v3 (the SQLPS.exe used by the SQL Agent is based on v2 in SQL 2008R2), single element (or empty) arrays are collapsed into a single (non-array) item, which lacks the .Count property. the workaround is to wrap the results as an array type with @(), like IF (@(QueryResults).Count -gt 0). Sure enough, this worked ... in testing on my local machine. Now, when running through the SQL Agent, I receive the emailed results even if the array is empty. Some testing confirmed that when the array is ostensibly empty, the Count property is still returning a value of 1, but only when running through the SQL Agent. WTH.

Having not yet discovered the underlying cause of this issue, I've found a way around it. I first convert the entire array to a string and test its length rather than the number of elements in the array. If the length > 1 then I know I should send the notification email. It's kind of a hack and I don't like it. So, if anyone reading this should know the real reason for the behavior and a better way around it, I'd love to hear it.

Should you find it useful, here is an example of the full script that I use to find databases not owned by 'sa'. You can add exceptions to it, to ignore specific non 'sa' users. This is useful to skip databases for applications like Sharepoint that use database ownership as part of its security strategy.

$ServerGroup = 'Production' #Replace with the name of your CMS Server group to check
$CMServer = 'CMSServerInstance' #Replace with the name of your CMS SQL instance
$Operator = 'DBAs' #Replace with the Operator Group that should be emailed
$FromAddress = 'sql_checkup@yourdomain.com' #Replace with From Address for email being sent
$msg_Subject = "$ServerGroup Database Ownership Report" #Replace with subject line for emailbeing sent
$SmtpServer = 'relay.yourdomain.local' #Replace with name of SMTP Server for email relay

## Fetch List of Servers in the group $ServerGroup from Central Management Server $CMServer ##
$ServerList = Invoke-Sqlcmd -Query "SELECT DISTINCT s.server_name AS 'ServerName'
FROM msdb.dbo.sysmanagement_shared_server_groups_internal g
INNER JOIN msdb.dbo.sysmanagement_shared_registered_servers_internal s
ON g.server_group_id = s.server_group_id
WHERE g.name = '$ServerGroup'
ORDER BY s.server_name ;" -ServerInstance $CMServer

## Build results by executing our query against each server in the list we just built ##
$QueryResults = foreach ($ServerName in $ServerList)
{
Invoke-Sqlcmd -Query "SELECT @@ServerName As ServerName, d.name As DatabaseName, s.name As Owner
       FROM sys.databases d LEFT OUTER JOIN sys.syslogins s on d.owner_sid = s.sid
       WHERE ((s.name <> 'sa' OR s.name IS NULL) AND ISNULL(s.name,'') NOT IN('DOMAIN\excluded_user', 'APP_ExcludedUser')) AND d.source_database_id IS NULL
       ORDER BY @@ServerName, d.name
" -ServerInstance $ServerName.ServerName
}

## If there are any rows in the result set, email it to the Operator specified in $Operator ##
$QueryResultsString = Out-String -InputObject $QueryResults
If ($QueryResultsString.Length -gt 0)
{
  ## Build data set of Email addresses for the operator specified (if more than one address, assumes ';' delimiter) ##
  $email_addresses = Invoke-Sqlcmd -Query "create table #email (address nvarchar(100))
  declare @email_addresses nvarchar(100)
  declare @address nvarchar(100)
  declare @Pos INT
  select @email_addresses = email_address from msdb.dbo.sysoperators where name = '$Operator'
  SET @email_addresses = LTRIM(RTRIM(@email_addresses))+ ';'
  SET @Pos = CHARINDEX(';', @email_addresses, 1)
  WHILE @Pos > 0
      BEGIN
          SET @address = LTRIM(RTRIM(LEFT(@email_addresses, @Pos - 1)))
          IF @address <> ''
              BEGIN
                  INSERT INTO #email (address)
                    VALUES (CAST(@address AS VARCHAR(MAX)))
              END
          SET @email_addresses = RIGHT(@email_addresses, LEN(@email_addresses) - @Pos)
          SET @Pos = CHARINDEX(';', @email_addresses, 1)
      END
  select * from #email
  drop table #email ;" -ServerInstance $CMServer

  ## Convert the data set to a string array for later use to build addressee's ##
  $email_addresses_array = @()
  for ($i=0; $i -le $email_addresses.Length; $i++)
    {
      $email_addresses_array = $email_addresses_array + ($email_addresses[$i].ItemArray -join ",")
    }

  ## Build the Email  and sent it##
  $Body = "The following databases are not owned by sa :`n"
  $Body += $QueryResults | Out-String
  $msg = New-Object System.Net.Mail.MailMessage
  $msg.From = new-object System.Net.Mail.MailAddress($FromAddress)
  foreach ($email_address in $email_addresses_array)
    {
      If ($email_address.Length -gt 0)
        {
          $msg.To.Add("$email_address")
        }
    }
  $msg.Subject = $msg_Subject
  $msg.Body = $Body
  $smtpClient = new-object System.Net.Mail.SmtpClient
  $smtpClient.Host = $SmtpServer
  $smtpClient.Send($msg)
}


No comments:

Post a Comment