Showing posts with label Tail. Show all posts
Showing posts with label Tail. Show all posts

Friday, June 17, 2011

PowerShell Tail Improvements

Here are the latest improvements I have made to my tail functions for PowerShell. I typically work in a heterogenous computing environment so I need to be able to handle the various text file encodings and new line delimiters I encounter. The previous blogs posts (here, here & here) showed a steady improvement and functionality with the major feature I needed to complete being the detecting and reading of text files that were not were ASCII encoded with Windows end-of-line (CR+LF). This stops hard coding specific changes in my code to deal with each situation I encounter.

I have added two functions to the code to handle these two items. Looking at the head of the file, I attempt to detect the byte order mark (BOM) to determine if the file is unicode encoded and its endianess. If I am unable to make that determination, revert to ASCII as the encoding. I work with the System.Text.Encoding class to assist in the decode of the unicode based text files. The second function detects the new line delimiter by searching the head for the match of Windows (CR+LF), UNIX (LF) or Classic Macintosh (CR) to assist in the breaking of the lines for initial tail read.

In the code sample below, you will find these two new functions with the log file "tailed" being a system.log from a hypothetical Mac OS X server sharing out its "var" directory via SAMBA so we can access the system.log file in the log subdirectory. This file is typically ASCII encoded with UNIX new lines.

If you are running Microsoft Forefront Client Security and want to monitor the updates, virus detections and removals, you need to access the "MPLog-*.log" file which is Unicode-16 Little-Endian encoded. Swap out the inputFile variable to watch this file for updates. You can find that file here:
$env:ALLUSERSPROFILE\Application Data\Microsoft\Microsoft Forefront\Client Security\Client\Antimalware\Support
These are good examples to demonstrate the capability of the added functions add flexibility to my prior attempts.
Function Get-FileEncoding($fileStream) {
 $fileEncoding = $null
 if($fileStream.CanSeek) {
  [byte[]] $bytesToRead = New-Object byte[] 4
  $fileStream.Read($bytesToRead, 0, 4) | Out-Null
  if($bytesToRead[0] -eq 0x2B -and  $bytesToRead[1] -eq 0x2F -and  $bytesToRead[2] -eq 0x76) { # UTF-7
   $encoding = "utf7"
  } elseif($bytesToRead[0] -eq 0xFF -and $bytesToRead[1] -eq 0xFE) { # UTF-16 Little-Endian
   $encoding = "unicode-le"
  } elseif($bytesToRead[0] -eq 0xFE -and $bytesToRead[1] -eq 0xFF) { # UTF-16 Big-Endian
   $encoding = "unicode-be"
  } elseif($bytesToRead[0] -eq 0 -and $bytesToRead[1] -eq 0 -and $bytesToRead[2] -eq 0xFE -and $bytesToRead[3] -eq 0xFF) { # UTF-32 Big Endian
   $encoding = "utf32-be"
  } elseif($bytesToRead[0] -eq 0xFF -and $bytesToRead[1] -eq 0xFE -and $bytesToRead[2] -eq 0 -and $bytesToRead[3] -eq 0) { # UTF-32 Little Endian
   $encoding = "utf32-le"
  } elseif($bytesToRead[0] -eq 0xDD -and $bytesToRead[1] -eq 0x73 -and $bytesToRead[2] -eq 0x66 -and $bytesToRead[3] -eq 0x73) { # UTF-EBCDIC
   $encoding = "unicode"
  } elseif($bytesToRead[0] -eq 0xEF -and $bytesToRead[1] -eq 0xBB -and $bytesToRead[2] -eq 0xBF) { # UTF-8 with BOM
   $encoding = "utf8"
  } else { # ASCII Catch-All
   $encoding = "ascii"
  }
  switch($encoding) {
   "unicode-be" { $fileEncoding = New-Object System.Text.UnicodeEncoding($true, $true) }
   "unicode-le" { $fileEncoding = New-Object System.Text.UnicodeEncoding($false, $true) }
   "utf32-be" { $fileEncoding = New-Object System.Text.UTF32Encoding($true, $true) }
   "utf32-le" { $fileEncoding = New-Object System.Text.UTF32Encoding($false, $true) }
   "unicode" { $fileEncoding = New-Object System.Text.UnicodeEncoding($true, $true) }
   "utf7" { $fileEncoding = New-Object System.Text.UTF7Encoding } 
   "utf8" { $fileEncoding = New-Object System.Text.UTF8Encoding } 
   "utf32" { $fileEncoding = New-Object System.Text.UTF32Encoding } 
   "ascii" { $fileEncoding = New-Object System.Text.AsciiEncoding }
  }
 }
 return $fileEncoding 
}
#--------------------------------------------------------------------------------------------------#
Function Get-NewLine($fileStream, $fileEncoding) {
 $newLine = $null
 $byteChunk = 512
 if($fileStream.CanSeek) {
  $fileSize = $fileStream.Length
  if($fileSize -lt $byteChunk) { $byteChunk -eq $fileSize }
  [byte[]] $bytesToRead = New-Object byte[] $byteChunk
  $fileStream.Read($bytesToRead, 0, $byteChunk) | Out-Null
  $testLines = $fileEncoding.GetString($bytesToRead)
  if($testLines -match "\r\n") { # Windows
   $newLine = "\r\n"
  } elseif($testLines -match "\n") { # Unix
   $newLine = "\n"
  } elseif($testLines -match "\r") { # Classic Mac
   $newLine = "\r"
  } else { # When all else fails, Go Windows
   $newLine = "\r\n"
  }
 }
 return $newLine
}
#--------------------------------------------------------------------------------------------------#
Function Read-EndOfFileByByteChunk($fileName,$totalNumberOfLines,$byteChunk) {
 if($totalNumberOfLines -lt 1) { $totalNumberOfLines = 1 }
 if($byteChunk -le 0) { $byteChunk = 10240 }
 $linesOfText = New-Object System.Collections.ArrayList
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $fileEncoding = Get-FileEncoding $fileStream
  $newLine = Get-NewLine $fileStream $fileEncoding
  $fileSize = $fileStream.Length
  $byteOffset = $byteChunk
  [byte[]] $bytesRead = New-Object byte[] $byteChunk
  $totalBytesProcessed = 0
  $lastReadAttempt = $false
  do {
   if($byteOffset -ge $fileSize) {
    $byteChunk = $fileSize - $totalBytesProcessed
    [byte[]] $bytesRead = New-Object byte[] $byteChunk
    $byteOffset = $fileSize
    $lastReadAttempt = $true
   }
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   $chunkOfText = New-Object System.Collections.ArrayList
   $chunkOfText.AddRange(([System.Text.RegularExpressions.Regex]::Split($fileEncoding.GetString($bytesRead),$newLine)))
   $firstLineLength = ($chunkOfText[0].Length)
   $byteOffset = ($byteOffset + $byteChunk) - ($firstLineLength)
   if($lastReadAttempt -eq $false -and $chunkOfText.count -lt $totalNumberOfLines) {
    $chunkOfText.RemoveAt(0)
   }
   $totalBytesProcessed += ($byteChunk - $firstLineLength)
   $linesOfText.InsertRange(0, $chunkOfText)
  } while($totalNumberOfLines -ge $linesOfText.count -and $lastReadAttempt -eq $false -and $totalBytesProcessed -lt $fileSize)
  $fileStream.Close()
  if($linesOfText.count -gt 1) {
   $linesOfText.RemoveAt($linesOfText.count-1)
  }
  $deltaLines = ($linesOfText.count - $totalNumberOfLines)
  if($deltaLines -gt 0) {
   $linesOfText.RemoveRange(0, $deltaLines)
  }
 } else {
  $linesOfText.Add("[ERROR] $fileName not found") | Out-Null
 }
 Write-Host $linesOfText.count
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
Function Read-FileUpdates($fileName,$startSize) {
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $fileEncoding = Get-FileEncoding $fileStream
  $fileStream.Close()
  while([System.IO.File]::Exists($fileName)) {
   $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
   if($fileStream.CanSeek) {
    $currentFileSize = $fileStream.Length
    if($currentFileSize -gt $startSize) {
     $byteChunk = $currentFileSize - $startSize
     [byte[]] $bytesRead = New-Object byte[] $byteChunk
     $fileStream.Seek((-$byteChunk), [System.IO.SeekOrigin]::End) | Out-Null
     $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
     Write-Host ($fileEncoding.GetString($bytesRead)) -noNewLine
     $startSize = $currentFileSize
     }
    }
   $fileStream.Close()
   Start-Sleep -milliseconds 250
  }
 }
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name inputFile -option Constant -value "\\macosx-server.mydomain.local\var\log\system.log"
#--------------------------------------------------------------------------------------------------#
if([System.IO.File]::Exists($inputFile)) {
 Write-Host (Read-EndOfFileByByteChunk $inputFile 10 1280 | Out-String) -noNewLine
 $fileStream = New-Object System.IO.FileStream($inputFile,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $fileSize = $fileStream.Length
 $fileStream.Close()
 Read-FileUpdates $inputFile $fileSize
} else {
 Write-Host "Could not find $inputFile..." -foregroundColor Red
}

Monday, June 13, 2011

Nagios Check for Scheduled TSM Backups

Good backups are the best friends you have as a System Administrator. If you don't know if your backups are successful, your business is at risk. Being unable to recover data after a catastrophic failure on a business critical system is typically an RGE and can put a company out of business. IBM's Tivoli Storage Manager system provides a robust backup and recovery and has decent reporting. I prefer to consolidate system health monitoring in one system, Nagios, so I do not need a separate monitoring console for every product in production. Monitoring backups successfully over hundreds or thousands of systems from a generated report is laborious. Nagios provides checks and balances for resolving system issues. In one view, you know your risk level. If you don't resolve a problem, it remains CRITICAL. Having a check for TSM backups simplifies the process and greatly reduces the labor required to monitor backups.

Scheduled TSM backups write to a log defined in the schedule's .opt file; typically dsmsched.log. On large file servers with long dsmsched.log retention periods, this file can grow easily over 1 gigabyte. Reading the entire dsmsched.log file to determine success of the last backup will likely breach the timeout of the Nagios check. To compensate for this, we need to tail the log file and retrieve the summary data from the last backup. In the check below, I do just that. If you pass the name of the schedule log file (you can run multiple schedules on a client; each with a different log file name), the check will look for it install directory in the "Program Files" stored in the environmental variable. If no log file name is provided to the check, it will search the registry to look for the default log filename. If you are running custom install locations, this will need to me modified.

As you follow through the flow of the code, you will see what triggers are passed on to Nagios. They are fairly straightforward:
  • If a backup has not completed in 24 hours, a critical alarm is generated
  • If a certain number of failed file backups are reported, a warning alarm is generated
  • If a backup is still running, a warning alarm is generated
  • If a successful backup is detected, a normal return is generated
These are all customizable via the initial variables in the code. One special feature of this code (and is optional) is the ability to restart the TSM Client Scheduler if no communication between the client and server have occurred in the last 36 hours. Not a common problem but one that I have encountered enough times to make this a feature; saving time spent manually restarting the process. Restarting the TSM Client Scheduler service will re-initiate the communication.

And remember, just because you have log files saying you have "good backups" that doesn't mean it's true. You need to test restores on a regular basis as a part of your disaster recovery practice. Ultimately, backups are only as good as their ability to be restored.

UPDATE: I have made some improvements to this code here.
param([string]$tsmFile)
#--------------------------------------------------------------------------------------------------#
Function Get-TSMInfo($server, $tsmInfo) {
 $key = "SOFTWARE"
 $hKey = [Microsoft.Win32.RegistryHive]::LocalMachine
 $baseKey = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey($hKey, $server)
 foreach($rootKeyValue in ($baseKey.OpenSubKey($key)).GetSubKeyNames()) {
  if($rootKeyValue -eq "IBM" -and ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion")).SubKeyCount -gt 2) {
   $tsmVersion = ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion\BackupClient")).GetValue("PtfLevel")
   $tsmPath = ($baseKey.OpenSubKey("$key\IBM\ADSM\CurrentVersion\BackupClient")).GetValue("Path")
   $key = "SYSTEM\CurrentControlSet\Services"
   if($tsmVersion -ne "" -and $tsmPath -ne "") {
    foreach($keyValue in ($baseKey.OpenSubKey($key)).GetSubKeyNames()) {
     foreach($subKeyValue in ($baseKey.OpenSubKey("$key\$keyValue")).GetSubKeyNames()) {
      $clientNodeName = ""
      $errorLog = ""
      $optionsFile = ""
      $scheduleLog = ""
      if(($baseKey.OpenSubKey("$key\$keyValue").GetValue("Start")) -eq "2") {
       if($subKeyValue -eq "Parameters") {
        foreach($value in ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValueNames()) {
         if($value -eq "clientNodeName") {
          $clientNodeName = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "errorLog") {
          $errorLog = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "optionsFile") {
          $optionsFile = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         } elseif($value -eq "scheduleLog") {
          $scheduleLog = ($baseKey.OpenSubKey("$key\$keyValue\Parameters")).GetValue($value)
         }
        }
       }
      }
      if($clientNodeName -ne "" -and $errorLog -ne "" -and $optionsFile -ne "" -and $scheduleLog -ne "") {
       $optionsFileUncPath = ("\\$server\" + ($optionsFile.SubString(0,1) + "$" + $optionsFile.SubString(2)))
       $tsmServer = "FAILED"
       $tsmClientPort = "FAILED"
       if(Test-Path -path $optionsFileUncPath) {
        foreach($line in (Get-Content -path $optionsFileUncPath)){
         if($line -match "TCPSERVERADDRESS") {
          $tsmServer = ($line -replace "TCPSERVERADDRESS","").Trim()
         }
         if($line -match "TCPCLIENTPORT") {
          $tsmClientPort = ($line -replace "TCPCLIENTPORT","").Trim()
         }
        }
       }
       $serviceStatus = $null
       foreach($service in Get-Service) {
        if($service.DisplayName -eq $keyValue) {
         $serviceStatus = $service.Status
         break
        }
       }
       if($serviceStatus -eq "Running" -or $serviceStatus -eq "Stopped") {
        $clientNodeInformation = New-Object -typeName PSObject
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "server" -value $server
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmVersion" -value $tsmVersion
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "installPath" -value $tsmPath
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmServer" -value $tsmServer
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "tsmClientPort" -value $tsmClientPort
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "scheduleName" -value $keyValue
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "clientNodeName" -value $clientNodeName
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "optionsFile" -value $optionsFile
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "scheduleLog" -value $scheduleLog
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "errorLog" -value $errorLog
        Add-Member -inputObject $clientNodeInformation -type NoteProperty -name "status" -value $serviceStatus
        $tsmInfo += $clientNodeInformation
       }
      }
     }
    }
   }
  }
 }
 return $tsmInfo
}
#--------------------------------------------------------------------------------------------------#
Function Read-EndOfFileByByteChunk($fileName,$totalNumberOfLines,$byteChunk) {
 if($totalNumberOfLines -lt 1) { $totalNumberOfLines = 1 }
 if($byteChunk -le 0) { $byteChunk = 10240 }
 $linesOfText = New-Object System.Collections.ArrayList
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $asciiEncoding = New-Object System.Text.ASCIIEncoding
  $fileSize = $fileStream.Length
  $byteOffset = $byteChunk
  [byte[]] $bytesRead = New-Object byte[] $byteChunk
  $totalBytesProcessed = 0
  $lastReadAttempt = $false
  do {
   if($byteOffset -ge $fileSize) {
    $byteChunk = $fileSize - $totalBytesProcessed
    [byte[]] $bytesRead = New-Object byte[] $byteChunk
    $byteOffset = $fileSize
    $lastReadAttempt = $true
   }
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   $chunkOfText = New-Object System.Collections.ArrayList
   $chunkOfText.AddRange(([System.Text.RegularExpressions.Regex]::Split($asciiEncoding.GetString($bytesRead),"\r\n")))
   $firstLineLength = ($chunkOfText[0].Length)
   $byteOffset = ($byteOffset + $byteChunk) - ($firstLineLength)
   if($lastReadAttempt -eq $false -and $chunkOfText.count -lt $totalNumberOfLines) {
    $chunkOfText.RemoveAt(0)
   }
   $totalBytesProcessed += ($byteChunk - $firstLineLength)
   $linesOfText.InsertRange(0, $chunkOfText)
  } while($totalNumberOfLines -ge $linesOfText.count -and $lastReadAttempt -eq $false -and $totalBytesProcessed -lt $fileSize)
  $fileStream.Close()
  if($linesOfText.count -gt 1) {
   $linesOfText.RemoveAt($linesOfText.count-1)
  }
  $deltaLines = ($linesOfText.count - $totalNumberOfLines)
  if($deltaLines -gt 0) {
   $linesOfText.RemoveRange(0, $deltaLines)
  }
 } else {
  $linesOfText.Add("[ERROR] $fileName not found") | Out-Null
 }
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name returnNormal -option Constant -value 0
Set-Variable -name returnWarning -option Constant -value 1
Set-Variable -name returnError -option Constant -value 2
Set-Variable -name returnUnknown -option Constant -value 3
Set-Variable -name computerFqdn -option Constant -value (([System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()).HostName + "." + ([System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties()).DomainName)
Set-Variable -name backupWindow -option Constant -value 24 # in Hours
Set-Variable -name deafService -option Constant -value 36 # in Hours
Set-Variable -name enableRestarts -option Constant -value $true # Allow check to restart TSM if the service
Set-Variable -name lookBack -option Constant -value 250 # Number of lines to tail
Set-Variable -name maximumFailures -option Constant -value 5 # Your tolerance for failed files
Set-Variable -name successfulBackup -value $false
Set-Variable -name todaysBackupFound -value $false
Set-Variable -name backupStillRunning -value $false
Set-Variable -name completionTime -value $null
Set-Variable -name totalFailed -value 0
Set-Variable -name logEntries -value @()
Set-Variable -name exitMessage -value "Massive Script Failure"
Set-Variable -name exitValue -value $returnError
#--------------------------------------------------------------------------------------------------#
if($tsmFile -eq "$" -or (!$tsmFile)) {
 $tsmInfo = @(Get-TSMInfo $computerFqdn @())
 foreach($tsmInstance in $tsmInfo) {
  if($tsmInstance.scheduleLog -match $tsmFile) {
   if($tsmInstance.scheduleLog -match "\\dsmsched.log") {
    $tsmLogFile = $tsmInstance.scheduleLog
    Write-Host $tsmLogFile
    break
   }
  }
 }
} else {
 $tsmLogFile = ($env:programfiles + "\Tivoli\TSM\baclient\$tsmFile")
}

if(Test-Path -path $tsmLogFile) {
 $logEntries = Read-EndOfFileByByteChunk $tsmLogFile $lookBack 1280
 
 foreach($logEntry in $logEntries) {
  if($logEntry.Length -ge 19) {
   $dateTest = $logEntry.SubString(0,19) -as [DateTime]
   if($dateTest) {
    if(((Get-Date) - (Get-Date $logEntry.SubString(0,19))).TotalHours -le $backupWindow) {
     if($logEntry -match "Scheduled event '(.*?)' completed successfully.") {
      $successfulBackup = $true
      $completionTime = Get-Date $logEntry.SubString(0,19)
     }
     if($logEntry -match "Total number of objects failed:") {
      [int]$totalFailed = ($logEntry -Replace "(.*)Total number of objects failed:", "").Trim()
     }
     $todaysBackupFound = $true
    }
    $lastLine = $logEntry
   }
  }
 }
 
 if($successfulBackup -eq $false -and $todaysBackupFound -eq $true) {
  $lastLogTime = ((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalMinutes
  if($lastLogTime -le 15) {
   $backupStillRunning = $true
  }
 }
 
 if($todaysBackupFound -eq $false) {
  if(((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalHours -ge $deafService -and $enableRestarts -eq $true) {
   $tsmInfo = @(Get-TSMInfo $computerFqdn @())
   $schedulerFound = $false
   foreach($tsmInstance in $tsmInfo) {
    if($tsmInstance.scheduleLog -match $tsmFile) {
     if($tsmInstance.status -eq "Running") {
      Restart-Service -name $tsmInstance.scheduleName
      $exitMessage = ("TSM Scheduler `"" + $tsmInstance.scheduleName + "`" has not contacted the TSM server in $deafService hours. Restarting service.")
     } else {
      Start-Service -name $tsmInstance.scheduleName
      $exitMessage = ("TSM Scheduler `"" + $tsmInstance.scheduleName + "`" was stopped and hasn't contacted the TSM server in $deafService hours. Starting service.")
     }
     $schedulerFound = $true
     $exitValue = $returnError
     break
    }
   }
   if($schedulerFound -eq $false) {
    $timeSinceLastContact = ((Get-Date) - (Get-Date $lastLine.SubString(0,19))).TotalHours
    $exitMessage = ("Unable to find data in the last $backupWindow hours in $tsmLogFile and the client hasn't contacted the TSM Server in $timeSinceLastContact hours. Last Backup log date: " + (Get-Date $lastLine.SubString(0,19)))
    $exitValue = $returnError
   }
  } else {
   $exitMessage = ("Unable to find data in the last $backupWindow hours in $tsmLogFile. Last Backup log date: " + (Get-Date $lastLine.SubString(0,19)))
   $exitValue = $returnError
  }
 } elseif($totalFailed -ge $maximumFailures) {
  $exitMessage = "Backup completed with $totalFailed failed objects."
  $exitValue = $returnWarning
 } elseif($successfulBackup -eq $true) {
  $exitMessage = "Backup completed successfully: $completionTime"
  $exitValue = $returnNormal
 } elseif($backupStillRunning -eq $true) {
  $exitMessage = ("Backup still running! Please allow to complete. Current status: " + $lastLine -Replace "\\","/")
  $exitValue = $returnWarning
 } else {
  $exitMessage = ("Unable to find a successful backup. Last status: " + $lastLine -Replace "\\","/")
  $exitValue = $returnError
 }
} else {
 $exitMessage = "Unable to locate $tsmLogFile"
 $exitValue = $returnError
}

Write-Host $exitMessage
$Host.SetShouldExit($exitValue)

Monday, April 18, 2011

Replicating UNIX "tail -f" in PowerShell

Building upon my previous blog post, Unix Tail-like Functionality in PowerShell Revisited, I have completed the next step in providing tail functionality in my PowerShell scripts; the ability to emulate the "-f" argument.

From the TAIL(1) man page:
The -f option causes tail to not stop when end of file is reached, but rather to wait for additional data to be appended to the input.
My approach to replicating this functionality in PowerShell is to once again take advantage of System.IO.FileStream Class and read from the end of file as I did in my previous tail emulations. Originally, I thought this was going to be a more difficult task to accomplish but since I learned so much from my prior attempts, it turned out to be fairly simple and a lot less code to implement. My solution focuses on the fact that the file size grows as more data is appended to the text file. If I know how large the file was in a prior reading than it is currently, I know how many bytes have been added to the file and from there, I know the number of bytes I need to read from the end of the file and return to the console. All I need is a looping routine to constantly check the file for changes. In my code sample below, I emulate "tail -f" to the console by first reading the last 10 lines of a hypothetical BlackBerry Enterprise Server Management Agent log file (on an active server this file grows constantly) on a remote server as Unix "tail -f" would then start to monitor the log file for changes by comparing the file size waiting 100 milliseconds between each comparison. This process will continue until you send a break or the file is deleted.

With this bit of starter code, you should be able to implement some unique tools to monitor and react to data in log files in real-time.

UPDATE: I have made some improvements to this code here.
Function Read-EndOfFileByByteChunk($fileName,$totalNumberOfLines,$byteChunk) {
 if($totalNumberOfLines -lt 1) { $totalNumberOfLines = 1 }
 if($byteChunk -le 0) { $byteChunk = 10240 }
 $linesOfText = New-Object System.Collections.ArrayList
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $asciiEncoding = New-Object System.Text.ASCIIEncoding
  $fileSize = $fileStream.Length
  $byteOffset = $byteChunk
  [byte[]] $bytesRead = New-Object byte[] $byteChunk
  $totalBytesProcessed = 0
  $lastReadAttempt = $false
  do {
   if($byteOffset -ge $fileSize) {
    $byteChunk = $fileSize - $totalBytesProcessed
    [byte[]] $bytesRead = New-Object byte[] $byteChunk
    $byteOffset = $fileSize
    $lastReadAttempt = $true
   }
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   $chunkOfText = New-Object System.Collections.ArrayList
   $chunkOfText.AddRange(([System.Text.RegularExpressions.Regex]::Split($asciiEncoding.GetString($bytesRead),"\r\n")))
   $firstLineLength = ($chunkOfText[0].Length)
   $byteOffset = ($byteOffset + $byteChunk) - ($firstLineLength)
   if($lastReadAttempt -eq $false -and $chunkOfText.count -lt $totalNumberOfLines) {
    $chunkOfText.RemoveAt(0)
   }
   $totalBytesProcessed += ($byteChunk - $firstLineLength)
   $linesOfText.InsertRange(0, $chunkOfText)
  } while($totalNumberOfLines -ge $linesOfText.count -and $lastReadAttempt -eq $false -and $totalBytesProcessed -lt $fileSize)
  $fileStream.Close()
  if($linesOfText.count -gt 1) {
   $linesOfText.RemoveAt($linesOfText.count-1)
  }
  $deltaLines = ($linesOfText.count - $totalNumberOfLines)
  if($deltaLines -gt 0) {
   $linesOfText.RemoveRange(0, $deltaLines)
  }
 } else {
  $linesOfText.Add("[ERROR] $fileName not found") | Out-Null
 }
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
Function Read-FileUpdates($fileName,$startSize) {
 $asciiEncoding = New-Object System.Text.ASCIIEncoding
 while([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $currentFileSize = $fileStream.Length
  if($currentFileSize -gt $startSize) {
   $byteChunk = $currentFileSize - $startSize
   [byte[]] $bytesRead = New-Object byte[] $byteChunk
   $fileStream.Seek((-$byteChunk), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   Write-Host ($asciiEncoding.GetString($bytesRead)) -noNewLine
   $startSize = $currentFileSize
  }
  $fileStream.Close()
  Start-Sleep -milliseconds 100
 }
}
#--------------------------------------------------------------------------------------------------#
Set-Variable -name inputFile -option Constant -value "\\japan-bes.ad.mydomain.local\E$\Program Files\Research In Motion\BlackBerry Enterprise Server\Logs\20110418\JAPAN-BES_MAGT_02_20110418_0001.txt"
#--------------------------------------------------------------------------------------------------#
if([System.IO.File]::Exists($inputFile)) {
 Write-Host (@(Read-EndOfFileByByteChunk $inputFile 10 1280) | Out-String) -noNewLine
 $fileStream = New-Object System.IO.FileStream($inputFile,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $fileSize = $fileStream.Length
 $fileStream.Close()
 Read-FileUpdates $inputFile $fileSize
} else {
 Write-Host "Could not find $inputFile..." -foregroundColor Red
}

Monday, March 14, 2011

Unix Tail-like Functionality in PowerShell Revisited

My first attempt to replicate tail for PowerShell, which I wrote about in "Unix Tail-like Functionality in PowerShell", was horribly inefficient once you got past a couple dozen lines. This makes since given the method I was using -- a byte by byte reverse read of a text file, converting each byte at a time to ASCII. I knew the solution was to "wolf down" large byte chunks and process them as a whole. Using System.Text.ASCIIEncoding.GetString, I am doing that just after reading into memory multiple bytes using System.IO.FileStream.Read. With this change in methodology, I am getting to within 3% of the speed of tail in UNIX in my tests. The largest test I've performed was returning 1,000,000 lines from a 850MB log file. A Mac OS X 10.6.6 workstation performed the task in 16 seconds using tail and a Windows Server 2003 server returned in 17 seconds using this method. Good enough for me. Most of my needs are in the thousands of lines which I am able to return in hundreds of milliseconds which is perfect my monitoring scripts in Nagios. Compared to my previous attempt, this is a Lockheed SR-71 vs. a Wright Brothers Flyer. A small 5,000 tail using the old code took 5 1/2 minutes to return while this code returned the same lines in 200 milliseconds. Huge difference!

In the code sample below, I am using 10 kilobytes for that chunking. I found that number suited most of my needs. However, you can greatly increase that number for large number of lines to be returned (I used 4MB for my million line test). You can also do a little automatic tuning by altering the number of bytes using the number of lines you are seeking. One thing to be aware when passing files to this code, if you pass a file to System.IO.File/FileStream without a full path, it will not assume the file is located in the path of the executed script so Test-Path is not a valid test. Using System.IO.Directory.GetCurrentDirectory, you can find this by running the following in PowerShell:
[System.IO.Directory]::GetCurrentDirectory()
More than likely, it will point to the home directory of the profile the shell is executed under.

Also be aware that this tail-like function does not handle unicode log files. The method I am using to decode the bytes is ASCII dependent. I am not using System.Text.UnicodeEncoding yet in the code. Currently ASCII meets all my needs for reading log files but I am still interested in adding compatibility to this function. I am also assuming that all log files denote the end of a line using carriage return & line feed (CHR 13 + CHR 10) which is how majority of text files are written in Windows. UNIX & old style Macintosh text files will not work properly with this code. You will need to modify line 23 to change the delimiter for the split for those text file formats.

UPDATE: I have now finished an update that provides the "tail -f" functionality for continuously reading the updates to a text file. Read about it in my blog post, Replicating UNIX "tail -f" in PowerShell.

UPDATE: I have updated the code to handle unicode text files and non-Windows new lines. You can review the code here.
Function Read-EndOfFileByByteChunk($fileName,$totalNumberOfLines,$byteChunk) {
 if($totalNumberOfLines -lt 1) { $totalNumberOfLines = 1 }
 if($byteChunk -le 0) { $byteChunk = 10240 }
 $linesOfText = New-Object System.Collections.ArrayList
 if([System.IO.File]::Exists($fileName)) {
  $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
  $asciiEncoding = New-Object System.Text.ASCIIEncoding
  $fileSize = $fileStream.Length
  $byteOffset = $byteChunk
  [byte[]] $bytesRead = New-Object byte[] $byteChunk
  $totalBytesProcessed = 0
  $lastReadAttempt = $false
  do {
   if($byteOffset -ge $fileSize) {
    $byteChunk = $fileSize - $totalBytesProcessed
    [byte[]] $bytesRead = New-Object byte[] $byteChunk
    $byteOffset = $fileSize
    $lastReadAttempt = $true
   }
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
   $fileStream.Read($bytesRead, 0, $byteChunk) | Out-Null
   $chunkOfText = New-Object System.Collections.ArrayList
   $chunkOfText.AddRange(([System.Text.RegularExpressions.Regex]::Split($asciiEncoding.GetString($bytesRead),"\r\n")))
   $firstLineLength = ($chunkOfText[0].Length)
   $byteOffset = ($byteOffset + $byteChunk) - ($firstLineLength)
   if($lastReadAttempt -eq $false -and $chunkOfText.count -lt $totalNumberOfLines) {
    $chunkOfText.RemoveAt(0)
   }
   $totalBytesProcessed += ($byteChunk - $firstLineLength)
   $linesOfText.InsertRange(0, $chunkOfText)
  } while($totalNumberOfLines -ge $linesOfText.count -and $lastReadAttempt -eq $false -and $totalBytesProcessed -lt $fileSize)
  $fileStream.Close()
  if($linesOfText.count -gt 1) {
   $linesOfText.RemoveAt($linesOfText.count-1)
  }
  $deltaLines = ($linesOfText.count - $totalNumberOfLines)
  if($deltaLines -gt 0) {
   $linesOfText.RemoveRange(0, $deltaLines)
  }
 } else {
  $linesOfText.Add("[ERROR] $fileName not found") | Out-Null
 }
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
$fileName = "C:\Logs\really-huge.log" # Your really big log file
$numberOfLines = 100 # Number of lines from the end of the really big log file to return
$byteChunk = 10240 # Size of bytes read per seek during the search for lines to return
####################################################################################################
## This is a possible self-tuning method you can use but will blow up memory on an enormous 
## number of lines to return
## $byteChunk = $numberOfLines * 256 
####################################################################################################
$lastLines = @()

$lastLines = Read-EndOfFileByByteChunk $fileName $numberOfLines $byteChunk
foreach($lineOfText in $lastLines) {
 Write-Output $lineOfText
}

Monday, March 7, 2011

Unix Tail-like Functionality in PowerShell

A common tool I use in shell scripts on Unix/Linux/Mac OS X servers is tail. While there are command-line tail conversions for Windows, I need something I can integrate into a script for reading the end of large log files, search for information and act on that result. I don't want to distribute third party software along with the script to accomplish the task. Get-Content and Select-Object are not suitable for large files.

After researching the capabilities of File IO in .Net, I found that System.IO.FileStream class had just what I needed. Using this class, I read the target text file byte by byte from the end of the file until I reach a selected number of lines of text delimited by a carriage return. The amount of time it takes to obtain the data is related to the number of characters per line. It works very well in 500 lines or less in my typical log files (I tested up to 1 gigabyte) and much faster than using:
Get-Content "C:\Logs\really-huge.log" | Select-Object -last 100
The code meets 95% of my needs but I am sure I can optimize it so it comes close to matching the speed of tail from the Unix distributions I commonly use. It's my first stab at tackling the problem. One interesting part of the code is that I use System.Collections.ArrayList instead of a standard PowerShell array. The reason is since I am reading the file in reverse, I need to return the data back in the proper order. The ArrayList object allows me to insert into the first element so I don't have to re-write the array in the right order after collecting the data. Also I noticed that using System.Convert to covert the bytes to a character instead of using PowerShell's native [char] was faster. Returning large number of lines, it was significant -- about .5 seconds per 100 lines.

I will keep working on this to close that 5% and update this post with a link to an updated blog post in the future with the improvements.

UPDATE: I have rewritten this function in a new blog post and it is lightning fast. This code is deprecated and should only be used for amusement purposes.
Function Read-EndOfFile($fileName,$totalNumberOfLines) {
 $fileStream = New-Object System.IO.FileStream($fileName,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite)
 $linesOfText = New-Object System.Collections.ArrayList
 $byteOffset = 1
 $lineOfText = ""
 do {
   $fileStream.Seek((-$byteOffset), [System.IO.SeekOrigin]::End) | Out-Null
  $byte = $fileStream.ReadByte()
  if($byte -eq 13) {
  } elseif($byte -eq 10) {
   $linesOfText.Insert(0, $lineOfText)
   $lineOfText = ""
  } else {
   $lineOfText = [System.Convert]::ToChar($byte) + $lineOfText
  }
  $byteOffset++
 } while ($linesOfText.count -le $totalNumberOfLines)
 $fileStream.Close()
 return $linesOfText
}
#--------------------------------------------------------------------------------------------------#
$fileName = "C:\Logs\really-huge.log" # Your really big log file
$numberOfLines = 100 # Number of lines from the end of the really big log file to return

if([System.IO.File]::Exists($fileName) -and $numberOfLines -gt 0) {
 $lastLines = Read-EndOfFile $fileName $numberOfLines

 foreach($lineOfText in $lastLines) {
  Write-Output $lineOfText
 }
}