Tag Archives: Powershell

Server 2012 boot hang in vSphere/ESXi

Over the past year or so we have been having some problems with Server 2012 and 2012 R2 virtual machines hanging during reboot operations. The systems hang at the “spash screen”, showing the Windows logo and the ring of spinning dots… forever!

Finally I was able to find an fix for this problem here:
http://kb.vmware.com/kb/2092807

From a tip-off here:
https://social.technet.microsoft.com/Forums/windowsserver/en-US/595c3048-4d70-48ad-a78e-9380df1bbd70/windows-2012-r2-sometimes-hangs-at-splash-screen-after-reboot?forum=winserver8gen

The problem? Well, probably it is best that you just read the TechNet social thread, if you really want to know. It is none too exciting, and all very aggravating. The fix? Run a PowerShell script, then vMotion your machines to force ESXi to re-read the VMX file for your guests.

I am posting my variation on the script in the KB here, because VMware’s script is incomplete, and difficult to read.

#Source: http://kb.vmware.com/selfservice/microsites/search.do?language=en_US&cmd=displayKC&externalId=2092807
#Script intended to correct the bug where Windows Server 2012+ systems on VM Hardware v10 will hang at the 
#boot-up splash screen.  Problem is caused by the failure of ESX to clear the "TS" counter on system reboot.

Set-PSDebug -Strict

#Initialize the VIToolkit:
if ( (Get-PSSnapin -Name VMware.VimAutomation.Core -ErrorAction SilentlyContinue) -eq $null ) {
    Add-PsSnapin VMware.VimAutomation.Core
}
[Reflection.Assembly]::LoadWithPartialName("VMware.Vim")

#Connect to your virtual center:
$viServ = "myVCenter.domain.com"
Connect-VIServer -Server $viServ

#Get all VMs in the vCenter:
$vms = Get-VM 
#Loop though the VMs:
ForEach ($vm in $vms){
	#Get a "View" object for each VM.  Views expose useful data that is not contined in the VM object:
	$vmv = Get-VM $vm | Get-View
	$name = $vmv.Name
	$guestid = $vmv.Summary.Config.GuestId
	
	if ($guestid -like "windows8*Guest") {
		#windows8*Guest will match Windows 8 client 32-bit and 64-bit, as well as 
		# "windows8Server64Guest" (which is Windows Server 2012 and 2012 R2).
		
		#We need to update the VMX file for the VM, which is dong using a VirtualMachineConfigSpec.
		$vmx = New-Object VMware.Vim.VirtualMachineConfigSpec
		$vmx.extraConfig += New-Object VMware.Vim.OptionValue
		$vmx.extraConfig[0].key = "monitor_control.enable_softResetClearTSC"
		$vmx.extraConfig[0].value = "TRUE"
		($vmv).ReconfigVM_Task($vmx)
		write-host "Edited" $vmv.name  
		$vmv.name | out-file -FilePath c:\local\temp\softTscOut.txt -NoClobber -Append
	}
}

Discover user rights in SharePoint

Among the top items capable of derailing your whole day or week are requests from auditors.  Who has access to a resource?  When did they exercise those rights?  In the pas few months, I have had several requests of this sort related to SharePoint rights.  Since I have once again started working on our SharePoint 2010-to-2013 migration project, and most of the SharePoint Powershell cmdlets were fresh in my mind, I though I would take a crack at this somewhat intimidating task.

As usual, writing a useful script took more time that I would have liked, but I am fairly pleased with the results.  The final product makes heavy use of Regular Expressions.  Special thanks go out to RegEx Hero, an online .NET regular expressions tester:
http://regexhero.net/tester/
AND, of course, to the Regular-Expressions.info site:
http://www.regular-expressions.info/

Using .NET-style RegEx named capture groups, I was able to eliminate redundant loops though the SharePoint web site list, thus making it possible to crawl all SharePoint web and site-level ACLs in only a few minutes. Hurray!

This code will work only on SharePoint 2010 farms that use Windows authentication. There may be limitations related to sites with multiple Windows domains as well. I will need to update this script in the near future to handle claims authentication, but we will cross that bridge when we come to it.

The script has some pretty convoluted loops that may not make any intuitive sense… I have tried to insert comments to explain what is going on. If you do choose to use this script in your environment and find it difficult to understand, feel free to contact me with questions.

I think that the script has been posted without wordpress-induced errors. The main problem there is the use of .NET style named capture groups, which use ‘greater than’/’less than’ characters. WordPress hates gt/lt, which resemble XML/HTML/XHTML tags. I had to enter these bits of code as HTML escape sequences instead of raw code. It looks like it worked this time.

#findUserPermissions.ps1
#J. Greg Mackinnon, 2015-03-25
#
#Recurses though the web application provided in -webAppplication for the users (samaccountname format) provided in -Users,
#for the domain provided in -Domain.
#
# Requires: Microsoft.SharePoint.PowerShell PSSnapin
#          ActiveDirectory PowerShell module
#
# Provides: Comma-separated value file with user, permission, and site data for all discovered permissions, 
#          at path provided in the -logPath parameter.          
[cmdletBinding()]
param(
    [parameter(
        Mandatory=$true,
        HelpMessage='Enter a username or comma-separated list of usernames in samAccountName format.')]
        [ValidatePattern('^\b[\w\.-]{1,20}\b$')]
        [string[]]$users,
    [parameter()]
        [ValidatePattern('\b[\w\.-]{1,15}\b')]
        [string]$domain = 'CAMPUS',
    [parameter(
        HelpMessage='Enter the URL of the SharePoint web application for which all webs will be searched.')]
        [ValidatePattern('^http[s]*://\w+\.\w+')]
        [string]$webApplication,
    [parameter(
        HelpMessage='Enter the URL of the single SharePoint site for which all subwebs will be searched.')]
        [ValidatePattern('^http[s]*://\w+\.\w+')]
        [string]$spSite,
    [string]$logPath = 'c:\local\temp\findUserPermissions.log'
)
Set-PSDebug -Strict

function getPermType($mask) {
    #SharePoint permissions/roles are exposed programatically as "PermissionMask" attribute of the SPWeb.Permissions.Member objects.
    #Most of these masks are numeric.  This function will convert the numeric value into a "friendly" string, derived by examining 
    #site permissions in a web browser:
    [string]$return = switch ($mask) {
        'FullMask'   {[string]'FullControl'}
        '1012866047' {[string]'Design'}
        '1011028719' {[string]'Contribute'}
        '138612833'  {[string]'Read'}
        '138612801'  {[string]'ViewOnly'}
        '134287360'  {[string]'List-Library:UnknownAccess'} #Note that this mask has the label "Limited Access" in the GUI.
        default      {[string]"Unknown:$mask"}
    }
    return $return
}

function checkADGroupMembers {
    param ([string]$adGroupName,[string]$adGroupSid,[string]$userRegex)
    #Searches the AD Group provided in -adGroup for matches against the regex provided in -userRegex.
    #Search is performed against the domain of the computer running the script.
    #The userRegex value can be generated using the "regexifyDomainUser" function. It MUST capture the 
    # username/samAccountName to a named capture group called 'name'.
    #Returns boolean true/false.
    #Need to add ability to return RegEx match objects from the search.
    #Optional ability to specify the domain to search?
    
    [Int32]$i = $adGroupName.IndexOf('\')
    [string]$groupSam = $adGroupName.Substring($i + 1)
    #write-host "Getting members of:" $groupSam
    
    [String[]]$returns = @()
    
    #Domain Users could take a long time to process, so let's just assume that the user is a domain user:
    if (($groupSam -eq 'domain users') -or ($groupSam -eq 'authenticated users')) {
        $returns += "!AllUsers!"
        return $returns
        break
    }
    #Get-ADGroupMember will error frequently because SharePoint contains a lot of orphaned groups.
    #Use "ErrorAction Stop" for force an breaking error when this happens, and just set $match to $false/
    try {
        [array]$grpMembers = @()
        if ($adGroupSid) {
            $grpMembers += Get-ADGroupMember -Recursive -Identity $adGroupSid -ErrorAction Stop `
                | Select-Object -ExpandProperty SamAccountName 
        } else {
            $grpMembers += Get-ADGroupMember -Recursive -Identity $groupSam -ErrorAction Stop `
                | Select-Object -ExpandProperty SamAccountName 
        }
    } catch {
        write-host "    AD group $groupSam does not exist" -ForegroundColor Red
        $returns = $null
        break
    }
    if ($grpMembers.count -gt 0) {
        foreach ($memb in $grpMembers) {
            #write-host "testing" $memb "against" $userregex
            if ($memb -match $userRegex) {
                #write-host "regexmatch found: " $matches.user
                #Return the current regex named group "user".  THis is just showing off...
                #I could simply skip the capture groups and just return $memb.
                $returns += $matches.user
            }
        }
    } else {
        write-host "    AD group $groupSam has no members" -ForegroundColor Red
    }
    if ($returns.count -gt 0) {
        return $returns
    } else {
        return $null
    }
}

function regexifyDomainUser {
    param ([string]$user,[string]$domain)
    #Converts the provided domain\username pair into a regex that can 
    #be used to search for the same pattern in a larger string.
    #This regex will return the username in a capture group named "user".
    if ($user -match '\.') {
        $userMatch = $user.Replace('.','\.')
    } else {
        $userMatch = $user
    }
    [string]$regexUser = '^' + $domain + '\\(?<user>' + $userMatch + ')$'
    return $regexUser
}
function regexifyUser {
    #Converts the provided username into a regex that can 
    #be used to search for the same pattern in a larger string.
    #This regex will return the username in a capture group named "user".
    param ([string]$user)
    if ($user -match '\.') {
        $userMatch = $user.Replace('.','\.')
    } else {
        $userMatch = $user
    }
    [string]$regexUser = '^(?<user>' + $userMatch + ')$'
    return $regexUser
}

if ((Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) -eq $null) {
    Add-PSSnapin Microsoft.SharePoint.PowerShell
}
Import-Module ActiveDirectory

#Set up regex strings that contain all of the samAccountNames provided in the input parameters.
#We want to be able to match all possible users with one regex for processing efficiency.

#DomainUsers will match against DOMAIN\username.
[string]$regexDomainUsers = regexifyDomainUser -user $users[0] -domain $domain
#Users will match againstjust username.
[string]$regexUsers = regexifyUser -user $users[0]
if ($users.count -gt 1) {
    for ($i=1; $i -lt $users.count; $i++) {
        #Set up a regex for matching the user in domain\username format:
        [string]$regexDomainUser = '|' + $(regexifyDomainUser -user $users[$i] -domain $domain)
        $regexDomainUsers += $regexDomainUser
        [string]$regexUser = '|' + $(regexifyUser -user $users[$i])
        $regexUsers += $regexUser
    }
}

## Initialize output log:
if (Test-Path -LiteralPath $logPath) {
    Remove-Item -LiteralPath $logPath -Force
}
#CSV header row:
[string]$out = "user,Role,WebUrl,aceDetails"
$out | Out-File -FilePath $logPath 

#Get selected SharePoint sites:
[array]$sites = @()
if ($spSite) {                 #If spSite is specified, search only one site:
    $sites += Get-SPSite -Identity $spSite -Limit All
} elseif ($webApplication){    #If webApplication is specified, search all sites in the webapp:
    $sites += Get-SPSite -WebApplication $webApplication -Limit All
} else {                       #Otherwise, search all web applications defined on the local farm:
    $sites += Get-SPSite -Limit All
}

##Begin Main Loop:
foreach ($site in $sites) {
    $webs = @()
    # Gets webs in current site
    $webs = $site | % {Get-SPWeb -Site $_ -Limit All}
    foreach ($web in $webs) {
        Write-Host "Testing web:" $web.url -ForegroundColor cyan 
        $webPerms = @()
        $webPerms = $web.Permissions
        foreach ($perm in $webPerms) {
            #Scenario 1: ACL entry is for this specific user:
            if ($perm.member.loginName -match $regexDomainUsers) {
                [string]$user = ($perm.member.loginName).split('\') | select -Last 1
                write-host "    Found match $user in the web ACL list." -ForegroundColor yellow
                [string]$aclData = 'Acl:Direct'
                [string]$out = $user + ',' + (getPermType($perm.PermissionMask)) + ',' + $web.Url + ',' + $aclData
                $out | Out-File -Append -FilePath $logPath
            }
            #Scenario 2: ACL entry is an Active Directory Group
            if ($perm.Member.IsDomainGroup) {
                [String[]]$ADGroupusers = @()
                $ADGroupUsers = checkADGroupMembers -adGroupName $perm.Member.LoginName -adGroupSid $perm.Member.Sid -userRegex $regexUsers
                if ($ADGroupUsers.count -gt 0) {
                    foreach ($user in $ADGroupUsers) {
                        write-host "    Found user $user in AD Group:" $perm.member.loginName ", which is on the web ACL list." -ForegroundColor yellow
                        [string]$aclData = 'Acl:Embedded:' + $perm.Member.LoginName 
                        [string]$out = $user + ',' + (getPermType($perm.PermissionMask)) + ',' + $web.Url + ',' + $aclData 
                        $out | Out-File -Append -FilePath $logPath
                    }
                }
            }
            #Scenarios: ACE is for a SharePoint group:
            if ($perm.Member.GetType().Name -eq 'SPGroup') {
                $members = @()
                $members += $perm.member.users
                foreach ($member in $members) {
                    #Scenario 3: ACL is a SharePoint group, and the group contains a matching user:
                    if ($member.loginName -match $regexDomainUsers) {
                        [string]$user = ($member.loginName).split('\') | select -Last 1
                        write-host "    Found match for" $user "in a Sharepoint group that is in the web site ACL." -ForegroundColor yellow

                        [string]$aclData = 'SPGroup:Direct:' + $perm.Member.LoginName
                        [string]$out = $user + ',' + (getPermType($perm.PermissionMask)) + ',' + $web.Url + ',' + $aclData
                        $out | Out-File -Append -FilePath $logPath
                    } #End Scenario 3
                    #Scenario 4: ACL is a SharePoint group, and the group contains an AD group that contains a matching user:
                    if ($Member.IsDomainGroup) {
                        [String[]]$ADGroupUsers = @()
                        $ADGroupUsers = checkADGroupMembers -adGroupName $Member.LoginName -adGroupSod $Member.LoginName.Sid -userRegex $regexUsers
                        if ($ADGroupUsers.count -gt 0) {
                            foreach ($user in $ADGroupUsers) {
                                write-host "    Found user $user in an AD Group that is found in a SharePoint group that appears in the web site ACL." -ForegroundColor yellow
                                [string]$aclData = 'SPGroup:Embedded:' + $perm.Member.LoginName + ':' + $member.loginName
                                [string]$out = $user + ',' + (getPermType($perm.PermissionMask)) + ',' + $web.Url + ',' + $aclData
                                $out | Out-File -Append -FilePath $logPath
                            }
                        }
                    } #End Scenario 4
                }
            } #End SPGroup Eval
        } #End foreach $perm
        foreach ($admin in $web.SiteAdministrators) {
            #Enumerate Web Site Administrators (should be at least two)
            if ($admin.LoginName -match $regexDomainUsers) {
                [string]$user = $matches.user
                write-host "    Found web site administrator match for:" $user -ForegroundColor yellow
                [string]$out = $user + ',' + 'webSiteAdministrator,' + $web.Url
                $out | Out-File -Append -FilePath $logPath
                #exit #for debugging
            }
        }
        $web.Dispose()
    }
    if ($site.owner.UserLogin -match $regexDomainUsers) {
        #Discover Site Owner (should be only one)
        [string]$user = $matches.user
        write-host "    Found site collection owned by:" $user -ForegroundColor yellow
        [string]$out = $user + ',' + 'SiteCollectionOwner,' + $site.Url
        $out | Out-File -Append -FilePath $logPath
        #exit #for debugging
    }
    $site.Dispose()
}

Migrating to the SCCM UDI for OSD, part 4: Applications

Continued from part 3b:
http://blog.uvm.edu/jgm/2015/03/10/sccm-udi-3b-os/

Previously in this series I demonstrated how you can manage Operating System selection and application in UDI, and one (rather complicated) method for managing drivers in SCCM/UDI. To complete our UDI experience, I now present a handy script for automation of populating select SCCM applications into UDI.

There is some conventional wisdom floating around out there in the ‘tubes that you should not deploy the new SCCM 2012-style “Applications” in SCCM OSD Task Sequences. Instead, it is asserted that you should use only traditional application “Packages”. Since we have developed all or our application installer using the new “Application” objects, I have chosen to ignore this guidance. Fortunately, it appears that the earlier bugs that led to dispensing of this advice have been corrected in the 2012 R2 CU2-CU3 timeframe.

The script below will make use of Configuration Manager application tags to determine which Applications should be populated into UDI. Additionally, the tags are used to generate application groups in the UDI wizard. If you don’t want an application to appear in UDI, don’t tag it. Applications can have multiple tags, and thus can end up getting defined more than once un UDI. This does not appear to cause any problems during deployment.

This script will re-write the entire UDI designer “.app” file, which is an XML-formatted document that defines all of the applications and groups that will be displayed during the UDI Wizard (the default name for this file is ‘UDIWizard_Config.xml.app’). Because I am writing out the entire file, I chose to use the System.Xml.XmlTextWriter .Net Framework class to do the heavy lifting for me. I probably could have used System.Xml.XmlDocument (which I used in part 3 of this series for updating the main UDI XML control file), but the XmlTextWriter seemed to be a more direct route to getting the job done in this case.

Note that in this code I am again using the SCCM PowerShell cmdlets. In this case, I am using Get-CMCategory and Get-CMApplication. These commands easily could be replaced with simple WMI queries (as seen in part 3 of this series). I also am using some SCCM managed code. I call the “Microsoft.ConfigurationManagement.ApplicationManagement.Serialization.SccmSerializer” class in order to use the “Deserialize” method. This method converts the large volume of data in the SDMPackageXML attribute of the application object into somewhat more accessible PowerShell objects.

To use this script in your environment, you will need to update the $outPath, $CMSiteCode, and $CMBinPath variables to match your environment. After running the script, you will need to redistribute the content of your MDT Files package to your distribution points. You UDI clients will read the new data as soon as it is made available on your DP.

# build-UDIImageList.ps1
# J. Greg Mackinnon
# Created: 2015-01-26
# Updated: 2015-03-17 - Added host output to indicate task progress.  Removed block comments for blogging clarity.
# Populates the UDI Configuration Wizard XML APP file with all categorized applications gathered 
# from Configuration Manager.  
# Requires: a local installation of the Configuration Manager administration tools.
#   Modify $outPath, $CMSiteCode, and $CMBinPath to match your environment.
Set-PSDebug -Strict

[string] $outPath = 'O:\sources\os\mdt\files\Scripts\UDIWizard_Config.xml.app'

[string] $CMSiteCode = 'UVM'
[string] $CMBinPath = 'F:\Program Files\Microsoft Configuration Manager\AdminConsole\bin\'
[string] $CMModName = 'ConfigurationManager.psd1'
[string] $CMModPath = Join-Path -Path $CMBinPath -ChildPath $CMModName

[string] $CMDrive = $CMSiteCode + ':\'

Import-Module -Name $CMModPath | out-null
Push-Location $CMDrive

#Gather current "Administrative Categories" used to classify Applications, capture to an Array of strings:
write-host "Gathering Application Categories..."
[String[]] $CMAppGroups = Get-CMCategory -CategoryType AppCategories | % {$_.LocalizedCategoryInstanceName}

[int] $appCount = 1

# Output XML requires: 
    # DisplayName (map to LocalizedDisplayName)
    # Name, 
    # Guid (with ScopeID, ApplicationGUID... maps to "ModelName" CMApplication Property), 
    # description (optional? map to LocalizedDescription), 
    # type (deployment type?), 
    # ProductID (which can be found in the sdmpackagexml.deploymenttypes[#].installer.productcode)
# All separated into "ApplicationGroup" stanzas, with name= attributes, I think we can map this to "LocalizedCategoryInstanceNames"


#$xmlDoc = New-Object System.Xml.XmlDocument # Note this is the same as the 1 type accelerator
$utf8 = New-Object System.Text.UTF8Encoding
# Create The Document Writer:
$XmlWriter = New-Object System.XMl.XmlTextWriter($outPath,$utf8)
$xmlWriter.Formatting = "Indented"
$xmlWriter.Indentation = "5"
$xmlWriter.WriteStartDocument()
$xmlWriter.WriteStartElement("Applications")
$xmlWriter.WriteAttributeString('RootDisplayName','Applications')

write-host "Gathering CM Applications..."
$CMApps = Get-CMApplication | Select-Object -Property LocalizedDisplayName,LocalizedDescription,ModelName,LocalizedCategoryInstanceNames,IsLatest,IsExpired,IsSuperseded,SDMPackageXML | ? {$_.IsLatest -and ($_.IsExpired -eq $false) -and ($_.IsSuperseded -eq $false)}

write-host
foreach ($group in $CMAppGroups) {
    $xmlWriter.WriteStartElement('ApplicationGroup')
    $xmlWriter.WriteAttributeString('Name',$group)
    write-host "Processing group: $group" -ForegroundColor yellow
    
    foreach ($app in $CMApps) {
        if ($app.LocalizedCategoryInstanceNames.contains($group)) {
            write-host "    Adding application " $app.LocalizedDisplayName -ForegroundColor cyan
            $xmlWriter.WriteStartElement('Application')
            $xmlWriter.WriteAttributeString('DisplayName',$app.LocalizedDisplayName)
            $xmlWriter.WriteAttributeString('State','enabled')
            $xmlWriter.WriteAttributeString('Id',[string]$appCount)
            $xmlWriter.WriteAttributeString('Name',$app.LocalizedDisplayName)
            $xmlWriter.WriteAttributeString('Guid',$app.ModelName)

            $appXml = [Microsoft.ConfigurationManagement.ApplicationManagement.Serialization.SccmSerializer]::Deserialize($app.SDMPackageXML,$true)

                $xmlWriter.WriteStartElement('Setter')
                $xmlWriter.WriteAttributeString('Property','description')
                $xmlWriter.WriteEndElement()

                $xmlWriter.WriteStartElement('Dependencies')
                $xmlWriter.WriteEndElement()

                $xmlWriter.WriteStartElement('Filters')
                $xmlWriter.WriteEndElement()

                $xmlWriter.WriteStartElement('ApplicationMappings')
                
                    $xmlWriter.WriteStartElement('Match')           
                    $xmlWriter.WriteAttributeString('Type','WMI')
                    $xmlWriter.WriteAttributeString('OperatorCondition','OR')
                    $xmlWriter.WriteAttributeString('DisplayName',$app.LocalizedDisplayName)
                        $xmlWriter.WriteStartElement('Setter')
                        $xmlWriter.WriteAttributeString('Property','Name')
                            $xmlWriter.WriteString($app.LocalizedDisplayName)
                        $xmlWriter.WriteEndElement() # <-- End Setter
                    $xmlWriter.WriteEndElement() # <-- End Match
                    foreach ($type in $appXml.DeploymentTypes) {
                        $xmlWriter.WriteStartElement('Match')
                        $xmlWriter.WriteAttributeString('Type','MSI')
                        $xmlWriter.WriteAttributeString('OperatorCondition','OR')
                        $xmlWriter.WriteAttributeString('DisplayName',$app.LocalizedDisplayName)
                            $xmlWriter.WriteStartElement('Setter')
                            $xmlWriter.WriteAttributeString('Property','ProductId')
                                if ($type.Installer.Technology -match 'MSI') {
                                    $xmlWriter.WriteString($type.Installer.ProductCode)
                                } else {
                                    $xmlWriter.WriteString(' ')
                                }
                            $xmlWriter.WriteEndElement() # <-- End Setter
                        $xmlWriter.WriteEndElement() # <-- End Match
                    }
                $xmlWriter.writeEndElement() # <-- End ApplicationMappings

            $xmlWriter.WriteEndElement() # <-- End Application
            $appCount++ # Increment the appCount by one for use in the "ID" Application element property.
        }
    }
    $XmlWriter.WriteEndElement() # <-- End ApplicationGroup
}

$xmlWriter.WriteEndElement() # <-- End Application
$xmlWriter.WriteEndDocument() # <-- End XML Document
 
# Finish The Document
$xmlWriter.Finalize
$xmlWriter.Flush
$xmlWriter.Close()

Pop-Location

And that is all of the new code that I needed to write for SCCM/UDI. In the final part of this series, I will discuss a few SCCM/UDI quirks that were easily corrected without custom coding:
http://blog.uvm.edu/jgm/2015/03/10/sccm-udi-5-quirks/


Series Index:

Migrating to the SCCM UDI for OSD, part 3b: Operating System Selection (Continued)

Continued from part 3a:
http://blog.uvm.edu/jgm/2015/03/10/sccm-udi-3a-os/

In part 3a of this series, I provided a script that automates updating of the OS selection dialog boxes in UDI. I also noted that while UDI presents that OS selection, the UDI client ignores the selection and instead installs whichever OS image was specified at image creation time. This was very frustrating because I was able to verify that the syntax of the generated UDI XML file was correct. In examining the task sequence logs, I even could see that the “OSDImageName” and “OSDImageIndex” variables had been set as expected. Yet still UDI would not apply the select image. Why?

I found multiple explanations for this phenomenon, none in the MDT/UDI documentation:
http://kb.matthewtrotter.com/?p=94
http://www.myitforum.com/forums/SCCM-2012-MDT-2012-UDI-Image-Selection-m238011.aspx

The upshot is, while UDI wizard will set the “OSDImageName” variable, the SCCM Task Sequence ignores it by design. This issue is exposed in the Task Sequence reference:
https://technet.microsoft.com/en-us/library/hh273365.aspx

The observant reader will notice that the “Apply Operating System Image” task sequence step does not contain a variable for supplying an image name or path. You only can provide an image index. It looks like MS only supports selection of different images indexed in one monolithic WIM image. I am not enamored with this limitation, so I have authored the following script which will create one “Apply Operating System Image” for each OS in the SCCM inventory. Each step will have a condition that will allow the step to run only if the OSDImageName environment variable matches the name of the image specified in the task sequence step. Note that we are assuming only one image per WIM file in this case, so the image index of the selected WIM has to be ‘1’.

Note that this script requires the “UVM-ConfigurationManager.psm1” module file, which can be found in this post:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2c-drivers/

# Update-OSApplyTaskSequence:
# Created 2015-03-09, by J. Greg Mackinnon
# Updated 2015-03-12 - Added ability to update an existing, full OS installation Task Sequence.
#                    - Also removes pre-existing "Apply Apply Operating System Image " step.  

# Script will update the SCCM Task Sequence named in the $TSPackageName variable with a conditional 
# "Apply OS Image" step for each OS currently defined in SCCM.  

# Why?  Because unless all of your OS images are contained within a single WIM file, the SCCM deployment
# sequence will ignore any OS Image selection that you make in UDI.  Instead, we will have to rely on 
# UDI to set the "OSDImageName", and we will apply OS images based on that information.  
# NOTE: We are ASSUMING one image per WIM file, so the image index value will always be set to "1".

#WMI Classes associated with CM Task Sequences (that are relevant to us):
#   SMS_TaskSequencePackage                <-- The master Task Sequence object
#   SMS_TaskSequence                       <-- Each Task Sequence Package has one of these.
#   SMS_TaskSequence_Group                 <-- Logical groups of actions in the sequence.
#   SMS_TaskSequence_Condition             <-- A condition that can be attached to an action or group
#      SMS_TaskSequence_WMIConditionExpression        <-- Use "Model" MDT TS Variable instead!
#      SMS_TaskSequence_MakeModelConditionExpression  <-- Does not exist in the GUI! Do not use!
#      SMS_TaskSequence_VariableConditionExpression   <-- Condition based on a TS variable.
#      SMS_TaskSequence_ApplyOperatingSystemAction    <-- Apply an Operating System Image

# See them all by running:
#    Get-WmiObject -list -Namespace $namespace | select -property name | ? -Property name -Match "SMS_TaskSequence"

# Helpful resources:
#    The authority... MSDN on programming task sequences (VBScript and C#):
#      https://msdn.microsoft.com/en-us/library/jj217977.aspx
#    Describes how to expand properties from SMS objects with "lazy" properties:
#      http://trevorsullivan.net/2010/09/28/powershell-configmgr-wmi-provider-feat-lazy-properties/
#    Describes using the [wmi] type accelerator to retrieve WMI objects by absolute path:
#      http://windowsitpro.com/scripting/type-accelerators-useful-undocumented-feature-powershell-10
#    Describes the difference between [wmi] and [wmiclass] objects:
#      http://tfl09.blogspot.com/2008/12/powershells-wmiclass-type-accelerator.html

[CmdletBinding()]
Param(
    [Parameter(Mandatory=$True,Position=1)][string]$name,
    [Parameter(Position=2)][string]$namespace
)

Set-PSDebug -Strict

######## Begin Site-Specific Settings: ########
#Name of the TaskSequence to configure (must already exist):
[string]$TSPackageName = $name

#Name of the MDT Settings Package that contains the unattend.xml file to be used during deployment:
[string] $MDTSettingsName = 'MDT 2013 Settings'

#Name of the new Apply OS Images Group to be applied to the Task Sequence:
[string]$TSGroupName = 'UVM Apply Operating System Image Group'
######### End Site-Specific Settings: #########

Import-Module 'c:\local\scripts\UVM-ConfigurationManager.psm1'

#Set default value for $namespace, if not provided as a parameter:
if ((-not (Test-Path Variable:\namespace)) -or !$namespace) {[string]$namespace = Get-SMSSiteNamespace}

########################################################
########## Begin Create New OS Apply TS Group ##########
# Get the Name and PackageID for all OS image packages currently defined in SCCM, and put them into an array:
[array]$OSPackages = @()
$OSPackages = Get-WmiObject -Namespace $namespace -Query "Select Name,PackageID from SMS_ImagePackage" | Select-Object -Property Name,PackageID | Sort-Object -Property Name 
#Instantiate a new, empty TS Group:
$newTSGroup = New-SMSObject -class SMS_TaskSequence_Group
#Configure the group:
$newTSGroup.Name = $TSGroupName
$newTSGroup.Description = "Copy this group into a task sequence to replace all pre-existing 'Apply Operating System Image' actions."
#Get the PackageID for our MDT Settings Package.  We will need this later when defining the "Apply Operating System Image" TS Step:
[string] $MDTSettingsID = $(Get-WmiObject  -namespace $namespace -query "Select PackageID from SMS_Package where NAME = '$MDTSettingsName'").PackageID
foreach ($package in $OSPackages) {
    #Add an action to run each Apply Operating System Image Action:
    $ApplyOSTSAction = New-SMSObject -class SMS_TaskSequence_ApplyOperatingSystemAction
    $ApplyOSTSAction.Name = ("Apply OS Package: " + $package.name)
    $ApplyOSTSAction.ConfigFileName = "unattend.xml"
    $ApplyOSTSAction.ConfigFilePackage = $MDTSettingsID
    $ApplyOSTSAction.Description = "Conditionally applies this OS Image Package to the local drive."
    $ApplyOSTSAction.DestinationVariable = "OSDisk"
    $ApplyOSTSAction.ImagePackageID = $package.PackageID
    $ApplyOSTSAction.ImageIndex = "1"
    #Create a Task Sequence Condition object:
    $TSCondition = New-SMSObject -namespace $namespace -class SMS_TaskSequence_Condition
    #Create a Task Sequence Condition Expression object:
    $TSConditionExp = New-SMSObject -namespace $namespace -class SMS_TaskSequence_VariableConditionExpression
    $TSConditionExp.Operator = "equals"
    $TSConditionExp.Value = $package.name
    $TSConditionExp.Variable = "OSDImageName"
    #Add the condition expression to the "operands" attribute of the condition object:
    $TSCondition.Operands = @($TSConditionExp) #  Multiple conditions are possible, use an array.
    #Add the Condition object to the condition attribute of the Group object:
    $ApplyOSTSAction.Condition = $TSCondition #  Only one condition, not an array.
    $TSConditionExp.Dispose()
    $TSCondition.Dispose()
    $newTSGroup.Steps += @($ApplyOSTSAction)
}
########### End Create New OS Apply TS Group ###########
########################################################

######################################################
############# Begin Retrieve Existing TS #############
#Run the WQL queries required to get the fully-populated Task Sequence Package object (no loosely bound parameters)
[string]$filter = "name = '" + $TSPackageName + "'"
$TSP = Get-SMSFullObject -namespace $namespace -class SMS_TaskSequencePackage -filter $filter
#Get a class object for the object retrieved above.  This will allow access to static properties and methods not available in individual WMI objects.
$TSPClass = New-SMSClass -smsObject $TSP 
#For Task Sequence Packages, the GetSequence method allows us to get the sequence associated with a package.
#(Under CM 2012, each Package has one (and only one) Task Sequence)
#  Q: Why do this? The WMI object retrieved above already has a property "sequence", which contains all groups/steps in XML format.  Why can't we use that?
#  A: Because this is XML data that will be challenging to manipulate!  CM has separate classes for more controlled TS step manipulation.
#Note1: Interestingly, the retrieved object as a property "TaskSequence", which is the actual Task Sequence.  WHY!?!?!
#Note2: We also could use "New-SMSObject" to create an entirely new Task Sequence.
$TS = $TSPClass.GetSequence($TSP).TaskSequence
############## End Retrieve Existing TS ##############
######################################################

#Locate the Task Sequence items that need to be modified:
[int32]$exeIndex = Get-SMSTSStepIndex -TSObject $TS -TSStepName 'Execute Task Sequence'
$ExeTSGroup = $TS.steps[$exeIndex]
[int32]$instIndex = Get-SMSTSStepIndex -TSObject $ExeTSGroup -TSStepName 'Install'
$instTSGroup = $ExeTSGroup.steps[$instIndex]

#Remove the existing "Apply OS Image" step, if it exists:
[string]$osApplyStepName = 'Apply Operating System Image'
if (Test-SMSTSStep -TSObject $instTSGroup -TSStepName $osApplyStepName) {
    Remove-SMSTSStep -TSObject $instTSGroup -TSStepName $osApplyStepName
}

#Remove the existing UVM Driver Group (if it exists):
if (Test-SMSTSStep -TSObject $instTSGroup -TSStepName $TSGroupName) {
    Remove-SMSTSStep -TSObject $instTSGroup -TSStepName $TSGroupName
}

#Identify the position within the task sequence group where we will add our new UVM OS Apply Group:
[int]$i = [int]$(Get-SMSTSStepIndex -TSObject $instTSGroup -TSStepName 'Set Variable for Drive Letter') + 1

#Add the new OS Apply Group to the Install Group after the position discovered in the previous step:
Add-SMSTSStep -TSObject $instTSGroup -TSStep $NewTSGroup -StepIndex $i
$newTSGroup.Dispose()

#Walk back up the task sequence tree, updating each parent group with the revised child groups:
Remove-SMSTSStep -TSObject $ExeTSGroup -TSStepName $instTSGroup.Name
Add-SMSTSStep -TSObject $ExeTSGroup -TSStep $instTSGroup -StepIndex $instIndex
$instTSGroup.Dispose()
Remove-SMSTSStep -TSObject $TS -TSStepName $ExeTSGroup.Name 
Add-SMSTSStep -TSObject $TS -TSStep $ExeTSGroup -StepIndex $exeIndex
$ExeTSGroup.Dispose()

#The moment of truth...
#Use the Task Sequence Package class "SetSequence" method to add our new or updated sequence to the task sequence package object:
try {
    $TSPClass.SetSequence($TSP,$TS)
} catch  [System.Management.Automation.MethodInvocationException] {
    [string] $out = "Could not commit the Task Sequence to the Task Sequence Package. "
    $out += "Perhaps this Package is open for editing elsewhere? "
    $out += "Check the Management Point SMSProv.log for details. "
    Write-Error $out
}
#At this point, our Task Sequence has been updated, and the new steps will be available to clients!

# Dispose of all remaining objects: (Do we really need to do this?)
$TS.Dispose()
$TSP.Dispose()
$TSPClass.Dispose()

The script will create a single group of Apply Operating System tasks (one task for each OS Image Package in the infrastructure) in the Task Sequence, using the group name supplied in the $TSPackageName variable. If the group already exists, it will be updated. Additionally, if the stock "Apply Operating System Image" step is still present, it will be removed. Each time you add or remove an OS Image, you will need to re-run the script and re-copy/paste the actions.

We have now dealt with handling Drivers and Operating Systems in SCCM/UDI OS deployment. That leaves managing UDI application selection, which I will discuss in part four of this series, coming up next.

http://blog.uvm.edu/jgm/2015/03/10/sccm-udi-4-apps/


Series Index:

Migrating to the SCCM UDI for OSD, part 3a: Operating System Selection

Continued from part 2f:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2f/

In this part of the series, I will make it possible for the end-user to select from a list of available operating system images. I will provide required scripting logic to update this selection automatically, and I will provide an additional script to make these selections work as expected. (The out-of-box UDI image selection process makes no intuitive sense at all, as we shall soon see.)

Under MDT/LTI, we used Task Sequences as the unit to control the selection of operating systems by the end user. This was necessary because the LTI wizard did not provide an operating system selection dialog. We could have authored our own OS selection dialog, but back when UVM was new to MDT (then BDD) I was a wet-behind-the-ears programmer, and programming of BDD was more difficult. However, under UDI we have the option to allow the user to select different operating systems within the UDI Wizard, so we no longer need one Task Sequence per operating system. I think that this is a positive development, although maintenance of this option proved to be more difficult than expected.

First challenge: Programmatic updating of OS Selections in the UDI Wizard

Anyone who has done experimentation with UDI surely is familiar with the UDI Designer tool. This tool provides a GUI which generates a somewhat large XML file that controls the options that are presented to UDI clients. The UDI designer allows the administrator to select the objects within SCCM that will be presented to the end-user. While this presentation granularity may be desirable for some, it presents a maintenance challenge for us. Any time an object is updated in the SCCM inventory, we need to update the UDI dialogs as well, and Microsoft provides no out-of-box means of updating these links. While this is only a minor problem for OS Image updates, it is a major hassle for Application updates. Since we really needed to solve this problem for Application updates, adding logic for operating systems was an easy extension.

To solve this problem, we need to script the reconfiguration of our UDI wizard XML file. Microsoft likes to claim that PowerShell provides “powerful” XML handling capabilities. In my experience, this claim is debatable as the built-in cmdlets have limited XML formatting capabilities. However, the .NET framework upon which PowerShell is built does provide many classes for XML handling. In this script we will be using the ” type accelerator. This accelerator represents the System.Xml.XmlDocument .NET Class, for which full documentation is available in MSDN:
https://msdn.microsoft.com/en-us/library/system.xml.xmldocument(v=vs.110).aspx

I also use the “Get-CMOperatingSystemImage” SCCM PowerShell cmdlet. As mentioned previously, I recommend avoiding the use of the cmdlets as they behave unpredictably. However, this particular cmdlets appears to work as well as we need it to. If you hate it, the commands could replaced with a WMI calls, although you would need to first discover the OS images:

$images = Get-WmiObject -namespace root/sms/site_[SiteCode] -Class SMS_ImagePackage | %{$_.__Path}

And then retrieve the “full object”, since the above query will not retrieve the required “ImageProperty” attribute (which Microsoft calls a “loosely bound” attribute):

$fullImages = @()
$fullImages += $images | % {[wmi] $_}

To use this script in your environment, you would need to update the $udiXmlIn and $udiXmlOut paths to match the desired locations of the UDIWizard_Config.xml in your environment. These paths can be the same, if desired. You also will need to update the $CMSiteCode and $CMBinPath variables.

# build-UDIImageList.ps1
# J. Greg Mackinnon
# Created: 2015-01-26
# Updated: 2015-03-17 - Added host output to indicate task progress.
# Populates the UDI Configuration Wizard XML file with all Operating System images gathered from
# Configuration Manager.  
# Requires: a local installation of the Configuration Manager administration tools.
#   Modify $udiXmlIn, $udiXmlOut, $CMSiteCode, and $CMBinPath to match your environment.

[string] $udiXmlIn = 'O:\sources\os\mdt\files\Scripts\UDIWizard_Config.xml'
[string] $udiXmlOut = 'O:\sources\os\mdt\files\Scripts\UDIWizard_Config.xml'


[string] $CMSiteCode = 'UVM'

[string] $CMBinPath = 'F:\Program Files\Microsoft Configuration Manager\AdminConsole\bin\'
[string] $CMModName = 'ConfigurationManager.psd1'
[string] $CMModPath = Join-Path -Path $CMBinPath -ChildPath $CMModName

[string] $CMDrive = $CMSiteCode + ':\'

Import-Module -Name $CMModPath | Out-Null

Push-Location $CMDrive

write-host "Gathering OS Images from SCCM..." -ForegroundColor Yellow
$osImages = Get-CMOperatingSystemImage | select -Property Name,ImageProperty

write-host "Loading the current UDI Wizard configuration file..." -ForegroundColor Yellow
1$udiXml = Get-Content $udiXmlIn
$dataElement = ($udixml.wizard.pages.page | ? -Property Name -eq 'VolumePage').data
# XPath variation, not working for some reason:
#$imgSel = $udiXml.SelectNodes("wizard/pages/page[@Name=""VolumePage""]")

#Clear the existing Nodes:
$dataElement.RemoveAll()
#Add the name/Imageselection attribute back in to the element:
$dataElement.SetAttribute('Name','ImageSelection')

write-host
foreach ($image in $osImages) {
    #Create a new DataItem element for each OS Image:
    $dataItemElement = $udiXml.CreateElement('DataItem')
    $dataElement.AppendChild($dataItemElement) | out-null

    # Read information from the existing image:
    1$imageXml = $image.ImageProperty
    [string]$ImageName = $image.Name
    [string]$Index = $imageXml.WIM.IMAGE.Index
    [string]$archNumber = ($imageXml.WIM.IMAGE.Property | ? -Property Name -eq 'Architecture').'#text'
    if ($archNumber -eq '9') {
        [string]$Architecture = 'amd64'
    } elseif ($archNumber -eq '0') {
        [string]$Architecture = 'x86'
    } else {
        [string]$Architecture = ''
    }
    #The UDI DisplayName value does not need to be tied to a property in SCCM, 
    # but we will use the matching Display Name in the SCCM GUI, which is mapped out below:
    [string]$DisplayName = $imageXml.WIM.IMAGE.name
    
    write-host "Adding image named: $DisplayName" -ForegroundColor Yellow

    #Add collected image info to a new array
    [array[]]$setters = @(
        @('DisplayName', $DisplayName),
        @('Index', $Index),
        @('Architecture', $Architecture),
        @('ImageName', $ImageName)
    )

    #Now feed data from the info array as "setter" elements under the "DataItem" element:
    foreach ($setter in $setters) {
        write-host "    Adding element: '"$setter[0]"' with property '"$setter[1]"'" -ForegroundColor cyan
        $setterElement = $udiXml.CreateElement('Setter')
        $setterElement.SetAttribute('Property',$setter[0])
        $setterElement.InnerText = $setter[1]
        $dataItemElement.AppendChild($setterElement) | out-null
    }
}

$udiXml.Save($udiXmlOut)

Pop-Location

After running this script, our UDI Wizard correctly displays all available OS Images in our environment. However, we found that the selections were not being honored by the UDI process. The fault is not in this script, but rather in the logic used by UDI. We will explore the fix for this in the next part of the series (3b).

Next: Operating Systems – Update the Task Sequence with new OS Image data
http://blog.uvm.edu/jgm/2015/03/10/sccm-udi-3b-os/


Series Index:

Migrating to the SCCM UDI for OSD, Part 2e: Driver Handling (continued)

Continued from part 2d:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2d/

In the previously explored client-side SCCM script, we saw some CSV files referenced by the client. These CSV files are not a natural part of the UDI environment, but must be generated by server-side script. What is in these files? Information about the driver packages and OS images that are defined in the SCCM database. Why not just have the client query SCCM for this information? Because to do so, we would need to inject privileged credentials into the WinPE environment, and I don’t want to do that. Creating the CSV files is a safe way to expose SCCM database info.

In authoring these posts, I see that at present the ‘zUVM-DriverCategories.csv’ gets generated twice… once when I run the ImportDrivers.ps1, and then again when running this script. Clearly this redundancy is not required. As I think about it, I probably should retire this script and merge the creation of the zUVM-OSImages.csv file into the script that updates our UDI Wizard display. I guess that will have to wait for version 2.0 of this guide.

This approach comes with the requirement that we update the CSV files any time a new OS or driver package is added to SCCM. We will get to the automation of the update process in a bit. But first, here is the UDI client CSV info file generation script, in PowerShell, of course:

# Build-UDIInfoFiles.ps1
# J. Greg Mackinnon, 2015-01-03
# Builds two files to be used by the zUVMDetectDriverPackage.wsf script that runs in WinPE during ZTI/UDI client installations.
#   YOU MUST ALSO RUN "build-UDIImageList.ps1" to ensure that the images in the UDI Wizard are identical to the images used by the zUVMSetDriverCategories script.
#   YOU MUST run this script after each run of the ImportDrivers.ps1 script.
# These are CSV files contain the following information gathered from Configuration Manager:
#   - a list of all current OS Images and a matching text string to indicate the major OS version.
#   - a list Driver Categories names, with matching Driver Category IDs.
# Requires: Configuration Manager administration tools (including CM PowerShell), and access to the SCCM server using WMI.
# Update $outFile to change the output file names ($outFile is defined twice in the script, rather unprofessionally, really).
# Update $CMSiteCode, $CMBinPath to run in a different server environment.

set-psdebug -strict

###############################################################################
######################### Start Build Driver Info File ########################
    #Build a CSV consisting of each SCCM Driver Category Name, and its corresponding UniqueID 
    [string] $computer = $env:COMPUTERNAME 
    [string] $CMSiteCode = 'UVM'

    [string] $CMBinPath = 'F:\Program Files\Microsoft Configuration Manager\AdminConsole\bin\'
    [string] $CMModName = 'ConfigurationManager.psd1'
    [string] $CMModPath = Join-Path -Path $CMBinPath -ChildPath $CMModName

    [string] $CMDrive = $CMSiteCode + ':\'

    # First output file - Driver Categories:
    [string] $outFile = 'O:\sources\os\mdt\files\Scripts\zUVM-DriverCategories.csv'

    #Cleanup existing file:
    if (Test-Path $outFile) {Remove-Item $outFile -Force -Confirm:$false}
    [string] $namespace = "root\SMS\site_" + $CMSiteCode
    
    #Note: Since we don't actually use the Category UniqueID anymore, a safer approach would be to import a list of SMS_DriverPackage objects instead:
    # Get-WmiObject -Namespace $namespace -Class SMS_DriverPackage -Property name | Select-Object -Property name | Sort-Object -Property name

    [string] $query = "select LocalizedCategoryInstanceName,CategoryInstance_UniqueID from sms_categoryinstance WHERE CategoryTypeName ='DriverCategories'"
    $wmiDriverCats = Get-WmiObject -ComputerName $computer -namespace $namespace -query $query | Sort-Object -Property LocalizedCategoryInstanceName

    #Generate CSV file with CategoryName,UniqueID:
    $driverCats = $wmiDriverCats | Select-Object -Property LocalizedCategoryInstanceName,CategoryInstance_UniqueID
    foreach ($cat in $driverCats) {
        $outStr = $cat.LocalizedCategoryInstanceName + ',' + $cat.CategoryInstance_UniqueID
        $outStr | Out-File -FilePath $outFile -Append -Encoding ascii
    }
########################## End Build Driver Info File #########################
###############################################################################


###############################################################################
########################### Start Build OS Info File ##########################
    #Second output file - OSImage information:
    [string] $outFile = 'O:\sources\os\mdt\files\Scripts\zUVM-OSImages.csv'
    if (Test-Path $outFile) {Remove-Item $outFile -Force -Confirm:$false}

    #We /could/ (and probably should) use WMI here to query root/sms/site_[siteCode]/ImagePackage 
    # (and corresponding ImagePackageInfo), but I am being lazy and will stick with the xml parsing code I already wrote:

    #Load the Configuration Manager PS module, needed for the CM cmdlet:
    Import-Module -Name $CMModPath

    Push-Location
    Set-Location $CMDrive
    $OSImages = Get-CMOperatingSystemImage
    Pop-Location

    foreach ($image in $OSImages) {
        1$imageXml = $image.ImageProperty
        $OSVer = ($imageXml.WIM.IMAGE.Property | ? -Property name -eq 'OS version').'#text'
        if ($OSVer -match '^6\.2|^6\.3') {
            [string] $winVer = 'Win8'
        } elseif ($OSVer -match '^6\.1') {
            [string] $winVer = 'Win7'
        } else {
            [string] $winVer = 'unknown'
        }
        $outStr = $image.Name + ',' + $winVer
        $OutStr | Out-File $outFile -Append -Encoding ascii
    }
############################ End Build OS Info File ###########################
###############################################################################

You may note that this code uses the Get-CMOperatingSystem PowerShell module. Earlier I said that you should not use these PS modules. I did mean it, but I wrote this code before I discovered that the module is evil, and I don’t feel like re-writing the snippet. Call me lazy.

Observant readers may have noticed in the comments the text that reads: “YOU MUST ALSO RUN ‘build-UDIImageList.ps1’ to ensure that the images in the UDI Wizard are identical to the images used by the zUVMSetDriverCategories script.” The build-UDIImageList.ps1 script is a direct component of this driver handling routine. Instead, this script is used to populate SCCM OS Images into the UDI dialogs. However, since our driver handling logic is sensitive to the OS that was selected for installation, we now have a dependency on the our UDI dialog building process. I will present this script in part 3 of this blog series, where I will discuss OS Image selection in UDI.

But first we need to wrap up our discussion of driver handling, which I will do in part 2f, next…

Next: Drivers – File Structure and Dependencies
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2f-drivers/


Series Index:

Migrating to the SCCM UDI for OSD, Part 2c: Driver Handling (continued)

Continued from part 2b:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2b/

In order for this code to function, you will need the UVM-ConfigurationManager.psm1 powershell module. This module is just a function library written in plain PowerShell. I thought it might be useful to split these functions out for future re-use. This method for creating PowerShell modules must be new, as I am sure it did not exist the last time I needed a PowerShell function library. It is a nice addition in that it adds some Class-like functionality to the language that previously was missing.

# UVM Configuraiton Manager Module
# Functions use "SMS" prefix to avoid collision with Microsoft Configuration Manager cmdlets (which use "CM" prefix).

#History:
#  2015-02-19 - Created, added SMSProviderNamespace and SMSObject, SMSFullObject, and SMSClass cmdlets.
#  2015-03-12 - Added SMSSiteNamespace and Add/Get/Remove/Test SMSTSStep cmdlets.  Bug fixes.
Set-PSDebug -Strict

function Add-SMSTSStep {
    param (
        [Parameter(Mandatory=$true)][System.Management.ManagementBaseObject]$TSStep,
        [Parameter(Mandatory=$true)][System.Management.ManagementBaseObject]$TSObject,
        [int32]$StepIndex = 2147483647
    )
    # Adds the Task Sequence Step (which can be wither an Action or Group) specified in 
    #   $TSNewStep to the Task Sequencw or Task Sequence Group supplied in $TSObject. If 
    #   specified, the step will be added at the index localtion specified in $TSStepIndex.
    #   Otherwise, the step will be appended the end of the sequence or group.

    [System.Management.ManagementBaseObject[]]$newSteps = @()
    #Kludge: The max TS step index could not get this hight without crashing the TS:
    if ($StepIndex -eq 2147483647) { 
        $TSObject.steps += $TSStep
    } else {
        [int32]$i = 0
        foreach ($step in $TSObject.steps) {
            if ($i -eq $StepIndex) {
                $newSteps += $TSStep
            }
            $newSteps += $step
            $i ++
        }
        $TSObject.steps = $newSteps
    }
    return $TSObject
    $TSObject.Dispose()
}

function Get-SMSSiteNamespace {
    #Returns the namespace for the SMS Site on the local server.  
    #  Useful for Get-WMIObject commands.
    $SMSPN = Get-SMSProviderNamespace
    [string]$namespace = $SMSPN.Substring(($SMSPN.IndexOf('\root') + 1))
    return $namespace
}

function Get-SMSProviderNamespace {
    # Returns the SMS_ProviderLocation NamespacePath string on the local server.  
    #   Useful when calling SMS objects by their full path using the [wmi]$smsObjectPath constructor.
    $SMSPL = Get-WmiObject -Query "select * from sms_providerlocation" -Namespace root/sms
    return $SMSPL.NamespacePath
    $SMSPL.Dispose()
}

function Get-SMSSiteCode {
    $SMSPL = Get-WmiObject -Query "select * from sms_providerlocation" -Namespace root/sms
    return $SMSPL.SiteCode
    $SMSPL.Dispose()
}

function Get-SMSFullObject {
    param (
        [string]$namespace = (Get-SMSSiteNamespace),
        [Parameter(Mandatory=$true)][string]$class,
        [Parameter(Mandatory=$true)][string]$filter
    )
    #Do a WMI query to retrieve a specific WMI object.  The "filter" must be constructed to return only one result:
    $looseObject = Get-WmiObject -namespace $namespace -class $class -filter $filter
    #Directly retrieve the object that was queried for above.  The resultanat object will have all properties available.
    return [wmi] $looseObject.__Path
    $looseObject.Dispose()
}

function Get-SMSTSStepIndex {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)][System.Management.ManagementBaseObject]$TSObject,
        [Parameter(Mandatory=$true)][string]$TSStepName
    )
    [int]$index = 0
    [bool]$found = $false
    foreach ($step in $TSObject.steps) {
        if (($step.Name -eq $TSStepName) -and (($step.__DYNASTY -eq "SMS_TaskSequence_Step") -or ($step.__DYNASTY -eq "SMS_TaskSequence"))) {
            $found = $true
            break
        }
        $index ++
    }
    if ($found) {
        return $index
    } else {
        throw [string]$("Step with name '$TSStepName' was not found in the specified TaskSequence object '" + $TSObject.name + "'.")
    }
    $TSObject.Dispose()
}

function New-SMSClass {
    param (
        [Parameter(Mandatory=$false)][wmi]$smsObject,
        [Parameter(Mandatory=$false)][string]$smsClass
    )
    if ($smsClass) {
        [string] $classPath = (Get-SMSProviderNameSpace) + ':' + $smsClass
    } elseif ($smsObject) {
        [string] $classPath = $smsObject.__NAMESPACE + ':' + $smsObject.__CLASS
    } else {
        Write-Host "Get-SMSClass requires either an SMS Object (-smsObject) or an SMS Class Name (-class) as input"
    }
    if ($classpath) {
        try {
            $outClass = [wmiclass] $classPath
        } catch [System.Management.Automation.RuntimeException] {
            Write-Host "An error occurred."
            Write-Host ""
            Write-Host "Perhaps you did not provide a valid class name? Try running the following command for a full list of valid classes: "
            Write-Host $([string]'Get-WmiObject -list -Namespace $namespace | select -property name | Sort-Object -Property name | ? -Property name -match ' + "'" + 'SMS_|BDD_' + "'")
        }
        return $outClass
        $outClass.Dispose()
    }
}

function New-SMSObject {
    param (
        [string]$namespace = (Get-SMSProviderNamespace),
        [Parameter(Mandatory=$true)][string]$class
    )
    [string] $wmiPath = $namespace + ':' + $class
    $wmiClass = [wmiclass] $wmiPath
    return $wmiClass.CreateInstance()
}

function Remove-SMSTSStep {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)][System.Management.ManagementBaseObject]$TSObject,
        [Parameter(Mandatory=$true)][string]$TSStepName
    )
    # Removes the first instance of a Task Sequence Step with the .name attribute matchine the import param $TSStepName.
    #   Intent was to return a new object with the step removed, but the script modifies the input object as-is owing to some incomprehensible 
    #   linkage between the function object $TSObject and the supplied input object.  Seems like a scope violation, but I guess that's just WMI.
    # Requires:
    #   $TSObject - An ManagementBaseObject that must be of the SMS_TaskSequence or SMS_TaskSequence_Group class.  Must contain a "steps" property.
    #   $TSStepName - Must be the full name of the TSStep or TSGroup to be removed from the TSSteps object.
    # Returns:
    #   A new TSObject with the first instance of the specified step removed.
    [bool]$found = $false
    foreach ($step in $TSObject.steps) {
        if (($step.Name -eq $TSStepName) -and (($step.__DYNASTY -eq "SMS_TaskSequence_Step") -or ($step.__DYNASTY -eq "SMS_TaskSequence"))) {
            $killStep = $step
            $found = $true
            break
        }
    }
    if ($found) {
        #-- This will not work because the Array is of fixed length.  The remove method is present but does not actually work:
        #$groupSteps.Remove($killStep)
        #-- This does work, but I can't really see why.  It is nice compact code, but I cannot bring myself to trust it: 
        #$newSteps = $postInstTSGroup.steps -ne $killStep

        #-- The following works... note that we have to cast $newSteps as an array of managementBaseObjects, otherwise we
        #   will get casting error that 'PSObject' cannot be cast to type 'ManagementBaseObject'.  
        [System.Management.ManagementBaseObject[]]$newSteps = $TSObject.steps | ? {$_.Name -ne $killStep.Name}
        #Use local scope in case there is a global $newSteps.  PowerShell should prefer local, but I like to play it safe.
        $TSObject.steps = $local:newSteps
        return $TSObject
        $killStep.Dispose()
    } else {
        throw "Specified step was not found within the task sequence object."
    }
    $TSObject.Dispose()
}

function Test-SMSTSStep {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)][System.Management.ManagementBaseObject]$TSObject,
        [Parameter(Mandatory=$true)][string]$TSStepName
    )
    # Tests the input $TSObject (which needs to be of Class SMS_TaskSequence or SMS_TaskSequenceGroup) for a 
    #    step with a .name attribute matching the string parameter $TSStepName.
    # Returns: A Boolean $true or $false.
    [bool] $found = $false
    foreach ($step in $TSObject.steps) {
        if (($step.Name -eq $TSStepName) -and (($step.__DYNASTY -eq "SMS_TaskSequence_Step") -or ($step.__DYNASTY -eq "SMS_TaskSequence"))) {
            $found = $true
            break
        }
    }
    return $found
}

Export-ModuleMember -Function Add-SMSTSStep, Get-SMSProviderNameSpace, Get-SMSSiteNamespace, Get-SMSSiteCode, Get-SMSFullObject, Get-SMSTSStepIndex, New-SMSClass, New-SMSObject, Remove-SMSTSStep, Test-SMSTSStep

In part “2D” of this post, we will explore the client-side script used in this solution…

Next: Drivers – Client-side driver package selection
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2d-drivers/


Series Index:

Migrating to the SCCM UDI for OSD, Part 2b: Driver Handling (continued)

Continued from part 2a:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2a/

So, after importing the drivers, we needed an automated method of updating our task sequences so that the drivers can be injected. Words cannot describe the frustration that I felt while dealing with WMI, PowerShell, and the so-called SCCM SDK reference on Task Sequences:
https://msdn.microsoft.com/en-us/library/jj217977.aspx
Let’s just leave it at “it was painful”.

A few days of heads-down programming yielded code that could create a task sequence that contained the necessary driver injection task sequence steps. Another day and a half of coding later, I now am able to update an existing task sequence to contain the driver injection step. The code must be run directly on your SCCM management point:

# Update-DriverInjectionTaskSequence:
# Created 2015-02-19, by J. Greg Mackinnon
# Updated 2015-03-12 - Added ability to update an existing, full OS installation Task Sequence.
#                    - Also removes pre-existing "AutoApplyDrivers" step.  

# Script will update the SCCM Task Sequence named in the mandatory $name parameter. It will add conditional 
# driver package application steps.  One step will be generated for each supported OS/model combination.
# If a driver cannot be found for a "higher level" OS (i.e. Windows 8), the script will attempt to locate
# a "lower level" OS driver package for that model instead (i.e. Windows 7).
# Additionally, supported "peripheral" drivers will be installed for all systems.
# If no matching driver package is detected, an "AutoApplyDrivers" task sequence step will be executed.

# WMI Classes associated with CM Task Sequences (that are relevant to us):
#   SMS_TaskSequencePackage                        <-- The master Task Sequence object
#   SMS_TaskSequence                               <-- Each Task Sequence Package has one of these.
#   SMS_TaskSequence_Group                         <-- Logical groups of actions in the sequence.
#   SMS_TaskSequence_Condition                     <-- A condition that can be attached to an action or group
#      SMS_TaskSequence_WMIConditionExpression        <-- Use "Model" MDT TS Variable instead!
#      SMS_TaskSequence_MakeModelConditionExpression  <-- Does not exist in the GUI! Do not use!
#      SMS_TaskSequence_VariableConditionExpression   <-- Condition based on a TS variable.
#   SMS_TaskSequence_ApplyDriverPackageAction
#   SMS_TaskSequence_AutoApplyAction               <-- Runs an "Auto Apply Drivers" action.

# See them all by running:
#    Get-WmiObject -list -Namespace $namespace | select -property name | ? -Property name -Match "SMS_TaskSequence"

# Helpful resources:
#    The authority... MSDN on programming task sequences (VBScript and C#):
#      https://msdn.microsoft.com/en-us/library/jj217977.aspx
#    Describes how to expand properties from SMS objects with "lazy" properties:
#      http://trevorsullivan.net/2010/09/28/powershell-configmgr-wmi-provider-feat-lazy-properties/
#    Describes using the [wmi] type accelerator to retrieve WMI objects by absolute path:
#      http://windowsitpro.com/scripting/type-accelerators-useful-undocumented-feature-powershell-10
#    Describes the difference between [wmi] and [wmiclass] objects:
#      http://tfl09.blogspot.com/2008/12/powershells-wmiclass-type-accelerator.html
[CmdletBinding()]
Param(
    [Parameter(Mandatory=$True,Position=1)][string]$name,
    [Parameter(Position=2)][string]$namespace
)
Set-PSDebug -Strict

#CM Server info:
#[string]$namespace = 'root\sms\site_' + $SiteCode

#Set the WQL-formatted filter which will return a specific WMI object (Task Sequence Package object):
[string]$TSPackageName = $name

#Specify the name of the Driver Injection Group to be added to the Task Sequence:
[string]$TSGroupName = 'UVM Driver Package Injection Group'

#Supported OS Versions:
[string[]]$OSList = @('Win7','Win8','Win10')

Import-Module 'c:\local\scripts\UVM-ConfigurationManager.psm1'

#Set default value for $namespace, if not provided as a parameter:
if ((-not (Test-Path Variable:\namespace)) -or !$namespace) {[string]$namespace = Get-SMSSiteNamespace}

# Get the Name and PackageID for all driver packages currently defined in SCCM, and put them into an array:
[array]$DPackages = @()
$DPackages = Get-WmiObject -Namespace $namespace -Query "Select Name,PackageID from SMS_DriverPackage" | Select-Object -Property Name,PackageID | Sort-Object -Property Name 

######################################################
########## Begin Create New Driver TS Group ##########
#Create a new Task Sequence Group:
$NewTSGroup = New-SMSObject -class SMS_TaskSequence_Group
$NewTSGroup.Name = $TSGroupName
$NewTSGroup.Description = "Copy this group into a task sequence to replace all pre-existing driver actions."
#Add an action to run the Package Detection script:
$PkgDetectTSAction = New-SMSObject -class SMS_TaskSequence_RunCommandLineAction
$PkgDetectTSAction.Name = "Run the Driver Package Detection Script"
$PkgDetectTSAction.Description = "Run a script to determine which (if any) driver package to apply to the operating " `
    + "system.  This script will set the 'UVMDriverPackageDetected' and 'UVMDriverPackage' variables."
$PkgDetectTSAction.CommandLine = 'cscript.exe %DeployRoot%\Scripts\ZUVMDetectDriverPackage.wsf'
#Create the "Apply Packages" Group":
$ApplyPkgsTSGroup = New-SMSObject -class SMS_TaskSequence_Group
$ApplyPkgsTSGroup.Name = "Apply Driver Packages Group"
$ApplyPkgsTSGroup.Description = "Apply the detected package group and other mandatory groups, if a package was detected."
$TSCondition = New-SMSObject -namespace $namespace -class SMS_TaskSequence_Condition
#Create a Task Sequence Condition Expression object:
$TSConditionExp = New-SMSObject -namespace $namespace -class SMS_TaskSequence_VariableConditionExpression
$TSConditionExp.Operator = "equals"
$TSConditionExp.Value = "YES"
$TSConditionExp.Variable = "UVMDriverPackageDetected"
#Add the condition expression to the "operands" attribute of the condition object:
$TSCondition.Operands = @($TSConditionExp) #  Multiple conditions are possile, use an array.
#Add the Condition object to the condition attribute of the Group object:
$ApplyPkgsTSGroup.Condition = $TSCondition #  Only one condition, not an array.
$TSConditionExp.Dispose()
$TSCondition.Dispose()
foreach ($package in $DPackages) {
    if ($package.name -notmatch '^Other|^WinPE') { # Exclude Other and WinPE packages... these are for "AutoApply" logic only.
        #Create a new Task Sequence Action object:
        $TSAction = New-SMSObject -namespace $namespace -class SMS_TaskSequence_ApplyDriverPackageAction
        #Set the required properties
        $TSAction.name = 'Apply the ' + $package.Name + ' Driver Packge'
        $TSAction.Description = "Conditionally install this driver package, if it matches the UVMDriverPackage TS Environment Variable."
        $TSAction.DriverPackageID = $package.PackageID
        $TSAction.UnsignedDriver = $true

        if ($package.name -notmatch '^Peripherals'){ # Exclude conditional logic for peripherals, because we want all systems to get these. 
            #Create a Task Sequence Condition object:
            $TSCondition = New-SMSObject -namespace $namespace -class SMS_TaskSequence_Condition
            #Create a Task Sequence Condition Expression object:
            $TSConditionExp = New-SMSObject -namespace $namespace -class SMS_TaskSequence_VariableConditionExpression
            $TSConditionExp.Operator = "equals"
            $TSConditionExp.Value = $package.Name
            $TSConditionExp.Variable = "UVMDriverPackage"
            #Add the condition expression to the "operands" attribute of the condition object:
            $TSCondition.Operands = @($TSConditionExp) #  Multiple conditions are possible, use an array.
            #Add the Condition object to the condition attribute of the Action object:
            $TSAction.Condition = $TSCondition #  Only one condition, not an array.
            $TSConditionExp.Dispose()
            $TSCondition.Dispose()
        }
        #Add the TS Action to the parent TS Group:
        $ApplyPkgsTSGroup.Steps += @($TSAction)
        $TSAction.Dispose()
    }
}
#Create the "Auto Apply Drivers" Step":
$AutoApplyTSAction = New-SMSObject -class SMS_TaskSequence_AutoApplyAction
$AutoApplyTSAction.Name = "Auto Apply Drivers Action"
$AutoApplyTSAction.Description = "Automatically apply all matching drivers, ONLY IF a matching driver package was not detected."
$AutoApplyTSAction.UnsignedDriver = $true
$TSCondition = New-SMSObject -namespace $namespace -class SMS_TaskSequence_Condition
#Create a Task Sequence Condition Expression object:
$TSConditionExp = New-SMSObject -namespace $namespace -class SMS_TaskSequence_VariableConditionExpression
$TSConditionExp.Operator = "equals"
$TSConditionExp.Value = "NO"
$TSConditionExp.Variable = "UVMDriverPackageDetected"
#Add the condition expression to the "operands" attribute of the condition object:
$TSCondition.Operands = @($TSConditionExp) #  Multiple conditions are possible, use an array.
#Add the Condition object to the condition attribute of the Group object:
$AutoApplyTSAction.Condition = $TSCondition # Only one condition, not an array.
$TSConditionExp.Dispose()
$TSCondition.Dispose()
#Put the Apply Packages Group into the Root Group:
$NewTSGroup.Steps = @($PkgDetectTSAction,$ApplyPkgsTSGroup,$AutoApplyTSAction)
$PkgDetectTSAction.Dispose()
$ApplyPkgsTSGroup.Dispose()
$AutoApplyTSAction.Dispose()
########### End Create New Driver TS Group ###########
######################################################


######################################################
############# Begin Retrieve Existing TS #############
#Run the WQL queries required to get the fully-populated Task Sequence Package object (no loosely bound parameters)
[string]$filter = "name = '" + $TSPackageName + "'"
$TSP = Get-SMSFullObject -namespace $namespace -class SMS_TaskSequencePackage -filter $filter
#Get a class object for the object retrieved above.  This will allow access to static properties and methods not available in individual WMI objects.
$TSPClass = New-SMSClass -smsObject $TSP 
#For Task Sequence Packages, the GetSequence method allows us to get the sequence associated with a package.
#(Under CM 2012, each Package has one (and only one) Task Sequence)
#  Q: Why do this? The WMI object retrieved above already has a property "sequence", which contains all groups/steps in XML format.  Why can't we use that?
#  A: Because this is XML data that will be challenging to manipulate!  CM has separate classes for more controlled TS step manipulation.
#Note1: Interestingly, the retrieved object as a property "TaskSequence", which is the actual Task Sequence.  WHY!?!?!
#Note2: We also could use "New-SMSObject" to create an entirely new Task Sequence.
$TS = $TSPClass.GetSequence($TSP).TaskSequence
############## End Retrieve Existing TS ##############
######################################################

#Locate the Task Sequence items that need to be modified:
[int32]$exeIndex = Get-SMSTSStepIndex -TSObject $TS -TSStepName 'Execute Task Sequence'
$ExeTSGroup = $TS.steps[$exeIndex]
[int32]$postIndex = Get-SMSTSStepIndex -TSObject $ExeTSGroup -TSStepName 'PostInstall'
$postInstTSGroup = $ExeTSGroup.steps[$postIndex]

#Remove the existing "Auto Apply Drivers" step, if it exists:
[string]$autoApplyStepName = 'Auto Apply Drivers'
if (Test-SMSTSStep -TSObject $postInstTSGroup -TSStepName $autoApplyStepName) {
    Remove-SMSTSStep -TSObject $postInstTSGroup -TSStepName $autoApplyStepName
}   

#Remove the existing UVM Driver Group (if it exists):
if (Test-SMSTSStep -TSObject $postInstTSGroup -TSStepName $TSGroupName) {
    Remove-SMSTSStep -TSObject $postInstTSGroup -TSStepName $TSGroupName
}

#Identify the position within the task sequence group where we will add our new UVM Driver Group:
[int]$i = [int]$(Get-SMSTSStepIndex -TSObject $postInstTSGroup -TSStepName 'Configure') + 1

#Add the new TS Driver Group to the PostInstall Group after the position discovered in the previous step:
Add-SMSTSStep -TSObject $postInstTSGroup -TSStep $NewTSGroup -StepIndex $i

#Walk back up the task sequence tree, updating each parent group with the revised child groups:
Remove-SMSTSStep -TSObject $ExeTSGroup -TSStepName $postInstTSGroup.Name
Add-SMSTSStep -TSObject $ExeTSGroup -TSStep $postInstTSGroup -StepIndex $postIndex
Remove-SMSTSStep -TSObject $TS -TSStepName $ExeTSGroup.Name 
Add-SMSTSStep -TSObject $TS -TSStep $ExeTSGroup -StepIndex $exeIndex
$NewTSGroup.Dispose()
$postInstTSGroup.Dispose()
$ExeTSGroup.Dispose()

#The moment of truth...
#Use the Task Sequence Package class "SetSequence" method to add our new or updated sequence to the task sequence package object:
try {
    $TSPClass.SetSequence($TSP,$TS)
} catch  [System.Management.Automation.MethodInvocationException] {
    [string] $out = "Could not commit the Task Sequence to the Task Sequence Package. "
    $out += "Perhaps this Package is open for editing elsewhere? "
    $out += "Check the Management Point SMSProv.log for details. "
    Write-Error $out
}

#At this point, our Task Sequence has been updated, and the new steps will be available to clients!

# Dispose of all remaining objects: (Do we really need to do this?)
$TS.Dispose()
$TSP.Dispose()
$TSPClass.Dispose()

In order for this code to function, you will need the UVM-ConfigurationManager.psm1 powershell module. This module is just a function library written in plain PowerShell. The contents of this module will be shown in a continuation of this post…

Next: Drivers – Powershell Support Module
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2c-drivers/


Series Index:

Migrating to the SCCM UDI for OSD, Part 2a: Driver Handling

Continued from part 1:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-1/

Frequent readers of this blog (Anyone? Anyone? Bueller?) will recall my epic series on driver handling under MDT/LiteTouch. For everyone else, you will have to trust me that we came up with an effective and sustainable set of scripts for managing drivers under LiteTouch.

In transitioning to UDI, I hit some pretty serious roadblocks. Notably, UDI does not implement a usable environment variable that can be used to specify the SCCM driver group or package that you want to inject into or make available to your operating system during deployment. Under LTI, we added a stock “Inject Drivers” task sequence step, and used the task sequence variables “DriverGroup001” to specify the path on the deployment share that contained the drivers for the current model of computer.

Under SCCM/UDI, there is no such task sequence variable. Oh sure, the documentation does make reference to a variable “OSDAutoApplyDriverCategoryList” which is used in the “Auto Apply Drivers” task sequence step. But if you set this variable within your running task sequence, it gets ignored by the task sequence step at execution time. While you may find many blogs that provide information to the contrary (by Ben Hunter, no less), my experimentation suggests otherwise. I can see “OSDAutoApplyDriverCategoryList” get set by my script (it shows up in the logs), and then the settings get completely ignored when the driver injection takes place. An MS consultant confirmed this finding, stating that using OSDAutoApplyDriverCategoryList to control driver injection is “impossible”.

“What about using the ‘Apply Driver Package’ task sequence step instead?”, do I hear you ask? Well, that sounds like a good idea, except that ‘Apply Driver Package’ does not support a Task Sequence variable that allows you to set the Driver Package that you wish to apply to the OS. To use this step, you need one ‘Apply Driver Package’ action for each make/model/OS that you support in your environment! To make matters worse, you need to set a condition on each step so that it only runs on a supported model. For us, that means (at present) 75 separate task sequence steps with 75 separate condition statements. That’s a lot of pointing and clicking, and I am not going to do it.

Being the bull-headed guy that I am, I thought that I should be able to script this job out. Probably this was not the best use of my time this month, but I did it anyway, and below you can see the fruits of my labor, such as they are. Programming: the art of doing in 2000 lines of code and 20 hours of work what could have been done in two hours with a mouse and a reference guide.

So here is the UVM SCCM Driver Handling Solution, presented in six “easy parts”… First, we needed to get our drivers into SCCM. After wasting several hours with the SCCM PowerShell cmdlets (boo!), I found the following script which does just what I need using raw WMI calls under PowerShell:

http://blog.coretech.dk/kea/automate-importing-and-creating-drivers-packages-in-sccm-2012-r2/

Many thanks to the team at CoreTech for this code. I had to do some lite modifications to get the script to work in our site. The modified code is included in-line, below:

# From: http://blog.coretech.dk/kea/automate-importing-and-creating-drivers-packages-in-sccm-2012-r2/

# Imports drivers into SCCM from the directory specified in $sourceDir
# Creates driver packages in the directory specified in $packageDir
# The script does not use any MS-provided Configuration Manager PowerShell cmdlets, nor does it use any Configuration Manager DLL files/assemblies.
# It's all implemented in WMI, which is good because it bypasses several bugs in SCCM 2012 R2 CU3 that were preventing this process from working when we tried it with the CM PowerShell cmdlets!

# Drivers will be grouped into Administrative Categories and corresponding Driver Packages based on the folder structure of $sourceDir:

# root
#  |
#  +-> Win7
#  |    |
#  |    +- > Latitude E6500
#  |    +- > Optiplex 780
#  |
#  +-> WinPE
#       |
#	    +-> 5.0-x86
#       +-> 5.0-x64

# Will create the following Categories/Groups:
#  Win7-Latitude E6500
#  Win7-Optiplex 780
#  WinPE-5.0-x86
#  WinPE-5.0-x64

# The granularity of the category names can be changed by nesting more "get-childitem | foreach-object" loops into the function "SDS-ProcessFolder".
# Re-processing or existing folders can be forced by removing the "*.hash" files from the import source as follows:
# > Set-Location $sourceDir
# > gci -recurse -include *.hash | remove-item -force -confirm:$false

[string] $CMServer = "confman3"
[string] $SiteCode = "UVM"
[string] $sourceDir = "\\confman3\sources\drivers\import"
[string] $packageDir = "\\confman3\sources\drivers\packages"
[int] $currentDepth = 1

Function Clean-DriverDir {
    param ([string]$dir)
	# Clean up "cruft" files that lead to duplicate drivers in the share:
	Write-Custom "Cleaning extraneous files from $dir" -ForegroundColor Cyan
	$delItems = gci -recurse -Include version.txt,release.dat,cachescrubbed.txt,btpmwin.inf -LiteralPath $dir
	Write-Custom "Found " $delItems.count " files to delete..." -ForegroundColor Yellow
	if ($delItems.count -ne 0) {
		$delItems | remove-Item -force -confirm:$false
		$delItems = gci -recurse -Include version.txt,release.dat,cachescrubbed.txt,btpmwin.inf -LiteralPath $dir
		Write-Custom "New count for extraneous files: " $delItems.count -ForegroundColor Yellow
	}	
}

Function Get-SCCMDriverCategory
{
    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $categoryName
    )

    # Build the appropriate filter to return all categories or just one specified by name
    $filter = "CategoryTypeName = 'DriverCategories'"
    if ($categoryName -eq "" -or $categoryName -eq $null)
    {
        Write-Debug "Retriving all categories"
    }
    else
    {
        $filter += " and LocalizedCategoryInstanceName = '" + $categoryName + "'"
    }

    # Retrieve the matching list
    Get-SCCMObject SMS_CategoryInstance -filter $filter
}

Function New-SCCMDriverCategory
{
    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $categoryName
    )

    # Create a SMS_Category_LocalizedProperties instance
    $localizedClass = [wmiclass]"\\$sccmServer\$($sccmNamespace):SMS_Category_LocalizedProperties"

    # Populate the localized settings to be used with the new driver instance
    $localizedSetting = $localizedClass.psbase.CreateInstance()
    $localizedSetting.LocaleID =  1033 
    $localizedSetting.CategoryInstanceName = $categoryName
    [System.Management.ManagementObject[]] $localizedSettings += $localizedSetting

    # Create the unique ID
    $categoryGuid = [System.Guid]::NewGuid().ToString()
    $uniqueID = "DriverCategories:$categoryGuid"

    # Build the parameters for creating the collection
    $arguments = @{CategoryInstance_UniqueID = $uniqueID; LocalizedInformation = $localizedSettings; SourceSite = $sccmSiteCode; CategoryTypeName = 'DriverCategories'}

    # Create the new instance
    set-wmiinstance -class SMS_CategoryInstance -arguments $arguments -computername $sccmServer -namespace $sccmNamespace
}

Function New-SCCMDriverPackage
{
    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $name, 
        [Parameter(Position=2)] $description,
        [Parameter(Position=3)] $sourcePath
    )

    # Build the parameters for creating the collection
    $arguments = @{Name = $name; Description = $description; PkgSourceFlag = 2; PkgSourcePath = $sourcePath}
    $newPackage = set-wmiinstance -class SMS_DriverPackage -arguments $arguments -computername $sccmServer -namespace $sccmNamespace
    
    # Hack - for some reason without this we don't get the PackageID value
    $hack = $newPackage.PSBase | select * | out-null
    
    # Return the package
    $newPackage
}

Function New-SCCMFolder            
{            
  Param(                      
    $FolderName,
    $FolderType,            
    $ParentFolderID = 0
  )            
    
  If ($FolderType -eq "Device") { $FolderType = 5000 }
  If ($FolderType -eq "User") { $FolderType = 5001 }
                
  $SMSFolderClass = "SMS_ObjectContainerNode"             
  $Colon = ":"            
                    
  $WMIConnection = [WMIClass]"\\$sccmServer\$sccmNamespace$Colon$SMSFolderClass"            
  $CreateFolder = $WMIConnection.psbase.CreateInstance()            
  $CreateFolder.Name = $FolderName            
  $CreateFolder.ObjectType = $FolderType            
  $CreateFolder.ParentContainerNodeid = $ParentFolderID            
  $FolderResult = $CreateFolder.Put()
  
  $FolderID = $FolderResult.RelativePath.Substring($FolderResult.RelativePath.Length - 8, 8)
  
  $FolderID            
                
}

Function Move-SCCMObject            
{            
  Param(                    
    $SourceFolderID = 0,            
    $TargetFolderID,            
    $ObjectID,            
    $ObjectType           
  )
          
  If ($ObjectType -eq "Device") { $ObjectType = 5000 }
  If ($ObjectType -eq "User") { $ObjectType = 5001 }           
                      
  $Method = "MoveMembers"            
  $SMSObjectClass = "SMS_ObjectContainerItem"            
  $Colon = ":"            
                    
  $WMIConnection = [WMIClass]"\\$sccmServer\$sccmNamespace$Colon$SMSObjectClass"            
  $InParams = $WMIConnection.psbase.GetMethodParameters("MoveMembers")            
  $InParams.ContainerNodeID = $SourceFolderId            
  $InParams.InstanceKeys = $ObjectID           
  $InParams.ObjectType = $ObjectType            
  $InParams.TargetContainerNodeID = $TargetFolderID            
  $null = $WMIConnection.psbase.InvokeMethod($Method,$InParams,$null)
           
}

Function Get-ContentHash
{
    Param (
        $File,
        [ValidateSet("sha1","md5")]
        [string]$Algorithm="md5"
    )
	
    $content = "$($file.Name)$($file.Length)"
    $algo = [type]"System.Security.Cryptography.md5"
	$crypto = $algo::Create()
    $hash = [BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($content))).Replace("-", "")
    $hash
}

Function Get-FolderHash
{
    Param (
        [string]$Folder=$(throw("You must specify a folder to get the checksum of.")),
        [ValidateSet("sha1","md5")]
        [string]$Algorithm="md5"
    )
    
     $content = @()
	Get-ChildItem $Folder -Recurse -Exclude "*.hash" | % { $content += Get-ContentHash $_ $Algorithm }
   
    $algo = [type]"System.Security.Cryptography.$Algorithm"
	$crypto = $algo::Create()
	$hash = [BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($content))).Replace("-", "")
    
    $hash
}

Function Write-Custom($message, [System.ConsoleColor]$foregroundcolor)  
{  
	
	For ($i = 2; $i -le $currentDepth; $i++)
	{
		$tab += "`t"
	}
	
	$currentColor = $Host.UI.RawUI.ForegroundColor  
	if ($foregroundcolor)
	{
		$Host.UI.RawUI.ForegroundColor = $foregroundcolor
	}
	if ($message)  
	{  
		Write-Output "$($tab)$($message)"
	}  
	$Host.UI.RawUI.ForegroundColor = $currentColor 
}

Function Write-Headline($message)
{

	$dot = "------------------------------------------------------------------------------------------------------------"
	
	For ($i = 2; $i -le $currentDepth; $i++)
	{
		$dot = $dot.Substring(0, $dot.Length-8)
	}
	Write-Custom " "
	Write-Custom "$($dot)"
	Write-Custom "$($message)"
	Write-Custom "$($dot)"
}

Function New-SCCMConnection {

    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $serverName,
        [Parameter(Position=2)] $siteCode
    )


    # Clear the results from any previous execution

    Clear-Variable -name sccmServer -errorAction SilentlyContinue
    Clear-Variable -name sccmNamespace -errorAction SilentlyContinue
    Clear-Variable -name sccmSiteCode -errorAction SilentlyContinue
    Clear-Variable -name sccmConnection -errorAction SilentlyContinue


    # If the $serverName is not specified, use "."

    if ($serverName -eq $null -or $serverName -eq "")
    {
        $serverName = "."
    }


    # Get the pointer to the provider for the site code

    if ($siteCode -eq $null -or $siteCode -eq "")
    {
        Write-Verbose "Getting provider location for default site on server $serverName"
        $providerLocation = get-wmiobject -query "select * from SMS_ProviderLocation where ProviderForLocalSite = true" -namespace "root\sms" -computername $serverName -errorAction Stop
    }
    else
    {
        Write-Verbose "Getting provider location for site $siteName on server $serverName"
        $providerLocation = get-wmiobject -query "select * from SMS_ProviderLocation where SiteCode = '$siteCode'" -namespace "root\sms" -computername $serverName -errorAction Stop
    }


    # Split up the namespace path

    $parts = $providerLocation.NamespacePath -split "\\", 4
    Write-Verbose "Provider is located on $($providerLocation.Machine) in namespace $($parts[3])"
    $global:sccmServer = $providerLocation.Machine
    $global:sccmNamespace = $parts[3]
    $global:sccmSiteCode = $providerLocation.SiteCode


     # Make sure we can get a connection

    $global:sccmConnection = [wmi]"${providerLocation.NamespacePath}"
    Write-Verbose "Successfully connected to the specified provider"
}

function Get-SCCMObject {

    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $class, 
        [Parameter(Position=2)] $filter
    )

    if ($filter -eq $null -or $filter -eq "")
    {
        get-wmiobject -class $class -computername $sccmServer -namespace $sccmNamespace
    }
    else
    {
        get-wmiobject -query "select * from $class where $filter" -computername $sccmServer -namespace $sccmNamespace
    }
}


Function Import-SCCMDriverStore
{
	PARAM
    (
    [Parameter(Position=1)] $DriverStore,
    [Parameter(Position=3)] $CMPackageSource,
		#[Parameter(Position=4)] $PackageDepth,
		#[Parameter(Position=5)] $FolderDepth = ($packageDepth - 1),
		#[Parameter(Position=6)] $NameDepth = 1,
		[switch] $cleanup
    )
	
	
	if ($cleanup)
    {
		$currentPrincipal = New-Object Security.Principal.WindowsPrincipal( [Security.Principal.WindowsIdentity]::GetCurrent() )
		if (!$currentPrincipal.IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator ))
		{
			Write-Custom "You need to run Powershell as Administrator, to use the -Mirror switch." Red
			return;
		}
	
	}

	Write-Headline "Started Importing Driver Store: $($driverStore)"
	
	Get-ChildItem $driverStore | ? {$_.psIsContainer -eq $true} | % {
	
		$global:CurrentDepth = 1

		SDS-ProcessFolder $_
		
	
	}
	
}

Function SDS-ProcessFolder($path)
{
	$FolderPath = $path.FullName.Substring($DriverStore.Length+1, $path.FullName.Length-($DriverStore.Length+1))
	$FolderName = $path.FullName.Substring($DriverStore.Length+1, $path.FullName.Length-($DriverStore.Length+1))
	Write-Headline "Processing Folder: $($FolderName)"
	$CMFolderID = SDS-Folder $path 0
	Get-ChildItem $_.FullName | ? {$_.psIsContainer -eq $true} | % {
		$CurrentDepth = 2
		SDS-ProcessPackage $_ $FolderPath $CMFolderID
	}
}

Function SDS-Folder($folder, $parentID)  
{	
		
		$CMFolder = Get-SCCMObject -Class "SMS_ObjectContainerNode" -Filter "Name = `"$($folder.Name)`" AND ParentContainerNodeID = $($parentID) AND ObjectType = 23"
		
		If ($CMFolder)
		{
			$CMFolderID = $CMFolder.ContainerNodeID
		}
		Else
		{
			$CMFolderID = New-SCCMFolder -FolderName $folder.Name -FolderType 23 -ParentFolderID $parentID
			#Write-Custom "Created SCCM folder $($folder.Name) ($($SCCMFolderID))"
		}
		$CMFolderID
}


Function SDS-ProcessPackage($package, $folderPath, $folderID)
{
	$PackageName = $package.FullName.Substring($DriverStore.Length+1, $package.FullName.Length-($DriverStore.Length+1))
	
	#$PackageName = $PackageName.Substring($NameIndex+1, $PackageName.Length-($NameIndex+1))
	$PackageName = $PackageName.Replace("\","-")
	
	Write-Headline "Processing Driver Package: $($PackageName)"
	$PackageHash = Get-FolderHash $package.FullName
	If (Get-ChildItem $package.FullName -Filter "$($PackageHash).hash")
	{
		Write-Custom "No changes has been made to this Driver Package. Skipping." DarkGray
	}
	Else
	{
		#Cleanup the source directory to avoid import of duplicate drivers:
		Clean-DriverDir($package.FullName)
		
		$CMCategory = Get-SCCMDriverCategory -categoryName $PackageName
		if ($CMCategory -eq $null)
		{
			$CMCategory = New-SCCMDriverCategory $PackageName
			Write-Custom "Created new driver category $($PackageName)"
		}
		

		$CMPackageTrue = (get-wmiobject -query "Select * from SMS_DriverPackage join SMS_ObjectContainerItem ON SMS_ObjectContainerItem.InstanceKey = SMS_DriverPackage.PackageID WHERE SMS_ObjectContainerItem.ObjectType = 23 AND SMS_ObjectContainerItem.ContainerNodeID = `"$($folderID)`" AND SMS_DriverPackage.Name = `"$($PackageName)`"" -computername $sccmServer -namespace $sccmNamespace).SMS_DriverPackage
		if ($CMPackageTrue -eq $null) { $CMPackageTrue = get-wmiobject -query "Select * from SMS_DriverPackage join SMS_ObjectContainerItem ON SMS_ObjectContainerItem.InstanceKey = SMS_DriverPackage.PackageID WHERE SMS_ObjectContainerItem.ObjectType = 23 AND SMS_ObjectContainerItem.ContainerNodeID = `"$($folderID)`" AND SMS_DriverPackage.Name = `"$($PackageName)`"" -computername $sccmServer -namespace $sccmNamespace }
		$CMPackage = get-wmiobject -query "Select * from SMS_DriverPackage WHERE SMS_DriverPackage.PackageID = `"$($CMPackageTrue.PackageID)`"" -computername $sccmServer -namespace $sccmNamespace
		
		if ($CMPackage -eq $null)
		{
			Write-Custom "Creating new driver package $($PackageName)"
			$CMPackageSource = "$($CMPackageSource)\$($folderPath)\$($PackageName)"
			#$CMPackageSource = "$($CMPackageSource)\$($PackageName)"
			if (Test-Path $CMPackageSource)
				{
				if((Get-Item $CMPackageSource | %{$_.GetDirectories().Count + $_.GetFiles().Count}) -gt 0)
				{
					if ($cleanup)
					{
						Write-Custom "Folder already exists, removing content" Yellow
						dir $driverPackageSource | remove-item -recurse -force
					}
					else
					{
						Write-Custom "Folder already exists, remove it manually." Red
						return
					}
				}
			}
			else
			{
				$null = MkDir $CMPackageSource
			}
			
			$CMPackage = New-SCCMDriverPackage -name $PackageName -sourcePath $CMPackageSource
			Move-SCCMObject -TargetFolderID $folderID -ObjectID $CMPackage.PackageID -ObjectType 23
		}
		else
		{
			Write-Custom "Existing driver package $($PackageName) ($($CMPackage.PackageID)) retrieved." DarkGray
		}
		
		#$CurrentDepth += 1
		
		#$driverPackageContent = get-wmiobject -computername $sccmServer -namespace $sccmNamespace -query "SELECT * FROM SMS_Driver WHERE CI_ID IN (SELECT CTC.CI_ID FROM SMS_CIToContent AS CTC JOIN SMS_PackageToContent AS PTC ON CTC.ContentID=PTC.ContentID JOIN SMS_DriverPackage AS Pkg ON PTC.PackageID=Pkg.PackageID WHERE Pkg.PackageID='$($CMPackage.PackageID)')"
		#Get-ChildItem $package.FullName -Filter *.inf -recurse | Import-SCCMDriver -category $CMCategory -package $CMPackage | % {
		
		
		#}
		
		Get-ChildItem $package.FullName -Filter *.inf -recurse | % { SDS-ImportDriver $_ $CMCategory $CMPackage }
		
		Get-ChildItem $package.FullName -Filter "*.hash"  | Remove-Item 
		$null = New-Item "$($package.FullName)\$($PackageHash).hash" -type file 
	}
}

Function SDS-ImportDriver($dv, $category, $package)
{

		# Split the path
        $driverINF = split-path $dv.FullName -leaf 
        $driverPath = split-path $dv.FullName

        # Create the class objects needed
        $driverClass = [WmiClass]("\\$sccmServer\$($sccmNamespace):SMS_Driver")
        $localizedClass = [WmiClass]("\\$sccmServer\$($sccmNamespace):SMS_CI_LocalizedProperties")

        # Call the CreateFromINF method
        $driver = $null
        $InParams = $driverClass.psbase.GetMethodParameters("CreateFromINF")
        $InParams.DriverPath = $driverPath
        $InParams.INFFile = $driverINF
        try
        {
            $R = $driverClass.PSBase.InvokeMethod("CreateFromINF", $inParams, $Null)

            # Get the display name out of the result
            $driverXML = [XML]$R.Driver.SDMPackageXML
            $displayName = $driverXML.DesiredConfigurationDigest.Driver.Annotation.DisplayName.Text

            # Populate the localized settings to be used with the new driver instance
            $localizedSetting = $localizedClass.psbase.CreateInstance()
            $localizedSetting.LocaleID =  1033 
            $localizedSetting.DisplayName = $displayName
            $localizedSetting.Description = ""
            [System.Management.ManagementObject[]] $localizedSettings += $localizedSetting

            # Create a new driver instance (one tied to the right namespace) and copy the needed 
            # properties to it.
            $driver = $driverClass.CreateInstance()
            $driver.SDMPackageXML = $R.Driver.SDMPackageXML
            $driver.ContentSourcePath = $R.Driver.ContentSourcePath
            $driver.IsEnabled = $true
            $driver.LocalizedInformation = $localizedSettings
            $driver.CategoryInstance_UniqueIDs = @($category.CategoryInstance_UniqueID)

            # Put the driver instance
            $null = $driver.Put()
        
            Write-Custom "New Driver: $($displayName)"
        }
        catch [System.Management.Automation.MethodInvocationException]
        {
            $e = $_.Exception.GetBaseException()
            if ($e.ErrorInformation.ErrorCode -eq 183)
            {
                
                # Look for a match on the CI_UniqueID    
                $query = "select * from SMS_Driver where CI_UniqueID = '" + $e.ErrorInformation.ObjectInfo + "'"
                $driver = get-WMIObject -query $query.Replace("\", "/") -computername $sccmServer -namespace $sccmNamespace         
 
				Write-Custom "Duplicate Driver: $($driver.LocalizedDisplayName)" DarkGray
	
                # Set the category
                if (-not $driver)
                {
                    Write-Custom "`tUnable to import and no existing driver found." Yellow
                    return
                }
                elseif ($driver.CategoryInstance_UniqueIDs -contains $category.CategoryInstance_UniqueID)
                {
                    Write-Verbose "Existing driver is already in the specified category."
                }
                else
                {
                    $driver.CategoryInstance_UniqueIDs += $category.CategoryInstance_UniqueID
                    $null = $driver.Put()
                    Write-Verbose "Adding driver to category"
                }
            }
            else
            {
                Write-Custom "`tUnexpected error, skipping INF $($infFile): $($e.ErrorInformation.Description) $($e.ErrorInformation.ErrorCode)" Yellow
                return
            }
        }
        
        # Hack - for some reason without this we don't get the CollectionID value
		$hack = $driver.PSBase | select * | out-null

        # If a package was specified, add the driver to it
        if ($package -ne $null)
        {
			$driverPackageContent = get-wmiobject -computername $sccmServer -namespace $sccmNamespace -query "SELECT * FROM SMS_Driver WHERE CI_ID IN (SELECT CTC.CI_ID FROM SMS_CIToContent AS CTC JOIN SMS_PackageToContent AS PTC ON CTC.ContentID=PTC.ContentID JOIN SMS_DriverPackage AS Pkg ON PTC.PackageID=Pkg.PackageID WHERE Pkg.PackageID='$($package.PackageID)')"
            
			$doesDriverExist = $driverPackageContent | ? {$_.CI_UniqueID -eq $driver.CI_UniqueID}
			if ($doesDriverExist -eq $null)
			{
				# Add the driver to the package since it's not already there
				Write-Verbose "Adding driver to package"
				$null = Add-SCCMDriverPackageContent -package $package -driver $driver
			}

        }

        # Write the driver object to the pipeline
        #$driver

}

function Add-SCCMDriverPackageContent
{
    [CmdletBinding()]
    PARAM
    (
        [Parameter(Position=1)] $package,
        [Parameter(Position=2, ValueFromPipeline=$true)] $driver
    )

    Process
    {
        # Get the list of content IDs
        $idlist = @()
        $ci = $driver.CI_ID
        
        $i = 1
		$ids = Get-SCCMObject -class SMS_CIToContent -filter "CI_ID = '$ci'"

        if ($ids -eq $null)
        {
            Write-Warning "Warning: Driver not found in SMS_CIToContent"
        }
        foreach ($id in $ids)
        {
            $idlist += $id.ContentID
        }

        # Build a list of content source paths (one entry in the array)
        $sources = @($driver.ContentSourcePath)

        # Invoke the method
        try
        {
            $package.AddDriverContent($idlist, $sources, $false)
        }
        catch [System.Management.Automation.MethodInvocationException]
        {
            $e = $_.Exception.GetBaseException()
            if ($e.ErrorInformation.ErrorCode -eq 1078462229)
            {
                Write-Verbose "Driver is already in the driver package (possibly because there are multiple INFs in the same folder or the driver already was added from a different location): $($e.ErrorInformation.Description)"
            }
        }
    }

}

New-SCCMConnection $CMServer $SiteCode
Import-SCCMDriverStore $sourceDir $packageDir

#    This section formerly contained logic that has been moved to "Build-UDIInfoFiles.ps1",
#    which creates CSV files containing SCCM database info for use by UDI clients.

My version changes the import directory format a bit, adds some in-line documentation, and moves the local site variables to the top of the file. I also included a source-tree cleanup command that we used under MDT/LTI that removed useless Dell info files from the import structure. This helped reduce duplicate driver imports under LTI, but it may not be applicable under UDI.

Because of nuances in WordPress, I have had to split part 2 of this post into multiple pages. Driver scripting continues in part 2b:
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2b-drivers/


Series Index:

Provisioning students with Office 365 ProPlus licenses

NOTE:  This post was updated on 2014-12-23 to reflect refinements in the PowerShell provisioning script. The script now has support for provisioning O365 ProPlus for faculty/staff under the new Office 365 Faculty/Staff Benefit.

Interesting… I still seem to be working at UVM. There must be a story there, but you won’t read about it here.

Anyway, after getting back from my brief hiatus at Stanford University, I got back on the job of setting up Azure DirSync with Federated login to our in-house Web SSO platforms. I’ll need to post about the security changes required to make that work with UVM’s FERPA interpretation. To summarize, we got it working.

However, once students can log in to Office 365, we need to provision them with licenses. DirSync can’t do this, so I needed to script a task that will grant an office 365 ProPlus license to any un-provisioned active student. You will find the script, mostly unaltered, below. I just set it up as a scheduled task that runs sometime after the nightly in-house Active Directory update process.

To be useful outside of UVM, the code would need to be customized to handle the logic used in your organization to determine who is a student. We have extended the AD schema to include the eduPerson schema, and have populated the “eduPersonPrimaryAffiliation” attribute with “Student” for currently active students. If you do something different, have a look at the “Get-ADUser” line, and use a different LDAP query to fetch your student objects.

Enjoy.

# Provision-MSOLUsers.ps1 script, by J. Greg Mackinnon, 2014-07-30
# Updated 2014-11-20, new license SKU, corrections to error capture commands, and stronger typing of variables.
# Updated 2014-11-21, added "license options" package to the add license command, for granular service provisioning.
# Updated 2014-12-22, Now provisions student, faculty, and staff Office 365 Pro Plus with different SKUs.
#
# Provisions all active student accounts in Active Directory with an Office 365 ProPlus license.
#
# Requires:
# - PowerShell Module "MSOnline"
# - PowerShell Module "ActiveDirectory"
# - Azure AD account with rights to read account information and set license status
# - Credentials for this account, with password saved in a file, as detailed below.
# - Script runs as a user with rights to read the eduPersonAffiliation property of all accounts in Active Directory.
#
#    Create a credential file using the following procedure:
#    1. Log in as the user that will execute the script.
#    2. Execute the following line of code in PowerShell:
#    ConvertTo-SecureString -String 'password' -AsPlainText -Force | ConvertFrom-SecureString | out-file "c:\pathToCreds\msolCreds.txt" -Force
#

Set-PSDebug -Strict

#### Local Variables, modify for your environment: ####
#
[string] $to = 'saa-ad@uvm.edu'
[string] $from = 'Provisioning@myserver.mydomain.edu'
[string] $smtp = 'smtp.mydomain.edu'
[string] $msolUser = 'dirsync@myTennant.onmicrosoft.com'
[string] $searchBase = 'ou=people,dc=mydomain,dc=edu'
[string] $ea1Filter = '(&(ObjectClass=inetOrgPerson)(|(extensionAttribute1=*Student*)(extensionAttribute1=*Staff*)(extensionAttribute1=*Faculty*)))'
### Example Filters:
# Filter for all fac/staff/students: 
#   (&(ObjectClass=inetOrgPerson)(|(extensionAttribute1=*Student*)(extensionAttribute1=*Staff*)(extensionAttribute1=*Faculty*)))
# Filter for just students:
#   (&(ObjectClass=inetOrgPerson)(extensionAttribute1=*Student*))
#
#### End Local Variables ##############################


#### Initialize logging and script variables: #########
#initialize log and counter:
[string[]] $log = @()
[long] $pCount = 0

#initialize logging:
[string] $logFQPath = "c:\local\temp\provision-MSOLUsers.log"
New-Item -Path $logFQPath -ItemType file -Force

[DateTime] $sTime = get-date
$log += "Provisioning report for Office 365/Azure AD for: " + ($sTime.ToString()) + "`r`n"


#### Define Functions:  ###############################
#
function errLogMail ($err,$msg) {
    # Write error to log and e-mail function
    # Writes out the error object in $err to the global $log object.
    # Flushes the contents of the $log array to file, 
    # E-mails the log contents to the mail address specified in $to.
    [string] $except = $err.exception;
    [string] $invoke = $err.invocationInfo.Line;
    [string] $posMsg = $err.InvocationInfo.PositionMessage;
    $log +=  $msg + "`r`n`r`nException: `r`n$except `r`n`r`nError Position: `r`n$posMsg";
    $log | Out-File -FilePath $logFQPath -Append;
    [string] $subj = 'Office 365 Provisioning Script:  ERROR'
    [string] $body = $log | % {$_ + "`r`n"}
    Send-MailMessage -To $to -From $from -Subject $subj -Body $body -SmtpServer $smtp
}

function licReport ($sku) {  
    $script:log += 'License report for: ' + $sku.AccountSkuId 
    $total = $sku.ActiveUnits
    $consumed = $sku.ConsumedUnits
    $script:log += 'Total licenses: ' + $total  
    $script:log += 'Consumed licenses: ' + $consumed  
    [int32] $alCount = $total - $consumed
    $script:log += 'Remaining licenses: ' + $alCount.toString() + "`r`n"
}
#
#### End Functions  ###################################

#Import PS Modules used by this script:
try {
    Import-Module MSOnline -ErrorAction Stop ;
} catch {
    $myError = $_
    [string] $myMsg = "Error encountered loading Azure AD (MSOnline) PowerShell module."
    errLogMail $myError $myMsg
    exit 101
}

try {
    Import-Module ActiveDirectory -ErrorAction Stop ;
} catch {
    $myError = $_
    [string] $myMsg = "Error encountered loading ActiveDirectory PowerShell module."
    errLogMail $myError $myMsg
    exit 102
}

#Get credentials for use with MS Online Services:
try {
    $msolPwd = get-content C:\pathToCreds\msolCreds.txt | convertto-securestring -ErrorAction Stop ;
} catch {
    $myError = $_
    [string] $myMsg = "Error encountered getting creds from file."
    errLogMail $myError $myMsg
    exit 110
}

try {
    $msolCreds = New-Object System.Management.Automation.PSCredential ($msolUser, $msolPwd) -ErrorAction Stop ;
} catch {
    $myError = $_
    [string] $myMsg = "Error encountered in generating credential object."
    errLogMail $myError $myMsg
    exit 120
}
#Use the following credential command instead of the block above if running this script interactively:
#$msolCreds = get-credential

#Connect to MS Online Services:
try {
    #ErrorAction set to "Stop" for force any errors to be terminating errors.
    # default behavior for connection errors is non-terminating, so the "catch" block will not be processed.
    Connect-MsolService -Credential $msolCreds -ErrorAction Stop
} catch {
    $myError = $_
    [string] $myMsg = "Error encountered in connecting to MSOL Services."
    errLogMail $myError $myMsg
    exit 130
}
$log += "Connected to MS Online Services.`r`n"

#Generate license report:
$studAdv = Get-MsolAccountSku | ? {$_.accountSkuId -match 'STANDARDWOFFPACK_IW_STUDENT'}  
$facStaffBene = Get-MsolAccountSku | ? {$_.accountSkuId -match 'OFFICESUBSCRIPTION_FACULTY'} 
licReport($studAdv)
licReport($facStaffBene)

#Set license options for student and faculty/staff SKUs: 
# NOTE: License options for a SKU can be enumerated by examining the "ServiceStatus" property of the MsolAccountSku objects fetched above using "Get-MsolAccountSku".
$stuLicOpts = New-MsolLicenseOptions -AccountSkuId $studAdv.AccountSkuId -DisabledPlans YAMMER_EDU,SHAREPOINTWAC_EDU,SHAREPOINTSTANDARD_EDU,EXCHANGE_S_STANDARD,MCOSTANDARD
$facStaffLicOpts = New-MsolLicenseOptions -AccountSkuId $facStaffBene.AccountSkuId -DisabledPlans ONEDRIVESTANDARD

#Retrieve active fac/staff/student accounts into a hashtable:
[hashtable] $adAccounts = @{}
try {
    #$NOTE:  The filter used for collecting students needed to be modified to fetch admitted students that are not yet active
    #   This is a 'hack' implemented by FCS to address the tendency for the registrar not to change student status until the first day of class.
    #   (Actual student count should be lower, but we have no way to know what the final count will be until the first day of classes.)
    #
    #get-aduser -LdapFilter '(&(ObjectClass=inetOrgPerson)(eduPersonAffiliation=Student))' -SearchBase 'ou=people,dc=campus,dc=ad,dc=uvm,dc=edu' -SearchScope Subtree -ErrorAction Stop | % {$students.Add($_.userPrincipalName,$_.Enabled)}
    get-aduser -LdapFilter $ea1Filter -Properties extensionAttribute1 -SearchBase $searchBase -SearchScope Subtree -ErrorAction Stop | % {$adAccounts.Add($_.userPrincipalName,$_.extensionAttribute1)}
} catch {
    $myError = $_
    $myMsg = "Error encountered in reading accounts from Active Directory."
    errLogMail $myError $myMsg
    exit 200
}
$log += "Retrieved accounts from Active Directory."
$log += "Current account count: " + $adAccounts.count


#Retrieve unprovisioned accounts from Azure AD:
[array] $ulUsers = @()
try {
    #Note use of "Synchronized" to suppress processing of cloud-only accounts.
    $ulUsers += Get-MsolUser -UnlicensedUsersOnly -Synchronized -All -errorAction Stop
} catch {
    $myError = $_
    $myMsg = "Error encountered in reading accounts from Azure AD. "
    errLogMail $myError $myMsg
    exit 300
}
$log += "Retrieved unlicensed MSOL users."
$log += "Unlicensed user count: " + $ulUsers.Count + "`r`n"

#Provision any account in $ulUsers that also is in the $adAccounts array:
foreach ($u in $ulUsers) {
    #Lookup current unlicensed user in the AD account hashtable:
    $acct = $adAccounts.item($u.UserPrincipalName)
    if ($acct -ne $null) {
        #Uncomment to enable verbose logging of user to be processed.
        #$log += $u.UserPrincipalName + " is an active student."
        try {
            if ($u.UsageLocation -notmatch 'US') {
                #Set the usage location to the US... this is a prerequisite to assigning licenses.
                $u | Set-MsolUser -UsageLocation 'US' -ErrorAction Stop ;
                #Uncomment to enable verbose logging of usage location assignments.
                #$log += 'Successfully set them usage location for the user. '
            }
        } catch {
            $myError = $_
            $myMsg = "Error encountered in setting Office 365 usage location to user. "
            errLogMail $myError $myMsg
            exit 410
        }
        try {
            # Note: Order of if/elseif logic determines which SKU "wins" for people with multiple affiliations (both student and faculty/staff)
            # At present student affiliation "wins", which is preferable because we have 1 million student licenses and only 6 thousand fac/staff.
            # If we change licensing options in the future, we will want to revisit this logic.
            if ($acct -match 'Student') {
                #Uncomment to enable verbose logging of Student license assignments.
                #$log += 'Setting Student license options for user...'
                $u | Set-MsolUserLicense -AddLicenses $studAdv.AccountSkuId -LicenseOptions $stuLicOpts -ErrorAction Stop ;
            } elseif ($acct -match 'Faculty|Staff') {
                #Uncomment to enable verbose logging of Fac/Staff license assignments.
                #$log += 'Setting Fac/Staff license options for user...'
                #Assign the student advantage license to the user, with desired license options
                $u | Set-MsolUserLicense -AddLicenses $facStaffBene.AccountSkuId -LicenseOptions $facStaffLicOpts -ErrorAction Stop ;
            }
            #Uncomment to enable verbose logging of license assignments.
            #$log += 'Successfully set the Office license for user: ' + $u.UserPrincipalName
            $pCount += 1
        } catch {
            $myError = $_
            $myMsg = "Error encountered in assigning Office 365 license to user. "
            errLogMail $myError $myMsg
            exit 420
        }
    } else {
        $log += $u.UserPrincipalName + " does not have an active AD account.  Skipped Provisioning."
    }
    Remove-Variable acct
}

#Add reporting details to the log:
$eTime = Get-Date
$log += "`r`nProvisioning successfully completed at: " + ($eTime.ToString())
$log += "Provisioned $pCount accounts."
$tTime = new-timespan -Start $stime -End $etime
$log += 'Elapsed Time (hh:mm:ss): ' + $tTime.Hours + ':' + $tTime.Minutes + ':' + $tTime.Seconds

#Flush out the log and mail it:
$log | Out-File -FilePath $logFQPath -Append;
[string] $subj = 'Office 365 Provisioning Script:  SUCCESS'
[string] $body = $log | % {$_ + "`r`n"}
Send-MailMessage -To $to -From $from -Subject $subj -Body $body -SmtpServer $smtp