PowerShell VirtualDirectory Wrappers

on Friday, October 17, 2014

The WebAdministration Module ships with a couple functions for working with virtual directories:

But, there are some glitches with them.

A way to get around these problems is to write a wrapper class around the functions. Making the PhysicalPath property on New-WebVirtualDirectory is pretty easy to do, but the other one …

To prevent the confirmation prompt from popping up, the wrapper function can create an empty temporary directory, repoint the virtual directory to it, remove the virtual directory, and then remove the temporary directory.

This code below also wrapped the Get-WebVirtualDirectory because I wanted an API that took a Url as input and figure out how to use it.

Here’s a full list of the wrappers and helper functions:

<#
.SYNOPSIS
 Takes a url and breaks it into these parts: Ssl, SiteName, AppName, AppNames.
 
.DESCRIPTION 
 Takes a url and breaks it into these parts for a [PSObject]:

 Ssl   - true/false - does the url request ssl
 SiteName - string - the dns host name
 AppName  - string - the AppNames as a single string. It starts with '/'.
 AppNames - Array<string> - each folder name within the local path

.PARAMETER Url
 The url to convert into it's UriPaths

.EXAMPLE
 $uriPaths = ConvertTo-WebUriPaths "https://www.contoso.com/services"
#>
Function ConvertTo-WebUriPaths {
Param (
 [Parameter(Mandatory = $true)]
 [string] $Url
)

 $paths = @{
  Ssl = $null;
  SiteName = "";
  AppNames = @();
  AppName = "";
 };

 $uri = New-Object System.Uri $Url;

 $paths = New-PsType "UriPaths"

 Add-PsTypeField $paths "Ssl" ($uri.Scheme -eq "https")
 Add-PsTypeField $paths "SiteName" $uri.Host
 Add-PsTypeField $paths "AppNames" $uri.LocalPath.Split("/", [StringSplitOptions]::RemoveEmptyEntries)
 Add-PsTypeField $paths "AppName" $uri.LocalPath

 # remove trailing slash, if exists
 if($paths.AppName) {
  $appNameLen = $paths.AppName.Length;
  if($paths.AppName[$appNameLen - 1] -eq "/") {
   $paths.AppName = $paths.AppName.Substring(0, $appNameLen - 1);
  }
 }

 return $paths;
}



<#
.SYNOPSIS
 Takes a UriPaths object (from ConvertTo-UriPaths) and turns it into
 and IIS:\Sites\XXXX string value.

.PARAMETER UriPaths
 The UriPaths object from ConvertTo-WebUriPaths

.EXAMPLE
    $uriPaths = ConvertTo-WebUriPaths "https://www.contoso.com/services"
 $iisPath = ConvertTo-WebIISPath $uriPaths

.LINK
 ConvertTo-WebUriPaths
#>
Function ConvertTo-WebIISPath {
Param (
 [Parameter(Mandatory = $true)]
 [PSObject] $UriPaths
)

 $iisPath = "IIS:\Sites\" + $UriPaths.SiteName;
 $UriPaths.AppNames |% { $iisPath += "\" + $_ }; # alternateively, AppName could also be used

 return $iisPath;
}



<#
.SYNOPSIS
    Using the given url to search the current server for the longest parent website/webapp path that matches the url.
    It will only return the parent website/app information. if the root website is given, then $null will be returned.
    The webapp's information from the IIS:\Sites protocol is returned.

.PARAMETER Url
    The url to search on
    
.EXAMPLE
    $webApp = Get-WebParentAppByUrl "http://www.contoso.com/services" 
#>
Function Get-WebParentAppByUrl {
Param (
    [Parameter(Mandatory = $true)]
    [string] $Url
)

    $uriPaths = ConvertTo-WebUriPaths -Url $Url

    $currentPath = "IIS:\Sites\{0}" -f $uriPaths.SiteName
    if((Test-Path $currentPath) -eq $false) { return $null }
    if($uriPaths.AppName -eq "" -or $uriPaths.AppName -eq "/") { return $null}

    $webApp = Get-Item $currentPath
    if($uriPaths.AppNames -is [Array]) {
        for($i = 0; $i -lt $uriPaths.AppNames.Count - 1; $i++) {
            $currentPath += "\{0}" -f $uriPaths.AppNames[$i]
            if(Test-Path $currentPath) { $webApp = Get-Item $currentPath }
        }
    }

    return $webApp
}



<#
.SYNOPSIS
 Get virtual directory information from a site/app. If the given Url is a site/app, this will
    search for all virtual directories at the same level as the given the path. If not a site/app,
    this will search for a virtual directory under the parent site/app.

.PARAMETER Url
 The url to search at.

.EXAMPLE
 $vdirs = Get-WebVirtualDirectoryWrapper -Url "http://www.contoso.com"
#>
Function Get-WebVirtualDirectoryWrapper {
[OutputType([Microsoft.IIs.PowerShell.Framework.ConfigurationElement])]
Param (
    [Parameter(Mandatory = $true)]
    [string] $Url
)

    $uriPaths = ConvertTo-WebUriPaths -Url $Url

    # check if the url is a site/app
    $iisPath = ConvertTo-WebIISPath -UriPaths $uriPaths

    if(-not (Test-Path $iisPath)) {
        Write-Warning "IIS $env:COMPUTERNAME - No path could be found for '$Url'. No virtual directories could be looked up."
        return $null
    }

    $node = Get-Item $iisPath
    if(@("Application", "Site") -contains $node.ElementTagName) {
        # search for virtual directories below this level

        $vdirs = Get-WebVirtualDirectory -Site $uriPaths.SiteName -Application $uriPaths.AppName

    } else {
        # search the parent app for the given virtual directory name

        $parentApp = Get-WebParentAppByUrl $Url
        $vdir = $uriPaths.AppName.Substring($parentApp.path.Length)
        $appPath = $parentApp.path
        if(-not $appPath) { $appPath = "/" }

        $vdirs = Get-WebVirtualDirectory -Site $uripaths.SiteName -Application $appPath -Name $vdir
    }
        
    return $vdirs
}


<#
.SYNOPSIS
 Set a virtual directory for a site/app. This will set the physical path for the given Url.

.PARAMETER Url
 The url to turn into a virtual directory.

.PARAMETER PhysicalPath
    The physical path on the server to attach to the virtual path.

.PARAMETER Force
    Overwrites the current physical path if already set.

.EXAMPLE
    # Create a new virtual directory

 $vdir = New-WebVirtualDirectoryWrapper `
                    -Url "http://admissions.{env}.sa.ucsb.edu" `
                    -PhysicalPath "D:\AllContent\Data\admissions.{env}.sa.ucsb.edu"
                    -ServerName "SA89"  
#>
Function New-WebVirtualDirectoryWrapper {
[OutputType([Microsoft.IIs.PowerShell.Framework.ConfigurationElement])]
Param (
    [Parameter(Mandatory = $true)]
    [string] $Url,
    [Parameter(Mandatory = $true)]
    [string] $PhysicalPath,
    [switch] $Force
)

    $uriPaths = ConvertTo-WebUriPaths -Url $Url

    # parse the name of the virtual directory from the given url
    if($uriPaths.AppName -eq "" -or $uriPaths.AppName -eq "/") {
        throw "IIS $env:COMPUTERNAME - No virtual path could be found in url '$Url'. A subpath needs to be defined within the url."
    }

    $parentApp = Get-WebParentAppByUrl -Url $Url

    if($parentApp -eq $null) {
        throw "IIS $env:COMPUTERNAME - No parent application could be found for url '$Url'. No virtual directory could be added."
    }

    $appPath = $parentApp.path
    if(-not $appPath) { $appPath = "/" }

    $vdirPath = $uriPaths.AppName.Substring($appPath.Length)

    # if the vdirPath is multiple levels deep, check that the root path exists
    if($vdirPath.Split("/", [StringSplitOptions]::RemoveEmptyEntries).Count -gt 1) {
        $i = $vdirPath.LastIndexOf("/")
        $rootSubLevel = $vdirPath.Substring(0,$i).Replace("/","\")
        $iisPath = "IIS:\Sites\{0}\{1}" -f $uriPaths.SiteName, $rootSubLevel
        if((Test-Path $iisPath) -eq $false) {
            throw "IIS $env:COMPUTERNAME - Part of the sub path for '$Url' could not be found. Please ensure the full base path exists in IIS."
        }
    }

    Write-Warning "IIS $env:COMPUTERNAME - Creating a virtual directory for $Url to $PhysicalPath."
    if($Force) {
        if($appPath -eq "/") { # it adds an extra / if you set the applicationName to '/'
            $vdir = New-WebVirtualDirectory -Site $uriPaths.SiteName -Name $vdirPath -PhysicalPath $PhysicalPath -Force
        } else {
            $vdir = New-WebVirtualDirectory -Site $uriPaths.SiteName -Application $appPath -Name $vdirPath -PhysicalPath $PhysicalPath -Force
        }
    } else {
        if($appPath -eq "/") { # it adds an extra / if you set the applicationName to '/'
            $vdir = New-WebVirtualDirectory -Site $uriPaths.SiteName -Name $vdirPath -PhysicalPath $PhysicalPath
        } else {
            $vdir = New-WebVirtualDirectory -Site $uriPaths.SiteName -Application $appPath -Name $vdirPath -PhysicalPath $PhysicalPath
        }
    }
    Write-Host "IIS $env:COMPUTERNAME - Created a virtual directory for $Url to $PhysicalPath."

    return $vdir
}


<#
.SYNOPSIS
 Removes a virtual directory from a site/app. It will only remove the virtual directory if the Url
    given matches up with a virtual directory.

.PARAMETER Url
 The url to search at.

.EXAMPLE
    Remove-WebVirtualDirectoryWrapper -Url "http://www.contoso.com/services"
#>
Function Remove-WebVirtualDirectoryWrapper {
Param (
    [Parameter(Mandatory = $true)]
    [string] $Url
)

    $uriPaths = ConvertTo-WebUriPaths -Url $Url

    # parse the name of the virtual directory from the given url
    if($uriPaths.AppName -eq "" -or $uriPaths.AppName -eq "/") {
        throw "IIS $env:COMPUTERNAME - No virtual path could be found in url '$Url'. A subpath needs to be defined within the url."
    }

    $parentApp = Get-WebParentAppByUrl -Url $Url

    if($parentApp -eq $null) {
        throw "IIS $env:COMPUTERNAME - No parent application could be found for url '$Url'. No virtual directory could be added."
    }

    # ensure the path is a virtual directory
    $iisPath = ConvertTo-WebIISPath -UriPaths $uriPaths
    if(-not (Test-Path $iisPath)) {
        throw "IIS $env:COMPUTERNAME - No path for $Url could be found in IIS."
    }

    $node = Get-Item $iisPath
    if($node.ElementTagName -ne "VirtualDirectory") {
        switch($node.GetType().FullName) {
            "System.IO.FileInfo" { $type = "File" }
            "System.IO.DirectoryInfo" { $type = "Directory" }
            "Microsoft.IIs.PowerShell.Framework.ConfigurationElement" { $type = $node.ElementTagName }
        }
        throw "IIS $env:COMPUTERNAME - The url '$Url' doesn't match with a Virtual Directory. It is a $type."
    }

    $vdirPath = $uriPaths.AppName.Substring($parentApp.path.Length)

    # check if the virtual path has files or folders beneath it. An error will occur if there are.
    $iisVPath = ConvertTo-WebIISPath -UriPaths $uriPaths

    $childItems = Get-ChildItem $iisVPath
    if($childItems) {
            Write-Warning ("IIS $env:COMPUTERNAME - The virtual path at '$Url' has items beneth it. Due to a bug in " + `
            " WebAdministration\Remove-WebVirtualDirectory this would force a windows pop-up dialog to get approval." + `
            " To get around this, a temporary folder will be created and the current virtual directory will" + `
            " be repointed to the new (empty) location before removal. After removal of the virtual directory" + `
            " the temporary folder will also be removed. The domain account this process runs under will need" + `
            " permissions to the temporary folder location to create and remove it.")


        $guid = [Guid]::NewGuid()
        $PhysicalPath = (Get-WebVirtualDirectoryWrapper -Url $Url).PhysicalPath
        $tempPath = Join-Path $PhysicalPath $guid

        Write-Warning "IIS $env:COMPUTERNAME - Creating temp directory '$tempDir' in order to remove a virtual directory."
        $tempDir = New-Item $tempPath -ItemType Directory
        Write-Host "IIS $env:COMPUTERNAME - Created temp directory '$tempDir' in order to remove a virtual directory."
        $void = New-WebVirtualDirectoryWrapper -Url $Url -PhysicalPath $tempPath -Force
    }

    $appPath = $parentApp.path
    if(-not $appPath) { $appPath = "/" }

    Write-Warning "IIS $env:COMPUTERNAME - Removing a virtual directory '$vdirPath' for '$Url'."
    Remove-WebVirtualDirectory -Site $uriPaths.SiteName -Application $appPath -Name $vdirPath
    Write-Host "IIS $env:COMPUTERNAME - Removed a virtual directory '$vdirPath' for '$Url'."

    if($tempDir) {
        Write-Warning "IIS $env:COMPUTERNAME - Removing temp directory '$tempDir' in order to remove a virtual directory."
        $void = Remove-Item $tempDir
        Write-Host "IIS $env:COMPUTERNAME - Removed temp directory '$tempDir' in order to remove a virtual directory."
    }
}

Customized Internal NuGet Gallery

on Friday, August 15, 2014

NuGet’s great and there are plenty of resources to help get your team setup with private feeds (MyGet, Inedo's ProGet, JFrog's Artifactory, Sonatype's Nexus), but sometimes there are needs to host your own feed internally.

It’s not too hard to do, but there are a few hoops that you need to jump through in order to get it all setup:

  1. NuGet already provides a great guide for downloading the Gallery code and getting it running on your local machine.
  2. They also have a guide for altering the Gallery code (LocalGuide) to prepare it to run on a local IIS instance.
  3. But, there are a few details that you might want to change to customize the Gallery for your organization/needs:
    1. At the end of the LocalGuide it mentions “you can register a user and make it an Admin by adding a record to the UserRoles table”. Here’s the script:
      select * from [dbo].[Users] -- find your id
      insert into [dbo].[Roles] (name) values ('Admins')
      insert into [dbo].[UserRoles] (UserKey, RoleKey) values (<your id>, 1)
    2. Remove Alert.md – This feeds the yellow bar that appears at the top of the screen and states “This is a development environment. No data will be preserved.”
      1. It’s under FrontEnd/NuGetGallery/App_Data/Files/Content/Alert.md
      2. I think it’s a good idea to remember that file. It’s a really nice implementation to be able to set an alert without disrupting the service.
    3. Update Web.config – These will kinda be obvious
      1. Gallery.Environment should be empty
      2. Gallery.SiteRoot
      3. Gallery.SmtpUri
      4. Gallery.Brand
      5. Gallery.GalleryOwner
      6. Remove <rewrite> rules (from LocalGuide)
    4. Update the Title Icon/Name – This is defined by CSS
      1. FrontEnd/NuGetGallery/Content – Both Layout.css and Site.css (it’s just a good idea to keep them insync)
      2. If you have the time to make a new image, that would be best.
      3. If you don’t have time, then
        1. comment out
          1. background
          2. text-indent
        2. add
          1. font-weight: bold
          2. color: white
          3. font-size: 1.2 em
          4. text-decoration: none
        3. The Web.Config setting of Gallery.Brand text will be displayed
    5. Add Gallery URL
      1. FrontEnd/NuGetGallery/Views/Pages/Home.cshtml
      2. Add some text before @ViewBag.Content like: Visual Studio URL: http://nuget.xyz.com/api/v2/
    6. Have Lucene Search Index update on each package upload
      1. FrontEnd/NuGetGallery/Controllers/ApiController – PublishPacakge function – By default the line IndexingService.UpdatePackage(package) is supposed to update the search index. But, sometimes it doesn’t.
      2. Replace that line with: IndexingService.UpdateIndex(forceRefresh: true)

I’m sure the first thing you’ll want to do once you have the website up and running is play around with some test packages. Here is a script to help cleanup the database once you’re done testing. (Also, delete any .nupkg files under <website>/App_Data/Files/packages/)

declare @trunc bit = 0
if(@trunc = 1) begin
 truncate table [dbo].[GallerySettings]
 truncate table [dbo].[PackageAuthors]
 truncate table [dbo].[PackageDependencies]
 truncate table [dbo].[PackageRegistrationOwners]
 delete from [dbo].[PackageStatistics] where [key] = 1
 delete from dbo.Packages where [key] = 1
 delete from [dbo].[PackageRegistrations] where [key] = 2
 /*delete from [dbo].[UserRoles] where [Userkey] = 1
 delete from [dbo].[Users] where [key] = 1*/
end

/****** Script for SelectTopNRows command from SSMS  ******/
select * from [dbo].[GallerySettings]
select * from [dbo].[PackageAuthors]
select * from [dbo].[PackageDependencies]
select * from [dbo].[PackageRegistrationOwners]
select * from [dbo].[PackageRegistrations]
select * from dbo.Packages
select * from [dbo].[PackageStatistics]
/*select * from [dbo].[Roles]
select * from [dbo].[UserRoles]
select * from [dbo].[Users]*/

Remote profile.ps1

on Friday, August 8, 2014

There have been a lot of articles on how profile.ps1 is used.

They seem to be incorrect; or at least the system has changed under their feet. You can check out how much your system conforms to the documentation standard by creating 6 (six!) different profile.ps1 files. Each one, with a statement of “Write-Host ‘ran xyz profile.ps1’”.

The fun part is that none of them will run when connecting from a remote a session. To do that you Have To Use a Session Profile. Which is kind of weird. But, it kinda fits in with the whole DSC thing. You configure a server once; when its created, and you never touch it again.

I’m not sure I agree with that approach.

Running Local On Remote

on Friday, August 1, 2014

A lot of PowerShell functions/Cmdlets are written in a way that they can only be run on a localhost. But, sometimes you need to run them remotely.

PSSession will let you run a command on a remote host (One Hop). If you need to connect to more hosts than that, you’ll to need setup CredSSP in your environment.

One Hop Scripts

This function is a template for running a local command on a remote host:

Function Verb-Noun {
[CmdletBinding()]
[OutputType(If you can set this, that's awesome)]
Param (
    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "A")]
    [PSObject] $A
    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "A")]
    [string] $AZ,
    [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = "B")]
    [string] $B,
    [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
    [string] $ServerName = $env:COMPUTERNAME,
    [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
    [System.Management.Automation.Runspaces.PSSession] $Session = $null
)

    $scriptBlock = {
        Import-Module WebAdministration
        Import-Module ABC

        if($args) {
   Merge-AllParams -Arguments $args[0];
  }

        ... code goes here ...

        return $XYZ
    }

    # handle calling with sessions
    $sessInfo = Test-CreateNewSession -Session $Session -ServerName $ServerName
    $Session = $sessInfo.Session

    try {
     if($session -and -not (Test-IsLocalSession $session)) {
            # copy all variables to pass across with Invoke-Command
         $allParams = Get-AllParams -Command $MyInvocation.MyCommand -Local (Get-Variable -Scope Local)

      # if a session is avaliable, run it in the session; unless its the local sysem
      $XYZ = Invoke-Command -Session $Session -ArgumentList $allParams -ScriptBlock $scriptblock;
     } else {
      # if it's a local session or if no session is avaliable, then run the script block inline
      $XYZ = (. $scriptblock);
     }
    } finally {
        if($sessInfo.CreatedSession) { Remove-PSSession $Session }
    }

    return $XYZ
}

The function relies on Get-AllParams, Merge-AllParams, and Test-CreateNewSession.

<#
.SYNOPSIS
 Will retrieve all arguments passed into a function. This can help ease passing those values
 to an Invoke-Command cmdlet.

.EXAMPLE
 Get-AllParams -Command $MyInvocation.MyCommand -Locals (Get-Variable -Scope Local);
#>
Function Get-AllParams {
[CmdletBinding()]
Param(
 [Parameter(Mandatory = $true)]
 [System.Management.Automation.FunctionInfo]$Command,
 [Parameter(Mandatory = $true)]
 [Array]$Locals
)

 $allParams = @{};
 $Command.Parameters.Keys| foreach {
   $i = $_;
   $allParams[$i] = ($Locals |? { $_.Name -eq $i; }).Value;
  }
 return $allParams;
}

<#
.SYNOPSIS
 Will load all parameters passed in into the Script scope. This can be used in conjuction with
 Get-AllParams to pass variables into an Invoke-Command block.

.EXAMPLE
 Merge-AllParams -Arguments $args[0];
#>
Function Merge-AllParams {
[CmdletBinding()]
Param (
 [Hashtable]$Arguments
)

 $Arguments.GetEnumerator() |% { Set-Variable -Name $_.key -Value  $_.value -Scope Global; }
}

<#
.SYNOPSIS
    Sets up a Session object if needed. It also returns a flag if a session object was created.

.DESCRIPTION
    Sets up a Session object if needed. It also returns a flag if a session object was created.

    When functions sometimes need to run remotely (through a Session) or sometime locally, the
    code can be written to use a script block and logic can be added to call the code with a Session.
    The logic can become redundant when determing if and how to call the Session. This helper
    function helps with the process.

.PARAMETER Session
    The current Session variable passed into the calling function

.PARAMETER ServerName
    The current ServerName variable available in the calling function

.EXAMPLE
    $sessInfo = Test-CreateNewSession -Session $Session -ServerName $ServerName
    $Session = $sessInfo.Session

    try {
        ... determine if the session needs to be called or a local execution should be used ...
    } finally {
        if($sessInfo.CreatedSession) { Remove-PSSession $sessInfo.Session }
    }
#>
Function Test-CreateNewSession {
[CmdletBinding()]
Param (
    [System.Management.Automation.Runspaces.PSSession] $Session = $null,   
    [string] $ServerName = ""
)

    $createdSession = $false
    if($Session -eq $null -and $ServerName -ne "") {
        if(-not (Test-IsLocalComputerName $ServerName)) {
            $Session = New-PSSession $ServerName
            $createdSession = $true
        }
    }

    $sessInfo = New-PsType "CoreUcsb.PSSessionCreate" @{
                    Session = $Session
                    CreatedSession = $createdSession
                }

    return $sessInfo
}

PowerShellGet Install and Import Module

on Friday, July 25, 2014

WMF v5.0 Preview’s PowerShellGet module is pretty nice, but it is lacking some functionality. Today I went through and added two new features: Install-Module also Imports module and NuGet.exe is now located with the module.

Install-Module Also Imports the Module

… even when it doesn’t install the module, it still imports the module. This allows for all Import-Module statements to be replaced with Install-Module statements.

PSGet had this feature, and that made it just a little more user friendly. But, I also think I understand why Microsoft didn’t implement this feature. It seems to complicate the lifecycle of when to Update the modules. How do you answer these questions:

  • If the current version on the gallery is newer than the installed version, should the installed version be updated?
  • If Install-Module replaces the usage of Import-Module how does the management of which versions are on which servers play out? Does it break Server Management consistency?

I choose to ignore those concerns, and I’ll circle back to them at a later date. For now, it can replace the Import-Module statement.

NuGet.exe is now located with the Module

There were actually 3 updates with this one:

  • NuGet.exe is no longer downloaded to %UserAppData%\Local\Microsoft\Windows\PowerShell\PowerShellGet\NuGet.exe. It’s now downloaded to %ProgramFiles%\WindowsPowerShell\Modules\PowerShellGet. The same location as the rest of the module.
  • A default NuGet.config is installed to the same location if it doesn’t exist.
  • The prompt which asks if you want to download NuGet.exe has been removed.

If NuGet.exe is downloaded into a user specific folder, then it has to download it for every user which runs Install-Module. Since PowerShell scripts can be run by both privileged users and by service accounts on a server, this made for multiple copies.

And, a default NuGet.config file lowers the cost on new users to find the config file and update it.

 

Since I’m starting to work on these enhancements, I’ll try to keep this community feed updated with stable builds on a weekly basis. (It doesn’t have the latest at the time of this posting, my apologizes.)

Custom IIS log files with PowerShell

on Friday, June 27, 2014

Depending on your infrastructure you may have a need to place IIS logs onto a separate disk. A disk which can fill up without taking down the server. The easiest solution to this is to set the Default Site Settings with log file locations other than the C: drive. But, then you still run into the problem of each log file being written under a folder with a name like W3SVC12.

The name W3SVC12 corresponds with the website which has SiteID 12. Unfortunately, you can only find out that information if you have access to IIS manager. And, most developers don’t have access to IIS manager on the production servers. So, it would be nice to give the log files a location with a more friendly name.

I’m sure there’s an appcmd which can setup both IIS log files and Failed Request Tracing log files for an individual website. But, in this post, I’ll show the few commands needed to setup those locations by directly editing the applicationHost.config file.

When an individual website is setup with custom IIS log file and Failed Request Tracing log file locations, the applicationHost.config file will look like this:

<site name="unittest.dev.yoursite.com" id="15" serverAutoStart="true">
<application path="/">
  <virtualDirectory path="/" physicalPath="D:\AllContent\Websites\unittest.dev.yoursite.com\" />
 </application>
 <application path="/normal/childapp">
  <virtualDirectory path="/" />
 </application>
 <bindings>
  <binding protocol="http" bindingInformation="*:80:" />
 </bindings>
 <traceFailedRequestsLogging enabled="false" directory="D:\AllContent\logs\unittest.dev.yoursite.com\FailedReqLogFiles" />
 <logFile directory="D:\AllContent\logs\unittest.dev.yoursite.com\LogFiles" />
</site>

The two commands below create and remove those xml elements. The script will also use the name of the website when creating the log file location path.

<#
.SYNOPSIS
 Adds a specialized log folder and FRT folder. See ConvertTo-WebUriPaths to create a $UriPaths Hashtable.

.EXAMPLE
 New-WebAppLogFile -UriPaths $paths

#>
Function New-WebAppLogFile {
Param (
 [Parameter(Mandatory = $true)]
 [Hashtable]$UriPaths,
 [string]$PhysicalPath = "",
 [string]$ServerName = $env:COMPUTERNAME
)
Process {
 # if the web application can't be found, then skip
 $configPath = Get-WebConfigPath $ServerName;
 $appHost = [System.Xml.XmlDocument](Read-WebConfig -ConfigPath $configPath);
 $sites = $appHost.configuration.'system.applicationHost'.sites;
 $site = [System.Xml.XmlElement]($sites.site |? { $_.name -eq $UriPaths.SiteName });
 if($site -eq $null) {
  Write-Warning "IIS $ServerName - Web site $($UriPaths.SiteName) couldn't be found. The log and FRT paths will be skipped.";
  return;
 }

 # get the physical path
 $rootLogsPath = $PhysicalPath;
 if($rootLogsPath -eq "") {
  $rootLogsPath = Join-Path $global:WebAdministrationUcsb.DefaultLogPath $UriPaths.SiteName;
 }
 $frtPath = Join-Path $rootLogsPath "FailedReqLogFiles";
 $logPath = Join-Path $rootLogsPath "LogFiles";

 # add the FRT location
 $frt = [System.Xml.XmlElement]($appHost.CreateElement("traceFailedRequestsLogging"));
 $frt.SetAttribute("enabled", "false");
 $frt.SetAttribute("directory", $frtPath);
 $frt = $site.AppendChild($frt);
 
 Write-Warning "IIS $ServerName - Adding custom FRT path for $($UriPaths.SiteName) to $frtPath.";
 Save-WebConfig -WebConfig $appHost -ConfigPath $configPath
 Write-Host "IIS $ServerName - Added custom FRT path for $($UriPaths.SiteName) to $frtPath.";

 # add the log location
 $log = [System.Xml.XmlElement]($appHost.CreateElement("logFile"));
 $log.SetAttribute("directory", $logPath);
 $log = $site.AppendChild($log);
 
 Write-Warning "IIS $ServerName - Adding custom log file path for $($UriPaths.SiteName) to $logPath.";
 Save-WebConfig -WebConfig $appHost -ConfigPath $configPath
 Write-Host "IIS $ServerName - Added custom log file path for $($UriPaths.SiteName) to $logPath.";
}
}


<#
.SYNOPSIS
 Remove a specialized log folder and FRT folder. See ConvertTo-WebUriPaths to create a $UriPaths Hashtable.

.EXAMPLE
 Remove-WebAppLogFile -UriPaths $paths
#>
Function Remove-WebAppLogFile {
Param (
 [Parameter(Mandatory = $true)]
 [Hashtable]$UriPaths,
 [string]$ServerName = $env:COMPUTERNAME
)
Process {
 # if the web application can't be found, then skip
 $configPath = Get-WebConfigPath $ServerName;
 $appHost = Read-WebConfig -ConfigPath $configPath;
 $sites = $appHost.configuration.'system.applicationHost'.sites;
 $site = $sites.site |? { $_.name -eq $UriPaths.SiteName };
 if($site -eq $null) {
  Write-Warning "IIS $ServerName - Web site $($UriPaths.SiteName) couldn't be found. The log and FRT path removal will be skipped.";
  return;
 }

 # remove the FRT location
 $frt = $site.traceFailedRequestsLogging
 if($frt -eq $null) {
  Write-Warning "IIS $ServerName - Web site $($UriPaths.SiteName) doesn't have a custom FRT path. Skipping its removal.";
 } else {
  $frt = $site.RemoveChild($frt)

  Write-Warning "IIS $ServerName - Removing custom FRT path from $($UriPaths.SiteName).";
  Save-WebConfig -WebConfig $appHost -ConfigPath $configPath
  Write-Host "IIS $ServerName - Removed custom FRT path from $($UriPaths.SiteName).";
 }

 # remove the log location
 $log = $site.logFile
 if($log -eq $null) {
  Write-Warning "IIS $ServerName - Web site $($UriPaths.SiteName) doesn't have a custom log file path. Skipping its removal.";
 } else {
  $log = $site.RemoveChild($log)

  Write-Warning "IIS $ServerName - Removing custom log file path from $($UriPaths.SiteName).";
  Save-WebConfig -WebConfig $appHost -ConfigPath $configPath
  Write-Host "IIS $ServerName - Removed custom log file path from $($UriPaths.SiteName).";
 }
}
}

These commands rely on the Read-WebConfig and Save-WebConfig from an earlier post.

Enable an Application Server on all Web Farms

on Friday, June 20, 2014

Last time, I looked at Enabling/Disabling an Application Server within a single Web Farm. I’ll try to continue on that same thread and update the script to Enable or Disable an Application Server on all Web Farms on a Proxy Server.

The core of the work is done by searching for all web farms which use the server, Get-WebFarmsByAppServer. After that, it’s just a matter of calling a mass update function, Set-WebFarmsAppServerEnabled.

<#
.SYNOPSIS
 Retrieves a list of all Web Farms which contain the given list of App Servers on the given Proxy Server.

 This is used to retrieve the list of Web Farms which will need to up updated in order to remove a single
 server from all Web Farms at once.

 The resulting list will be of type [System.Collections.Generic.List[PSObject]]. The 
 inner PSObject's will have these properties:

 WebFarmName The name of the web farm which has the given App Server in its list
 AppServerName The search will look for both shorthand names (App1) and FQDN's (App1.your.domain.here), this
   will have the value which was matched
 Enabled  Is the server currently enabled.

.PARAMETER ServerName
 The name of the proxy server to update. If this parameter is not supplied, the local computers config
 file will be updated.

.PARAMETER AppServerNames
 The name of the App Servers to search for.

.EXAMPLE
 Get-WebFarmsByAppServer -ServerName "Proxy1" -AppServerNames "App1"
#>
Function Get-WebFarmsByAppServer {
Param (
 [string] $ServerName = $env:COMPUTERNAME,
 [Parameter(Mandatory = $true)]
 [System.Array] $AppServerNames
)
 $configPath = Get-WebConfigPath $ServerName
 $appHost = [System.Xml.XmlDocument](Read-WebConfig -ConfigPath $configPath)

 $farms = $appHost.configuration.webFarms.webfarm;

 # if there are no web farms defined, write a warning and return an empty array
 if($farms -eq $null) {
  Write-Warning "IIS Proxy $ServerName - No web farms are currently defined."
  return @();
 }

 # determine search values, check if an fqdn might also be possible value to search on
 $searchValues = New-Object System.Collections.Generic.List[string]
 $AppServerNames |% { $searchValues.Add($_); }

 <# You could add a check for Fully Qualified Domain Names along with the supplied values
 $AppServerNames |% {
  $isFqdn = $_ -match "\.your\.domain\.here"
  if($isFqdn -eq $false) {
   $fqdn = $_ + ".your.domain.here"
   try {
    $result = [System.Net.Dns]::GetHostAddresses($fqdn);

    $searchValues.Add($fqdn);
   } catch {}
  }
 }
 #>

 # search for all occurrences in the web farm list
 $found = New-Object System.Collections.Generic.List[PSObject]
 for($i = 0; $i -lt $farms.Count; $i++) {
  $farm = $farms[$i];

  $servers = New-Object System.Collections.Generic.List[System.Xml.XmlElement]
  $serverlist = $farm.server;
  $serverlist |% { $servers.Add($_); }

  for($j = 0; $j -lt $servers.Count; $j++) {
   $server = $servers[$j];

   $searchValues |% {
    if($server.address.ToLower() -eq $_.ToLower()) {
     # http://stackoverflow.com/questions/59819/how-do-i-create-a-custom-type-in-powershell-for-my-scripts-to-use
     $m = new-object PSObject
     $m.PSObject.TypeNames.Insert(0,'WebAdministrationExt.WebFarmAppServerMatch')

     $m | add-member -type NoteProperty -Name WebFarmName -Value $farm.Name
     $m | add-member -type NoteProperty -Name AppServerName -Value $server.Address
     $m | add-member -type NoteProperty -Name Enabled -Value $server.Enabled
     
     $found.Add($m);
    }
   }
  }
 }

 # return the list
 return $found;
}


<#
.SYNOPSIS
 Set the given list of AppServers to be enabled/disabled in all Web Farms on the Proxy Server.

 TODO: This could probably be updated to handle pipeline input

.PARAMETER ServerName
 The name of the proxy server to update. If this parameter is not supplied, the local computers config
 file will be updated.

.PARAMETER AppServerNames
 The name of the App Servers to set to enabled/disabled.

.PARAMETER Enabled
 Set the server to enabled or disabled.

.EXAMPLE
 $updatedFarms = Set-WebFarmsAppServerEnabled -ServerName "Proxy1" -AppServerNames "App1" -Enabled $false
#>
Function Set-WebFarmsAppServerEnabled {
[CmdletBinding()]
Param (
 [string] $ServerName = $env:COMPUTERNAME,
 [Parameter(Mandatory = $true)]
 [System.Array] $AppServerNames,
 [Parameter(Mandatory = $true)]
 [bool] $Enabled
)

 $farms = Get-WebFarmsByAppServer -ServerName $ServerName -AppServerNames $AppServerNames

 # if no farms we're found, then skip this
 if($farms -eq $null) {
  Write-Warning "IIS Proxy $ServerName - No web farms we're found which use $AppServerNames. Skipping setting the App Servers to $Enabled."
  return;
 }

 # set the servers to the desired values
 for($i = 0; $i -lt $farms.Count; $i++) {
  $farm = $farms[$i];

  # NOTE: SkipLoadBalancingDelay is set to true because it incurs a 10 second delay for each update. That could
  # be a long time for large updates. The LoadBalancingDelay was introduced to handle web deployments, this
  # function is expected to be used with Windows Server updates (Windows Servers updates will have a delay built into them).
  Set-WebFarmServerEnabled -ServerName $ServerName `
   -WebFarmName $farm.WebFarmName -AppServerName $farm.AppServerName -Enabled $Enabled `
   -SkipLoadBalancingDelay $true
 }

 return $farms;
}


Creative Commons License
This site uses Alex Gorbatchev's SyntaxHighlighter, and hosted by herdingcode.com's Jon Galloway.