MOSS MVP

I've moved my blog to http://blog.falchionconsulting.com!. Please update your links. This blog is no longer in use--you can find all posts and comments at my new blog; I will no longer be posting to this site and comments have been disabled.

Thursday, April 29, 2010

Discovering Who Has Access to SharePoint 2010 Securable Objects

I've talked on several occasions about how we can easily use the SharePoint 2010 object model (OM) to discover who has access to a securable object (SPWeb, SPList, or SPListItem) and the fact that we can use the same mechanisms within PowerShell to create useful security/audit reports. On some of those occasions I've shown a version of a PowerShell script which gives you a dump to the screen or a text file of every securable object and who has access to it and how they were given access to it - today I'd like to share a new version of that script.

Before we get to the actual script let's first talk about how to get the information. All securable objects have a method named GetUserEffectivePermissionInfo which is defined in the abstract base class SPSecurableObject (in 2007 this method was defined directly on the SPWeb, SPList, and SPListItem objects). This method returns back an SPPermissionInfo object which we can use to inspect the various role definition bindings and corresponding permission levels.

Once we have the permission details we simple loop through the SPRoleAssignments objects via the RoleAssignments property. This will give us information about how the user is given access to the resource. Next we look at the RoleDefinitionBindings property which returns back a collection of SPRoleDefinition objects that tell us about the type of access granted (e.g., Full Control, etc.).

I then take all this information, stick it in a hash table which I then use to create a new object which gets written to the pipeline.

So with that, let's take a look at the code:

function Get-SPUserEffectivePermissions([object[]]$users, [Microsoft.SharePoint.SPSecurableObject]$InputObject) {
   
begin { }
   
process {
       
if ($_ -isnot [Microsoft.SharePoint.SPSecurableObject]) {
           
throw "A valid SPWeb, SPList, or SPListItem must be provided."
        }
       
$so = $_
       
foreach ($user in $users) {
           
# Set the users login name
           $loginName = $user
           
if ($user -is [Microsoft.SharePoint.SPUser] -or $user -is [PSCustomObject]) {
               
$loginName = $user.LoginName
            }
           
if ($loginName -eq $null) {
               
throw "The provided user is null or empty. Specify a valid SPUser object or login name."
            }
           
           
# Get the users permission details.
           $permInfo = $so.GetUserEffectivePermissionInfo($loginName)
           
           
# Determine the URL to the securable object being evaluated
           $resource = $null
           
if ($so -is [Microsoft.SharePoint.SPWeb]) {
               
$resource = $so.Url
            }
elseif ($so -is [Microsoft.SharePoint.SPList]) {
               
$resource = $so.ParentWeb.Site.MakeFullUrl($so.RootFolder.ServerRelativeUrl)
            }
elseif ($so -is [Microsoft.SharePoint.SPListItem]) {
               
$resource = $so.ParentList.ParentWeb.Site.MakeFullUrl($so.Url)
            }

           
# Get the role assignments and iterate through them
           $roleAssignments = $permInfo.RoleAssignments
           
if ($roleAssignments.Count -gt 0) {
               
foreach ($roleAssignment in $roleAssignments) {
                   
$member = $roleAssignment.Member
                   
                   
# Build a string array of all the permission level names
                   $permName = @()
                   
foreach ($definition in $roleAssignment.RoleDefinitionBindings) {
                       
$permName += $definition.Name
                    }
                   
                   
# Determine how the users permissions were assigned
                   $assignment = "Direct Assignment"
                   
if ($member -is [Microsoft.SharePoint.SPGroup]) {
                       
$assignment = $member.Name
                    }
else {
                       
if ($member.IsDomainGroup -and ($member.LoginName -ne $loginName)) {
                           
$assignment = $member.LoginName
                        }
                    }
                   
                   
# Create a hash table with all the data
                   $hash = @{
                        Resource
= $resource
                       
"Resource Type" = $so.GetType().Name
                        User
= $loginName
                        Permission
= $permName -join ", "
                       
"Granted By" = $assignment
                    }
                   
                   
# Convert the hash to an object and output to the pipeline
                   New-Object PSObject -Property $hash
                }
            }
        }
    }
   
end {}
}

Great - we've got the code - so now you're probably asking, "how the heck do I use it?" Well the first thing you need to do is save it to a file, let's call it SecurityReport.ps1 and we'll put it in the root of the C drive. Once saved we can load it in memory using the following:

C:\ PS> . .\SecurityReport.ps1

Now for the fun stuff :). The examples I'm going to show will build off of each other and will eventually conclude with an example that gives me a report for all users and all securable objects throughout the entire farm. The first example I want to show is how to retrieve a report for a single user and a single web (we'll reuse the $user variable throughout the script so I'll only define it once here):

$user = "sp2010\siteowner2"
Get-SPWeb http://portal | Get-SPUserEffectivePermissions $user | Out-GridView -Title "Web Permissions for $user"

Running this command will generate a grid view as shown here:

image

Note that I could have just as easily saved the results to a CSV file which I could then open in Excel using the Export-Csv cmdlet:

Get-SPWeb http://portal | Get-SPUserEffectivePermissions $user | Export-Csv -NoTypeInformation -Path c:\perms.csv

For this next example I'm going to show the permissions for the same user for ALL webs throughout the entire farm (note that this won't include lists or items):

Get-SPSite -Limit All | Get-SPWeb | Get-SPUserEffectivePermissions $user | Out-GridView -Title "All Web Permissions for $user"

Now I want to get the permissions for the same user for all lists throughout the entire farm:

Get-SPSite -Limit All | Get-SPWeb | %{$_.Lists | Get-SPUserEffectivePermissions $user} | Out-GridView -Title "List Permissions for $user"

Now we're going to get nice and deep and show the permissions for every single item throughout the entire farm (probably don't want to run this on any front-end servers):

Get-SPSite -Limit All | Get-SPWeb | %{$_.Lists | %{$_.Items | Get-SPUserEffectivePermissions $user}} | Out-GridView -Title "Item Permissions for $user"

So now that I've shown you how to get the individual securable objects results throughout the farm for a single user let's now go ahead and stitch them together into one report:

Get-SPSite -Limit All | ForEach-Object {
    $site = $_
    $webPermissions += $site | Get-SPWeb | Get-SPUserEffectivePermissions $user
    $listPermissions += $site | Get-SPWeb | %{$_.Lists | Get-SPUserEffectivePermissions $user}
    $itemPermissions += $site | Get-SPWeb | %{$_.Lists | %{$_.Items | Get-SPUserEffectivePermissions $user}}
    $site.Dispose();
}
$webPermissions + $listPermissions + $itemPermissions | Out-GridView -Title "Web, List, and Item Permissions for $user"

In this example I'm simply performing the same calls but appending to an array of objects and then dumping the combination of those arrays to the grid. Note that in this case I'm calling $site.Dispose() but below I'll be using the SPAssignmentCollection to dispose of objects - keep reading for an explanation.

So now lets take it one step further and see how we can get the same reports but this time for every user. We'll start with webs again - in this example we'll get the permissions for all users for a given site:

$gc = Start-SPAssignment
$site = $gc | Get-SPSite http://portal
$site | Get-SPWeb | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName) | Out-GridView -Title "Web Permissions for All Users In $($site.Url)"
$gc | Stop-SPAssignment

As you can see I'm basically using the SiteUsers property from the root web and passing the login name for each user into the function. Note that here I'm using the Start-SPAssignment and Stop-SPAssignment cmdlets - that's because I'm using the SPSite object after the pipeline execution finishes (as opposed to the above) so I need to make sure it gets disposed (I could just as easily called Dispose on the object as I did above but I'm attempting to demonstrate when/why you'd use the assignment collections).

Now lets see the lists:

$gc = Start-SPAssignment
$site = $gc | Get-SPSite http://portal
$site | Get-SPWeb | %{$_.Lists | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)} | Out-GridView -Title "List Permissions for All Users in $($site.Url)"
$gc | Stop-SPAssignment

Starting to see a pattern? Let's take a look at the list items now:

$gc = Start-SPAssignment
$site = $gc | Get-SPSite http://portal
$site | Get-SPWeb | %{$_.Lists | %{$_.Items | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)}} | Out-GridView -Title "Item Permissions for All Users in $($site.Url)"
$gc | Stop-SPAssignment

Great! So now lets piece this last bit together so we can see the permissions for all webs, lists, and list items for every user within a single site collection:

$gc = Start-SPAssignment
$site = $gc | Get-SPSite http://portal
$webPermissions = $site | Get-SPWeb | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)
$listPermissions = $site | Get-SPWeb | %{$_.Lists | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)}
$itemPermissions = $site | Get-SPWeb | %{$_.Lists | %{$_.Items | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)}}
$webPermissions + $listPermissions + $itemPermissions Out-GridView -Title "Web, List, and Item Permissions for All Users in $($site.Url)"
$gc | Stop-SPAssignment

Alright, we're almost done - let's now stitch this all together and generate a single report showing all permissions for all securable objects (webs, lists, and list items) for every user within every site collection:

Get-SPSite -Limit All | ForEach-Object {
    $site = $_
    $webPermissions += $site | Get-SPWeb | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)
    $listPermissions += $site | Get-SPWeb | %{$_.Lists | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)}
    $itemPermissions += $site | Get-SPWeb | %{$_.Lists | %{$_.Items | Get-SPUserEffectivePermissions ($site.RootWeb.SiteUsers | select LoginName)}}
    $site.Dispose();
}
$webPermissions + $listPermissions + $itemPermissions | Out-GridView -Title "Web, List, and Item Permissions for All Users in All Sites"

Note in this last example, as I did previously when looping through all site collections, I'm calling the Dispose() method inside the ForEach-Object script block. I do this because objects wouldn't otherwise get disposed until the pipeline execution has finished and because it's continuing to iterate so the pipeline has not yet completed. If I used the assignment collection I wouldn't get a disposal until after I'm done iterating which would be too late - I want to dispose right when I'm done with the individual SPSite objects to avoid out of memory errors.

Reporting on who has access to what is one of the things I get asked about most frequently so hopefully this code sample and corresponding examples will prove to be useful to people. One possible area of improvement to the script would be to accommodate groups being passed in - right now I'm only considering users; and of course you could easily turn the example usages into functions. As always, if anyone has any feedback (bugs, improvements, etc.) please post here so that myself and others may benefit.

Sunday, April 4, 2010

Starting the SharePoint 2010 Foundation Search Service using PowerShell

It's been a while since my last real SharePoint 2010 scripting post but we're getting close to RTM so I figured I need to buckle down and play some catch up and get some long overdue posts published. So, continuing my series of posts on scripting the various services and service applications within SharePoint 2010 I decided that I would share something that I know a lot of people have been struggling with recently - scripting the SharePoint Foundation Search Service.

This one threw me for a bit of a loop because all the other services and service applications can be configured almost exclusively using PowerShell cmdlets - this one though has to be configured almost exclusively using the object model. We basically have four cmdlets available to help with the configuration and unfortunately they're not much help at all:

  • Get-SPSearchService - Returns back an object representing the actual service
  • Get-SPSearchServiceInstance - Returns an object representing a service configuration for the service
  • Set-SPSearchService - Updates a few select properties associated with the service
  • Set-SPSearchServiceInstance - Updates the ProxyType for the service

The main failing with these cmdlets is that you can't set the services process identity, the database name and server or failover server, and you can't trigger the provisioning of the service instances which is required for the service to be considered fully "started". All of these things I can do through Central Admin but there's no way to do it using any provided cmdlets - so how do we solve the problem? By getting our hands dirty and writing a boat load of code against the object model.

So let's get started. As before we'll use an XML file to drive the setup process:

<Services>
<FoundationSearchService Enable="true"
AddStartAddressForNonNTZone="false"
MaxBackupDuration="2880"
PerformanceLevel="PartlyReduced"
DatabaseServer="SPSQL1"
DatabaseName="SharePoint_Search_Help"
FailoverDatabaseServer="">
<SvcAccount Name="sp2010\spsearch" />
<CrawlAccount Name="sp2010\spcrawl" />
<Servers>
<Server Name="sp2010svr" ProxyType="Default" />
</Servers>
</FoundationSearchService>
</Services>

As you can see the configuration file is pretty simple. We define two accounts that we'll use, one for the process identity of the service and the other for the crawl account. There's a few simple attributes for the database and some miscellaneous configurations and a list of all the servers in which the service should be started on.

Okay, let's start digging into the actual script. The first thing I do is load the XML file to a variable, $svcConfig, which I use throughout the function:

   1: [xml]$config = Get-Content $settingsFile
   2: $svcConfig = $config.Services.FoundationSearchService

Line 1 loads the file into a System.Xml.XmlDocument typed variable and then I grab the <FoundationSearchService /> element and set that to the $svcConfig variable. Next I need to determine if the script should continue on this server by checking the <Servers /> element to see if there's a match for the current machine:

   1: #See if we want to start the svc on the current server.
   2: $install = (($svcConfig.Servers.Server | where {$_.Name -eq $env:computername}) -ne $null)
   3: if (!$install) { 
   4:     Write-Host "Machine not specified in Servers element, service will not be started on this server."
   5:     return
   6: }

So at this point we know that we're on a target machine so the first thing we want to do is use the Start-SPServiceInstance to start the Foundation Search Service:

   1: #Start the service instance
   2: $svc = Get-SPServiceInstance | where {$_.TypeName -eq "SharePoint Foundation Search" -and $_.Parent.Name -eq $env:ComputerName}
   3: if ($svc -eq $null) {
   4:     $svc = Get-SPServiceInstance | where {$_.TypeName -eq "SharePoint Foundation Help Search" -and $_.Parent.Name -eq $env:ComputerName}
   5: }
   6: Start-SPServiceInstance -Identity $svc

The trick with this is that if we're not using SharePoint Foundation then once the service is initially started it renames itself to "SharePoint Foundation Help Search" so I had to put a provision to look for one name or the other to allow this script to be run multiple times and from multiple machines. Now that the service is started lets set a few variables that we'll use throughout the rest of the script:

   1: #Get the service and service instance
   2: $searchSvc = Get-SPSearchService
   3: $searchSvcInstance = Get-SPSearchServiceInstance -Local
   4:  
   5: $dbServer = $svcConfig.DatabaseServer
   6: $failoverDbServer = $svcConfig.FailoverDatabaseServer

We'll use the $searchSvc and $searchSvcInstance variables extensively. Note that we'll also need to repeat lines one and two at least a couple of times to avoid update conflicts as a result of timer jobs modifying those objects.

The next step will be to set the process identity for the service. We'll go ahead and also get the crawl account information while we're at it to avoid prompting for passwords in more than one location:

   1: #Get the service account details
   2: Write-Host "Provide the username and password for the search crawl account..."
   3: $crawlAccount = Get-Credential $svcConfig.CrawlAccount.Name
   4: Write-Host "Provide the username and password for the search service account..."
   5:   $searchSvcAccount = Get-Credential $svcConfig.SvcAccount.Name
   6:  
   7: #Get or Create a managed account for the search service account.
   8: $searchSvcManagedAccount = (Get-SPManagedAccount -Identity $svcConfig.SvcAccount.Name -ErrorVariable err -ErrorAction SilentlyContinue)
   9: if ($err) {
  10:     $searchSvcManagedAccount = New-SPManagedAccount -Credential $searchSvcAccount
  11: }
  12:  
  13: #Set the account details if different than what is current.
  14: $processIdentity = $searchSvc.ProcessIdentity
  15: if ($processIdentity.ManagedAccount.Username -ne $searchSvcManagedAccount.Username) {
  16:     $processIdentity = $searchSvc.ProcessIdentity
  17:     $processIdentity.CurrentIdentityType = "SpecificUser"
  18:     $processIdentity.ManagedAccount = $searchSvcManagedAccount
  19:     Write-Host "Updating the service process identity..."
  20:     $processIdentity.Update()
  21:     $searchSvc.Update()
  22: }    

This is where things start to get interesting. I use the Get-Credential cmdlet to return back the credentials of the user to use for the service but once I have that there's no parameter on any cmdlet that will allow me to set the credential so I have to do it using the object model. I use the $searchSvc variable from earlier and edit the object returned by the ProcessIdentity property (after confirming that the value needs to be changed).

Once we have the process set we can go ahead and set the other simple properties on the service - fortunately the cmdlet Set-SPSearchService can actually help us out with this one:

   1: #It doesn't hurt if this runs more than once so we don't bother checking before running.
   2: Write-Host "Updating the search service properties..."
   3: $searchSvc | Set-SPSearchService `
   4:     -CrawlAccount $crawlAccount.Username `
   5:     -CrawlPassword $crawlAccount.Password `
   6:     -AddStartAddressForNonNTZone $svcConfig.AddStartAddressForNonNTZone `
   7:     -MaxBackupDuration $svcConfig.MaxBackupDuration `
   8:     -PerformanceLevel $svcConfig.PerformanceLevel `
   9:     -ErrorVariable err `
  10:     -ErrorAction SilentlyContinue
  11: if ($err) {
  12:     throw $err
  13: }

Alright, that was the easy stuff - now we have to deal with the database. The first step is to see if there's already a database defined for the service and if it matches what we want. This is important as we want to be able to run the script more than once so we don't want to just blindly delete and recreate the database. The first bit of code builds a connection string using the SqlConnectionStringBuilder object (note that in PowerShell you have to use the PSBase property to access the properties on this object) and then compares that to what is currently set. If a match is not found then the existing database is deleted and the search service updated:

   1: #Build the connection string to the new database.
   2: [System.Data.SqlClient.SqlConnectionStringBuilder]$builder1 = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
   3: $builder1.psbase.DataSource = $dbServer
   4: $builder1.psbase.InitialCatalog = $svcConfig.DatabaseName
   5: $builder1.psbase.IntegratedSecurity = $true
   6: Write-Host "Proposed database connection: {$builder1}"
   7:  
   8: [Microsoft.SharePoint.Search.Administration.SPSearchDatabase]$searchDb = $searchSvcInstance.SearchDatabase
   9: $dbMatch = $false
  10: if ($searchDb -ne $null) {
  11:     #A database is already set - if it's the one we expect then keep it, otherwise we want to delete it.
  12:     [System.Data.SqlClient.SqlConnectionStringBuilder]$builder2 = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($searchDb.DatabaseConnectionString)
  13:     Write-Host "Existing database connection: {$builder2}"
  14:     if ($builder2.ConnectionString.StartsWith($builder1.ConnectionString, [StringComparison]::OrdinalIgnoreCase)) {
  15:         $dbMatch = $true
  16:     }
  17:     if (!$dbMatch -and $searchDb.DatabaseConnectionString.Equals($builder1.ConnectionString, [StringComparison]::OrdinalIgnoreCase)) {
  18:         $dbMatch = $true
  19:     }
  20:     if (!$dbMatch) {
  21:         #The database does not match the configuration provided so delete it.
  22:         Write-Host "The specified database details do not match existing details. Clearing existing."
  23:         $searchSvcInstance.SearchDatabase = $null
  24:         $searchSvcInstance.Update()
  25:         Write-Host "Deleting {$($searchDb.DatabaseConnectionString)}..."
  26:         $searchDb.Delete()
  27:         Write-Host "Finished deleting search DB."
  28:         $searchDb = $null
  29:     } else {
  30:         Write-Host "Existing Database details match provided details ($($builder2))"
  31:     }
  32: }

At this point if the $searchDb variable is null then we want to go ahead and create a new search database:

   1: #If we don't have a DB go ahead and create one.
   2: if ($searchDb -eq $null) {
   3:     $dbCreated = $false
   4:     try
   5:     {
   6:         Write-Host "Creating new search database {$builder1}..."
   7:         $searchDb = [Microsoft.SharePoint.Search.Administration.SPSearchDatabase]::Create($builder1)
   8:         Write-Host "Provisioning new search database..."
   9:         $searchDb.Provision()
  10:         Write-Host "Provisioning search database complete."
  11:         $dbCreated = $true
  12:  
  13:         #Re-get the service to avoid update conflicts
  14:         $searchSvc = Get-SPSearchService
  15:         $searchSvcInstance = Get-SPSearchServiceInstance -Local
  16:         
  17:         Write-Host "Associating new database with search service instance..."
  18:         $searchSvcInstance.SearchDatabase = $searchDb
  19:         Write-Host "Updating search service instance..."
  20:         $searchSvcInstance.Update()
  21:         
  22:         #Re-get the service to avoid update conflicts
  23:         $searchSvc = Get-SPSearchService
  24:         $searchSvcInstance = Get-SPSearchServiceInstance -Local
  25:     }
  26:     catch
  27:     {
  28:         if ($searchDb -ne $null -and $dbCreated) {
  29:             Write-Warning "An error occurred updating the search service instance, deleting search database..."
  30:             try
  31:             {
  32:                 #Clean up
  33:                 $searchDb.Delete()
  34:             }
  35:             catch
  36:             {
  37:                 Write-Warning "Unable to delete search database."
  38:                 Write-Error $_
  39:             }
  40:         }
  41:         throw $_
  42:     }        
  43: }

I first create a new SPSearchDatabase object by calling the static Create() method and passing in the SqlConnectionStringBuilder object that was previously created. I then call the Provision() method to actually create the database on the SQL server instance. Once it's created we can associate the database with the service by setting the SearchDatabase property on the $searchSvcInstance variable. If an error occurs then I attempt to delete the database from SQL Server if it's not yet associated with the service.

Now that we have our database provisioned we can go ahead and set the failover server:

   1: #Set the database failover server
   2: if (![string]::IsNullOrEmpty($failoverDbServer)) {
   3:     if (($searchDb.FailoverServiceInstance -eq $null) -or `
   4:         ![string]::Equals($searchDb.FailoverServiceInstance.NormalizedDataSource, $failoverDbServer, [StringComparison]::OrdinalIgnoreCase))
   5:     {
   6:         try
   7:         {
   8:             Write-Host "Adding failover database instance..."
   9:             $searchSvcInstance.SearchDatabase.AddFailoverServiceInstance($failoverDbServer)
  10:             Write-Host "Updating search service instance..."
  11:             $searchSvcInstance.Update()
  12:         }
  13:         catch
  14:         {
  15:             Write-Warning "Unable to set failover database server. $_"
  16:         }
  17:     }
  18: }

Most of the logic here is just in determining whether or not to set the failover server. Basically you just call the AddFailoverServiceInstance() method of the SearchDatabase property (SPSearchDatabase) and then update the service instance.

We're almost there - we've set all the properties we can now we need to complete the provisioning process:

   1: $status = $searchSvcInstance.Status
   2: #Provision the service instance on the current server
   3: if ($status -ne [Microsoft.SharePoint.Administration.SPObjectStatus]::Online) {
   4:     if ([Microsoft.SharePoint.Administration.SPServer]::Local -eq $searchSvcInstance.Server) {
   5:         try
   6:         {
   7:             Write-Host "Provisioning search service instance..."
   8:             $searchSvcInstance.Provision()
   9:         }
  10:         catch
  11:         {
  12:             Write-Warning "The call to SPSearchServiceInstance.Provision (server '$($searchSvcInstance.Server.Name)') failed. Setting back to previous status '$status'. $($_.Exception)"
  13:             if ($status -ne $searchSvcInstance.Status) {
  14:                 try
  15:                 {
  16:                     $searchSvcInstance.Status = $status
  17:                     $searchSvcInstance.Update()
  18:                 }
  19:                 catch
  20:                 {
  21:                     Write-Warning "Failed to restore previous status on the SPSearchServiceInstance (server '$($searchSvcInstance.Server.Name)'). $($_.Exception)"
  22:                 }
  23:             }
  24:             throw $_
  25:         }
  26:     }
  27: }

If the service instance is not currently marked as Online (again, accounting for multiple runs) and the service instance we're working with is for the current machine then we call the Provision() method on the service instance. If an error occurs provisioning the service then I try to set the status back to its previous value.

Only two steps left; First we need to create a timer job to trigger the search service instance to be provisioned on the other servers in the farm:

   1: #Re-get the service to avoid update conflicts
   2: $searchSvc = Get-SPSearchService
   3:  
   4: #Create the timer job to update the instances for the other servers.
   5: foreach ($serviceInstance in $searchSvc.Instances) {
   6:     if ($serviceInstance -is [Microsoft.SharePoint.Search.Administration.SPSearchServiceInstance] `
   7:         -and $serviceInstance -ne $searchSvcInstance `
   8:         -and $serviceInstance.Status -eq [Microsoft.SharePoint.Administration.SPObjectStatus]::Online) {
   9:         $definition = $serviceInstance.Farm.GetObject("job-service-instance-$($serviceInstance.Id.ToString())", $serviceInstance.Farm.TimerService.Id, [Microsoft.SharePoint.Administration.SPServiceInstanceJobDefinition])
  10:         if ($definition -ne $null) {
  11:             Write-Host  "A provisioning job for the SPSearchServiceInstance on server '$($serviceInstance.Server.Name)' already exists; skipping."
  12:         } else {
  13:             Write-Host "Creating provisioning job for the SPSearchServiceInstance on server '$($serviceInstance.Server.Name)'..."
  14:             $job = New-Object Microsoft.SharePoint.Administration.SPServiceInstanceJobDefinition($serviceInstance, $true)
  15:             $job.Update($true)
  16:         }
  17:     }
  18: }

And finally, we need to set the ProxyType for the service instances so I loop through the <Server /> elements and call the Set-SPSearchServiceInstance cmdlet, providing the ProxyType attribute as defined in the XML:

   1: #Set the proxy type for all the service instances.
   2: $svcConfig.Servers.Server | ForEach-Object {
   3:     $server = $_
   4:     $instance = Get-SPSearchServiceInstance | where {$_.Server.Name -eq $server.Name}
   5:     if ($instance -ne $null `
   6:         -and $server.ProxyType.ToLowerInvariant() -ne $instance.ProxyType.ToLowerInvariant) {
   7:         Write-Host "Setting proxy type for $($instance.Server.Name) to $($server.ProxyType)..."
   8:         $instance | Set-SPSearchServiceInstance -ProxyType $server.ProxyType   
   9:     }
  10: }

Phew - we're done! Let's put it all together now - here's the complete script:


function Start-FoundationSearch([string]$settingsFile = "Configurations.xml") {
[xml]$config = Get-Content $settingsFile
$svcConfig = $config.Services.FoundationSearchService

#See if we want to start the svc on the current server.
$install = (($svcConfig.Servers.Server | where {$_.Name -eq $env:computername}) -ne $null)
if (!$install) {
Write-Host "Machine not specified in Servers element, service will not be started on this server."
return
}

#Start the service instance
$svc = Get-SPServiceInstance | where {$_.TypeName -eq "SharePoint Foundation Search" -and $_.Parent.Name -eq $env:ComputerName}
if ($svc -eq $null) {
$svc = Get-SPServiceInstance | where {$_.TypeName -eq "SharePoint Foundation Help Search" -and $_.Parent.Name -eq $env:ComputerName}
}
Start-SPServiceInstance -Identity $svc

#Get the service and service instance
$searchSvc = Get-SPSearchService
$searchSvcInstance = Get-SPSearchServiceInstance -Local

$dbServer = $svcConfig.DatabaseServer
$failoverDbServer = $svcConfig.FailoverDatabaseServer

#Get the service account details
Write-Host "Provide the username and password for the search crawl account..."
$crawlAccount = Get-Credential $svcConfig.CrawlAccount.Name
Write-Host "Provide the username and password for the search service account..."
$searchSvcAccount = Get-Credential $svcConfig.SvcAccount.Name

#Get or Create a managed account for the search service account.
$searchSvcManagedAccount = (Get-SPManagedAccount -Identity $svcConfig.SvcAccount.Name -ErrorVariable err -ErrorAction SilentlyContinue)
if ($err) {
$searchSvcManagedAccount = New-SPManagedAccount -Credential $searchSvcAccount
}

#Set the account details if different than what is current.
$processIdentity = $searchSvc.ProcessIdentity
if ($processIdentity.ManagedAccount.Username -ne $searchSvcManagedAccount.Username) {
$processIdentity = $searchSvc.ProcessIdentity
$processIdentity.CurrentIdentityType = "SpecificUser"
$processIdentity.ManagedAccount = $searchSvcManagedAccount
Write-Host "Updating the service process identity..."
$processIdentity.Update()
$searchSvc.Update()
}

#It doesn't hurt if this runs more than once so we don't bother checking before running.
Write-Host "Updating the search service properties..."
$searchSvc | Set-SPSearchService `
-CrawlAccount $crawlAccount.Username `
-CrawlPassword $crawlAccount.Password `
-AddStartAddressForNonNTZone $svcConfig.AddStartAddressForNonNTZone `
-MaxBackupDuration $svcConfig.MaxBackupDuration `
-PerformanceLevel $svcConfig.PerformanceLevel `
-ErrorVariable err `
-ErrorAction SilentlyContinue
if ($err) {
throw $err
}

#Build the connection string to the new database.
[System.Data.SqlClient.SqlConnectionStringBuilder]$builder1 = New-Object System.Data.SqlClient.SqlConnectionStringBuilder
$builder1.psbase.DataSource = $dbServer
$builder1.psbase.InitialCatalog = $svcConfig.DatabaseName
$builder1.psbase.IntegratedSecurity = $true
Write-Host "Proposed database connection: {$builder1}"

[Microsoft.SharePoint.Search.Administration.SPSearchDatabase]$searchDb = $searchSvcInstance.SearchDatabase
$dbMatch = $false
if ($searchDb -ne $null) {
#A database is already set - if it's the one we expect then keep it, otherwise we want to delete it.
[System.Data.SqlClient.SqlConnectionStringBuilder]$builder2 = New-Object System.Data.SqlClient.SqlConnectionStringBuilder($searchDb.DatabaseConnectionString)
Write-Host "Existing database connection: {$builder2}"
if ($builder2.ConnectionString.StartsWith($builder1.ConnectionString, [StringComparison]::OrdinalIgnoreCase)) {
$dbMatch = $true
}
if (!$dbMatch -and $searchDb.DatabaseConnectionString.Equals($builder1.ConnectionString, [StringComparison]::OrdinalIgnoreCase)) {
$dbMatch = $true
}
if (!$dbMatch) {
#The database does not match the configuration provided so delete it.
Write-Host "The specified database details do not match existing details. Clearing existing."
$searchSvcInstance.SearchDatabase = $null
$searchSvcInstance.Update()
Write-Host "Deleting {$($searchDb.DatabaseConnectionString)}..."
$searchDb.Delete()
Write-Host "Finished deleting search DB."
$searchDb = $null
} else {
Write-Host "Existing Database details match provided details ($($builder2))"
}
}

#If we don't have a DB go ahead and create one.
if ($searchDb -eq $null) {
$dbCreated = $false
try
{
Write-Host "Creating new search database {$builder1}..."
$searchDb = [Microsoft.SharePoint.Search.Administration.SPSearchDatabase]::Create($builder1)
Write-Host "Provisioning new search database..."
$searchDb.Provision()
Write-Host "Provisioning search database complete."
$dbCreated = $true

#Re-get the service to avoid update conflicts
$searchSvc = Get-SPSearchService
$searchSvcInstance = Get-SPSearchServiceInstance -Local

Write-Host "Associating new database with search service instance..."
$searchSvcInstance.SearchDatabase = $searchDb
Write-Host "Updating search service instance..."
$searchSvcInstance.Update()

#Re-get the service to avoid update conflicts
$searchSvc = Get-SPSearchService
$searchSvcInstance = Get-SPSearchServiceInstance -Local
}
catch
{
if ($searchDb -ne $null -and $dbCreated) {
Write-Warning "An error occurred updating the search service instance, deleting search database..."
try
{
#Clean up
$searchDb.Delete()
}
catch
{
Write-Warning "Unable to delete search database."
Write-Error $_
}
}
throw $_
}
}

#Set the database failover server
if (![string]::IsNullOrEmpty($failoverDbServer)) {
if (($searchDb.FailoverServiceInstance -eq $null) -or `
![string]::Equals($searchDb.FailoverServiceInstance.NormalizedDataSource, $failoverDbServer, [StringComparison]::OrdinalIgnoreCase))
{
try
{
Write-Host "Adding failover database instance..."
$searchSvcInstance.SearchDatabase.AddFailoverServiceInstance($failoverDbServer)
Write-Host "Updating search service instance..."
$searchSvcInstance.Update()
}
catch
{
Write-Warning "Unable to set failover database server. $_"
}
}
}

$status = $searchSvcInstance.Status
#Provision the service instance on the current server
if ($status -ne [Microsoft.SharePoint.Administration.SPObjectStatus]::Online) {
if ([Microsoft.SharePoint.Administration.SPServer]::Local -eq $searchSvcInstance.Server) {
try
{
Write-Host "Provisioning search service instance..."
$searchSvcInstance.Provision()
}
catch
{
Write-Warning "The call to SPSearchServiceInstance.Provision (server '$($searchSvcInstance.Server.Name)') failed. Setting back to previous status '$status'. $($_.Exception)"
if ($status -ne $searchSvcInstance.Status) {
try
{
$searchSvcInstance.Status = $status
$searchSvcInstance.Update()
}
catch
{
Write-Warning "Failed to restore previous status on the SPSearchServiceInstance (server '$($searchSvcInstance.Server.Name)'). $($_.Exception)"
}
}
throw $_
}
}
}

#Re-get the service to avoid update conflicts
$searchSvc = Get-SPSearchService

#Create the timer job to update the instances for the other servers.
foreach ($serviceInstance in $searchSvc.Instances) {
if ($serviceInstance -is [Microsoft.SharePoint.Search.Administration.SPSearchServiceInstance] `
-and $serviceInstance -ne $searchSvcInstance `
-and $serviceInstance.Status -eq [Microsoft.SharePoint.Administration.SPObjectStatus]::Online) {
$definition = $serviceInstance.Farm.GetObject("job-service-instance-$($serviceInstance.Id.ToString())", $serviceInstance.Farm.TimerService.Id, [Microsoft.SharePoint.Administration.SPServiceInstanceJobDefinition])
if ($definition -ne $null) {
Write-Host "A provisioning job for the SPSearchServiceInstance on server '$($serviceInstance.Server.Name)' already exists; skipping."
} else {
Write-Host "Creating provisioning job for the SPSearchServiceInstance on server '$($serviceInstance.Server.Name)'..."
$job = New-Object Microsoft.SharePoint.Administration.SPServiceInstanceJobDefinition($serviceInstance, $true)
$job.Update($true)
}
}
}

#Set the proxy type for all the service instances.
$svcConfig.Servers.Server | ForEach-Object {
$server = $_
$instance = Get-SPSearchServiceInstance | where {$_.Server.Name -eq $server.Name}
if ($instance -ne $null `
-and $server.ProxyType.ToLowerInvariant() -ne $instance.ProxyType.ToLowerInvariant) {
Write-Host "Setting proxy type for $($instance.Server.Name) to $($server.ProxyType)..."
$instance | Set-SPSearchServiceInstance -ProxyType $server.ProxyType
}
}
}

One thing you should note is that I'm not setting the schedule for the service. This is because the timer job class that I'd need to use to set the schedule is marked internal thus making it impossible for me to set the schedule without using reflection.

As you can see we're in a bit of a conundrum with SharePoint 2010 - scripting your installations is considered to be a best practice and you should strive to do so whenever possible but the level of complexity involved with scripting such simple things has made it prohibitively complex for the average administrator to do.

I recognized this issue the very first day I started working with SharePoint 2010 and to solve the problem I've been working on a product for ShareSquared called SharePoint Composer which will allow administrators, architects, and developers to visually design their SharePoint configurations and then build out the entire Farm using the model they create in the design tool. This tool will allow you to enforce your corporate standards by clearly documenting every configuration and building the farm based on those configurations in a single-click, automated way - all without having to know any PowerShell at all! Keep a watch here for more information about SharePoint Composer.

Note - I've not had a chance to test this in a multi-server farm so if anyone can give me some feedback about their experiences with it I'd greatly appreciate it.