2021-10-20

A "review" of Paul Tremblay - A Head Full of Ghosts

This is the story of a girl who cried a river and bored the whole world.

I have tried and failed to finish "A Head Full of Ghosts" off and on for about 3 years now. I started it around 2019 and I've finally put it down for good because I've realized I'm mortal and I have better things to do with my time than read a garbage novel that doesn't go anywhere.

I don't think I've ever reviewed a book I've never finished before, so this is a new experience for me. That said, I put this book down at page 142 of 284, coincidentally exactly halfway. Perhaps it gets good after that. Don't bother telling me, I do not fucking care and you will not change my mind.

"A Head Full of Ghosts" tries to be a horror novel. It is allegedly the story of a family with no surname. If they had one, I don't remember it and it doesn't matter. I'm going to call them the Flumps. The Flump family consists of Smokey Mom, Bible Dad, Violet from The Incredibles Only Edgy, and Merry. Merry is the youngest, blandest, and least interesting member of the family, so of course she's the only character to have a voice in the entire miserable book.

Merry is in the single digits (8? 4? Who cares?) and spends most of her time preoccupied with how the toys are arranged in her bedroom. Being so young, she is wholly disinterested in the machinations of her family and so her view is the worst possible way to tell a story about a family fractured by, I'm guessing, mental illness, marital friction, and financial struggles. Why bother exploring the potent powder keg of a fragile family dynamic wrought with money troubles when you can spend pages laboring over what kinds of toys you have and how you like to eat your cereal in the mornings? That's a far more important use of the reader's time.

Chapters alternate between POV ramblings of a child barely comprehending how her sister's breakdowns are stressing her parents and some asinine pseudo-blog of a horror movie buff uploading reviews of films and talking like she's a too-cool MTV veejay. "Hey, it's ya girl! Back with another slammin' review of The Cabinet of Dr. Caligari! Dis a good one, mon! He keeps showing up and bam! Someone gets deadified! Dat's a spicy corpse-ball!"

It turns out that this annoying blog-written-by-a-man-trying-to-sound-like-a-girl is written by an adult Merry, though its purpose or narrative meaning remain unknown to me.

Worse, adult Merry ends up telling this entire boring tale in flashback to a writer, ostensibly a real writer who wouldn't fuck it up, and that writer is carrying a tape recorder and visiting Merry's home. So between chapters of actual potential value wasted by being told by a child and chapters of kitsch movie drivel, there are chapters of two adult women trying their damnedest to out-polite each other. Trees died to make the reams of paper wasted on "We walked into my kitchen, tidy but fully furnished with silvered appliances. I offered her tea. 'Would you like some tea?' I asked. 'Yes, I'd love some, thank you,' she said, politely. She folded her hands neatly. 'What kind of tea would you like? I have black, oolong, and green,' I asked. 'Whatever you'd like to make, I do hope you'll have some too,' she said. 'Of course, thank you,' I said. 'Would it trouble you if I kept recording?' she asked. 'I don't mind at all,' I said."


"'Tis!' replied Aunt Hellllga." — "Bart of Darkness", The Simpsons, S6E1

It goes on like this for fucking pages as Merry gives the writer an excruciating tour of her condominium and we learn all the different colors of paint and/or wallpaper in her home and what the floors look like and what color coat the writer likes to wear and how big the buttons on it are.

Stick a gun in my mouth. This book doesn't go anywhere, and it loves itself for staying in park with the engine running.

Allegedly, the older sister, Edgy Violet, is either making up ghost stories for attention because Edgy! or she's possessed, though there's no direct evidence of this. There's one engaging page in the first part of the book I won't ruin (it's gripping, literally), but blink and you'll miss it because the author only wrote this book to fixate on trying to describe a teenage girl who pisses herself while having seizures.

I think he might have a fetish.

There's potential in the book that's wasted here as the teen talks to her kid sister and tells her stories and they build a camaraderie as siblings often do. The teen tells scary stories, the child overreacts, and it's rather heartwarming — in principle — to see them bond, but my gripe is that there's scant moments of sisterly bonding in 100 pages and what's there doesn't go anywhere.

Nothing in this book goes anywhere.

Edgy Violet has a therapist who isn't helping, so Bible Dad decides on his own to start taking her to see a priest instead, to the consternation of Smokey Mom who just starts smoking more. Smokey Mom is a useless character except to disagree with Bible Dad, and Bible Dad is a doofus. Except for Edgy Violet, the Flump family is just terribly uninteresting. (Did you forget they had a last name? So did the author.)

It's decided Edgy Violet must be possessed and, rather than having her exorcised with consent of a bishop as the Catholic Church requires, nothing becomes of that diagnosis except it gets the attention of a film crew who decides to make a reality show of the family and fills their home with cameras.

This leads to a chapter about young Merry spending all her time playing with one of the TV show producers and kicking around a soccer ball in the back yard. Because that's important, maybe? I dunno, man. This book doesn't go anywhere.

While the camera crew is still working in the home the TV series airs, which isn't how TV shows work. Due to its mature subject matter, they send Merry — who I will reiterate here is the narrator of the book — to bed early so that the adults can watch the premiere of their own TV show that they're still making.

If you're confused and not seeing a connecting thread between these things: teenager with mental problems and/or demonic posession; women in a condo drinking tea with their pinkies out; playing soccer in the back yard; a blog about horror movies; then you're not alone. I'm not seeing a connection either. "A Head Full of Ghosts" reads like an anthology that just keeps cutting back to the same three stories that never go anywhere and never pay off: paint drying, grass growing, women sipping tea.

Perhaps if the first part of the story was half as long I wouldn't be so deflated about having to churn through as many useless pages of pointless banality as there are. Perhaps if there was some kind of narrative thread that tied disparate moments together this story of an adult reflecting on her sister's troubled childhood wouldn't feel so jarring. Perhaps the current-day chapters of Merry giving her interview to the writer wouldn't feel like someone trying to rub a copy of "Martha Stewart Living Magazine" against my raw corneas. Perhaps if the story started going anywhere I would want to see how it resolves.

This book is like putting The Three Stooges in The Haunting of Hill House and still trying to call it a horror film.

As a pseudo-memoir, this book fails miserably. As a horror novel, this book fails miserably. As a waste of paper, this book succeeds and if I had three wishes, one would be for the trees that died to make this book be brought back to life again. They did a far more important job turning carbon dioxide into oxygen than in conveying Paul Tremblay's words into thoughts in my brain. This book feels like it started out as a forgettable high-numbered cable channel TV movie screenplay edited into a novel at the last minute for contractual obligations by an overseas third-party firm who got paid by the word, but the structure of the original melodrama stayed put.

Things I would do differently if I had editorial sway over this book: make some of the characters interesting. Eliminate the role of Merry or for God's sake not make her the single perspective of the entire book. One chapter of Merry's naive perspective while her family melts down around her would have been refreshing and clever. Every chapter being Merry saying "I didn't understand what the grownups were saying, so I got bored and went to go play" is just fucking insulting.

Maybe the second half of the book changes perspective? That would've been nice. It's hard to imagine anything happening to Merry though, because she's alive and well to be able to tell her boring story in retrospect to a real writer. I would rather read what the writer's getting out of her story instead of her in situ thoughts as a child with no interest in conveying story elements to the reader. An author's job is to tell a story. Saying "I'm going to write this story from the perspective of a little girl who can't tell a story" is an abdication of one's duty.

There's an advantage to embedded journalism, when you can have a reporter there, live and in the flesh, recounting major world events as they happen. When that reporter's story is "Some people were arguing, and I didn't like that, so I decided to go watch SpongeBob," well, you get an idea of what trying to read "A Head Full of Ghosts" is like.

2020-05-14

Patching Windows: Part 3

I have a scheduled task that runs every Tuesday after 10 AM Pacific time to check for new updates. Historically, Microsoft posts its monthly patches between 10 AM and 11 AM on the second Tuesday of the month. This script computes if it's Patch Tuesday, or up to a week after Patch Tuesday, and only continues to run during that period of time.

My slow-as-hell-in-checking-for-updates LSTB machine won't find the May Cumulative Update until June, so I fetch and install it myself with 2ndtuesday.ps1:

[CmdletBinding()]
Param(
  [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [DateTime]  $Date    = (Get-Date),
  [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String]    $Path    = [System.IO.Path]::Combine(${Env:UserProfile}, 'Desktop'),
  [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [Version[]] $OSBuild = [Environment]::OSVersion.Version,
  [Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [String]    $Filter  = 'x64',
  [Parameter(Mandatory=$False)]                            [Switch]    $Force   = $False,
  [Parameter(Mandatory=$False)]                            [Switch]    $Fetch   = $True
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2

Import-Module -Force -Name ([System.IO.Path]::Combine($PSScriptRoot, 'WU.psm1'))

Function Do-Start {
  Param()
  $tuesday = Get-SecondTuesday -DT $Date

  if (-not $Force) {
    if ( 8 -gt $Date.Day)              { Return $False }
    if (15 -lt $Date.Day)              { Return $False }
    if ($Date.Date -lt $tuesday.Date)  { Return $False }
    if ($Date -ge $tuesday.AddDays(7)) { Return $False }
  }

  Write-Host ("Date: {0}" -f ($Date.ToLongDateString()))
  Write-Host ("Second Tuesday of month: {0}`n" -f ($tuesday.ToLongDateString()))
  Return $True
}

Function Do-End {
  Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [PSObject[]] $Updates )

  Write-Host "`nSSU updates: https://portal.msrc.microsoft.com/en-us/security-guidance/advisory/ADV990001`n"

  foreach ($build in $OSBuild) {
    $build_str = Get-BuildString -Version $build
    $kb_obj    = $Updates | Where-Object { $_.OSBuild -match $build_str }
    if ([String]::IsNullOrEmpty($kb_obj)) {
      Write-Error 'KB not found'
    }

    $release_date = Get-Date $kb_obj.ReleaseDate
    $source       = Get-KBSource -Filter $Filter -KB $kb_obj.KB
    $uri          = New-Object -TypeName System.Uri -ArgumentList $source.Source
    $kb_name      = "{0:0000}-{1:00} Cumulative Update for Windows 10 Version {2} for {3}-based Systems ({4})" -f ($release_date.Year, $release_date.Month, $kb_obj.OSName, $Filter, $kb_obj.KB)

    # <URL:https://arstechnica.com/civis/viewtopic.php?t=1330043>
    $dst_path  = '\\?\' + [System.IO.Path]::Combine($Path, $kb_name)
    $file_name = $uri.Segments[-1]
    $tmp_path  = [System.IO.Path]::Combine(([System.IO.Path]::GetTempPath()), $file_name)

    mkdir $dst_path -ErrorAction SilentlyContinue | Out-Null

    if ($Fetch) {
      Start-BitsTransfer -Destination $tmp_path -Source $uri
      Move-Item -Verbose:$False -Force:$True -Path $tmp_path -Destination $dst_path
      Write-Host "Fetched:`n"
    }

    Write-Host ("OS Build: {0}`n" -f ($build))
    Write-Host ("KB Name: `"{0}`"`n" -f ($kb_name))
    Write-Host ("Uri: {0}" -f ($uri))
  }

  Read-Host -Prompt "`nok?"
  Return
}

$count               = 0
$global:LASTEXITCODE = 0
if (Do-Start) {
  do {
    $count++
    try {
      $updates = Get-PublishedKBUpdates
      $updates | Format-Table -Property ReleaseDate,KB,OSBuild,OSName,SupportID,ReleaseWeek -AutoSize
      Do-End -Updates $updates
      Write-Host ("run: {0}, exitcode: {1}" -f ($count, $global:LASTEXITCODE))
    } catch { Start-Sleep -Seconds 300 }
  } while (0 -ne $global:LASTEXITCODE)
}
# Start-Sleep -Seconds 10 # I like to read things before the window closes
Exit

# END

I don't mind fetching the .MSU file myself because I like to keep it around for my "Updates" folder, which I sometimes feed to a different script that builds a from-scratch Windows VM with an install ISO and can patch the base image for me with dism.exe.

Output looks a little like this:

PS C:\> .\2ndtuesday.ps1 -Fetch:$False
Date: Tuesday, May 12, 2020
Second Tuesday of month: Tuesday, May 12, 2020


ReleaseDate KB        OSBuild          OSName SupportId ReleaseWeek
----------- --        -------          ------ --------- -----------
2020-05-12  KB4556799 10.0.18363.836   1909   4529964             B
2020-05-12  KB4556799 10.0.18362.836   1903   4498140             B
2020-05-12  KB4551853 10.0.17763.1217  1809   4464619             B
2020-05-12  KB4556807 10.0.17134.1488  1803   4099479             B
2020-05-12  KB4556812 10.0.16299.1868  1709   4043454             B
2020-05-12  KB4556804 10.0.15063.2375  1703   4018124             B
2020-05-12  KB4556813 10.0.14393.3686  1607   4000825             B
2018-04-10  KB4093109 10.0.10586.1540  1511   4000824             B
2020-05-12  KB4556826 10.0.10240.18575 1507   4000823             B



SSU updates: https://portal.msrc.microsoft.com/en-us/security-guidance/advisory/ADV990001

OS Build: 10.0.17763.0

KB Name: "2020-05 Cumulative Update for Windows 10 Version 1809 for x64-based Systems (KB4551853)"

Uri: http://download.windowsupdate.com/c/msdownload/update/software/secu/2020/05/windows10.0-kb4551853x64_ce1ea7def481ee2eb8bba6db49ddb42e45cba54f.msu

ok?:

run: 1, exitcode: 0

The URL for self-servicing updates is there because Microsoft doesn't make those easily accessible in a way DeploySharedLibrary can find. So far as I know you either (a) walk the Microsoft Update Catalog for a known SSU update for your precise build and look if it's been obsoleted, or (b) open a browser and go to the portal.msrc.microsoft.com link and click the link that's right for you. (b) is quick and you don't have to do it every single month. Every other month is fine. (Don't like it? Don't blame me. I voted for Kodos.)

This script puts a new directory on your Desktop called "YYYY-MM Cumulative Update for Windows 10 Version VVVV for FILTER-based Systems (KBXXXXXX)" and will store the update there. Change your architecture with -Filter as needed... but if you do you need to ask yourself why you're still on x86 or trying to act cool with ARM. I honestly have never tested this because I have one x86 machine and it's a tablet that I use as an e-reader and I leave it in airplane mode constantly.

2020-05-13

Patching Windows: Part 2

The WU.psm1 module is a work in progress. For a long time I couldn't get it to correctly prioritize that 1903 and 1909 are different versions of Windows, because they share a single KB update. I fixed that yesterday, so I can post it here. (Remember: work in progress. There's a lot of stuff I'd still like to fix, but I want something functional now. Perfect can wait until next month.)

If you really want to know, every monthly Cumulative Update KB article on support.microsoft.com had (emphasis on "had") a format like "(OS build 10.0.12345.678)" that the DeploySharedLibrary looks for in order to fetch and parse its links... but 1903 and 1909 both have two separate KB articles that each have links with a format like "(OS Builds 10.0.12345.678 and 10.0.78901.234)" and the regex just barfed on that.

So I downloaded The Regex Coach and fixed the regex. But fixing the regex also required some changes to the way that the regex matches are handled. The KB number was correct, but it wouldn't display both OS versions, and that bugged me.

This module also maintains a $WindowsOSBuildMap object that will soon include Windows 10 version 2004 when I can find what the support.microsoft.com KB page for that release is. Maybe in June 2020? (Don't expect me to keep this script updated here. Windows is a moving target and I juuuuust got 1909 and 1903 playing well with each other. It's not that hard to update it.)

(Update, 2020-06-09: The KB support ID for Windows 10 version 2004 is 4557957.)

No point in posting the bevy of patches that led to this, you can do the comparison yourself.

(UPDATE, 2022-05-13: Microsoft changed their website to change a dash to a Unicode symbol em-dash and this broke the regex. So I switched the lookup in this module to check Microsoft's Windows 10 RSS feed. The newer version of this module is over on my website as WU.psm1. It is not a drop-in replacement, as I've made a few changes to it to also look up Windows 11 updates if specified.)

# Windows Update module
$VERSION = 0.0 # There is no manifest

# Use with:
#  Import-Module -Force -Name "C:\Path\to\WU.psm1"

# <URL:https://en.wikipedia.org/wiki/Windows_10_version_history>
New-Variable -Option ReadOnly -Name WindowsOSBuildMap -Value @{

  # "Windows 10, versions 1903 and 1909 share a common core operating system and an identical set of system files"
# '10.0.19041' = @{ 'Id' = '???????'; 'Name' = '2004'; EndOfLife = $null; }
  '10.0.18363' = @{ 'Id' = '4529964'; 'Name' = '1909'; EndOfLife = $null; }
  '10.0.18362' = @{ 'Id' = '4498140'; 'Name' = '1903'; EndOfLife = $null; }
  '10.0.17763' = @{ 'Id' = '4464619'; 'Name' = '1809'; EndOfLife = $null; }
  '10.0.17134' = @{ 'Id' = '4099479'; 'Name' = '1803'; EndOfLife = $null; }
  '10.0.16299' = @{ 'Id' = '4043454'; 'Name' = '1709'; EndOfLife = $null; }
  '10.0.15063' = @{ 'Id' = '4018124'; 'Name' = '1703'; EndOfLife = '2019-10-08'; }
  '10.0.14393' = @{ 'Id' = '4000825'; 'Name' = '1607'; EndOfLife = $null; }
  '10.0.10586' = @{ 'Id' = '4000824'; 'Name' = '1511'; EndOfLife = '2018-04-10'; }
  '10.0.10240' = @{ 'Id' = '4000823'; 'Name' = '1507'; EndOfLife = $null; }
}

Function Get-BuildString {
  Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [Version] $Version)
  Return [String]::Join('.', @($Version.Major, $Version.Minor, $Version.Build))
}

Function Get-SecondTuesday {
  Param([Parameter(Mandatory=$False)] [ValidateNotNullOrEmpty()] [DateTime] $DT = (Get-Date))

  $d     = Get-Date -Year $DT.Year -Month $DT.Month -Day 8
  $num   = [UInt16] $d.DayOfWeek # 0 = Sun, 1 = Mon, 2 = Tue, etc.
  $delta = (9 - $num) % 7 # 9 = 7 + 2 (2 = Tue)
  $d     = $d.AddDays($delta)
  Return $d
}

Function IsDateAfterSecondTuesday {
  Param([Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [DateTime] $DT)

  $d = Get-SecondTuesday -DT $DT
  Return (0 -lt [DateTime]::Compare($DT, $d))
}

$cal   = New-Object System.Globalization.GregorianCalendar
$ascii = New-Object System.Text.ASCIIEncoding

Function Get-CurrentKBUpdateList {
  Param( [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [Version] $Build)

  $entries    = @()
  $build_str  = Get-BuildString -Version $build
  $build_maj  = [String]::Join('.', @($build.Major, $build.Minor, ''))
  $uri        = "https://support.microsoft.com/en-us/help/{0}" -f ($WindowsOSBuildMap[$build_str].Id)
  $wr         = Invoke-WebRequest -Uri $uri
  $matches    = Select-String -InputObject $wr.Content -Pattern '"([^\-\"]*)\p{Pd}(KB[0-9]*) \(OS Build(s)? ([0-9\.]*)( and ([0-9\.]*))?.+"' -AllMatches

  foreach ($match in $matches.Matches) {
    $dt             = [DateTime]$match.Groups[1].Value
    $second_tuesday = Get-SecondTuesday -DT $dt
    $week_b         = $cal.GetWeekOfYear($second_tuesday, [System.Globalization.CalendarWeekRule]::FirstDay, [System.DayOfWeek]::Sunday)
    $week_num       = $cal.GetWeekOfYear($dt,             [System.Globalization.CalendarWeekRule]::FirstDay, [System.DayOfWeek]::Sunday)

    @(6,4) | foreach {
      if (-not ([String]::IsNullOrEmpty($match.Groups[$_].Value))) {
        $version     = [Version]($build_maj + $match.Groups[$_].Value)
        $version_key = Get-BuildString -Version $version

        if ($WindowsOSBuildMap[$version_key].Name -eq $WindowsOSBuildMap[$build_str].Name) {
          $entries += New-Object -TypeName PSObject -Property @{
            'KB'          = $match.Groups[2].Value;
            'OSBuild'     = $version;
            'OSName'      = $WindowsOSBuildMap[$build_str].Name;
            'ReleaseDate' = $dt.ToString('yyyy-MM-dd');
            'ReleaseWeek' = $ascii.GetChars(66 + ($week_num - $week_b))[0];
            'SupportId'   = $WindowsOSBuildMap[$build_str].Id;
          }
        }
      }
    }
  }

  Return (Sort-Object -InputObject $entries -Property ReleaseDate -Descending) | Select-Object -First 1
}

Function Get-PublishedKBUpdates {
  Param()

  $updates = @()
  foreach ($build in ($WindowsOSBuildMap.Keys | Sort-Object -Descending)) {
    $updates += Get-CurrentKBUpdateList -Build $build
  }
  Return $updates
}

Function Get-KBSource {
  Param(
    [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $KB,
    [Parameter(Mandatory=$True)] [ValidateNotNullOrEmpty()] [String] $Filter
  )

  $uri_search   = "http://www.catalog.update.microsoft.com/Search.aspx?q={0}" -f ($KB)
  $uri_download = 'http://www.catalog.update.microsoft.com/DownloadDialog.aspx'
  $wr           = Invoke-WebRequest -Uri $uri_search

  Set-StrictMode -Version 1 # some of these things won't exist
    $ids = $wr.InputFields | Where-Object { $_.type -eq 'Button' -and $_.Value -eq 'Download' } | Select-Object -ExpandProperty ID

    $guids = $wr.Links | 
      Where-Object ID -match '_link' |
      Where-Object { $_.OuterHTML -match $Filter } |
      ForEach-Object { $_.id.replace('_link','') } |
      Where-Object { $_ -in $ids }
  Set-StrictMode -Version 2

  $links = @()
  foreach ( $guid in $guids ) {
    $Post = @{ size = 0; updateID = $guid; uidInfo = $guid } | ConvertTo-Json -Compress
    $postBody = @{ updateIDs = "[$Post]" } 
    $links += Invoke-WebRequest -Uri $uri_download -Method POST -Body $postBody |
      Select-Object -ExpandProperty Content |
      Select-String -AllMatches -Pattern "(http[s]?\://download\.windowsupdate\.com\/[^\'\""]*)" | 
      ForEach-Object { $_.matches.value }
  }

  Return ($links | Select-Object -Unique | ForEach-Object { [PSCustomObject] @{ Source = $_ } })
}

Export-ModuleMember -Variable WindowsOSBuildMap

Export-ModuleMember -Function Get-BuildString
Export-ModuleMember -Function Get-SecondTuesday
Export-ModuleMember -Function IsDateAfterSecondTuesday
Export-ModuleMember -Function Get-CurrentKBUpdateList
Export-ModuleMember -Function Get-PublishedKBUpdates
Export-ModuleMember -Function Get-KBSource

# END

Next time: How do use this darn thing.

2020-05-12

Patching Windows: Part 1

I once built a script that would patch Windows for me. It used an old version of the PSWindowsUpdate PowerShell module that ran in a check/fetch/install/reboot loop over and over until there were no more pending updates left to apply. This turned out to be a non-trivial problem to solve, even with a command-line tool to do it which, in general, Windows Update does not support.

There's obviously a way to invoke updates, but if you look for automatable ways to fetch and install updates for Windows, you either get tutorials on "what to click" to enable automatic updates, or MOM/SCOM/WSUS/Group Policy stuff.

Sometimes, you just want a script. You really just want to run "apt-get update; apt-get upgrade" and move on with your life.

So for a long time I used PSWindowsUpdate and it's easy enough to script, but it didn't work on my home machine, because at home I have an LTSB/LTSC machine that is on the lowest, slowest Windows 10 update ring.

That's for my own peace of mind, but it has its consequences. I'm typically a month (or two) behind the latest update. I get March's cumulative update in April, April's in May, and so on.

That's bad for my own peace of mind. How do I find peace?

Well, I could scrape the Windows 10 update RSS feed. And I read that for about 6 weeks before I lost my mind. It's a noisy feed and it's almost impossible to find good signal amongst the noise. I kept looking, and eventually I found keithga's DeploySharedLibrary repo on Github that has an "Update" section that can check the designated Windows 10 knowledge base pages on support.microsoft.com and bubble up the latest Cumulative Update.

So if you are OK with just scoping your update scripts to checking for the all-important monthly Cumulative Update, this is the repo for you. Except, it doesn't quite work anymore.

I'm sure it used to be great, but it has two problems: it scrapes live HTML that doesn't have any strict formatting guidelines, and it is over a year old without an update.

Things got really hairy with the DeploySharedLibrary code when 1903 and 1909 came out and Microsoft chose to combine both of those OS versions under a single KB, but with different build numbers.

Long story short, I ended up extensively patching DeploySharedLibrary so it works with modern Windows 10 releases. This involves having two files: a WU.psm1 module and a 2ndtuesday.ps1 script that sorts through the Windows Updates it finds and fetches the right update to match your local machine (or, if you want, the Windows build you specify).

Next time: Masters of Kung WU.

2020-05-06

Loopy (Epilogue)

I would end up writing a loop-deploy4 and a loop-deploy5. Having finally written a reliable[1] deployment automation tool, it didn't seem that much more difficult to extend it to deploying multiple resource groups concurrently. I'd been using ConEmu to run multiple instances (ConEmu: the lazy man's fork()) but I felt that one instance of loop-deploy should be able to take all given target resource groups, divide them up into their respective colors, and do one of each in parallel until all groups were deployed.

I tried a couple of different parallelization approaches, but neither of them worked. One of them, either loop-deploy4 or loop-deploy5, I can't remember which, deployed correctly, but buffered all status logging until the end, so that was a showstopper. One of them logged things OK, but couldn't actually deploy things correctly because however you choose to do concurrency, each method moves your workflows and values around in ways that are at times too slippery to grasp. Scoping, man. It'll get ya every time.

Try though I might, I could never pin down parallel execution, whether it be through background jobs or RunspacePools. Multiple RunspacePools were an idea I hammered on for a week or more, one RunspacePool for each color, with a maximum concurrency limit of just 1 or 2 for safety's sake. I could never get that working in a way that (a) did the work and (b) logged things correctly. It was a mess.

Months went by and loop-deploy3, sequential as it ever was, remained my workhorse deployment utility. When the redeploy automation service that the other guy wrote started failing, I wrote "redeploy_wrapper", which was a wrapper around loop-deploy3 that just looked for machines flagged as "Redeploy" every 12 or 24 hours (I fiddled with the frequency as needed) and rebuilt them automatically.

Deployments typically took between 20 minutes and an hour. Things hummed along well enough that a parallelization feature didn't seem urgent. By the end of the following year the team would transition to a different service and we ended up shrinking the pool piece by piece, before shutting it down entirely. loop-deploy4 and 5 never worked well enough for me to trust them enough to run them in any production capacity, so loop-deploy3 loyally served me for only another year before becoming another artifact of a bygone system.

And still, it remains one of my proudest achievements for no reason better than that it gave me back my time.

Postscript

In reminiscing about loop-deploy3, I went back and re-read everything I'd read back then on leveraging concurrency in PowerShell. There's a nice turnkey module called PoshRSJob that will probably do what you need, but in my case it was (a) overkill, (b) too complex for me to understand, and (c) didn't solve the "multiple runspaces" problem in a way I could grok. A hashtable of RunspacePools is the "right" solution here (insofar as a solution can have a moral value like right or wrong) so I teased out that loop-deploy6 would've been a wrapper script around loop-deploy3 and would've looked a little something like this:

Param(
  [Parameter(Mandatory=$False)] [UInt16] $ThrottleLimit = 1
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2

# testing only:
#   strict naming conventions in service resources
#   means that in practice this table can be 
#   derived spontaneously as needed
$resource_groups = @{
  'RGNRED1'   = 'RED';
  'RGNRED2'   = 'RED';
  'RGNRED3'   = 'RED';
  'RGNRED4'   = 'RED';
  'RGNBLUE1'  = 'BLUE';
  'RGNBLUE2'  = 'BLUE';
  'RGNGREEN1' = 'GREEN';
}

$q   = [System.Collections.Queue]::Synchronized( (New-Object System.Collections.Queue) )
$tid = [AppDomain]::GetCurrentThreadId()

Function Now {
  Param()

  $now_utc = (Get-Date).ToUniversalTime()
  Return $now_utc.ToString('yyyy-MM-dd HH:mm:ss.fffZ')
}

Function Log {
  Param([Parameter(Mandatory=$True)] [String] $String)

  $out_str  = "{0} " -f ((Now))
  $out_str += "pid={0} " -f ($PID)
  $out_str += "tid=0x{0} " -f ($tid.ToString('x4'))
  $out_str += "HEAD "
  $out_str += $String

  if (-not ([String]::IsNullOrEmpty( $q.Count ))) {
    $q.Enqueue($out_str)
  }

  Return
}

Function Dequeue-Messages {
  Param()

  while(0 -lt $q.Count) {
    $out_str = $q.Dequeue()
    Write-Host $out_str

    # not a bad idea to write these things to a log file, too
    # Out-File -Append:$True -FilePath $log_file -Encoding 'UTF8' -InputObject $out_str
  }

  Return
}

Function Main {
  Param()

  $script_path = [System.IO.Path]::Combine($PSScriptRoot, 'loop-deploy3.ps1')
  if (-not (Test-Path -Path $script_path)) {
    Write-Error 'script path not found'
  }

  Log 'start'

  $jobs   = @()
  $psh    = @{}
  $rsp    = @{}
  $run_id = 0

  # given that loop-deploy3 can accept an array of ResourceGroupNames
  # and run them in serial, and that we typically only want to deploy
  # one resource group per color at once, a smart way to do this might
  # be to group all ResourceGroupNames by color and process one
  # PowerShellAutomation $psh object for each and pass in an array of
  # ResourceGroupNames as a single parameter...
  #
  # $resource_groups.GetEnumerator() | Group-Object -Property Value | % {
  #   @{ 'Count' = $_.Count;
  #      'Color' = $_.Name;
  #      'RGN'   = $_.Group.Name;
  #   }
  # }
  #
  # ...but this works too:

  $resource_groups.Keys | Sort-Object | foreach {
    $rgn   = $_.ToUpper()
    $color = $resource_groups[$rgn].ToUpper()
    $run_id++

    if ([String]::IsNullOrEmpty( $rsp[$color] )) {
      $rsp[$color] = [RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit) # (min, max)
      $rsp[$color].Open()
    }

    $psh[$run_id]              = [System.Management.Automation.PowerShell]::Create()
    $psh[$run_id].RunspacePool = $rsp[$color]

    [void] $psh[$run_id].AddCommand($script_path)
    [void] $psh[$run_id].AddParameter('Object', @{
      'Id'                = $run_id;
      'ResourceGroupName' = $rgn;
      'Color'             = $color;
      'Queue'             = $q;
    })

    Log ( ("queued: run_id={0} RGN={1} " -f ($run_id, $rgn)) ) # DEBUG
    $jobs += $psh[$run_id].BeginInvoke() # $jobs[0] == run_id 1
  }

  Log 'Everything has been queued'

  do {
    Start-Sleep -Milliseconds 250
    Dequeue-Messages
  } while ((0 -lt $q.Count) -or ($jobs.IsCompleted -Contains $False))

  Write-Host 'Cleaning up'

  # $psh keys are run_id ints
  foreach ($id in $psh.Keys) {
    $psh[$id].EndInvoke($jobs[$id - 1])
    $psh[$id].Dispose()
  }; $psh.Clear()

  foreach ($color in $rsp.Keys) {
    $rsp[$color].Close()
  }; $rsp.Clear()

  Return
}
. Main
Exit

This approach may not be ideal for you; in that case I'd recommend you use PoshRSJob or draft something simpler than this. Most concurrent programming examples you can find are of the type "If you have X different identical jobs, how do you run them in parallel up to at most Y at the same time until all of them are finished?" My situation, with a thousand machines, was a little different and my wish was for a system that could identify unrelated groups and run them simultaneously. Hence this design, which is "for every color of VM that needs to be deployed, run $ThrottleLimit deployments of that color at once, regardless of the total number of colors". There were only 8 permutations in the pool. If there had been 8,000 I'd have sung a different tune.

My big sticking point in writing loop-deploy4 or 5 back in the day was trying to pair up RunspacePools and PowerShell instances 1-to-1: one each for Red, one each for Blue, and so on. That's no good. The a-ha moment here was that I needed an M-to-N mapping of M PowerShell instances and N RunspacePools, where M is the quantity of resource groups to deploy and N is the quantity of colors of those resource groups. Seems totally obvious in retrospect. Programming experts and logisticians can feel free to laugh at me now.

I also looked into log management and the best thing I could get working was a synchronized queue that was passed to each invoked PowerShell instance to be hooked up to a lightly modified loop-deploy3 logging function:

Param(
  [Parameter(Mandatory=$True)] [PSObject] $Object
)
$tid = [System.AppDomain]::GetCurrentThreadId()

Function Log {
  Param([Parameter(Mandatory=$True)] [String] $String)

  $now_utc  = (Get-Date).ToUniversalTime()
  $out_str  = "{0} " -f ($now_utc.ToString('yyyy-MM-dd HH:mm:ss.fffZ'))
  $out_str += "pid={0} " -f ($PID)
  $out_str += "tid=0x{0} " -f ($tid.ToString('x4'))
  $out_str += "{0} " -f ($Object['ResourceGroupName'].ToUpper())
  $out_str += $String

  if (-not ([String]::IsNullOrEmpty( $Object['Queue'].Count ))) {
    $Object['Queue'].Enqueue($out_str)
  }
  Return
}

Test output looks promising:

2020-05-03 01:39:45.194Z pid=6832 tid=0x05dc HEAD start
2020-05-03 01:39:45.270Z pid=6832 tid=0x05dc HEAD queued: run_id=1 RGN=RGNBLUE1
2020-05-03 01:39:45.285Z pid=6832 tid=0x05dc HEAD queued: run_id=2 RGN=RGNBLUE2
2020-05-03 01:39:45.288Z pid=6832 tid=0x4534 RGNBLUE1 start
2020-05-03 01:39:45.324Z pid=6832 tid=0x05dc HEAD queued: run_id=3 RGN=RGNGREEN1
2020-05-03 01:39:45.360Z pid=6832 tid=0x3d04 RGNGREEN1 start
2020-05-03 01:39:45.409Z pid=6832 tid=0x05dc HEAD queued: run_id=4 RGN=RGNRED1
2020-05-03 01:39:45.428Z pid=6832 tid=0x05dc HEAD queued: run_id=5 RGN=RGNRED2
2020-05-03 01:39:45.428Z pid=6832 tid=0x05dc HEAD queued: run_id=6 RGN=RGNRED3
2020-05-03 01:39:45.428Z pid=6832 tid=0x05dc HEAD queued: run_id=7 RGN=RGNRED4
2020-05-03 01:39:45.428Z pid=6832 tid=0x05dc HEAD Everything has been queued
2020-05-03 01:39:45.428Z pid=6832 tid=0x14c0 RGNRED1 start
2020-05-03 01:39:46.317Z pid=6832 tid=0x4534 RGNBLUE1 end
2020-05-03 01:39:46.341Z pid=6832 tid=0x4534 RGNBLUE2 start
2020-05-03 01:39:48.347Z pid=6832 tid=0x4534 RGNBLUE2 end
2020-05-03 01:39:48.405Z pid=6832 tid=0x3d04 RGNGREEN1 end
2020-05-03 01:39:48.450Z pid=6832 tid=0x14c0 RGNRED1 end
2020-05-03 01:39:48.459Z pid=6832 tid=0x14c0 RGNRED2 start
2020-05-03 01:39:52.466Z pid=6832 tid=0x14c0 RGNRED2 end
2020-05-03 01:39:52.468Z pid=6832 tid=0x14c0 RGNRED3 start
2020-05-03 01:39:56.473Z pid=6832 tid=0x14c0 RGNRED3 end
2020-05-03 01:39:56.480Z pid=6832 tid=0x14c0 RGNRED4 start
2020-05-03 01:39:57.481Z pid=6832 tid=0x14c0 RGNRED4 end
Cleaning up

The $Object object used to pass things from loop-deploy6 to loop-deploy3 is just a lazy hashtable that contains whatever I need without having to explicitly Define All The Things. The 1,220 VMs don't exist anymore, but preliminarily running this on my Windows box shows enough promise that I'd have eagerly begun to run this in Prod under strict observation and, finally, get better concurrent deployment control.

Alas, better luck next time.

[1] I really can't overstate how reliable loop-deploy3 was. It was the culmination of 12 months of real-world deployment experience boiled down into one script. (And a module. And a suite of other scripts that loop-deploy3 called. But it knew what scripts to call and how, dammit.) I've written a ton of scripts in my day, and honestly, only about six of them have been the kind of thing that I'd stake my life upon. loop-deploy3 is one of them. Of all the life-staking scripts I've ever put into production, personally and professionally, loop-deploy3 is at the top of the list in terms of its biggest undocumented feature: "lets me sleep at night". If I ran loop-deploy3, I knew that it was either going to succeed, or that if it kept failing I was asking it to do something impossible. (The smartest thing loop-deploy3 did was sleep for ten seconds right at the start of its run. This was just so that I, the one who called the script, had a moment to think about what I'd just done and could Ctrl-C the script to end it before it did something stupid on my behalf. This was not a "day one" feature and was added after I made it do something stupid on my behalf.) loop-deploy3 was run, and patched, and run again, and again, and then again, vetted over the course of 1,220 machines, dozens of image updates, and countless reboots, repairs, redeployments, scale-ups, scale-downs, and a bunch of other service somersaults that naturally occur in organic, growing system usage. Its lifespan was short, but its impact was immeasurably vast. I may never look upon its likes again.

External links

https://blog.netnerds.net/2016/12/runspaces-simplified/

https://imanage.com/blog/quickly-and-easily-threading-your-powershell-scripts/

https://devblogs.microsoft.com/powershell/scaling-and-queuing-powershell-background-jobs/

https://devblogs.microsoft.com/powershell/powershell-foreach-object-parallel-feature/

https://adamtheautomator.com/powershell-multithreading/

https://blogs.technet.microsoft.com/heyscriptingguy/2015/11/26/beginning-use-of-powershell-runspaces-part-1/

https://github.com/proxb/PoshRSJob