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 2d: Driver Handling (continued)

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

All of the code that we have explored so far has been server-side PowerShell. In this next snippet, we will be seeing client-side VBScript that will be executed in the WinPE environment, while the task sequence is running. In part 2b, I present a Task Sequence builder script. That script created a Task Sequence step with the following command embedded:
cscript.exe %DeployRoot%\Scripts\ZUVMDetectDriverPackage.wsf

This script uses Model data generated by the “ZTIGather” script (part of MDT), and also some CSV information files that we will explore in part 2e of this post. The data is analyzed to determine which SCCM Driver Packages should be used by this client, and this information is specified in the custom TS variable “UVMDriverPackage”.

I have provided some additional logic to workaround annoying device model names like “Latitude E5440 without vPro” (the with/without vPro models use the same drivers.), and “Venue 11 Pro 7138″ (which uses the same drivers as the 7130).

When we start deploying Windows 10 OS Images, the script will need some minor updates to handle the Win10 driver pools. I already have logic for this in my production MDT/LTI client-side scripts. I just need to port that code over here.

The original code is a “wsf” file which contains XML headers and footers in addition to VBScript code. Since my WordPress instance hates in-line XML, I have had to strip these tags. The original code is available as an attachment to this post. Owing again to WordPress tomfoolery, I have had to disguise this file as a “.png”. If you wish to use the file, download it, and rename the extension to “.wsf”:

ZUVMDetectDriverPackageZUVMDetectDriverPackage.wsf

Option Explicit
RunNewInstance

'//--------------------------------------------------------
'// Main Class
'//--------------------------------------------------------
Class zUVMDetectDriverPackage
    
    '//--------------------------------------------------------
    '//  Constructor to initialize needed global objects
    '//--------------------------------------------------------
    Private Sub Class_Initialize
    End Sub
    
    '//--------------------------------------------------------
    '// Main routine
    '//--------------------------------------------------------

    Function Main()
    ' //*******************************************************
    ' //
    ' // File: ZUVMDetectDriverPackage.wsf
    ' //
    ' // Purpose: Script to determine the name of the driver package to be 
    ' //   applied to the operating system in the running task sequence.  Reads
    ' //   driver category and OS Image information from files generated 
    ' //   in PowerShell.
    ' //
    ' // Usage: cscript zUVM-SetDriverCategories.wsf [/Model:ComputerModel] [/OSDImageName:WIMImageName] [/debug:true]
    ' //     Model and OSDImageName arguments are for debugging, and will override the variables in the current deployment environment.
    ' //
    ' //*******************************************************

        Dim catFile, catFilePath, csProp, dict_key, imageFile, imageFilePath, OSDImageName, Make, Model, Out, WinVer 'String variables
        Dim driverCats, OSImages, oMatch, oRegEx 'Objects
		Dim i, iRetVal

        Set driverCats = createObject("Scripting.Dictionary")
        Set OSImages = createObject("Scripting.Dictionary")
		
		'Set initial value for "UVMDriverCatSet".  We will set this to "YES" if we find a matching driver category later.
		oEnvironment.Item("UVMDriverPackageDetected") = "NO"

        catFile = "zUVM-DriverCategories.csv"
        imageFile = "zUVM-OSImages.csv"
		
		'Test to see if image/driver information files are present.  Exit if they cannot be found.
		'These two if clauses should be implemented as functions.
		iRetVal = oUtility.FindFile(catFile, catFilePath)
		if iRetVal  Success then
			oLogging.CreateEntry catFile & " file not found. ", LogTypeError
			iRetVal = Failure
			Main = iRetVal
			exit function
		end if 
		oLogging.CreateEntry "Path to " & catFile &": " & catFilePath, LogTypeInfo

		iRetVal = oUtility.FindFile(imageFile, imageFilePath)
		if iRetVal  Success then
			oLogging.CreateEntry imageFile & " file not found. ", LogTypeError
			iRetVal = Failure
			Main = iRetVal
			exit function
		end if 
		oLogging.CreateEntry "Path to " & imageFile &": " & imageFilePath, LogTypeInfo
		
        'Load the information from the files into scripting dictionary objects.
        oLogging.CreateEntry  "Loading the driver categories info file into memory...",LogTypeInfo
		call load_dict(driverCats,catFilePath)
        oLogging.CreateEntry  "Loading the OS Images info file into memory...",LogTypeInfo
        call load_dict(OSImages,imageFilePath)
		
		'The following two "If Wscript.arguments..." clauses could be implemented as functions... I'll do that when I have time (Ha!).
		'Use MDT to get Model information:
		'(Supply a valid image name in the /OSDImageName: argument, such as "Windows 8.1 Update 1 64-bit with Office 2013" for debugging)
		If Wscript.arguments.named.Exists("OSDImageName") Then
			Out = "OSDImageName argument provided on the command line. Setting OSDImageName to the supplied argument instead."
			oLogging.CreateEntry Out, LogTypeWarning
			OSDImageName =  Wscript.arguments.named.Item("OSDImageName")
		Else
			OSDImageName = oEnvironment.Item("OSDImageName")
		End If
		
		'Special handling for the OSDImageName variable... the script should not continue if OSDImageName still is not defined.
		'wscript.echo "OSDImageName is of length: " & Len(OSDImageName)
		oLogging.CreateEntry "Selected OSDImageName now is: " & OSDImageName, LogTypeInfo
		If Len(OSDImageName) = 0 Then
			oLogging.CreateEntry "OSDImageName is not defined in the MDT environment, and was not provided on the command line.  Exiting...", LogTypeError
			iRetVal = Failure
			Main = iRetVal
			exit function
		End If
		
		If Wscript.arguments.named.Exists("Model") Then
			oLogging.CreateEntry "Model argument provided on the command line.  Overriding oEnvironment setting.", LogTypeWarning
			Model =  Wscript.arguments.named.Item("Model")
		Else
			Model = oEnvironment.Item("Model")
		End If
		oLogging.CreateEntry "Model variable now is: " & Model, LogTypeInfo
        
		Set oRegEx = New RegExp
        oRegEx.Global = True
        oRegEx.IgnoreCase = True
	  
        'Modify the detected model name to handle known variations:
		oRegEx.pattern = "Latitude"
        if oRegEx.test(Model) then
            oLogging.CreateEntry "Model is a Latitude.  Cleaning up the model name...", LogTypeInfo
            oRegEx.pattern = " "
            set oMatch = oRegEx.Execute(Model)
            'wscript.echo "oMatch Count is: " & oMatch.count
            if oMatch.Count > 1 then
				i = oMatch.item(1).FirstIndex
				Model = Left(Model,i)
				oLogging.CreateEntry "Model is now: " & Model, LogTypeInfo
            end if
        end if
        oRegEx.pattern = "Venue 11 Pro 713"
        if oRegEx.test(Model) then
            oLogging.CreateEntry "Model is a Venue 11 Pro 713x.  Cleaning up the model name...", LogTypeInfo
            oRegEx.pattern = "713"
            set oMatch = oRegEx.Execute(Model)
            'wscript.echo "oMatch Count is: " & oMatch.count
            i = oMatch.item(0).FirstIndex
            'wscript.echo "index of match is: " & i
            Model = Left(Model,i+3)
            oLogging.CreateEntry "Model is now: " & Model, LogTypeInfo
        end if
		
		'Lookup the Windows version string for this image from the OSImages dictionary (loaded from file).
		'Exit if the image is not listed.
        If OSImages.Exists(OSDImageName) Then
            WinVer = OSImages.Item(OSDImageName)
        Else
			oLogging.CreateEntry "Selected image was not found in the CSV list of available images.", LogTypeError
			oLogging.CreateEntry "Searched for: " & OSDImageName, LogTypeError
			iRetVal = Failure
			Main = iRetVal
            exit function
        End If

        dict_key = WinVer & "-" & Model
		oLogging.CreateEntry "Looking for driver category entry: " & dict_key, LogTypeInfo
        If driverCats.Exists(dict_key) Then
            'Lookup the driver category ID string from the driverCats dictionary (loaded from file):
			Out = CStr("Found entry for " & dict_key)
            oLogging.CreateEntry Out, LogTypeInfo
            oEnvironment.Item("UVMDriverPackageDetected") = "YES"
            oEnvironment.Item("UVMDriverPackage") = dict_key
        'If we are dealing with a Windows 8 image, then check for Windows 7 drivers when Win8 drivers can't be found:
        ' (Note: In MDT/LTI I had logic to set a "MaxOS" version, and a For or While loop to count down to the lowest supported OS.
        '  We should do that here, too.)
        ElseIf InStr(WinVer,"Win8") Then
			Out = "No entry found for " & dict_key & " in the Windows 8 driver categories.  Now looking in the Windows 7 driver categories."
			oLogging.CreateEntry Out, LogTypeWarning
            WinVer = "Win7"
            dict_key = WinVer & "-" & Model
			If driverCats.Exists(dict_key) Then
				Out =  CStr("Found entry for " & dict_key)
                oEnvironment.Item("UVMDriverPackageDetected") = "YES"
                oEnvironment.Item("UVMDriverPackage") = dict_key
				oLogging.CreateEntry Out, LogTypeInfo
			Else 
				Out = "No entry found for this model. Leaving UVMDriverPackageDetected set to 'NO'."
				oLogging.CreateEntry Out, LogTypeInfo
			End If
        Else
			Out = "No entry found for this model. Leaving UVMDriverPackageDetected set to 'NO'."
			oLogging.CreateEntry Out, LogTypeInfo
        End If
		
		iRetVal = Success
		Main = iRetVal

    End Function

    Function load_dict(dict_name,file_name)
        Dim objFSO,objText,line,pair_array,index,item
        Const ForReading = 1, ForWriting = 2, ForAppending = 8, ReadOnly = 1

        Set objFSO = CreateObject("Scripting.FileSystemObject")  'Create file object
        oLogging.CreateEntry "Opening the script dictionary source file '" &  file_name & "'.",LogTypeInfo
        Set objText = objFSO.OpenTextFile(file_name, ForReading) 'Open for read

        Do Until objText.AtEndOfStream        'Read to end of file
            line = objText.ReadLine                  'Read line from file
            'Uncomment for debugging:
            'oLogging.CreateEntry "Loading '" & line &"'.",LogTypeInfo
            pair_array = split(line,",")             'Split Index-Item pair
            index = pair_array(0) 'Decode Index
            item  = pair_array(1) 'Decode Item
            dict_name.Add index,item  'Add to Dictionary
        Loop                                      'Read another line
        objText.Close
    End Function

End Class

In part 2E of this post, I will discuss the final bits of server-side code required to generate the CSV information files used by this client-side script…

Next: Drivers – Providing SCCM database info to clients
http://blog.uvm.edu/jgm/2015/03/09/sccm-udi-2e-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:

Migrating to the SCCM UDI for OSD, part 1: Introduction

This post if the first installation in a series on migrating to Configuration Manager UDI from MDT “LiteTouch”.  Don’t know what I am talking about?  Well then, this blog series is likely of little interest to you.  (Hint:  This is all about deploying Windows operating systems using Microsoft’s own deployment technologies.)

“SCCM UDI for OSD”… sounds really cool, right?  Such snappy product names that we Windows Sys Admins get to work with!  For those not already bored to death, we are talking about the System Center Configuration Manager 2012 R2 User-Driven Installation for Operating System Deployment.  UDI is an optional extension to SCCM that is included in with free Microsoft “Solution Accelerator” called “MDT 2013″ (The Microsoft Deployment Toolkit, 2013 edition).

Here at UVM, we have been using MDT in “LTI”, or “Lite Touch Installation” mode for many years (for those in the know, we used LTI back when MDT was called “BDD”, or the “Microsoft Solution Accelerator for Business Desktop Deployment”).  LTI has served up well for a long time.  We used MDT throughout the XP, Vista and Windows 7 lifecycle.  But since at least 2012 we have been wanting to migrate to the SCCM/UDI platform.  The initial driver for this migration was a desire to reduce the number of application installation packages that we need to maintain.  Currently we need to maintain packages in SCCM and in LTI.  By migrating to UDI, we can drop all of the LTI work.

In recent months, some additional pressures have come about which make this migration a bit more pressing:

  1. We would like to ensure that the SCCM management agent gets installed on all new computers at deployment time.  We have received complaints about the failure of LTI to configure the SCCM management agent.  While we feel that our current system is reliable, there still is a perception that SCCM agents are not getting installed on new computers.  The SCCM agent installation steps that are built into UDI task sequences should address this problem.
  2. OS Images in LTI often are out-of-date by 3-6 months.  In an effort to speed deployment times, we defer the application of OS updates at install time, and instead rely on the management agent to install updates in its own good time.  However, many support staff in the field do not like to release new computers without all updates already in place.  Using SCCM with UDI will help to address this problem in two ways:
    1. We can use SCCM to apply regular OS updates to our system images while they are offline.  This greatly reduces the number of updates that need to be applied to newly deployed computers.
    2. We then can force any remaining updates to run at deployment time without greatly increasing deployment time.

While all of this sounds very appealing, we also have a great deal of custom logic built into our current MDT/LTI environment.  Remapping our current workflows into UDI land is a difficult and time consuming task.

Additionally, while it is true that MDT/LTI and SCCM/UDI share a great deal of code, it is important to understand that they are very different things.  Many task sequence steps found in these tools look very similar and share nearly identical names.  However, these steps often are radicaly different in implementation.  Most notably, injection of drivers, installation of applications, and application of operating system images are handled in ways that utterly shattered our exiting task sequence logic.

The whole process of adapting MDT/LTI to SCCM/UDI was, at the very least, educational. I now know a lot more about programming SCCM than I ever wanted to know. I just wish that the techniques used here were useful elsewhere. I don’t work with any other Windows products that are so thoroughly rooted in WMI, so I have my doubts.

Lessons learned about programming in SCCM:

  1. Don’t even think about using the PowerShell cmdlets included with SCCM 2012 R2 (RTW-CU4). They are very buggy and feature incomplete.
  2. If you are an experienced C# programmer, you might consider using SCCM managed code to do your scripting work, but be forewarned that the aforementioned buggy cmdlets work off of these same managed code DLLs, so you might not have the best experience with them.
  3. For everyone else, you probably should stick to straight WMI calls using VBScript or PowerShell. I am trying to wean myself off of VBScript, so I chose to blaze new territory in programming SCCM using WMI programming with PowerShell. Call me crazy, but it was the only way I could get this stuff to work and stay sane (for a given value of sane).
  4. SCCM WMI objects that are not called using a fully-qualified object path do not have all object attributes exposed. Microsoft calls this “loose binding” in their documentation, but this is a misnomer. “Loose binding” should mean that the attributes are not exposed until they are used. In this case, it means that the attributes are totally empty and never will contain any data until you call a new version of the object using its $_.__PATH attribute.
  5. In order to update many/most SCCM objects, you need to call a generic WMI CLASS object for that object, and use the generic class to manipulate the actual WMI object. Confusing? Yes!

In the coming posts, I will document the scripts and procedures that I developed to remap our LTI logic into UDI logic.  It is going to be a bumpy road, so grab a fresh cup-o-joe in a spill-proof cup, put on your padded shorts, and fasten your seat belts.

Series Index:

We are hiring!

We need an Exchange admin!  If you know Exchange, and you would like to work with us here on “Team Awesome” at UVM, read on…

———————————————————

The University of Vermont is seeking a Senior Windows and Exchange Systems Administrator/Engineer to help support UVM’s central server infrastructure, focusing primarily on Microsoft technologies, including Exchange, Sharepoint, and Lync. UVM is deploying a new Exchange environment and migrating all students, faculty, and staff to this system.

In this position, you will get to design and build your ideal Exchange environment for 20,000+ people.

The successful applicant will help design, deploy, and support this exciting new service for the campus. We are looking for someone with the expertise and creativity to help improve IT at UVM; someone who can design and build reliable and secure systems to solve complex problems.

The University of Vermont is located in beautiful Burlington, Vermont, recognized as one of the best places to live in the US.

Expertise with both on-premise and Office 365 services is valuable. In addition, this position will help support Active Directory, and related services such as ADFS. Security management and monitoring are also key functions. The successful applicant will provide advanced technical expertise and expert level troubleshooting of Microsoft collaboration services. The applicant will need to configure, install, maintain, and monitor enterprise server equipment and software; initiate and manage projects in collaboration with other systems administrators; train other technical and operational staff; perform system tuning and troubleshooting; identify and implement security enhancements; perform capacity planning, business continuity and disaster recovery services; and provide systems documentation.

UVM’s central Systems Architecture & Administration department works on the latest in server and storage technology across multiple datacenters. Our systems support most aspects of server computing at UVM, including research, on-line learning, and administrative functions. Our highly technical and energetic team works collaboratively to improve IT at UVM, positively affecting thousands of students, faculty, and staff.

Scripting experience in a Windows environment is required (PowerShell at minimum). This position will help build interfaces for the new Exchange environment to other IT systems on campus, such as our website, Blackboard, Banner, and Luminis.

We offer competitive compensation for exceptional people. UVM has a strong benefits package, including tuition remission benefits.

For more information, and to apply, please visit: http://www.uvm.edu/it/sysadmin/exchange/

Replacing DirSync with Microsoft Azure Active Directory Sync Services

Back in October, MS announced that the new “MS Azure AD Sync Services” (AAD Sync) had gone GA, which means that DirSync is now officially deprecated.

Ironically, I got wind that “Azure AD Connect” would be replacing Azure AD Sync Services, not but a few weeks before:

http://blogs.technet.com/b/ad/archive/2014/08/04/connecting-ad-and-azure-ad-only-4-clicks-with-azure-ad-connect.aspx

Deprecated on release?  Not quite… it appears that Azure AD Sync Services will be at the core of Azure AD Connect, so time spent migrating to Azure AD Sync Services will not be a complete waste of time.

I decided to make this migration happen now, because we will be ramping up to provision faculty and staff with access to Office 365 Pro Plus software in the near future, and I would like to be working with a tool that does not require me to run it in an unsupported configuration (we removed the “Replicating Directory Changes” right from our DirSync service account, which at least at one time was considered a Bad Thing To Do.).

The Good News:

  • The out-of-box configuration does not require a service account with the “Replicating Directory Changes” right, making filtering of sensitive user attributes much easier.
  • The initial setup wizard allows you to de-select attributes that you don’t want to sync to Azure.  No more wading though dense miisclient.exe dialogs and walking off-support to do basic filtering!  The require attributes are listed in grey, so that you don’t risk filtering an attribute that Azure absolutely must have.
  • AAD Sync actually has some documentation available on its installation and use over at MSDN:
    http://msdn.microsoft.com/en-us/library/azure/dn790204.aspx
    …And there actually are quite a few important details in this seemingly cursory documentation.
  • AAD Sync entirely replaces the UI-heavy, un-maintainable attribute filtering tools in DirSync with a new “Declarative Provisioning” engine, that allow robust attribute filtering and transformation rules to be defined by the sys admin.
  • Custom “Inbound” attribute filters will be preserved during product upgrades, according to the documentation.
  • The documentation has been updated with instructions on how to make domain-based sync filters actually work in a multi-domain forest.  This was a key detail that was missing in the DirSync documentation.

The Bad News:

  • The Declarative Provisioning language, while based on VB for Applications (The “best known language by systems administrators”, according to the docs.  Whaaaat?!?!), is not actually VBA.  Debugging methods for dealing with this new VB-like thing are not at all documented, and examples of its usage are few and far between.

So what did I learn during this deployment process that was Blog-worthy?  How to debug Declarative Provisioning!

Debugging Declarative Provisioning (Import Expression Filters):

Let’s take an example.  We want to sync only students, and users who are to be provisioned for use of Lync in Office 365 into Azure.  To accomplish this, we have populated students with the text value “Student” in extensionAttribute1.  Lync users have had extensionAttribute2 populated with the text value “lync”.

When running the Synchronization Rule Editor, we create a new “inbound” rule, assign it to object type “inetOrgPerson”, link type is “join”, and give it a precedence under 100.  We skip the Scoping and Join Rules options, and go to “Transformations”.

The flowType gets set to “Expression”, the Target Attribute to “cloudFiltered”, and we define a VBA-like expression in the “Source” field:

EditSyncRule

My expression follows:

IIF(CBool(InStr([extensionAttribute1], "Student", 1)), NULL, IIF(CBool(InStr([extensionAttribute2], "lync", 1)), NULL, True)

So what is the intent here? “cloudFiltered” is a metaverse attribute that can be set to suppress synchronization of an account upon export to Azure AD.  If set to “True”, the account should not get synced to Azure.
(Note: The expression that we use was updated on 2014-12-22 as follows:

 
IIF(CBool(InStr([extensionAttribute1], "Student", 1)), NULL, IIF(CBool(InStr([extensionAttribute1], "Faculty", 1)), NULL, IIF(CBool(InStr([extensionAttribute1], "Staff", 1)), NULL, True)))

This change was necessary to allow provisioning of faculty and staff accounts with the new Office 365 ProPlus Faculty/Staff benefit.)

Our expression uses the “IIF” fuction to check to see if “extensionAttrubute1″ contains “Student”.  If so, then “IIF” returns “NULL”, and NULL is fed to the “cloudFiltered” attribute (which, according to the docs, will cause the attribute to be cleared if it is already set).  However, if extensionAttribute1 does not contain “Student”, we perform a second test to see if “extensionAttribute2″ contains “lync”.  If so, cloudFiltered again gets set to NULL.  If “extensionAttribute2″ does not contain “lync”, then “cloudFiltered” gets set to the boolean value “True”.

Syntax quirks:

  • Items enclosed in square braces [] refer to metaverse attributes, and serve as a sort of automatic variable for provisioning.
  • Note that the InStr() function used here is not the same one documented in the VB for Applications language reference:
    http://msdn.microsoft.com/en-us/library/office/gg264811(v=office.15).aspx
    The “start position” parameter in the docs is not supported here, although it does appear that the “compare” parameter is supported (represented by the “1” in my example, which means perform a case-insensitive comparison).
  • Everything is case sensitive… even the function names!  “IIF()” is all caps.  CBool(), CStr(), InStr(), and Error() are all “Caml Cased”.
  • IIF() is a bit of a strange beast by itself.  You might need this syntax reference:
    http://msdn.microsoft.com/en-us/library/office/gg264412(v=office.15).aspx

Now that we have a filter, how to we test it?  The docs say “Run a full import followed by a full sync” on the AD management agent.  While this will work, it also is a bit time consuming.  If there are syntax errors in your expression, you will get a lot of errors in your sync logs.  Is there something better?

As it turns out, yes there is.  Special thanks to Brian Desmond (co-author of the o’Riley press “Active Directory Cookbook”) for putting me on the right track:

  1. In the MIISClient.exe, right click your AD management agent, and select “Search Connector Space”:
    SearchConnectorSpace
  2. Specify the DN of a user that you want to test your expression on in the search box.  You can quickly get a DN using PowerShell with the ActiveDirectory module:
    Get-ADUser -Identity [samAccountName]
    Click “Search”, click the returned object, and click “Preview”:
    PreviewUser
  3. In the “Preview” dialog, select either a Full or Delta sync, then click “Generate Preview”, and select the “Import Attribute Flow” option on the left.  Review the list of Sync Rules on the bottom pane to make sure your rule was processed.  Any rule that generates a change will be displayed in the top pane:
    ImportFlow
    In this case, the expression did not generate a change in the existing metaverse object.
  4. If an expression that you have authored generates an error, a full stack trace will appear in the Error Details section of the Preview pane.  Scroll down to the bottom of the stack, where it is likely that the actual problem with your expression will be identified:
    PreviewError
    In this example, I inserted an “Error()” function with a null argument.  The language engine did not like this.
  5. Back in the “Start Preview” tab, you could “Commit Preview” to apply the filter rules for this specific object, and then view the results in the Metaverse Search component of miisclient.exe.

Using the preview tool, I was able to get my filter expressions working correctly with minimal fuss and delay.  The final hurdle to getting filtered attributes set correctly was understanding what the various “run profiles” in AAD Sync actually do.

  • AD Agent – Full Import:  Appears to sync data from AD to the AAD Sync Metaverse without applying Declarative Provisioning Filters.  I think.  Not 100% on this one.
  • AD Agent – Full Synchronization:  Appears to apply Declarative Provisioning filters on all metaverse objects.
  • Azure AD Agent – Full Sync: Appears to apply export filtering logic to metaverse objects entering the Azure connector space.
  • Azure AD Agent – Export:  Appears to sync metaverse objects to Azure without applying filtering logic.  My impression is that you must do a “sync” before an export, if you want logic applied to Azure AD objects as you intended.

My understanding of the run profiles may be flawed, but I will note that if I perform syncs in the following sequence, I get the expected results:

AD Agent:Full Import -> AD Agent:Full Sync -> Azure Agent:Full Sync -> Azure Agent:Export

I am not sure what the “authoritative” way to perform synchronizations might be, but this works for me. When trying other sequences, I have seen strange results such as metaverse objects not getting updated with the intended filter results, or objects getting declared as “disconnectors” by the Azure AD Agent, and thus getting deleted from Azure.  Ugh!

That’s all for today… hopefully this info will help keep someone else out of trouble, too.

SharePoint Tip-of-the-day: Speed up Servicing

Hats off to Russ Maxwell over at MSDN blogs for this hot tip on SharePoint servicing:

http://blogs.msdn.com/b/russmax/archive/2013/04/01/why-sharepoint-2013-cumulative-update-takes-5-hours-to-install.aspx

I am getting back (finally) to working on our SharePoint 2013 migration, and being reminded of how much I hate servicing the SharePoint stack.  How can installing a few hundred megabytes of SharePoint bits take so much time?!?!?

It turns out you can speed up installation of service packs and cumulative updates simply by stopping (or pausing) Search services, the SharePoint Timer service, and the IIS Admin service.  I tried it, and it works!  Installation of the SharePoint 2013 September 2014 CU took not more than a minute with these services halted.  (At least, it did on three out of four servers… the fourth failed miserably owing to MSI errors.

More on that…

I found the following old, but still relevant, article on troubleshooting Office software installation problems:

http://support2.microsoft.com/kb/954713/en-us

To summarize, you go to your %temp% directory and look for “Opatchinstall(#).log” and “######_MSPLOG.log.” (In my case, there was a file called something like “wss##_MSPLOG.log.  Old-school SharePoint guys will recognize “WSS” as “Windows SharePoint Services”).  Try to locate a line containing “MainEngineThread is returning”, and look up the error code that was returned here:

Mine was error code 1646, or “ERROR_PATCH_REMOVAL_UNSUPPORTED: The patch package is not a removable patch package. Available beginning with Windows Installer version 3.0.”  Apparently the language pack for SP2013 Standard that I was using refused to uninstall.  That’s the legacy of excessive servicing from early release versions, I guess.  Since I was still running on Server 2012 (R1), I decided to nuke and repave rather than troubleshoot.  Boo.

VMware View – Provisioning/Composing hangs, Event log failures, and more!

VMware Horizon View… great product. View Composer? Thorn in my side.

Two weeks back I completed the upgrade of our View infrastructure from 5.3.2 to 6.0.1. It was a smooth upgrade, seemingly, and I was pretty pleased with how little time it took to complete the job. Victory for our team? Not so much.

Over the next week, I had dozens of complaints from IT staff that recompose operations were failing, searches for events related to these failures were returning no results (or just not completing at all), and there were multiple odd “I am getting this weird error on my desktops!” complaints.  The desktop errors all turned out to be unrelated to the upgrade (the template was out of disk space, so the user profile could not load, the View Agent installation was broken, etc. etc.), but sorting out the event log and composer problems were harder…

View 6 Event Log database bug:

Following the upgrade, I was looking into increasing the View Event Log query limit per the request of a client, who was not able to view more than the past few hours of events for his pool owing to the default event query limit of 2000 events.  I noticed that these queries, in addition to being short on useful information, also were taking several minutes to complete.  After bumping the query limit to 6000 events, we found that the queries were taking over 30 minutes to complete, and hogging up all the CPU on the Virtual Center server (where the events database is hosted)!  I verified that memory and disk were not bottlenecked on the SQL database (I could not add more CPU because I already was at the SQL Standard Edition max of four cores), and set SQL tracing to look for deadlock events.  After running into a bunch of dead ends, I finally opened a support case with VMware.

Unsurprisingly, the first response was “well, lower your query limit.”  I explained that no, I was not going to do that.  I also pointed out that selecting 6000 records from a 2.4 Gb database really should not take 30 minutes, and that engineering just needed to buckle down and fix whatever index was causing the problem.  A few days later, I was given one line of T-SQL to run against the View Events database to add a missing index.  Query got executed, index created, and voila!  Event queries started running in seconds, not hours.  Here is the T-SQL:

CREATE INDEX IX_eventid ON dbo.VE_event_data (eventid)

Your table name might be slightly different, depending on the table prefix you selected when setting up the events database.

Composer Failures:

We have seen this before… someone recomposes a pool, the job half-finishes then stops, no error.  The task cannot be canceled, the pool cannot be deleted, and all other Composer operations in the infrastructure grind to a halt.  Why?  If you call VMware support, the first thing they will tell you is “cache corruption”.  The next is “stale desktops”.  Huh?

Deleting Stale Desktops:

http://kb.vmware.com/selfservice/search.do?cmd=displayKC&docType=kc&docTypeID=DT_KB_1_1&externalId=2015112

Clearing the Connection Server Cache:

No KB for this one that I am aware of.  Here is that they always tell me to do… ready?  You are going to like this…

  1. Shut down all of the connection servers in your farm.
  2. Turn the connection servers back on, one at a time.

Augh!

The worst part is, that neither of these solutions worked.  However, what I did find was that after powering the connection servers back on, some composer operations would succeed, but it was only a matter of time before one job failed an brought operations to a halt.  Finally I noticed that when rebooting one of the connection servers (the newest one, used for testing security settings), jammed jobs would immediately resume.  After digging into the logs in C:\ProgramData\VMware\VDM\logs\, I found that the Connection Server was reporting literally thousands of “could not connect to vCenter server at URL…” errors per day.  Why?  Because like a noob I did not give this connection server in interface to the vCenter server.  Bad on me.  However, these critical failures do not show up in the Windows event logs, nor do they get reported up to the View Administrator console.  I had a bad connection server in my environment that was killing Composer operations, and View Administrator thinks everything is peachy.  Boo!  I have complained to VMware support, for what it is worth.  I also fixed the connection server, and things are back to “normal”, whatever that means.

I also got my manager to approve using Splunk to collect all View log files, so that I at least will have an easier time of discovering errors when they arise in the future.