Quick and Dirty Image Factory with MDT and PowerShell

I haven’t written a blog in a while, been busy with the new job at Tanium, but I did write this script recently, and thought I would share, in case anyone else found it interesting. Share it forwards.

Problem

Been working on solutions to upgrade Windows 7 to Windows 10 using Tanium as the delivery platform (it’s pretty awesome if I do say so my self). But as with all solutions, I need to test the system with some end to end tests.

As with most of my OS Deployment work, the Code was easy, the testing is HARD!

So I needed to create some Windows 7 Images with the latest Updates. MDT to the rescue! I created A MDT Deployment Share (thanks Ashish ;^), then created a Media Share to contain each Task Sequence. With some fancy CustomSettings.ini work and some PowerShell glue logic, I can now re-create the latest Windows 7 SP1 patched VHD and/or WIM file at moment’s notice.

Solution

First of all, you need a MDT Deployment Share, with a standard Build and Capture Task Sequence. A Build and Capture Task Sequence is just the standard Client.xml task sequence but we’ll override it to capture the image at the end.

In my case, I decided NOT to use MDT to capture the image into a WIM file at the end of the Task Sequence. Instead, I just have MDT perform the Sysprep and shut down. Then I can use PowerShell on the Host to perform the conversion from VHDX to WIM.

And when I say Host, I mean that all of my reference Images are built using Hyper-V, that way I don’t have any excess OEM driver junk, and I can spin up the process at any time.

In order to fully automate the process, for each MDT “Media” entry. I add the following line into the BootStrap.ini file:

    SkipBDDWelcome=YES

and the following lines into my CustomSettings.ini file:

    SKIPWIZARD=YES            ; Skip Starting Wizards
    SKIPFINALSUMMARY=YES      ; Skip Closing Wizards 
    ComputerName=*            ; Auto-Generate a random Computer Name
    DoCapture=SYSPREP         ; Run SysPrep, but don't capture the WIM.
    FINISHACTION=SHUTDOWN     ; Just Shutdown
    AdminPassword=P@ssw0rd    ; Any Password
    TASKSEQUENCEID=ICS001     ; The ID for your TaskSequence (Upper Case)

Now it’s just a matter of building the LitetouchMedia.iso image, mounting to a Hyper-V Virtual Machine, and capturing the results.

Orchestrator

What I present here is the Powershell script used to orchestrate the creation of a VHDX file from a MDT Litetouch Media Build.

  • The script will prompt for the location of your MDT Deployment Share. Or you can pass in as a command line argument.
  • The script will open up the Deployment Share and enumerate through all Media Entries, Prompting you to select which one to use.
  • For each Media Entry selected, the script will
    • Force MDT to update the Media build (just to be sure)
    • Create a New Virtual Machine (and blow away the old one)
    • Create a New VHD file, and Mount into the Virtual Machine
    • Mount the LitetouchMedia.iso file into the Virtual Machine
    • Start the VM
  • The script will wait for MDT to auto generate the build.
  • Once Done, for each Media Entry Selected, the script will
    • Dismount the VHDx
    • Create a WIM file (Compression Type none)
    • Auto Generate a cleaned VHDx file

Code

The code shows how to use Powershell to:

  • Connect to an existing MDT Deployment Share
  • Extract out Media information, and rebuild Media
  • How to create a Virtual Machine and assign resources
  • How to monitor a Virtual Machine
  • How to capture and apply WIM images to VHDx virtual Disks


#Requires -RunAsAdministrator
<#
.Synopsis
Auto create a VM from your MDT Deployment Media
.DESCRIPTION
Given an MDT Litetouch Deployment Share, this script will enumerate
through all "Offline Media" shares, allow you to select one or more,
and then auto-update and auto-create the Virtual Machine.
Ideal to create base reference images (like Windows7).
.NOTES
IN Addition to the default settings for your CustomSettings.ini file,
you should also have the following defined for each MEdia Share:
SKIPWIZARD=YES ; Skip Starting Wizards
SKIPFINALSUMMARY=YES ; Skip Closing Wizards
ComputerName=* ; AUto-Generate a random computername
DoCapture=SYSPREP ; Run SysPrep, but don't capture the WIM.
FINISHACTION=SHUTDOWN ; Just Shutdown
AdminPassword=P@ssw0rd ; Any Password
TASKSEQUENCEID=ICS001 ; The ID for your TaskSequence (allCaps)
Also requires https://github.com/keithga/DeploySharedLibrary powershell library
#>
[cmdletbinding()]
param(
[Parameter(Mandatory=$true)]
[string] $DeploymentShare = 'G:\Projects\DeploymentShares\DeploymentShare.Win7SP1',
[int] $VMGeneration = 1,
[int64] $MemoryStartupBytes = 4GB,
[int64] $NewVHDSizeBytes = 120GB,
[version]$VMVersion = '5.0.0.0',
[int] $ProcessorCount = 4,
[string] $ImageName = 'Windows 7 SP1',
$VMSwitch,
[switch] $SkipMediaRebuild
)
Start-Transcript
#region Initialize
if ( -not ( get-command 'Convert-WIMtoVHD' ) ) { throw 'Missing https://github.com/keithga/DeploySharedLibrary&#39; }
# On most of my machines, at least one switch will be external to the internet.
if ( -not $VMSwitch ) { $VMSwitch = get-vmswitch -SwitchType External | ? Name -NotLike 'Hyd-CorpNet' | Select-object -first 1 -ExpandProperty Name }
if ( -not $VMSwitch ) { throw "missing Virtual Switch" }
write-verbose $VHDPath
write-verbose $VMSwitch
#endregion
#region Open MDT Deployment Share
$MDTInstall = get-itemproperty 'HKLM:\SOFTWARE\Microsoft\Deployment 4' | % Install_dir
if ( -not ( test-path "$MDTInstall\Bin\microsoftDeploymentToolkit.psd1" ) ) { throw "Missing MDT" }
import-module -force "C:\Program Files\Microsoft Deployment Toolkit\Bin\microsoftDeploymentToolkit.psd1" -ErrorAction SilentlyContinue -Verbose:$false
new-PSDrive -Name "DS001" -PSProvider "MDTProvider" -Root $DeploymentShare -Description "MDT Deployment Share" -Verbose -Scope script | out-string | write-verbose
$OfflineMedias = dir DS001:\Media | select-object -Property * | Out-GridView -OutputMode Multiple
$OfflineMedias | out-string | Write-Verbose
#endregion
#region Create a VM for each Offline Media Entry and Start
foreach ( $Media in $OfflineMedias ) {
$Media | out-string | write-verbose
$VMName = split-path $Media.Root -Leaf
get-vm $VMName -ErrorAction SilentlyContinue | stop-vm -TurnOff -Force -ErrorAction SilentlyContinue
get-vm $VMName -ErrorAction SilentlyContinue | Remove-VM -Force
$VHDPath = join-path ((get-vmhost).VirtualHardDiskPath) "$($VMName).vhdx"
remove-item $VHDPath -ErrorAction SilentlyContinue -Force | out-null
$ISOPath = "$($media.root)\$($Media.ISOName)"
if (-not $SkipMediaRebuild) {
write-verbose "Update Media $ISOPath"
Update-MDTMedia $Media.PSPath.Substring($Media.PSProvider.ToString().length+2)
}
$NewVMHash = @{
Name = $VMName
MemoryStartupBytes = $MemoryStartupBytes
SwitchName = $VMSwitch
Generation = $VMGeneration
Version = $VMVersion
NewVHDSizeBytes = $NewVHDSizeBytes
NewVHDPath = $VHDPath
}
New-VM @NewVMHash -Force
Add-VMDvdDrive -VMName $VMName -Path $ISOpath
set-vm -Name $VMName -ProcessorCount $ProcessorCount
start-vm -Name $VMName
}
#endregion
#region Wait for process to finish, and extract VHDX
foreach ( $Media in $OfflineMedias ) {
$VMName = split-path $Media.Root -Leaf
[datetime]::Now | write-verbose
get-vm -vm $VMName <# -ComputerName $CaptureMachine #> | out-string | write-verbose
while ( $x = get-vm -vm $VMName | where state -ne off ) { write-progress "$($x.Name) – Uptime: $($X.Uptime)" ; start-sleep 1 }
$x | out-string | write-verbose
[datetime]::Now | write-verbose
start-sleep -Seconds 10
$VHDPath = join-path ((get-vmhost).VirtualHardDiskPath) "$($VMName).vhdx"
dismount-vhd -path $VHDPath -ErrorAction SilentlyContinue
$WIMPath = join-path ((get-vmhost).VirtualHardDiskPath) "$($VMName).WIM"
write-verbose "Convert-VHDToWIM -ImagePath '$WIMPath' -VHDFile '$VHDPath' -Name '$ImageName' -CompressionType None -Turbo -Force"
Convert-VHDtoWIM -ImagePath $WIMPath -VHDFile $VHDPath -Name $ImageName -CompressionType None -Turbo -Force
write-verbose "Convert-WIMtoVHD -ImagePath $WIMPath -VHDFile '$($VHDPath).Compressed.vhdx' -Name $ImageName -Generation $VMGeneration -SizeBytes $NewVHDSizeBytes -Turbo -Force"
Convert-WIMtoVHD -ImagePath $WIMPath -VHDFile "$($VHDPath).Compressed.vhdx" -Name $ImageName -Generation $VMGeneration -SizeBytes $NewVHDSizeBytes -Turbo -Force
}
#endregion

Notes

I’ve been struggling with how to create a MDT VHDx file with the smallest possible size. I tried tools like Optimize-Drive and sDelete.exe to clear out as much space as possible, but I’ve been disappointed with the results. So here I’m using a technique to Capture the VHDx file as a Volume to a WIM file (uncompressed for speed), and the apply the Capture back to a new VHDx file. That should ensure that no deleted files are transferred. Overall results are good:

Before:   19.5 GB VHDx file --> 7.4 GB compressed zip
After:    13.5 GB VHDx file --> 5.6 GB compressed zip

Links

Gist: https://gist.github.com/keithga/21007d2aeb310a57f58392dfa0bdfcc2

https://wordpress.com/read/feeds/26139167/posts/2120718261

https://community.tanium.com/s/article/How-to-execute-a-Windows-10-upgrade-with-Tanium-Deploy-Setup

https://community.tanium.com/s/article/How-to-execute-a-Windows-10-upgrade-with-Tanium-Deploy-The-Sensors

https://community.tanium.com/s/article/How-to-execute-a-Windows-10-upgrade-with-Tanium-Deploy-Setup

 

How could Windows 10 1803 be delayed

TL;DR: Some background on Windows Releases, and some speculation on why the latest build of Windows 10 Version 1803 has been delayed

BVT

My first job at Microsoft was working as a tester in the Windows NT build lab. First build 807. The job was to test Windows NT to ensure that it passed a series of automated regression tests, and met the basic functionality requirements to be sent out for broader testing within the Windows NT Product Groups. Called Build Verification Testing.

Testing things like Word, Excel, Notepad, Network Connectivity, printing, etc… Believe it or not, this was *before* Internet Explorer. so no Web browsing.

The idea is that if you could *not* perform some basic operations, then you wouldn’t want the build to get out to the larger test org, so they don’t have to waste their time on a build version that can’t even run notepad.

Windows 10

Which brings up back to Windows 10. There are a lot of testing phases involved with Windows 10. Each phase involves more people, broader testing, each phase, hopefully testing more functionality in the OS:

  • Build lab testing (BVT)
  • Microsoft internal testing
  • Fast Ring ( external to Microsoft )
  • Slow Ring
  • Semi-Annual Channel – Targeted ( Official release ) – RTM
  • Semi-Annual Channel ( Broad Deployment )

I still call the full releases “RTM” Release to Manufacturing, although for most builds, they get published to Windows Update and/or the volume licensing sites. There are still some builds that actually get put on USB Sticks, so I guess there is a factory… somewhere.

Cumulative Updates

Additionally Patches ( Cumulative updates ) also follow a Fast/Slow/Release schedule, so if you wanted to you could be deploying pre-release Cumulative updates to your test machines to get ahead of potential problems.

What’s interesting is that as of this post, the Cumulative Updates for Windows 10 version 1803 are already up to 17133.73!  Seventy Three builds since the start.

For most minor bugs that are identified after an OS is released, Microsoft has a well defined update process defined that can update and fix most issues. If you find a minor bug in notepad, then don’t release the FULL OS again, just send out the CU notepad fix via Windows Update!

Showstoppers

Not all builds make it to the next level. Sometimes the builds are just tests, and there is no need for them to continue on, they don’t have the final set of features, or have too many bugs.

But what could cause a build that nears full release to get reset like 1803? I don’t know the exact details of what is causing 17133 to have problems, but I know it’s not a minor problem. Again, minor problems can and *should* be fixed via Cumulative Updates.

Instead I speculate the problem can’t be fixed by Cumulative Update, or some other problem that prevents some machines from even installing CU’s.

That would be bad. This is my thoughts of why 17133 is delayed.

Take away

Sometimes we as IT professionals get so wrapped up in just one kind of testing that we forget to test all the environments. Perhaps we are only testing the bare-metal OS Wipe and Load process, or just testing In-Place upgrade. Really we need to test both.

(I Have a client that just had this problem, they were only testing Bare Metal Wipe and Load scenarios, and were surprised that a couple of Dell’s didn’t survive a In-Place upgrade to 1709, even though we strongly recommended In-Place upgrade testing across a wide selection of hardware types).

Additionally, add at least one Cumulative Update to your testing procedures, if you can’t service a machine after installing an OS, then you are going to have problems sometime in the future :).

-k

Out-Default Considered Harmful!

TL;DR: Don’t use Out-Default within a PowerShell cmdlet/function, unless you REALLY need to go to the console, otherwise use Write-Output.

Working with a client trying to narrow down a very quirky, but potentially damaging issue with Windows Update.

After spending several hours on the issue, we realized that we really didn’t have enough data, and it was suggested we programmatically search the WindowsUpdate.log, on a subset of machines, to search the presence of a specific string. If we find the string, then the machine is marked for further investigation.

New Log file format

For whatever reason, back in 2015, Microsoft decided to change the WindowsUpdate.log file format to a new format using the Event Tracing for Windows system.

See the blog here (the comments at the end of the blog are not kind).

The new system uses the Event Tracing for Windows system, and requires a convoluted Set of steps necessary to decode the data and write to a log file. Took me about an hour just to determine what the steps were to construct the command line arguments to extract a single *.etl file. In addition you must also connect to the Microsoft Symbol Servers to decode the data.

Thankfully Microsoft has included a PowerShell module and cmdlet to perform the operations… or so I thought…

WindowsUpdate PowerShell Module

Included in Windows 10 is a PowerShell module called WindowsUpdate. It’s not really that complex, the script is included, and you can see what it does:

C:\Windows\system32\WindowsPowerShell\v1.0\Modules\WindowsUpdate\WindowsUpdateLog.psm1

The cmdlet get-WindowsUpdateLog really just parses the c:\windows\Logs\WindowsUpdate\*.etl files and places all the information in a single log file, on the desktop by default. 

Honestly, I didn’t like the way the module connected to the Microsoft Symbol Servers, so I spent a while trying to figure out how to work around that, unfortunately the TraceRPT.exe tool couldn’t parse the file without the Symbols, and it frustrated me for other reasons. So I decided to use the PowerShell module as-is.

We wrote a PowerShell script and tried it out, but I noticed that the get-WindowsUpdateLog cmdlet was writing a lot data to the Console. I tried piping the output to null:

get-WindowsUpdateLog | out-null

But it didn’t work. A quick scan of the script source revealed that the author elected to write all output to Out-Default. Not Write-Host, not Write-Output, not Write-Verbose. To Out-Default

Why is that a problem?

Out-Default

Turns out that Out-Default is just a default handler for host output, not pipeline output. In the case of get-WindowsUpdate, it was just acting as a default wrapper around write-host. The background of why you would *NOT* want output from a cmdlet or script to go to the console, please Jeff Snover’s blog post on the matter: https://www.jsnover.com/blog/2013/12/07/write-host-considered-harmful/

That’s fine if we KNOW that we want the output to go to the console, but what if we want the output from a cmdlet to go to the pipeline? Well in that case get-WindowsUpdate is forcing output to the console no matter what. 

During a code review, I would have recommended using Write-Output instead, that would have redirected all output to the pipeline, allowing the out-null hack above to work.

SCCM Configuration Items and console output

The challenge is that if we elected to place this compliance script into a System Center Configuration Manager – Configuration Item script, it could lead to some undefined results.

For what ever reason, the SCCM team decided to key a PowerShell script’s success based on the console output. If it passes, the script would have called:

Write-Host "Compliant"

and have the Configuration Item search for the output “Compliant”. This is a case where we *KNOW* we want the script to write to the console. But we can’t have anything else in the script write to the console. Nothing! Otherwise it would be marked as a failure.

Personally, I would have also designed Configuration Item’s to measure pass/fail based on the process exit code directly.

The Hack

OK, Super! We have a PowerShell script that insists on writing output to the console, and a controller that get’s confused by non-deterministic console output. Sigh…

Time to write a hack. I developed a solution, and afterwards came across the same answer posted to StackExchange/SuperUser.com, so I’ll include that here.

https://superuser.com/questions/1058117/powershell-v5-suppress-out-default-output-in-nested-functions

Essentially the goal is to remove or replace the out-default cmdlet with our own function, PowerShell allows this action, I don’t usually recommend doing that, but it works in this case.

The Code


<#
.SYNOPSIS
Search WindowsUpdate Logs
.DESCRIPTION
Searches the Windows Update Log for a string
.NOTES
Ready to be used within a
Copyright Keith Garner, All rights reserved.
.LINK
https://keithga.wordpress.com/2018/04/03/out-default-considered-harmful
#>
[cmdletbinding()]
param(
[parameter(Mandatory=$true)]
$SearchString,
$CleanWU,
$ETLPath = "$env:WinDir\Logs\WindowsUpdate"
)
$WULog = New-TemporaryFile
# Hack Hack to work arround Windows Update SCCM Config Item interop issue.
if ( -not ( test-path alias:out-default ) ) { new-alias Out-Default Write-Verbose -Scope global }
Get-WindowsUpdateLog -LogPath $WULog -ETLPath $ETLPath
remove-item alias:Out-Default -force -EA SilentlyContinue
$WUResults = Select-String -path $WULog -Pattern $SearchString -AllMatches
if ( $WUResults ) {
write-host "Not Compliant $($WUResults.Count) $env:computerName"
$wuresults | out-string -Width 200 | write-verbose
}
else {
write-host "Compliant"
}
if ( $CleanWU ) {
write-verbose "Cleanup"
remove-item -Recurse -Force -Path $env:temp\WindowsUpdateLog
}

-k

 

 

A replacement for SCCM Add-CMDeviceCollectionDirectMembershipRule PowerShell cmdlet

TL;DR – The native Add-CMDeviceCollectionDirectMembershipRule PowerShell cmdlet sucks for adding more than 100 devices, use this replacement script instead.

How fast is good enough? When is the default, too slow?

I guess most of us have been spoiled with modern machines: Quad Xeon Procesors, couple hundred GB of ram, NVME cache drives, and Petabytes of storage at our command.

And don’t get me started with modern database indexing, you want to know what the average annual rainfall on the Spanish Plains are? If I don’t get 2 million responses within a half a second, I’ll be surprised, My Fair Lady.

But sometimes as a developer we need to account for actual performance, we can’t just use the default process and expect it to work in all scenarios to scale.

Background

Been working on a ConfigMgr project in an environment with a machine count well over ~300,000 devices. And we were prototyping a project that involved creating Device Collections and adding computers to the Collections using Direct Membership Rules.

Our design phase was complete, when one of our engineers mentioned that Direct Memberships are generally not optimal at scale. We figured that during the lifecycle of our project we might need to add 5000 arbitrary devices to a collection. What would happen then?

My colleague pointed to this article: http://rzander.azurewebsites.net/collection-scenarios Which discussed some of the pitfalls of Direct Memberships, but didn’t go into the details of why, or discuss what the optimal solution would be for our scenario.

I went to our NWSCUG meeting last week, and there was a knowledgeable Microsoft fella there so I asked him during Lunch. He mentioned that there were no on-going performance problems with Direct Membership collections, however there might be some performance issues when creating/adding to the collection, especially within the Console (Load up the large collection in memory, then add a single device, whew!). He recommended, of course, running our own performance analysis, to find out what worked for us.

OK, so the hard way…

The Test environment

So off to my Standard home SCCM test environment: I’m using the ever efficient Microsoft 365 Powered Device Lab Kit. It’s a bit big, 50GB, but once downloaded, I’ll have a fully functional SCCM Lab environment with a Domain Controller, MDT server, and a SCCM Server, all running within a Virtual Environment, within Seconds!

My test box is an old Intel Motherboard circa 2011, with a i7-3930k processor, 32GB of ram, and running all Virtual Machines running off a Intel 750 Series NVME SSD Drive!

First step was to create 5000 Fake computers. That was fairly easy with a CSV file and the SCCM PowerShell cmdlet Import-CMComputerInformation.  Done!

Using the native ConfigMgr PowerShell cmdlets

OK, lets write a script to create a new Direct Membership rule in ConfigMgr, and write some Device Objects to the Collection.


<#
Example of how to create a Device Collection and populate it with computer objects
The Slow way. <Yuck>
#>
[cmdletbinding()]
param(
$CollBaseName = 'MyTestCol_03_{0:D4}',
$name = 'PCTest*'
)
foreach ( $Count in 5,50 ) {
$CollName = $CollBaseName -f $Count
write-verbose "Create a collection called '$CollName'"
New-CMDeviceCollection -LimitingCollectionName 'All Systems' -Name $CollName | % name | write-Host
Measure-Command {
Write-Verbose "Find all Devices that match [$Name], grab only the first $Count, and add to Collection [$CollName]"
get-cmdevice -name $name -Fast |
Select-Object -first $count |
Foreach-Object {
Add-CMDeviceCollectionDirectMembershipRule -CollectionName $CollName -ResourceId $_.ResourceID -verbose:$False
}
} | % TotalSeconds | write-Host
}

Unfortunately the native Add-CMDeviceCollectionDirectMembershipRule cmdlet, doesn’t support adding devices using a pipe, and won’t let us add more than one Device at a time. Gee… I wonder if *that* will affect performance. Query the Collection, add a single device, and write back to the server, for each device added. Hum….

Well the performance numbers weren’t good:

Items to add Number of Seconds to add all items
5 4.9
50 53

As you can see the number of seconds increased proportionally to the number of items added. If I wanted to add 5000 items, were talking about 5000 seconds, or an hour and a half. Um… no.

In fact a bit of decompiling of the native function in CM suggests that it’s not really designed for scale, best for adding only one device at a time.

Yuck!

The WMI way

I decided to see if we could write a functional replacement to the Add-CMDeviceCollectionDirectMembershipRule cmdlet that made WMI calls instead.

I copied some code from Kadio on http://cm12sdk.net (sorry the site is down at the moment), and tried playing around with the function.

Turns out that the SMS_Collection WMI collection has  AddMembershipRule() <Singular> and a AddMembershipRules() <multiple> function. Hey, Adding more than once one device at a time sounds… better!

<Insert several hours of coding pain here>

And finally got something that I think works pretty well:


<#
Example of how to create a Device Collection and populate it with computer objects
The Faster way. <Yea!>
#>
[cmdletbinding()]
param(
$CollBaseName = 'MyTestCol_0C_{0:D4}',
$name = 'PCTest*'
)
#region Replacement function
Function Add-ResourceToCollection {
[CmdLetBinding()]
Param(
[string] $SiteCode = 'CHQ',
[string] $SiteServer = $env:computerName,
[string] $CollectionName,
[parameter(Mandatory=$true, ValueFromPipeline=$true)]
$System
)
begin {
$WmiArgs = @{ NameSpace = "Root\SMS\Site_$SiteCode"; ComputerName = $SiteServer }
$CollectionQuery = Get-WmiObject @WMIArgs -Class SMS_Collection -Filter "Name = '$CollectionName' and CollectionType='2'"
$InParams = $CollectionQuery.PSBase.GetMethodParameters('AddMembershipRules')
$Cls = [WMIClass]"Root\SMS\Site_$($SiteCode):SMS_CollectionRuleDirect"
$Rules = @()
}
process {
foreach ( $sys in $System ) {
$NewRule = $cls.CreateInstance()
$NewRule.ResourceClassName = "SMS_R_System"
$NewRule.ResourceID = $sys.ResourceID
$NewRule.Rulename = $sys.Name
$Rules += $NewRule.psobject.BaseObject
}
}
end {
$InParams.CollectionRules += $Rules.psobject.BaseOBject
$CollectionQuery.PSBase.InvokeMethod('AddMembershipRules',$InParams,$null) | Out-null
$CollectionQuery.RequestRefresh() | out-null
}
}
#endregion
foreach ( $Count in 5,50,500,5000 ) {
$CollName = $CollBaseName -f $Count
write-verbose "Create a collection called '$CollName'"
New-CMDeviceCollection -LimitingCollectionName 'All Systems' -Name $CollName | % name | write-Host
Measure-Command {
Write-Verbose "Find all Devices that match [$Name], grab only the first $Count, and add to Collection [$CollName]"
get-cmdevice -name $name -Fast |
Select-Object -first $count |
Add-ResourceToCollection -CollectionName $CollName
} | % TotalSeconds | write-Host
}

Performance numbers look much better:

Items to add Number of Seconds to add all items
5 1.1
50 1.62
500 8.06
5000 61.65

Takes about the same amount of time to add 5000 devices using my function as it takes to add 50 devices using the native CM function. Additionally some code testing suggests that about half of the time for each group is being performed creating each rule ( the process {} block ), and the remaining half in the call to AddMembershipRules(), my guess is that should be better for our production CM environment.

Note that this isn’t just a PowerShell Function, it’s operating like a PowerShell Cmdlet. The function will accept objects from the pipeline and process them as they arrive, as quickly as Get-CMDevice can feed them through the pipeline.

However more testing continues.

-k

 

 

 

New Tool – Disk Hogs

Edit: Heavily modified script for speed. Bulk of script is now running Compiled C# Code.

Been resolving some problems at work lately with respect to full disks. One of our charters is to manage the ConfigMgr cache sizes on each machine to ensure that the packages we need to get replicated, actually get replicated out to the right machines at the right time.

But we’ve been getting some feedback about one 3rd party SCCM caching tool failing in some scenarios. Was it really the 3rd party tool failing, or some other factor?

Well we looked at the problem and found:

  • Machines with a modest 120GB SSD Drive (most machines have a more robust 250GB SSD)
  • Configuration Manager Application Install packages that are around 10-5GB (yowza!)
  • Users who leave too much… crap laying around their desktop.
  • And several other factors that have contributed to disks getting full.

Golly, when I try to install an application package that requires 12GB to install, and there is only 10GB free, it fails.

Um… yea…

I wanted to get some data for machines that are full: What is using up the disk space? But it’s a little painful searching around a disk for directories that are larger than they should be.

Options

One of my favorite tools is “WinDirStat” which produces a great graphical representation of a disk, allowing you to visualize what directories are taking up the most space, and which files are the largest.  http://windirstat.net

Additionally I also like the “du.exe” tool from SysInternals.  https://live.sysinternals.com/du.exe

I wrap it up in a custom batch script file

@%~dps0du.exe -l 1 -q -accepteula %*

and it produces output that looks like:

PS C:\Users> dudir
    263,122 C:\Users\Administrator
      1,541 C:\Users\Default
  7,473,508 C:\Users\keith
      4,173 C:\Users\Public
  7,742,345 C:\Users
Files: 27330
Directories: 5703
Size: 7,928,161,747 bytes
Size on disk: 7,913,269,465 bytes

Cool, however, I wanted something that I could run remotely, and that would give me just the most interesting directories, say everything over 1GB, or something configurable like that.

So a tool was born.

Tool

The script will enumerate through all files on a local machine and return the totals. Along the way we can add in rules to “Group” interesting directories and output the results.

So, say we want to know if there are any folders under “c:\program files (x86)\Adobe\*” that are larger than 1GB. For the most part, we don’t care about Adobe Reader, since it’s under 1GB, but everything else would be interesting. Stuff like that.

We have a default set of rules built into the script, but you can pass a new set of rules into the script using a *.csv file ( I use excel )

Folder SizeMB
c:\* 500
C:\$Recycle.Bin 100
c:\Program Files 0
C:\Program Files\* 1000
C:\Program Files (x86) 0
C:\Program Files (x86)\Adobe\* 1000
C:\Program Files (x86)\* 1000
C:\ProgramData\* 1000
C:\ProgramData 0
C:\Windows 0
C:\Windows\* 1000
c:\users 0
C:\Users\* 100
C:\Users\*\* 500
C:\Users\*\AppData\Local\Microsoft\* 1000
C:\Users\*\AppData\Local\* 400

Example output:

The machine isn’t too interesting (it’s my home machine not work machine)

I’m still looking into tweaks and other things to modify in the rules to make the output more interesting.

  • Should I exclude \windows\System32 directories under X size?
  • etc…

If you have feedback, let me know

Script


<#
.SYNOPSIS
Report on Disk Hogs
.DESCRIPTION
Returns a list of the largest directories in use on the local machine
.NOTES
Copyright Keith Garner, All rights reserved.
Really Updated for Windows 7 and Optimized for !!!SPEED!!!
.PARAMETER Path
Start of the search, usually c:\
.PARAMETER IncludeManifest
Include basic info about the memory, OS, and Disk in the manifest
.PARAMETER OutFile
CLIXML file used to store results
Location of a custom rules *.csv file, otherwise use the default table
.LINK
https://keithga.wordpress.com
#>
[cmdletbinding()]
param(
$path = 'c:\',
[switch] $IncludeManifest,
$OutFile
)
###########################################################
$WatchList = @(
@{ Folder = 'c:\'; SizeMB = '0' }
@{ Folder = 'c:\*'; SizeMB = '500' }
@{ Folder = 'C:\$Recycle.Bin'; SizeMB = '100' }
@{ Folder = 'c:\Program Files'; SizeMB = '0' }
@{ Folder = 'C:\Program Files\*'; SizeMB = '1000' }
@{ Folder = 'C:\Program Files (x86)'; SizeMB = '0' }
@{ Folder = 'C:\Program Files (x86)\Adobe\*'; SizeMB = '1000' }
@{ Folder = 'C:\Program Files (x86)\*'; SizeMB = '1000' }
@{ Folder = 'C:\ProgramData\*'; SizeMB = '1000' }
@{ Folder = 'C:\ProgramData'; SizeMB = '0' }
@{ Folder = 'C:\Windows'; SizeMB = '0' }
@{ Folder = 'C:\Windows\*'; SizeMB = '1000' }
@{ Folder = 'c:\users'; SizeMB = '0' }
@{ Folder = 'C:\Users\*'; SizeMB = '100' }
@{ Folder = 'C:\Users\*\*'; SizeMB = '500' }
@{ Folder = 'C:\Users\*\AppData\Local\Microsoft\*'; SizeMB = '1000' }
@{ Folder = 'C:\Users\*\AppData\Local\*'; SizeMB = '400' }
)
###########################################################
Add-Type -TypeDefinition @"
public class EnumFolder
{
public static System.Collections.Generic.Dictionary<string, long> ListDir(string Path, System.Collections.Generic.Dictionary<string, long> ControlList)
{
System.Collections.Generic.Dictionary<string, long> Results = new System.Collections.Generic.Dictionary<string, long>();
System.IO.DirectoryInfo Root = new System.IO.DirectoryInfo(Path);
ListDirRecursive(Root, Results, ControlList);
return Results;
}
private static long ListDirRecursive
(
System.IO.DirectoryInfo Path,
System.Collections.Generic.Dictionary<string, long> Results,
System.Collections.Generic.Dictionary<string, long> ControlList
)
{
try
{
long Total = 0;
foreach (System.IO.DirectoryInfo Directory in Path.GetDirectories())
if ((Directory.Attributes & System.IO.FileAttributes.ReparsePoint) == 0)
Total += ListDirRecursive(Directory, Results, ControlList);
foreach (System.IO.FileInfo file in Path.GetFiles())
{
if ((file.Attributes & System.IO.FileAttributes.ReparsePoint) == 0)
{
if (ControlList.ContainsKey(file.FullName))
{
if ((ControlList[file.FullName] * 1024 * 1024) < file.Length)
{
Results.Add(file.FullName, file.Length);
}
else
{
Total += file.Length;
}
}
else
{
Total += file.Length;
}
}
}
if (ControlList.ContainsKey(Path.FullName))
{
if ((ControlList[Path.FullName] * 1024 * 1024) < Total)
{
Results.Add(Path.FullName, Total);
Total = 0;
}
}
return Total;
}
catch
{
return 0;
}
}
}
"@
###########################################################
$start = [datetime]::Now
$ControlList = new-object -TypeName 'System.Collections.Generic.Dictionary[String,int64]'
foreach ( $Item in $WatchList ) {
if ( $item.Folder.EndsWith('*') ) {
get-childitem $Item.Folder.TrimEnd('*') -force -ErrorAction SilentlyContinue |
ForEach-Object {
$_.FullName.Substring(0,1).ToLower() + $_.FullName.Substring(1)
} |
Where-Object { -not $ControlList.ContainsKey( $_ ) } |
foreach-object { $ControlList.Add($_,0 + $Item.SizeMB) }
}
else {
get-item $Item.Folder -force -ErrorAction SilentlyContinue |
ForEach-Object {
$_.FullName.Substring(0,1).ToLower() + $_.FullName.Substring(1)
} |
Where-Object { -not $ControlList.ContainsKey( $_ ) } |
foreach-object { $ControlList.Add($_,0 + $Item.SizeMB) }
}
}
$ControlList.Keys | write-verbose
###################
$Results = [EnumFolder]::ListDir($Path.ToLower(), $ControlList )
$Results | write-output
([datetime]::now – $Start).TotalSeconds | Write-verbose
###################
if ( $OutFile ) {
new-item -ItemType Directory -Path ( split-path $OutFile ) -ErrorAction SilentlyContinue | Out-Null
if ( $IncludeManifest ) {
@{
OS = GWMI Win32_OPeratingSystem | Select OSarchitecture,OSLanguage,InstallDate,Version
Mem = GWMI Win32_PhysicalMemory | Select Capacity
Vol = GWMI Win32_LogicalDisk -Filter "DeviceID='$($path.Substring(0,1))`:'" | Select Size,FreeSpace,VolumeName
Data = $Results
} | Export-Clixml -Path $OutFile
}
else {
$Results | Export-Clixml -Path $OutFile
}
}

Bypass OEM Setup and install your own image.

AutoPilot

Really Windows Autopilot is the future. As soon as the OEM’s get their act together, and offer machines without the bloatware and adware. Yea, I’m talking about you Anti-Virus Trial! Go away, shoo! Shoo! Give me Signature Images, or I’ll do it myself.

Unfortunately, I’m currently working for a client that is “Cloud Adverse”, and very… particular about Security. “have our machines go through the internet, and download our apps from a cloud, oh heavens no!!”.

So all machines come from the OEM’s and into a centralized distribution center, where they run a hodge-podge of OS Imaging tools to get the machines ready to ship out to each user.

And, No they don’t use any MDT… at least not yet…

Really it’s the Anti AutoPilot…

Where to start.

Well, when the machines arrive from the OEM, they are unboxed and placed on a configuration rack. If they are Desktop Machines, they are also connected to a KVM switch (Imagine several 8-port switches daisy chained together). Then they are plugged into power, network, and turned on.

Here’s our first challenge: How do we stop the PC from booting into the OEM’s OOBE process into OUR process? Well right now the technicians need to press the magic function key press at just the right time during boot up.

You know the drill, Press F12 for Dell, or perhaps press F9 for HP, or Press enter for Lenovo. Perhaps you have a Surface Device, and need to hold down the Volume button while starting the machine. Yuck, but better than nothing…

Well, the feedback we got from the technicians is that sometimes they miss pressing the button… at “just” the right time. This is really a problem for a Desktop PC’s connected to that KVM switch. If the Monitor doesn’t sync to the new PC quickly enough, you might easily miss pressing the boot override switch.

This sounded like a good challenge to start with.

Audit Mode

Really, IT departments don’t use Audit Mode. Audit Mode is a way to make customizations *during* Windows Setup and then re-seal the OS, so the end-user gets the nice shiny Windows Setup process (Specialize and OOBE) that they expect in a new PC.

Deployments in IT are all about bypassing the shiny Windows OOBE experience. No we don’t care about all the fancy new features in Cortana, We have already signed the SA agreement with Microsoft, we already know the domain to connect to, and our company has only one locale and keyboard type. IT departments would much rather skip all that, and get the user to their machine. So the thought of re-sealing a machine and going *back* to OOBE when we just finished joining to the domain and installing apps is silly.

But there are some Possibilities here. Turns out, that when Windows Setup is running, it will look for an Unattend.xml file and try to use it.

Methods for running Windows Setup

MDT uses an Unattend.xml file on the local machine it we can skip over the settings we know about, and re-launch MDT LiteTouch when finished. What about this process? If we place the Unattend.xml file on the root of a removable USB drive, the Windows version on the hard disk will look there and use these settings. The Lab Techs appeared to have a lot of USB sticks laying around, so using them shouldn’t be a problem.

We can’t use a MDT unattend.xml file as-is, but we can use AuditMode to get to a command prompt and install our own MDT LitetouchPE_x64.wim file.

  1. Boot into Audit Mode.
  2. While in Audit Mode, auto login using the Administrator Account.
  3. Find our PowerShell script and run it!


<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Reseal>
<Mode>Audit</Mode>
</Reseal>
</component>
</settings>
<settings pass="auditSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="wow64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<AutoLogon>
<Enabled>true</Enabled>
<LogonCount>5</LogonCount>
<Username>administrator</Username>
</AutoLogon>
</component>
</settings>
<settings pass="auditUser">
<component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Description>Run script</Description>
<Order>1</Order>
<!– Enumerate through all drives looking for the MYIT_OEMBypass.ps1 script, if found, run it. Leave the command prompt open. –>
<Path>cmd /c "(FOR %i IN (C D E F G H I J K L N M) DO IF EXIST %i:\MYIT_OEMBypass.ps1 Powershell -executionpolicy ByPass %i:\MYIT_OEMBypass.ps1) & pause"</Path>
<WillReboot>OnRequest</WillReboot>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
</unattend>

view raw

unattend.xml

hosted with ❤ by GitHub

PowerShell script

Once we are in PowerShell, we now have full access to the system, and can modify it in any we choose. In this case, I have copied a LiteTouchPE_x64.wim file to the USB Stick, and we can force the Hard Drive to boot from that instead, continuing our process in MDT LiteTouch. Yea!


<#
Bypass file for OEM OOBE Setup.
Called from within Audit Mode.
#>
param(
[int] $TargetDisk = 0,
[string] $NewBootWim = "$PSScriptRoot\Generic_x64.wim",
[string] $UserName = 'MDTServer\MDTNonInteractive',
[string] $Password = 'UnSecurePassword1234',
[string] $BootType = 'x64',
[string] $Target = 'h:'
)
$ErrorActionPreference = 'stop'
#region Find the largest on-disk partition
###############################################################################
$TargetDrive = get-disk -Number $TargetDisk |
Get-partition |
Sort -Descending -Property Size |
Select-Object -First 1 |
Get-Volume |
foreach-object { $_.DriveLetter + ':' }
# get a drive letter for the system partition
get-disk -Number $TargetDisk |
get-partition |
where-object { -not $_.DriveLetter } |
Where-Object Type -eq System |
Add-PartitionAccessPath -AccessPath $Target
#endregion
#region Connect to a network share if Source is over the network…
###############################################################################
if ( -not ( test-path $NewBootWim ) ) {
if ( $newBootWim.StartsWith('\\') -and $UserName -and $Password ) {
# COnnect to the network share.
net use "$(split-path $NewBootWim)" /user:$UserName "$Password"
}
}
#endregion
#region Copy the Boot WIM
###############################################################################
new-item -ItemType directory -path $TargetDrive\Sources -Force -ErrorAction SilentlyContinue | Out-Null
copy-item $NewBootWim $TargetDrive\Sources\Boot.wim
robocopy /e $PSScriptRoot\x64 $Target\ /xf bcd bcd.log
#endregion
#region Create a BCD entry
###############################################################################
Bcdedit /create "{ramdiskoptions}" /d "Ramdisk options"
Bcdedit /set "{ramdiskoptions}" ramdisksdidevice boot
Bcdedit /set "{ramdiskoptions}" ramdisksdipath \boot\boot.sdi
$Output = bcdedit -create /d "MYIT_OEMHack" /application OSLOADER
$GUID = $output | %{ $_.split(' ')[2] }
bcdedit /set $Guid device "ramdisk=[$TargetDrive]\sources\boot.wim,{ramdiskoptions}"
bcdedit /set $Guid osdevice "ramdisk=[$TargetDrive]\sources\boot.wim,{ramdiskoptions}"
bcdedit /set $Guid path \windows\system32\boot\winload.efi
bcdedit /set $Guid systemroot \windows
bcdedit /set $Guid detecthal yes
bcdedit /set $Guid winpe yes
bcdedit /set $Guid ems no
bcdedit /set $Guid isolatedcontext yes
Bcdedit /displayorder $Guid -addfirst
Bcdedit /default $Guid
Bcdedit /timeout 10
#endregion
#region Reboot
###############################################################################
write-host "DONE"
shutdown -r -f -t 0
#endregion

Now we have a bridge between the OEM system and our LiteTouch, or any other automated WinPE disk.

Yea! Now for the *REAL* automation to begin… 🙂

-k

 

Windows 1709 In Place Upgrade Bug

Thanks to Johan Arwidmark and Dan Vega for pointing me out to this bug. It took me a while to set up a scenario where it could reproduce, but it’s a good bug. Windows 10 In-Place Upgrade is an important new feature of Windows 10, and it’s good to have MDT support it.

The Bug

When upgrading to Windows 10 Version 1709 using the built-in MDT “Standard Client Upgrade Task Sequence”, you will get an error during OS upgrade within a MDT Wizard page that shows something like:

A VBScript Runtime Error has occurred:
Error: 500 = Variable is undefined

VBScript Code:
-------------------
IsThereAtLeastOneApplicationPresent

At this point, the OS *has* been upgraded, but the Task Sequence can no longer continue.

The Background

During OS upgrades, MDT needs a way to hook into the OS Installation process when done and continue installation tasks in the New OS.

Before In-Place upgrades, MDT would perform this by adding in a custom step into the unattend.xml file. Perhaps you have seen this segment of code before:

<FirstLogonCommands>
 <SynchronousCommand wcm:action="add">
  <CommandLine>wscript.exe %SystemDrive%\LTIBootstrap.vbs</CommandLine>
  <Description>Lite Touch new OS</Description>
  <Order>1</Order>
 </SynchronousCommand>
</FirstLogonCommands>

Windows would run the LTIBootStrap.vbs script, which would call the LiteTouch.wsf script, which would find the existing Task Sequence environment and kick of the remaining steps within the “State Restore” of the Task Sequence.

For the Windows 10 In-Place upgrade process, instead of processing the unattend.xml file, they have their own method of calling back into our LiteTouch environment using a SetupComplete.cmd file. This SetupComplete.cmd is responsible for finding our LiteTouch script and calling it.

The Analysis

It took me a while to setup a repro scenario (my test lab is configured for new-computer scenarios with the Windows 10 Eval bits, which can’t be used in an In-Place upgrade scenario). But I was able to reproduce the issue, and I got the bug, and was able to get the bdd.log file for analysis.

The challenge here is that during In-Place upgrade, I can’t open a Cmd.exe window for debugging using F8 or Shift-F10. Instead I hard coded a “start cmd.exe” line into the SetupComplete.cmd file.

What I observed is that the Window being displayed was the ZTIGather.wsf progress screen. LiteTouch.wsf will kick off the ZTIGather.wsf script early in the process, and will show a customized version of the LiteTouch wizard as a progress dialog. Well clearly the wizard isn’t working properly. But after closer analysis, ZTIGather.wsf shouldn’t be running AT ALL. For some reason, LiteTouch.wsf didn’t recall that it was in the MIDDLE of a task sequence, and that it should just directly go back to the TS in progress.

MDT has two methods for storing variables. When within the SMS Stand Alone Task Sequencing Engine, MDT LiteTouch scripts will read the SMS variables through the Microsoft.SMS.TSEnvironment COM object. But if ZTIUtility.vbs can’t open the variable store, it will store variables locally in the Variables.dat file.

Finally, after getting a powershell.exe window, creating the Microsoft.SMS.TSEnvironment, and making a couple of test calls to verify the contents of the variable store, a surprise. All variables returned empty *Successfully* (which is bad) but writes caused an Exception (which is correct). Since we were not running the SMS Stand Alone Task Sequencing Engine, all calls should have caused an exception.

Why is the Microsoft.SMS.TSEnvironment COM object registered, but not working? Well, that’s still under investigation, but now we can work on a fix/work around for MDT!!!

The Fix

The fix (Not written by me), is to force the Microsoft.SMS.TSEnvironment COM object to unregister before calling LiteTouch.wsf. This can be done by a simple call at the start of the SetupComplete.cmd script:

:: Workaround for incorrectly-registered TS environment
reg delete HKCR\Microsoft.SMS.TSEnvironment /f

The issue has been acknoleged by Microsoft, and this fix is currently targeted for the next release of MDT.

<Code Removed>

Thanks!

k

Make DisMount-DiskImage work

TL;DR – DisMount-DiskImage doesn’t work the same it did in Windows Server 2012 R2, here is how to make it work in Windows 10 and Server 2016.

Dirty Boy

OK, with the release of Windows 1709, I’ve been downloading all sorts of *.ISO images from MSDN to try out the latest features, kits, and support utilities. As I develop scripts to auto mount and extract the contents, sometimes I leave the ISO images mounted, so I’ll need to clear everything off before I begin a new test run.

I developed a new powershell script function to do all of this for me: Dismount-Everything. I had the VHDX part working, but somehow the DVD part wasn’t working well.

I specifically wanted to dismount ISO images, even though I might now recall the path where they came from, even though the drive letter should be easily visible.

Blogs

I went to a couple of blog sites to find out how to dismount ISO images, and got some hits.

https://rcmtech.wordpress.com/2012/12/07/powershell-mounting-and-dismounting-iso-images-on-windows-server-2012-and-windows-8/

https://superuser.com/questions/499264/how-can-i-mount-an-iso-via-powershell-programmatically

But on the superuser site, the author suggests that you should be able to take the output from get-volume and pipe it into Get-DiskImage, but that was not working for me.

Get-Volume [Drive Letter] | Get-DiskImage | Dismount-DiskImage

No matter what, Get-DiskImage didn’t like the volume Path.

Get-Volume would output:

\\?\Volume{1f8dfd40-b7ae-11e7-bf06-9c2a70836dd4}\

but Get-DiskImage would expect:

\\.\CDROM1

Well, Windows NT will create virtual shortcuts between long path names like \\?\volume and something more readable like \\.\CDROM1, so I assumed there was an association there.

Well after testing I found out that this command didn’t work:

get-diskimage -devicepath \\?\Volume{1f8dfd40-b7ae-11e7-bf06-9c2a70836dd4}\

But this command did:

get-diskimage -devicepath \\?\Volume{1f8dfd40-b7ae-11e7-bf06-9c2a70836dd4}

Turns out that I just needed to strip out the trailing \.

Easy!

Code

Get-Volume | 
  Where-Object DriveType -eq 'CD-ROM' |
  ForEach-Object {
    Get-DiskImage -DevicePath  $_.Path.trimend('\') -EA SilentlyContinue
  } |
  Dismount-DiskImage

ZTISelectBootDisk.wsf new with BusType

Several years ago I wrote a script to help select which disk to deploy Windows to during your MDT LiteTouch or ZeroTouch task sequence.

https://keithga.wordpress.com/2013/09/18/ztiselectbootdisk-wsf/

Well, based on a request from my latest client, I have created a similar script that support BusType.

BackGround

My client is trying to install Windows Server 2016 on a Server with a SAN. When the machine boots to WinPE, one of the SAN drives appears *first* as Disk 0 (Zero). By default MDT Task Sequences will deploy to Disk Zero! My ZTISelectBootDisk.wsf already shows how to override. All we need to do is to find a way to tell MDT which disk to choose based on the correct WMI query.

Turns out it was harder than I thought.

What we wanted was the BusType that appears in the “Type” field when you type “Select Disk X” and then “detail disk” in Diskpart.exe.  When we ran “Detail Disk” in DIskpart.exe we could see the bus type: Fibre as compared to regular disks like SCSI or SAS.

The challenge was that the regular Win32_diskDrive WMI query wasn’t returning the BusType value, and we couldn’t figure out how to get that data through other queries.

I tried running some PowerShell queries like ‘Get-Disk’ and noticed that the output type was MSFT_Disk, from a weird WMI Namespace: root\microsoft\windows\storage. But adding that query to the script works! Yea!!!

BusType

What kind of BusTypes are there?

Name Value Meaning
Unknown 0 The bus type is unknown.
SCSI 1 SCSI
ATAPI 2 ATAPI
ATA 3 ATA
1394 4 IEEE 1394
SSA 5 SSA
Fibre Channel 6 Fibre Channel
USB 7 USB
RAID 8 RAID
iSCSI 9 iSCSI
SAS 10 Serial Attached SCSI (SAS)
SATA 11 Serial ATA (SATA)
SD 12 Secure Digital (SD)
MMC 13 Multimedia Card (MMC)
Virtual 14 This value is reserved for system use.
File Backed Virtual  15 File-Backed Virtual
Storage Spaces  16 Storage spaces
NVMe 17 NVMe

For this script we are *excluding* the following devices:

Name Value Meaning
Fibre Channel 6 Fibre Channel
iSCSI 9 iSCSI
Storage Spaces  16 Storage spaces
NVMe 17 NVMe

Meaning that the *FIRST* fixed device not in this list will become the new *Target* OS Disk. Run this query on your machine to see what disk will become the target:

gwmi -namespace root\microsoft\windows\storage -query 'select Number,Size,BusType,Model from MSFT_Disk where BusType <> 6 and BusTy
pe <> 9 and BusType <> 16 and BusType <> 17' | Select -first 1

Requirements

Reminder that this script requires MDT (latest), and the script should be placed in the %DeploymentShare%\Scripts folder. Additionally you should install all the Storage packages for WinPE, sorry I don’t recall *which* packages I selected when I did testing.

Script


<job id="ZTISelectBootDisk">
<script language="VBScript" src="ZTIUtility.vbs"/>
<script language="VBScript" src="ZTIDiskUtility.vbs"/>
<script language="VBScript">
' // ***************************************************************************
' //
' // Copyright (c) Microsoft Corporation. All rights reserved.
' //
' // Microsoft Deployment Toolkit Solution Accelerator
' //
' // File: ZTISelectBootDisk.wsf
' //
' // Version: <VERSION>
' //
' // Purpose: Given a collection of Storage Devices on a machine,
' // this program will assist in finding the correct
' // device to be processed by "ZTIDiskPart.wsf"
' //
' // Currently hard coded to select the *FIRST* drive that is
' // Not iSCSI, Fibre Channel, Storage Spaces, nor NVMe.
' //
' // REQUIRES that you install the correct WinPE Storage Components!
' //
' //
' // WARNING: If there are any *other* disks that need to be Cleaned
' // and formatted, they should be processed first.
' // And this the global Variable OSDDiskIndex should be
' // set to <blank> when done being processed by ZTIDiskPart.wsf.
' //
' // Variables:
' // OSDDiskIndex [ Output ] – Disk Index
' //
' // Usage:
' // cscript.exe [//nologo] ZTISelectBootDisk.wsf [/debug:true]
' // cscript.exe [//nologo] ZTIDiskPart.wsf [/debug:true]
' // cscript.exe [//nologo] ZTISetVariable.wsf [/debug:true] /OSDDiskIndex:""
' //
' // ***************************************************************************
Option Explicit
RunNewInstance
'//—————————————————————————-
'// Main Class
'//—————————————————————————-
Class ZTISelectBootDisk
'//—————————————————————————-
'// Main routine
'//—————————————————————————-
Function Main
Dim oWMIDisk
Dim bFound
Dim oDiskPartBoot
Dim oContext, oLocator, objQuery, objStorageWMI, objStorage
oLogging.CreateEntry "—————- Initialization —————-", LogTypeInfo
IF oEnvironment.Item("DEPLOYMENTTYPE") <> "NEWCOMPUTER" Then
oLogging.ReportFailure "Not a new computer scenario, exiting Select Boot Disk.", 7700
End If
bFound = FAILURE
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
'
' 1st Pass – Find any disk that matches the Query "Select * From Win32_diskPartition %OSBootDiskOverrideWQL%"
'
Set oContext = CreateObject("WbemScripting.SWbemNamedValueSet")
oContext.Add "__ProviderArchitecture", 64
Set oLocator = CreateObject("Wbemscripting.SWbemLocator")
set objStorageWMI = oLocator.ConnectServer("","root\Microsoft\Windows\Storage","","",,,,oContext)
set objQuery = objStorageWMI.ExecQuery("select Number,Size,BusType,Model from MSFT_Disk where BusType <> 6 and BusType <> 9 and BusType <> 16 and BusType <> 17")
If objQuery.Count = 0 then
oLogging.CreateEntry "No Disk Drives Found!?!?! Dude, did you install the right storage drivers into WinPE 0x7b.",LogTypeError
exit function
End if
For each objStorage in objQuery
oLogging.CreateEntry "Found Device: N:" & ObjStorage.Number & " S:" & ObjStorage.Size & " M:" & ObjStorage.Model & " T:" & ObjStorage.BusType & " " , LogTypeInfo
oEnvironment.Item("OSDDiskIndex") = ObjStorage.Number
bFound = SUCCESS
exit for
Next
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
'
' 2nd pass – Use the 1st Partition larger than 15GB on the first disk with a bootable partition.
'
If bFound = FAILURE then
oLogging.CreateEntry "No drive was found using search parameters, Use the 1st \Windows Partition found.", LogTypeInfo
set oDiskPartBoot = GetBootDriveEx( false, oEnvironment.Item("ImageBuild"), false )
If not oDiskPartBoot is nothing then
oEnvironment.Item("OSDDiskIndex") = oDiskPartBoot.Disk
bFound = SUCCESS
End if
End if
TestAndLog bFound = SUCCESS, "Verify OSDDiskIndex was found and set: " & oEnvironment.Item("OSDDiskIndex")
Main = bFound
End Function
End class
</script>
</job>

-k

 

 

Update CustomSettings.ini file remotely!

Got on a discussion this week with someone how to use PowerShell to update an MDT CustomSettings.ini file over the network. Well a *lot* of CS.ini files.. 🙂

My manager is the Global Ops Manager and now he is asking me to find a way to run [update of customsettings.ini] on about 50 servers worldwide so the other MDT admins don’t have to log onto each server just to add one line.

The example given was to update the AdminPassword in CS.ini. I hope this company is following best practices, and disabling the local Administrator account and/or changing the Password once joined to the domain or connected to SCCM.

Anywho, INI files are a tad bit difficult to modify in Powershell because there are no native PowerShell or .NET functions to perform the action. So instead we need to do some ugly Pinvoke calls to the appropriate Win32 API.

-k


<#
.SYNOPSIS
Update CustomSettings.ini file.
.DESCRIPTION
Updates one or more CUstomSettings.ini files with a common value.
Calling powershell.exe instance must have read/write privelages to the share.
.PARAMETER DeployShares
The full path to the share. Can be input from the pipeline
Example:
c:\DeploymentShare
\\localhost\DeploymentShare$
.PARAMETER Section
The section name to update.
.PARAMETER Name
THe name to update
.PARAMETER Value
The value to write
.EXAMPLE
C:\PS> .\Update-INIFiles -DeployShares c:\DeploymentShare -Section Default -Name AdminPassword -value 'P@ssw0rd'
set a new password in an MDT deployment share
.EXAMPLE
C:\PS> "\\localhost\DeploymentShare$" | .\Update-INIFiles -Section Default -Name AdminPassword -value 'P@ssw0rd'
set a new password in an MDT deployment share, get the file from the pipeline.
C:\PS> type .\MyMDTServerList.txt | .\Update-INIFiles -Section Default -Name AdminPassword -value 'P@ssw0rd'
set a new password in an MDT deployment share, get the list of files from a list of servers passed in through the cmdline.
.EXAMPLE
C:\PS> [Reflection.Assembly]::LoadWithPartialName("System.Web") | out-null
C:\PS> $NewPassword = [System.Web.Security.Membership]::GeneratePassword(10,2)
C:\PS> "The new password will be: $NewPassword"
The new password will be: F{nK:*[L}H
C:\PS> type .\MyMDTServerList.txt | .\Update-INIFiles -Section Default -Name AdminPassword -value $NewPassword
Generate a new random password with powershell, then update all Cs.ini files from a list of servers passed in through the command line.
.LINK
https://foxdeploy.com/2014/09/04/adding-whatif-support-to-your-scripts-the-right-way-and-how-you-shouldnt-do-it/
#>
[cmdletbinding(SupportsShouldProcess=$true)]
param(
[parameter(Mandatory=$true, ValueFromPipeline=$true)]
$DeployShares,
[parameter(Mandatory=$true)]
[string] $Section,
[parameter(Mandatory=$true)]
[string] $Name,
[parameter(Mandatory=$true)]
[string] $Value
)
begin {
## The signature of the Windows API that retrieves INI settings
$signature = @'
[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
public static extern bool WritePrivateProfileString(
string lpAppName,
string lpKeyName,
string lpString,
string lpFileName);
[DllImport("Kernel32.dll")]
public static extern uint GetLastError();
'@
## Create a new type that lets us access the Windows API function
$type = Add-Type -MemberDefinition $signature -Name API -Namespace Win32 -PassThru
}
process {
foreach ( $DPShare in $DeployShares ) {
if ($pscmdlet.ShouldProcess("$DPShare", "CustomSettings.ini write")){
$result = [Win32.API]::WritePrivateProfileString($Section, $Name, $Value, "$DPShare\control\customsettings.ini")
if ( -not $result ) {
$err = [Win32.API]::GetLastError()
throw ( New-Object ComponentModel.Win32Exception ($err -as [int]) )
}
}
}
}