IIS7 & Self-elevating PowerShell

Route to automated deployment with PowerShell and IIS7

Despite my love for all things DVCS, DAG, and Git it's no secret that there is one area of TFS 2010 that I am quite fond of - Visual Studio Lab Management. Utilising Microsoft Hyper-V technology it allows us to manage and use virtual machines in building, testing, and deploying applications.

One post is not enough to cover this in detail (in fact I suspect a 3-day workshop might be more likely) but one element I was keen to share with you was our deployment script that enables our automated cradle-to-grave testing environment and which sits alongside our CI (Continuous Integration) and automated testing strategy.

Elevation & Execution

To achieve this we needed the workflow capability to deploy and configure an IIS 7 site on a completely fresh and clean deployed environment; importantly one with no prior manual configuration or deployments. As much of the IIS functionality would need elevated privileges this presented a couple of problems;

  • How to bypass PS ExecutionPolicy that by Windows installation would default to "Restricted"

  • How could we elevate the script once spawned without manual interaction

I quickly came across Ben Armstrong's blog on self-elevating an executing PowerShell script, more so it easily dropped into the top of our deployment script. Next using some previous in-house experimentation I was able to find that using the following PowerShell.exe line would bypass the "Restricted" ExecutionPolicy.

C:\Windows\System32\WindowsPowerShell\v1.0\Powershell.exe -ImportSystemModules -InputFormat None -ExecutionPolicy Bypass -Command "$(BuildLocation)\Scripts\DeployWeb.ps1 $(BuildLocation) ProjectX-UAT ProjectX.Web"

Our workflow command is executing PowerShell.exe and passing a -Command of the full path and filename of the PS deployment script (in our case location of our last good known integration build) together with;

  1. -ImportSystemModules
    This tells the PowerShell prompt to load of system modules, I do this to ensure we have the WebAdministration module for IIS7 is loaded; however if the script does self-elevate as we expect in this scenario it's likely redundant!

  2. -InputFormat None
    As we are passing the -Command argument I'm re-enforcing that STDIN shouldn't apply any formatting; incidentally I believe "None" is an undocumented option.

  3. -ExecutionPolicy ByPass
    This is what makes our baby fly, bypassing execution poliicies to ensure our scripts runs despite our newly deployed environment having an ExecutionPoliicy of "Restricted". Interestingly I haven't yet needed to re-apply this during elevation.

As a side note my -Command execution passes the following arguments to our executing deployment script;

  1. $(BuildLocation)
    I'm passing the build location where our deployment files are; in our scenario this is normally the DropDirectory of the CI build (e.g. \MyBuildMachineCIBuildsProjectX.Trunk.CIProjectX.Trunk.CI_20120101.1). I could determine this in-script using $MyInvocation.MyCommand.Path but I want engineers to be able to run this script manually.

  2. ProjectX-UAT
    Name of the website to create in IIS; it also becomes the name of the created IIS AppPool prefixed with AppPool_ and the physical directory name.

  3. ProjectX.Web
    The web project name to deploy from under the build's _PublishedWebsites directory; I'm looking to determine this intuitively in future.

Here is the top of our WebDeploy.ps1 script with elevation as guided by Ben but with some minor tweaks to pass the original arguments;

    # Get the ID and security principal of the current user account
    $myWindowsID=[System.Security.Principal.WindowsIdentity]::GetCurrent()
    $myWindowsPrincipal=new-object System.Security.Principal.WindowsPrincipal($myWindowsID)
     
    # Get the security principal for the Administrator role
    $adminRole=[System.Security.Principal.WindowsBuiltInRole]::Administrator
     
    # Check to see if we are currently running "as Administrator"
    if ($myWindowsPrincipal.IsInRole($adminRole))
    {
        # We are running "as Administrator" - so change the title and background color to indicate this
        $Host.UI.RawUI.Windowtitle = $myInvocation.MyCommand.Definition %2B "(Elevated)"
        $Host.UI.RawUI.BackgroundColor = "DarkRed"
        clear-host
    }
    else
    {
        # We are not running "as Administrator" - so relaunch as administrator
       
        # Create a new process object that starts PowerShell
        $newProcess = new-object System.Diagnostics.ProcessStartInfo;

        # We want to run Powershell
        $newProcess.FileName = "C:WindowsSystem32WindowsPowerShellv1.0powershell.exe";

        # Note: If you have trouble executing some IIS-related assemblies you 
        # may need to force 32bit PowerShell execution.
        # $newProcess.FileName = "C:WindowsSysWOW64WindowsPowerShellv1.0powershell.exe";
        
        # Specify the current script path and name as a parameter
        $newProcess.Arguments = [string]::join(" ", @($myInvocation.MyCommand.Definition, $Args));
       
        # Indicate that the process should be elevated
        $newProcess.Verb = "runas";
       
        # Start the new process
        [System.Diagnostics.Process]::Start($newProcess);
       
        # Exit from the current, unelevated, process
        exit
    }

    # Execute stuff here as per normal

Note: It is my suspicion that -ImportSystemModules should be added to our PowerShell.exe argument list here to ensure correct operation under all scenarios, doing Import-Module WebAdministration mid-script is giving me mixed results (update soon!).

Workflow

As part of our deployment script we need to do some key things;

  • Check if our deployment directories exist and if not create them

  • Configure IIS with our site and application pool

  • Copy our files across from the build directory

  • Set permission on our IIS directories (especially App_Data)

WebAdministration

An example function for configuring the site and application pool might be;

    ### -------------------------------------------------------------------
    ### Creates and/or configures IIS website
    ### -------------------------------------------------------------------
    function ConfigIIS
    {
    	$appPool = $null;
    	$iisSite = $null;
    
    	Write-Status ""
    	
    	Write-Status "IIS: Configuring"
    	set-webconfigurationproperty /system.webServer/security/isapiCgiRestriction/add -name "allowed" -value "True" -PSPath:IIS:
    	set-webconfigurationproperty /system.webServer/security/authentication/anonymousAuthentication -name userName -value ""
    
    	dir 'IIS:AppPools' | Where-Object { $_.Name -eq $global:g_Deployment.IISAppPool } | % { $appPool = $_ };
    	dir 'IIS:Sites' | Where-Object { $_.Name -eq $global:g_Deployment.IISSite } | % { $iisSite = $_ };
    
    	dir 'IIS:Sites' | % { if ($_.State -eq "Started") { Write-Status "IIS: Stopping 'iis:Sites$($_.Name)'"; Stop-WebItem "iis:Sites$($_.Name)"; } } | out-string | % { $global:g_ScriptLog %2B= $_ };
    
    	if (-not $appPool) {
    		Write-Status "IIS: Creating '$($global:g_Deployment.IISAppPool)'";
    		New-Item "IIS:AppPools$($global:g_Deployment.IISAppPool)" | out-string | % { $global:g_ScriptLog %2B= $_ };
    	}
    
    	if (-not $iisSite) {
    		Write-Status "IIS: Creating '$($global:g_Deployment.IISSite)'";		
    		New-Item "IIS:Sites$($global:g_Deployment.IISSite)" -bindings @{protocol="http";bindingInformation=":$($global:g_Config.PortHttp):"} -physicalPath "$($global:g_Deployment.IISDirectory)" | out-string | % { $global:g_ScriptLog %2B= $_ };
    
    		if ($global:g_Config.PortHttps) {
    			New-WebBinding -Name $global:g_Deployment.IISSite -IP "*" -Port $global:g_Config.PortHttps -Protocol https  | out-string | % { $global:g_ScriptLog %2B= $_ };
    		}
    	}
    
    	Write-Status "IIS: Configuring '$($global:g_Deployment.IISSite)'";
    	Set-ItemProperty "IIS:Sites$($global:g_Deployment.IISSite)" -name applicationPool -value "$($global:g_Deployment.IISAppPool)" | out-string | % { $global:g_ScriptLog %2B= $_ };
    	Set-ItemProperty "IIS:Sites$($global:g_Deployment.IISSite)" -name logFile -value @{directory="$($global:g_Config.WebRoot)Logs"}
    	Set-ItemProperty "IIS:AppPools$($global:g_Deployment.IISAppPool)" -name processModel -value @{identitytype=4}
    	Set-ItemProperty "IIS:AppPools$($global:g_Deployment.IISAppPool)" -name managedRuntimeVersion -value "v4.0"
    	Set-ItemProperty "IIS:AppPools$($global:g_Deployment.IISAppPool)" -name managedPipelineMode -value 1
    }

It's worth noting there are a lot of assumptions above, such as allowing CgiRestrictions against .NET 4.0 and changing attributes of the created application pool to be .NET v4.0 (Classic); I don't mind as it's consistent with our FubuMVC deployments but you may wish to make these configurable for your script. I should also mention functions such as "Write-Status" and capturing output into "$g_ScriptLog" global variable are our own.

Copy

As part of the copy I also remove the \_bin\_deployableAssemblies directory off the web-root as it's not needed and bad msbuild targets voodoo!

    ## Copy
    Write-Status "Deploy: Copying '$($global:g_Deployment.BuildDirectory)_PublishedWebsites$($global:g_Deployment.WebProject)*' to '$($global:g_Deployment.IISDirectory)'"
    Copy-Item "$($global:g_Deployment.BuildDirectory)_PublishedWebsites$($global:g_Deployment.WebProject)*" -destination "$($global:g_Deployment.IISDirectory)" -force -recurse | out-string | % { $global:g_ScriptLog %2B= $_ };
    
    ## Remove unwanted _bin_deployableAssemblies
    Write-Status "Deploy: Cleaning '$($global:g_Deployment.IISDirectory)_bin_deployableAssemblies'"
    Remove-Item "$($global:g_Deployment.IISDirectory)_bin_deployableAssemblies" -recurse | out-string | % { $global:g_ScriptLog %2B= $_ };

Permissions

If you don't get your IIS directory permissions right you'll be screaming to high heaven, especially if you are unfortunate enough to have CompactCe database in AppData (trust me!). Below is an example of using icacls to set permissions for both IIS and your create application pool identity;

    ## Set permissions
    Write-Status "Setting permissions: IIS_IUSRS & IUSR"
    & "icacls.exe" "$($global:g_Deployment.IISDirectory)App_Data" "/grant" "IIS_IUSRS:`(OI`)`(CI`)RXM" "/T" "/inheritance:e" | out-string | % { $global:g_ScriptLog %2B= $_ };
    & "icacls.exe" "$($global:g_Deployment.IISDirectory)App_Data" "/grant" "IUSR:`(OI`)`(CI`)RXM" "/T" "/inheritance:e" | out-string | % { $global:g_ScriptLog %2B= $_ };
    
    Write-Status "Setting permissions: IIS APPPOOL"
    & "icacls.exe" "$($global:g_Deployment.IISDirectory)" "/grant" "IIS APPPOOL$($global:g_Deployment.IISAppPool):`(OI`)`(CI`)RX" "/T" "/inheritance:e" | out-string | % { $global:g_ScriptLog %2B= $_ };
    & "icacls.exe" "$($global:g_Deployment.IISDirectory)App_Data" "/grant" "IIS APPPOOL$($global:g_Deployment.IISAppPool):`(OI`)`(CI`)RXM" "/T" "/inheritance:e" | out-string | % { $global:g_ScriptLog %2B= $_ };

When creating the web root directory (not shown above) I set inherited permissions for IUSRS, so not needed here.

Finally

I haven't posted the whole script here as it contains lots of our own PS wrangling for email notifications, log file creation, and such like; if the snippets above are missing something vital for you do shout :)