It's 2 a.m. Do you know where your processes are? - The Sequel

By The Microsoft Scripting Guys

Doctor Scripto at work

Doctor Scripto's Script Shop talks about practical system administration scripting problems, often from the experiences of our readers, and develops scripts to solve them. Most of our examples in the Script Repository perform one simple task; in this column, by contrast, we'll put those pieces together into more complex scripts. They won't be comprehensive or bullet-proof, but they will show how to build scripts from reusable code modules, handle errors and return codes, get input and output from different sources, run against multiple machines, and do other things you might want to do in your production scripts.

We hope you will find these columns and scripts useful – please let us know what you think of them, what solutions you've come up with for these problems, and what you'd like to see covered in the future.

This column is the second of a series. You may find it helpful to begin with the first column: It's 2 a.m. Do you know where your processes are?

For an archive of previous columns, see the Doctor Scripto's Script Shop archive.

(Note: Many thanks to Patrick Lanfear and Tom Yaguchi for Dr. Scripto's new look.)

On This Page

Creating and monitoring processes on multiple machines
Let there be scripts
As if you weren't randomized enough already
Review: creating and monitoring a process on a remote machine
Creating a process on multiple machines
Monitoring processes on multiple machines
Creating and monitoring processes on multiple machines
Final scripts: Patch 'n Sniff

Creating and monitoring processes on multiple machines

In our last column, we discussed how to run an executable on a local or remote machine by using the Create method of Win32_Process, and how to monitor the events generated by that process with WMI event-handling functionality.

As we pointed out in that column, running this type of script is a useful technique for installing patches or upgrades for applications in cases where you can't use Windows Server Update Services or Microsoft Systems Management Server. This technique also works for running specialized utilities that perform tasks that can't be scripted directly, such as a backup or diagnostic program.

But as you probably thought to yourself, a script that runs against only one machine is nice, but that and three bucks will buy me a venti caramel macchiato with extra foam. At least if you were from Seattle you might have thought that.

Whatever your favorite fix, if you're going to go to the trouble of writing a script to run an executable and monitor the results, it's probably not very bankable unless it can target multiple machines.

As a recent e-mail to the Scripting Guys put it: "I have a batch file I would like to run on around 50 computers one time, yet do not want to have to manually connect to each computer. Is there a way to call and run this batch file in a script?"

Dr. Scripto's answer is a resounding "Quite possibly." You might even be able to rewrite the batch file as a script that uses WMI or ADSI to accomplish what the batch file is doing. But if you just need to run a special tool or executable that goes beyond available scripting functionality (and that's way out there), scripting can still help expand its reach.

Let there be scripts

The need to run scripts against multiple machines, as Dr. Scripto's research in technological archaeology has established, is a primal human urge. Early system administrators began to write scripts shortly after the invention of agriculture. Some nascent IT geniuses realized that rather than traveling around to all the granaries to count the grain themselves, they could send out clay tablets over SandalNet with instructions to the granary keepers on how to count the grain and a form on which they could chisel the results and send them back.

We've come a long way: now we have processes to do the counting and WMI events to report the results back to us. But are these methods really so much better than clay tablets? Actually, yes, way better. They're faster, they weigh a lot less, and they don't fall apart in the rain. I mean, have you ever tried to debug cuneiform on a clay tablet?

Eschewing sentimentality and false nostalgia as he always does, Dr. Scripto has forged ahead and developed a revolutionary approach to running executables on multiple machines and handling the events those executables generate to determine how they fared. He found, however, that trying to perform this hair-raising feat presented some new scripting challenges. And since Dr. Scripto thrives in the face of adversity (and has very little hair to raise), he was thrilled.

One of the thorniest thickets resulted from a hidden foible of the ExecNotificationQuery method of SWbemServices, which we used in the scripts in the last column. This modest and efficient method requests its events and returns immediately. No problem there. But to consume the events, ExecNotificationQuery returns an SWbemEventSource object. This ne'er-do-well object loiters about on the first machine the script queries − probably smoking, drinking cheap bourbon and playing pool − waiting for its NextEvent method to be triggered. When the next event on the machine that matches the query comes along, NextEvent steals the identity of that event: if it's a process, NextEvent can reveal details as intimate as its handle or private page count.

Running ExecNotificationQuery

In the meantime, though, the script is stuck waiting for NextEvent on the first machine, which blocks it from going on to also query for events on other machines. Not a practical way to monitor a multitude of hosts.

Waiting for NextEvent

There has to be a better way. And we knew we could count on Dr. Scripto to envision it. For this column, he takes an approach that uses two scripts:

  • The first script creates processes that run an executable on multiple machines and gets back the process ID of each process. It then calls the second script for each machine in turn, passing it the name of the machine and the process ID to monitor.

  • The second script traps the event when the process that matches the process ID passed to it is deleted on the specified machine. From the event it gets the time the process started and ended, calculates the duration of the process, and writes it to a common log file which will contain the information for all machines. This script runs as many times as there are machines.

But before we reveal all the details of how Dr. Scripto coded his opus, we're going to walk you through some of the convoluted thought processes he used to conceive it.

As if you weren't randomized enough already

Let's begin with a mundane improvement. In the last column, we simulated an application running for an indeterminate length of time by running a simple test script that waited a set number of milliseconds. For our test script in this column, we'll take an approach that’s a bit more sophisticated: this time our test script will wait for a random interval between a maximum and a minimum before terminating. This provides a more realistic simulation of many command-line applications such as patches or upgrades, which vary in running time from machine to machine. It also adds a little spice to testing our scripts. That Dr. Scripto finds this exciting gives you an inkling of how starved the poor guy is for entertainment.

Listing: Wait for a random length of time.

intMax = 30 'seconds
intMin = 20 'seconds
Randomize
intWait = Int((intMax - intMin + 1) * Rnd + intMin)
WScript.Echo "Start: pausing for " & intWait & " seconds."
WScript.Sleep intWait * 1000
WScript.Echo "Stop"

This script runs for a random interval between 20 and 30 seconds. To run for a different range of intervals, just change the values of intMax and intMin (keeping in mind that intMax must be larger than intMin).

For a more detailed explanation of randomizing in VBScript, see: "Hey, Scripting Guy! How Can I Generate Random Numbers Using a Script?"

Review: creating and monitoring a process on a remote machine

In case you haven't yet memorized the last column (yes, there will be a quiz), in that masterpiece Dr. Scripto assembled a few scripts that can run against either the local or a remote machine, with the usual caveats: you need network access to the machine and you need to run the script with credentials that have administrative privileges on the machine.

With WMI, as you saw, you can create processes and monitor events remotely with minimal overhead. All you have to do is change the value assigned to the variable containing the computer name to that of a remote machine.

This works for most of the scripts in the previous column, as well as for many in the Script Repository. Change the line:

strComputer = "."

to

strComputer = "server1"

where server1 is a remote computer.

Then plug the variable strComputer into the WMI moniker, for example:

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")

Voilà! Instant remoting. WMI handles the messy details of remote procedure calls (RPC) and distributed COM (DCOM). Who said there's no free lunch any more?

Now we're going to break out the coding necessary to create processes on multiple machines from that required to monitor processes on those machines. To finish up, we'll put the two halves together to create processes and monitor the life of those created processes. Scripters cannot live by free lunches alone, and we need to put in a little work here before we can put our final script together.

Creating a process on multiple machines

Creating a process on several boxes is not a great deal harder than doing it on one remote box. You can get the names of the machines from many sources, including text files, Excel spreadsheets, databases and Active Directory containers (we've discussed how to script some of these in previous columns, including "Tales From the Script: Running WMI Scripts Against Multiple Computers"). But to demonstrate the basic algorithm for our task, for our first example we'll just use an array of machine names.

Having created the array with the VBScript Array function and assigned it to a variable (arrComputers), we use a For Each loop to iterate through the names and the Create method of Win32_Process to run a process on each machine in the array. This example runs Notepad, but you could substitute any other executable or batch file. We check for the return value of the method: 0 indicates success and any positive integer is a code for a specific kind of failure.

Listing: Create a process on multiple machines.

strCommand = "notepad.exe"
arrComputers = Array("client1", "client2", "server1")

WScript.Echo "Creating processes on:"

For Each strComputer In arrComputers
  WScript.Echo vbCrLf & "Computer name: " & strComputer
'Connect to WMI.
  Set objWMIService = GetObject("winmgmts:" _
   & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
'Create a new process.
  Set objProcess = objWMIService.Get("Win32_Process")
  intReturn = objProcess.Create _
   (strCommand, Null, Null, intProcessID)
  If intReturn = 0 Then
    WScript.Echo "Created process number " & intProcessID
  Else
    WScript.Echo "Unable to create process. Return code: " & intReturn
  End If
Next

Typical output:

C:\scripts\events-column5>col5-process-multi-1.vbs
Creating processes on:

Computer name: client1
Created process number 586

Computer name: client2
Unable to create process. Return code: 3

Computer name: server-d1
Created process number 1852

Keep in mind that when you run an executable on a remote machine with Create, that process automatically runs in a hidden window, which you cannot make visible. So after running this example script, which opens Notepad, you'll have to close those hidden windows with Task Manager or a command-line utility such as Kill.exe or Taskkill.exe, or face a ghost army of hidden processes that grows each time you run the script — scary thought.

If you wanted to run this script without having to close the hidden windows, you could use the test script we just wrote above, which runs for a random amount of time and then terminates. Just substitute "cscript test.vbs" (or whatever you want to call the test script) for "notepad.exe" as the value of strCommand. Or you could use some other utility which performs a task and then stops.

Come to think of it, if you were feeling a little adventurous, you could make this script clean up its own mess. All you'd have to do is call the Terminate method of Win32_Process on each machine with the process ID returned by Create. But we're going to forge ahead towards the main scripts and let you figure that out in your spare time.

Monitoring processes on multiple machines

Creating the processes was easy; but monitoring the process events on several machines is where things get a little trickier. A bit earlier, we fingered the SWbemEventSource object returned by ExecNotificationQuery as the source of our problems because it hangs around waiting for events on the first machine against which the script runs. How do we get around this limitation and move on to all the rest of the machines to monitor?

Dr. Scripto ruminated deeply and decided to use two scripts, taking advantage of the capability of a script to spawn a second script from within itself. He could have used Win32_Process.Create to do this, but because the scripts are all running on the local machine, he used the Exec method of WScript.Shell, which can run only locally and is a bit simpler to code.

In this model, the first script loops through an array of machine names, as in the previous script, calling a second script with Exec for each machine to be monitored and passing Exec the name of the machine as part of a parameter. This is a clunky scripting approximation of multi-threading: actually, it creates a different process (rather than a thread of a process) to monitor each machine by running a different instance of the monitoring script.

Note that the parameter passed to Exec is a string that begins with cmd.exe and the /c switch, which opens a command window to run the script in and then closes the window when the script is done running. The redirection character (>) sends the output to a log file, named after the computer, in the specified directory.

Then each instance of the second script runs an ExecNotificationQuery for __InstanceDeletionEvent where the target is a Win32_Process. The query runs against the machine whose name was passed to this second script and waits for the next process that meets the criteria of the query. If you wanted to catch such processes indefinitely, you could code the NextEvent object in a Do loop to wait for events continuously on each machine: the principle is the same. Because it would be running in a separate script for each machine, NextEvent can hang around to its heart's content, even have a couple of cold ones and take a nap if it's a slow event day. No matter how lazy it is, it's not slowing down the event-monitoring action on any other machine.

These two scripts merely show the bones of this approach. We'll flesh it out in the following scripts.

Listing: First script - monitor process events on multiple machines by running second script.

strEventHandlerScript = "c:\scripts\eventhandler.vbs"
strLogDir = "c:\scripts\logs\"
arrComputers = Array("client1", "client2", "server1")

WScript.Echo "Monitoring processes on:"

For Each strComputer In arrComputers
  WScript.Echo vbCrLf & "Computer name: " & strComputer
  Set WshShell = CreateObject("WScript.Shell")
  WshShell.Exec("cmd.exe /c cscript " & strEventHandlerScript & " " & _
   strComputer & " > " & strLogDir & strComputer & "-log.txt")
Next

The following script is called by the main script (as eventhandler.vbs in this example) and passed the name of a machine to monitor as the only argument, which is handled by the built-in WScript.Arguments collection. The rest of the code should be familiar from the last column.

This script simply gets the process name and ID, while the final one will calculate the duration that the process ran. As we mentioned in the previous column, we could capture anything about the process for which Win32_Process, Win32_ProcessStartTrace or Win32_ProcessStopTrace provides a property.

Listing: Second script - monitor first process deletion event on a machine.

strComputer = WScript.Arguments(0)

WScript.Echo "Computer Name: " & strComputer

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
Set colMonitorProcess = objWMIService.ExecNotificationQuery _
 ("SELECT * FROM __InstanceDeletionEvent " & _ 
 "WITHIN 1 WHERE TargetInstance ISA 'Win32_Process'")

WScript.Echo "Waiting for process to stop ..."

Set objLatestEvent = colMonitorProcess.NextEvent
Wscript.Echo VbCrLf & "  Process Name: " & _
 objLatestEvent.TargetInstance.Name
Wscript.Echo "  Process ID: " & objLatestEvent.TargetInstance.ProcessId

Creating and monitoring processes on multiple machines

By now, you're probably so sick of all the possible ways you can create processes and monitor events that you're screaming silently, "Please make him stop" (as one scripting webcast attendee once actually wrote when Dr. Scripto sang a scripting song). But the Doctor is a man on a mission as he forges ahead on two versions of the script pair. In his wisdom, he's decided to take this in gentle stages so that you can see the differences from one stage to the next.

In the previous two scripts, we've used separate code for the two potentially correlated tasks: creating a process on multiple machines, and monitoring process events and their duration on multiple machines. Finally, we're going to put them together into one script.

Script logic

Dr. Scripto has broken this script down into procedures, as he has with longer scripts in the past. The modular approach lets us build and add on functionality without rewriting the procedures we've already created.

This script, like the previous ones, gets a list of machines from an array. We'll wait for the grand finale before adding a more practical way of getting machine names.

The logic here again loops through the array of machines with a For Each loop, attempting to connect to the WMI service on each machine in turn. Next, the script checks for an error. If the binding failed, the script calls a separate subroutine, HandleError, to deal with the gruesome details. If the WMI connection was successfully made, the script goes ahead and calls the CreateProcess function, passing it a command string encapsulated in the strCommand variable.

If something looks vaguely familiar about CreateProcess, that's probably because you saw it in our last column. Is this self-plagiarism? Well, you could call it that, but Dr. Scripto prefers to call it practicing what he preaches. He's often used his bully pulpit in webcasts and workshops to exhort his adoring fans to beg, borrow and steal script code. That's the culture of scripting. So what kind of scripting guru would he be if he tried to reinvent the wheel with each script? CreateProcess worked fine in the last column, and it works fine in this one. Let's call it a take and add it to our code library.

Keep in mind that the executable or script to be run by CreateProcess (in this example c:\scripts\test.vbs) must be present on each machine in the array (or, for the final version of the script, in the text file). If the tool or application were not already known to be deployed on those machines, we might want to add code to the script to check for its presence and, if not found, copy it to each machine, which we'll do in the final script.

The HandleError subroutine is also back by popular demand. In the final script, though, we'll juice it up a little to make it work better in a multi-host script.

The new subroutine we're launching in this script is ExecMonitorScript. After calling CreateProcess, the main logic of the script checks the return value of that function: if it's -1, that indicates that it was not able to create a process. The output of the function to the display has already indicated this. If the return value is any other number, we take that to be a positive integer representing the process ID (PID) of the process just created. So we pass the PID and the name of the computer as parameters to ExecMonitorScript.

ExecMonitorScript creates a WshShell object and calls the Exec method on it. The script passes Exec a long string that concatenates five variables together. This command string opens a command-prompt window in which it runs cscript with the name of the monitoring script. The name of the computer to run against and the process ID to use in monitoring events are passed as parameters to the script and used in this command string. Then, as in the previous monitoring script, the Exec command string redirects the output of the script to a text file named after the machine in the appropriate log directory.

Here, we are still creating a separate log file for each machine. This is relatively easy, but may not be the best solution. We'll see in the final script if Dr. Scripto can come up with a better alternative.

Listing: First script - create a process on multiple machines and run a monitoring script.

On Error Resume Next

g_strMonScript = "c:\scripts\procmon.vbs"
g_strLogDir = "c:\scripts\logs\"
strCommand = "cscript c:\scripts\test.vbs"

arrComputers = Array("fictional", "localhost", "server-d1")
WScript.Echo "Monitoring processes on:"

For Each strComputer In arrComputers
  WScript.Echo vbCrLf & strComputer
'Connect to WMI.
  Set objWMIService = GetObject("winmgmts:" _
   & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
  If Err.Number <> 0 Then
    HandleError
  Else
    intPID = CreateProcess(strCommand)
    If intPID <> -1 Then
      ExecMonitorScript strComputer, intPID
    End If
  End If
Next

'******************************************************************************

Function CreateProcess(strCL)
'Create a process.

On Error Resume Next

Set objProcess = objWMIService.Get("Win32_Process")
intReturn = objProcess.Create _
 (strCL, Null, Null, intProcessID)

If intReturn = 0 Then
  Wscript.Echo "Process Created." & _
   vbCrLf & "Command line: " & strCL & _
   vbCrLf & "Process ID: " & intProcessID
  CreateProcess = intProcessID
Else
  Wscript.Echo "Process could not be created." & _
   vbCrLf & "Command line: " & strCL & _
   vbCrLf & "Return value: " & intReturn
  CreateProcess = -1
End If

End Function

'******************************************************************************

Sub ExecMonitorScript(strBox, intProcessID)
'Run script to monitor process deletion events

On Error Resume Next

Set WshShell = CreateObject("WScript.Shell")
WshShell.Exec("cmd.exe /k cscript " & g_strMonScript & " " & _
 strBox & " " & intProcessID & " > " & g_strLogDir & strBox & "-log.txt")

End Sub

'******************************************************************************

'Handle errors.
Sub HandleError

WScript.Echo "ERROR " & Err.Number & VbCrLf & _
 "Description: " & Err.Description & VbCrLf & _
 "Source: " & Err.Source
Err.Clear

End Sub

In the previous script, ExecMonitorScript launched a second script that monitors the process on each machine. Remember that in our first monitoring script above, just to illustrate the technique, we simply trapped the first __InstanceDeletionEvent whose target was a Win32_Process process and handled that event. But the ultimate purpose of these scripts is to find out what happened to the specific process that was kicked off by the first script.

How does the second script know which process to monitor? Easy: the first script passes it two parameters: the name of the machine to monitor and the process ID to look for. So the command-line syntax of the script looks like this:

procmon.vbs <MachineName> <processID>

You can test the second script by itself, without going through the first script:

  • Start a process with Notepad.

  • Get the process ID from Task Manager, Tlist.exe or Tasklist.exe.

  • Run this script with the name of your machine and that process ID.

  • Close the Notepad window.

The script should display the details of the Notepad process that just stopped.

This event-monitoring script starts by using the built-in WScript.Arguments collection that is created whenever you run a script under WSH. If the script is followed by a space and an argument, Arguments adds that to the collection as the first item (element 0). If there is yet another space and another argument, Arguments adds that as the second item (element 1). The script retrieves them as WScript.Arguments(0) and WScript.Arguments(1).

In a script designed to be run by users, we would usually check to make sure these arguments existed on the command line and display a usage message if they were not present. But thanks to Dr. Scripto's ingenuity, this script is being run by another script, so he's counting on that first script to pass the correct arguments to the second. Even though scripts may seem as though they have a mind of their own when you're trying to debug them, in this case Dr. Scripto's faith is reasonably well-placed.

Then, once we have the name of the computer as the value of strComputer and the number of the process as the value of intProcessID, we can use those two values. We concatenate strComputer into the moniker string for connecting to WMI to connect to WMI on that computer. And we use intProcessID as part of the WMI Query Language query that we pass to ExecNotificationQuery to tell it which events to monitor.

In this script, we do a bit of error checking after trying to connect to WMI on the remote machine. If the WMI binding attempt times out, which takes a few seconds, we get back an error message that the remote machine is not available. If we wanted to optimize this for many machines, we could first ping the remote machine before trying to connect to WMI. This would save us a couple of seconds, as the ping attempt returns faster than the WMI GetObject: the time saved becomes worth the extra coding as the number of machines gets larger.

When the process we’re monitoring ends, NextEvent contains information about the process, which we massage to calculate the amount of time that the process ran. Except for getting the arguments from the previous script, this script is nearly the same as the final monitoring script in the last column.

Recall that we couldn't use a For Each loop with the name of each machine in turn, because the script would hang while SWbemEventSource waited for the process on the first machine to terminate. Here we've avoided that trap by calling this second script separately for each machine: Dr. Scripto has truly thought outside the box on this one.

Listing: Second script – monitor a process with a specific PID on a given machine and output its duration.

On Error Resume Next

strComputer = WScript.Arguments(0)
intProcessID = WScript.Arguments(1)

WScript.Echo "Computer Name: " & strComputer

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
If Err.Number <> 0 Then
  HandleError
  WScript.Quit
End If

Set colMonitorProcess = objWMIService.ExecNotificationQuery _
 ("SELECT * FROM __InstanceDeletionEvent " & _ 
 "WITHIN 1 WHERE TargetInstance ISA 'Win32_Process' " & _
 "AND TargetInstance.ProcessId = '" & intProcessID & "'")

WScript.Echo "Waiting for process to stop ..."

Set objLatestEvent = colMonitorProcess.NextEvent
strProcDeleted = Now
strProcCreated = _
 WMIDateToString(objLatestEvent.TargetInstance.CreationDate)
Wscript.Echo VbCrLf & "Process Name: " & objLatestEvent.TargetInstance.Name
Wscript.Echo "Process ID: " & objLatestEvent.TargetInstance.ProcessId
Wscript.Echo "Time Created: " & strProcCreated
WScript.Echo "Time Deleted: " & strProcDeleted
intSecs = DateDiff("s", strProcCreated, strProcDeleted)
arrHMS = SecsToHours(intSecs)
WScript.Echo "Duration: " & arrHMS(2) & " hours, " & _
 arrHMS(1) & " minutes, " & arrHMS(0) & " seconds"

'******************************************************************************

Function WMIDateToString(dtmDate)
'Convert WMI DATETIME format to US-style date string.

WMIDateToString = CDate(Mid(dtmDate, 5, 2) & "/" & _
                  Mid(dtmDate, 7, 2) & "/" & _
                  Left(dtmDate, 4) & " " & _
                  Mid(dtmDate, 9, 2) & ":" & _
                  Mid(dtmDate, 11, 2) & ":" & _
                  Mid(dtmDate, 13, 2))

End Function

'******************************************************************************

Function SecsToHours(intTotalSecs)
'Convert time in seconds to hours, minutes, seconds and return in array.

intHours = intTotalSecs \ 3600
intMinutes = (intTotalSecs Mod 3600) \ 60
intSeconds = intTotalSecs Mod 60

SecsToHours = Array(intSeconds, intMinutes, intHours)

End Function

'******************************************************************************

Sub HandleError
'Handle errors.

WScript.Echo "ERROR " & Err.Number & VbCrLf & _
 "Description: " & Err.Description & VbCrLf & _
 "Source: " & Err.Source
Err.Clear

End Sub

Final scripts: Patch 'n Sniff

Our final pair of scripts is not tremendously different from the last two, but here we're using I/O methods that are more practical for a growing number of machines.

The first script gets the list of machines on which to create a process from a text file. It uses our faithful friend FileSystemObject, part of the Script Runtime, in the ReadTextFile function. You pass the function the path to the text file containing the names of hosts to run against, and ReadTextFile returns the names in an array. We’ve used this function before in this column, so it may be familiar to avid readers. If a text file is not where you prefer to keep lists of machines, you could use a similar function to get the names from a spreadsheet, a database, or an Active Directory container.

Do keep in mind that we haven't tested these scripts on a long list of machines. If the number of machines gets large, running a separate instance of the second script for each machine might start to devour memory. When we ran it on Windows XP with a list of ten machines, each instance of the monitoring script used roughly 4 to 6 megabytes of memory while it ran, but negligible CPU resources. Then again, if you found a practical limit for the size of the list on your workstation, you could always batch the computer names into a number of lists and schedule each to run at a different time. You'd just need to change the value of strInputFile to a different text file each time — with a few extra lines of code, you could pass the filename in as a parameter for the command run by the scheduled job.

Another important enhancement in the final scripts is the choice of whether to copy the executable or script to be run to the remote machines, as you might want to for a patch, or to run a file already present on the remote machines, as you might choose for a tool. You set this option in a variable in the change block: if you set blnCopyFile to True, the main logic of the script calls the FileCopy function, passing as parameters the name and path of the executable or script file to be copied and the remote directory to which to copy it. Only if the file is successfully copied does the logic go on to call the CreateProcess method. If blnCopyFile is set to False, the script assumes that the file to be run is already present in the desired folder on each remote machine and bypasses the FileCopy function.

The FileCopy function uses the CopyFile method of the FileSystemObject to copy the executable or script to be run to the specified directory on each remote machine. If the remote directory does not exist, the function creates it. If the file already exists, the function overwrites it. One minor caveat: CopyFile will not overwrite read-only files, an unlikely issue in this scenario.

Dr. Scripto wrote the FileCopy function to use methods from the FileSystemObject, which is part of Script Runtime and runs only on the local machine. The FileSystemObject, however, accepts UNC paths — for example, \\server1\c$\update — as parameters, which lets it copy files from one machine to another. Another approach the Doctor also considered was to use the Copy method of the WMI class CIM_DataFile. The Copy method returns a useful return value, but this approach requires using WSH to map the remote folder to a local network drive, and then unmapping it when the work is done. It seemed a bit more convoluted.

In these two final scripts, as we promised, Dr. Scripto has also enhanced the HandleError subroutine to work better in a multi-host script. You pass in the name of the computer on which the error occurred, and HandleError incorporates the name into its output. When you're running a script against many machines, you need to know on which one an error has occurred.

Listing: First script - create a process on multiple machines listed in a text file and run a monitoring script against each machine.

'******************************************************************************
'Change block
strInputFile = "c:\scripts\hosts.txt"
g_strMonScript = "c:\scripts\procmon-ex.vbs"
strCommand = "cscript c:\update\test-random.vbs"
blnCopyFile = True 'Set to True if exe must be copied to remote machines.
strFileToCopy = "c:\scripts\test-random.vbs" 'Executable or script
strRemoteDir = "\c$\update"
'******************************************************************************

On Error Resume Next

arrComputers = ReadTextFile(strInputFile)
WScript.Echo "Monitoring processes on:"

For Each strComputer In arrComputers
  WScript.Echo vbCrLf & strComputer
'Connect to WMI.
  Set objWMIService = GetObject("winmgmts:" _
   & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
  If Err.Number <> 0 Then
    HandleError strComputer
  Else
    If blnCopyFile Then
      strTarget = "\\" & strComputer & strRemoteDir
      intFC = FileCopy(strFileToCopy, strTarget)
      If intFC = 0 Then
        intPID = CreateProcess(strCommand)
        If intPID <> -1 Then
          ExecMonitorScript strComputer, intPID
        End If
      End If
    Else
      intPID = CreateProcess(strCommand)
      If intPID <> -1 Then
        ExecMonitorScript strComputer, intPID
      End If
    End If
  End If
Next
'******************************************************************************

Function ReadTextFile(strFileName)
'Get list of computers from text file.

On Error Resume Next

Const FOR_READING = 1
Dim arrLines()

Set objFSO = CreateObject("Scripting.FileSystemObject")
If objFSO.FileExists(strFilename) Then
  Set objTextStream = objFSO.OpenTextFile(strFilename, FOR_READING)
Else
  WScript.Echo "Input text file " & strFilename & " not found."
  WScript.Quit
End If

If objTextStream.AtEndOfStream Then
  WScript.Echo "Input text file " & strFilename & " is empty."
  WScript.Quit
End If

Do Until objTextStream.AtEndOfStream
  intLineNo = objTextStream.Line
  ReDim Preserve arrLines(intLineNo - 1)
  arrLines(intLineNo - 1) = objTextStream.ReadLine
Loop

objTextStream.Close

ReadTextFile = arrLines

End Function

'******************************************************************************

Function FileCopy(strSourceFile, strTargetFolder)
'Copy executable or script to remote machine.
'If remote folder does not exist, creates it.
'Overwrites file if it exists in remote folder.

On Error Resume Next

Set objFSO = CreateObject("Scripting.FileSystemObject")
If Not objFSO.FileExists(strSourceFile) Then
  WScript.Echo "Error: File " & strSourceFile & " not found."
  WScript.Quit
End If
If Not objFSO.FolderExists(strTargetFolder) Then
  objFSO.CreateFolder(strTargetFolder)
End If
If Err = 0 Then
  objFSO.CopyFile strSourceFile, strTargetFolder & "\"
  If Err = 0 Then
    WScript.Echo "Copied file " & strSourceFile & " to folder " & _
     strTargetFolder
    FileCopy = 0
  Else
    WScript.Echo "Unable to copy file " & strSourceFile & " to folder " & _
     strTargetFolder
    FileCopy = 2
    HandleError strHost
  End If
Else
  WScript.Echo "Unable to create folder " & strTargetFolder
  FileCopy = 1
  HandleError strHost
End If

End Function
 '******************************************************************************

Function CreateProcess(strCL)
'Create a process.

On Error Resume Next

Set objProcess = objWMIService.Get("Win32_Process")
intReturn = objProcess.Create _
 (strCL, Null, Null, intProcessID)
If intReturn = 0 Then
  Wscript.Echo "Process Created." & _
   vbCrLf & "Command line: " & strCL & _
   vbCrLf & "Process ID: " & intProcessID
  CreateProcess = intProcessID
Else
  Wscript.Echo "Process could not be created." & _
   vbCrLf & "Command line: " & strCL & _
   vbCrLf & "Return value: " & intReturn
  CreateProcess = -1
End If

End Function

'******************************************************************************

Sub ExecMonitorScript(strHost, intProcessID)
'Launch second script to monitor process deletion events

On Error Resume Next

strCommandLine = "cscript " & g_strMonScript & " " & _
 strHost & " " & intProcessID
Set WshShell = CreateObject("WScript.Shell")
WshShell.Exec(strCommandLine)
If Err.Number <> 0 Then
  HandleError strHost
Else
  WScript.Echo "Running command line:" & vbCrLf & _
   strCommandLine
End If

End Sub

'******************************************************************************

Sub HandleError(strHost)
'Handle errors.

strError = "Computer Name: " & strHost & VbCrLf & _
 "ERROR " & Err.Number & VbCrLf & _
 "Description: " & Err.Description & VbCrLf & _
 "Source: " & Err.Source
WScript.Echo strError
Err.Clear

End Sub

Typical input text file, hosts.txt. Substitute accessible machines on your network on which you have administrative privileges:

client1
client2
server1
server2

Typical command-line output:

client1
Process created.
Command line: cscript c:\update\test-random.vbs
Process ID: 2952
Running command line:
cscript c:\scripts\procmon-ex.vbs client1 1044

client2
Process created.
Command line: cscript c:\update\test-random.vbs
Process ID: 944
Running command line:
cscript c:\scripts\procmon-ex.vbs client2 1540

server1
Process created.
Command line: cscript c:\update\test-random.vbs
Process ID: 3748
Running command line:
cscript c:\scripts\procmon-ex.vbs server1 2384

server2
Process created.
Command line: cscript c:\update\test-random.vbs
Process ID: 3192
Running command line:
cscript c:\scripts\procmon-ex.vbs server2 3460

As in the previous pair of scripts, the event-monitoring script called by the main script traps the first event on a given machine with a specific process ID. The first script calls the following command line for each machine on which it has run a process:

procmon-ex.vbs <MachineName> <processID>

A text file was the source of the list of machines for the first script. So to maintain a sense of symmetry and decorum (qualities which Dr. Scripto values highly), the second script run by the first outputs its results to a text file as well.

In contrast with the previous monitoring script, though, which redirected its output to a separate text file for each machine, here each instance of this monitoring script opens a common text file and logs the duration and other details of the process-deletion event to it. Having all the results in one file makes it easier to use the results for searching, comparison or reporting.

If many machines were trying to write to the log file at the same time, an occasional logjam might occur. But this solution seems to work fine with a modest number of machines, as the write operation is very quick. To scale this up for a large number of machines, we might want to log the results to a database or other application that could deal with conflicting write attempts.

The WriteTextFile subroutine, which performs the job of logging, takes two parameters: the name of the log file (strFileName) and an output string containing the information on the process that has just terminated (strOutput). The sub uses FileSystemObject to check whether the file specified in strFileName exists: if it does, it opens the file for appending; if not, it creates the log file. Because the sub appends to the file, each attempt to write to it adds on to the existing content rather than overwriting it. Besides writing to the log file, this script also displays the same results in the cmd.exe window.

This monitoring script contains one other enhancement over the previous one: it checks to make sure that at least two arguments have been passed to it and complains if either one is missing. This could help catch problems in the input file, such as empty lines.

Listing: Second script – monitor a process with a specific ID on a given machine and output its duration and other details to a text log file.

On Error Resume Next

strOutputFile = "c:\scripts\logs\proclog.txt"

strComputer = WScript.Arguments(0)
intProcessID = WScript.Arguments(1)

'Check to make sure arguments are not empty.
If IsEmpty(strComputer) Or IsEmpty(intProcessID) Then
  strArgError = _
   "Computer Name: " & strComputer & vbCrLf & _
   "  Process ID: " & intProcessID & vbCrLf & _
   "  " & Now & vbCrLf & _
   "  ERROR: Computer name and process ID not passed as arguments."
  WScript.Echo strArgError
  WriteTextFile strOutputFile, strArgError
  WScript.Quit
End If

Set objWMIService = GetObject("winmgmts:" _
 & "{impersonationLevel=impersonate}!\\" & strComputer & "\root\cimv2")
If Err.Number <> 0 Then
  HandleError strComputer
  WScript.Quit
End If

Set colMonitorProcess = objWMIService.ExecNotificationQuery _
 ("SELECT * FROM __InstanceDeletionEvent " & _ 
 "WITHIN 1 WHERE TargetInstance ISA 'Win32_Process' " & _
 "AND TargetInstance.ProcessId = '" & intProcessID & "'")
If Err.Number <> 0 Then
  HandleError strComputer
  WScript.Quit
End If

WScript.Echo "Waiting for process to stop ..."

Set objLatestEvent = colMonitorProcess.NextEvent
strProcDeleted = Now
strProcCreated = _
 WMIDateToString(objLatestEvent.TargetInstance.CreationDate)
strProcessName = objLatestEvent.TargetInstance.Name
strPID = objLatestEvent.TargetInstance.ProcessId
strCSName = objLatestEvent.TargetInstance.CSName
intSecs = DateDiff("s", strProcCreated, strProcDeleted)
arrHMS = SecsToHours(intSecs)

strData = "Computer Name: " & strCSName & VbCrLf & _
 "  Process Name: " & strProcessName & VbCrLf & _
 "  Process ID: " & strPID & VbCrLf & _
 "  Time Created: " & strProcCreated & VbCrLf & _
 "  Time Deleted: " & strProcDeleted & VbCrLf & _
 "  Duration: " & arrHMS(2) & " hours, " & _
 arrHMS(1) & " minutes, " & arrHMS(0) & " seconds"

Wscript.Echo strData
WriteTextFile strOutputFile, strData
WScript.Echo "Data written to " & strOutputFile


'******************************************************************************

Function WMIDateToString(dtmDate)
'Convert WMI DATETIME format to US-style date string.

WMIDateToString = CDate(Mid(dtmDate, 5, 2) & "/" & _
                  Mid(dtmDate, 7, 2) & "/" & _
                  Left(dtmDate, 4) & " " & _
                  Mid(dtmDate, 9, 2) & ":" & _
                  Mid(dtmDate, 11, 2) & ":" & _
                  Mid(dtmDate, 13, 2))

End Function

'******************************************************************************

Function SecsToHours(intTotalSecs)
'Convert time in seconds to hours, minutes, seconds and return in array.

intHours = intTotalSecs \ 3600
intMinutes = (intTotalSecs Mod 3600) \ 60
intSeconds = intTotalSecs Mod 60

SecsToHours = Array(intSeconds, intMinutes, intHours)

End Function

'******************************************************************************

'Handle errors.
Sub HandleError(strHost)

strError = "Computer Name: " & strHost & VbCrLf & _
 "ERROR " & Err.Number & VbCrLf & _
 "Description: " & Err.Description & VbCrLf & _
 "Source: " & Err.Source
WScript.Echo strError
WriteTextFile strOutputFile, strError
Err.Clear

End Sub

'******************************************************************************

'Write or append data to text file.
Sub WriteTextFile(strFileName, strOutput)

On Error Resume Next

Const FOR_APPENDING = 8

'Open text file for output.
Set objFSO = CreateObject("Scripting.FileSystemObject")
If objFSO.FileExists(strFileName) Then
  Set objTextStream = objFSO.OpenTextFile(strFileName, FOR_APPENDING)
Else
  Set objTextStream = objFSO.CreateTextFile(strFileName)
End If

'Write data to file.
objTextStream.WriteLine strOutput
objTextStream.WriteLine

objTextStream.Close

End Sub

Typical output file contents:

Computer Name: CLIENT1
  Process Name: cscript.exe
  Process ID: 2952
  Time Created: 6/24/2005 5:05:49 PM
  Time Deleted: 6/24/2005 5:06:18 PM
  Duration: 0 hours, 0 minutes, 29 seconds

Computer Name: CLIENT2
  Process Name: cscript.exe
  Process ID: 944
  Time Created: 6/24/2005 5:12:00 PM
  Time Deleted: 6/24/2005 5:12:22 PM
  Duration: 0 hours, 0 minutes, 22 seconds

Computer Name: SERVER1
  Process Name: cscript.exe
  Process ID: 3748
  Time Created: 6/24/2005 5:11:57 PM
  Time Deleted: 6/24/2005 5:12:25 PM
  Duration: 0 hours, 0 minutes, 28 seconds

Computer Name: SERVER2
  Process Name: cscript.exe
  Process ID: 3192
  Time Created: 6/24/2005 5:12:01 PM
  Time Deleted: 6/24/2005 5:12:26 PM
  Duration: 0 hours, 0 minutes, 25 seconds

Well, we've pretty much beaten this idea of running processes and monitoring their events into the ground. As your head throbs with code overload and your eyes glaze over, maybe clay tablets and SandalNet, or at least GUIs and SneakerNet, are starting to sound pretty attractive. They're certainly simpler for a few machines in one office. But when you have to perform these kinds of tasks on a network that's bigger or more spread out, we hope you'll be able to incorporate some of the ideas from these columns into your own scripts for automating your patching and management work.

Will we finally give this theme a well-deserved rest and move on to greener scripting pastures? Or are there a few more rows to plow in the fertile field of processes and events? Dr. Scripto invites you to tune in next month to find out.