Monday, January 27, 2014

Dastardly Dissections: PowerShell and Software Deployment Dabbling

I was turned onto PowerShell several years ago, but like most music that I've held onto, it took awhile to grow on me.  Until I had one of those "a-HA!" moments, which was just this past week.  I have to give a double-extra "thank you!" to folks like Jeffery Hicks and Don Jones (among many others), as well as all the folks who patiently help others on sites like StackOverflow and Microsoft TechNet.  If I had that much patience I'd be a therapist.


The Meat and Potatoes

I wanted to find a balance between efficiency and reusable code structures.  Ever since I was forged in the fires of LISP programming by an incredible guru named Brad Hamilton, I've sought to make my code as refactoringly refined and reusable as possible.  It should work like a Lego block, as he once mentioned to me.  Another word he used was "organic".  It should work and feel like it grew out of nature, not like a 7-legged cat trying to climb an ice mountain.

Much of what you will see below (and soon-after stab your own eyes out with a plastic fork, out of the sheer horror of it all) is my own personal seasoning.  I like to put a nerdy block-style heading at the top, followed by a group of related custom variable assignments, and then start to work destroying any sense of productivity soon after.

In a nutshell: I define some variables to identify the product, the installer file, the source path, the target path that the installer creates on a typical client, and then move on.

The next part checks if the file is already present, indicating a previous installation was already completed and then exit if that's the case.  If not found, go ahead and run the installation and return the exit code.

(Updated 1/28/2014: line in red below replaces the line just above it.  Ensures script calls installer from the same location / path)

[powershell-begin-ugly-code]

#------------------------------------------------------------
# filename...: install-orca.ps1
# author.....: David M. Stein
# date.......: 01/27/2014
# purpose....: install Microsoft Orca using PowerShell 3
#------------------------------------------------------------

# comment: define variables and assignments

$appName = "Microsoft Orca"
$msifile = "orca.msi"
# $srcPath = "\\appserver3\utils\microsoft"
$srcPath = Split-Path -Parent $PSCommandPath
$path32  = "C:\Program Files (x86)\Orca\orca.exe"
$path64  = ""

$f1 = get-location

write-host "info: searching for existing installation of $appName..."
if (test-path -Path $path32) {
  write-host "info: $appName is already installed (aborting install)"
  $retval = 5000
} else {
  write-host "info: installing $msiFile....."
  set-location $srcPath
  write-host "info: working path is $srcPath"

# comment: the following line may wrap incorrectly in a browser...
  $retval = (start-process msiexec.exe -ArgumentList "/i $msifile /qn" -Wait -PassThru).ExitCode

  switch ($retval) { 
    0    {write-host "info: success"} 
    3010 {write-host "info: success (reboot pending)"} 
    1603 {write-host "fail: I hate 1603. A useless error code!"}
    1605 {write-host "skip: target application was not found (uninstall abort)"} 
    default {write-host "fail: uh-oh? exit code is $retval"}
  }
 
  set-location $f1
  write-host "info: installation complete."
}
exit $retval

[powershell-end-ugly-code]


Why exit with code number 5000?  Good question. I wanted to be able to filter in on that via System Center Configuration Manager, especially through direct T-SQL queries and BI reporting.  I tend to "live" in the SQL Server environment more than anywhere else for some reason.  It feels like wandering around a big-volume hardware store on a quiet night.

If I treated an existing install as a "failure" or exception, I would have to assign a non-zero result code.  I could consider it a "success" and return 0 (zero) as well, but then I wouldn't be able to query for unnecessary attempts in my production environments.  Artificial flavoring has its uses.

To invoke this from an non-Powershell state, I fire off the command string as follows...

powershell -File install-orca.ps1

Then I can fetch the result implicitly via the command pipeline or explicitly by interrogating %errorlevel% via the CMD shell interface.

Ripping It Out Again

So, what about the Uninstall flip-side of this?  Let's try this out...

[powershell-begin-stupid-code]

#------------------------------------------------------------
# filename...: uninstall-orca.ps1
# author.....: David M. Stein
# date.......: 01/27/2014
# purpose....: uninstall Microsoft Orca using PowerShell 3
#------------------------------------------------------------

# comment: define variables and assignments

$appName = "Microsoft Orca"
$guid    = "{85F4CBCB-9BBC-4B50-A7D8-E1106771498D}"
$path32  = "C:\Program Files (x86)\Orca\orca.exe"
$path64  = ""

$f1 = get-location

if (test-path -Path $path32) {
  write-host "info: $appName is installed.  Uninstall it now..."

  # comment: the following line may wrap incorrectly in a browser also...

  $retval = (start-process msiexec.exe -ArgumentList "/x ""$guid"" /qn" -Wait -PassThru).ExitCode

  switch ($retval) { 
    0 {
        write-host "info: uninstallation was successful."
        write-host "info: removing leftover files and folders..."
        Remove-Item $path32 -Recurse
      }
   3010 {write-host "info: success (reboot pending)"} 
   1605 {write-host "info: target application was not found (uninstall abort)"} 
        default {write-host "fail: exit code is $retval"}
  }
 
} else {
  $retval = 0
  write-host "info: $appName was not found on this computer (abort uninstall)"
}
write-host "info: completed"
exit $retval

[powershell-end-stupid-code]


A few notes on the example above:

  1. First, you may notice the additional code to remove leftover files and folders.  That's because it's not uncommon to find leftover files and folders after a "successful" uninstall.  The reasons are many, but in short: just clean them up if needed.  
  2. Second, if the installation was not found, I force a 0 return value here.  I could have also forced something like 5001 or 6000 or 227001 or whatever (as long as it's not in conflict with known result codes used by other apps or processes).  I chose 0 because I'm tired and sitting in a realllllllly comfortable chair right now.  Too lazy to use a longer value.
  3. I could have used Test-Path to find the Registry Key instead of a folder and file.  That would work as well, and the example would look instead something like the following...

Test-Path "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{85F4CBCB-9BBC-4B50-A7D8-E1106771498D}"

(note that I had to wrap the path in matching double quotes; otherwise it tries to evaluate the { and } as code).

If you're not familiar with Windows Installer methods (e.g. msiexec syntax), that's okay.  That means you're probably "normal".  I'm not.  You can invoke an uninstall using "/x" and provide a specific .msi package file, or you can locate the associated application GUID from the Registry (see HKLM\Software\Microsoft\Windows\CurrentVersion\Uninstall, or for 32-bit apps on a 64-bit client, like Orca, refer to HKLM\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall) and use that as well.  Below is a screen capture of REGEDIT showing the key and value on my cheap laptop...


Find the "UninstallString" value and grab a copy to inspect.  The irony is the "/I" prefix, which denotes "Install", but you can ignore that.  Almost every product entry will have an "UninstallString" value and it will almost always contain "Msiexec /I{blahblahblah}".  For an uninstall operation, you replace "/I" with "/X" (upper or lower case, doesn't matter), and move on.  It's the rest of that string that matters.  That's the GUID, and it is usually pretty reliable for use with msiexec to uninstall a known product entry.

More Mindless Notes:
  • You may encounter cases where you do NOT want to delete leftover files and folders (I didn't even mention leftover Registry keys and value, did I?).  Just comment that line and you're good to go.
  • You may need to stop services in order to perform some tasks.  You can use the Get-Service cmdlet or Stop-Service.  To remove a service, you can use the ancient SC.EXE command (under \Windows\System32) to invoke the Delete method.  Just don't forget that it may require a reboot.
  • Be careful to validate every exit code before assuming anything other than 0 (zero) is "bad".  3010 for example is good.  In some contexts (is that proper English?), the exit code 1605 could be considered "good" as well.
  • I'm NOT a PowerShell expert.  I'm still learning and very possibly farther behind on this stuff than you (in which case you're probably not reading sentence because you already left this page to find what you are really looking for).  I hope I'm not the smartest guy in the room.  That's a boring proposition to consider.  I'd rather be learning.
Namaste.

No comments: