Files
shopdb-flask/plugins/printers/client/Install-ShopdbPrinterDrivers.ps1
Cam Proudlock d267813999 Printer assignment end to end, and a container that keeps a site's files
Assign printers to a MACHINE and let whichever PC controls it install them, with
the observed half kept strictly apart from the assigned half: a collector report
can never write an assignment row. Adoption is explicit. Adds a singular
relationship type so defaultprinter can mean exactly one, client scripts that
converge a bay's queues and correct a drifted one in place, and a wave gate so a
fleet-wide driver rollout cannot stampede its own share.

On the container side: the compose stack never persisted the instance directory,
so a rebuild discarded plugins.json and every upload. It is a volume now, the
image ships instance/ owned by the app user so the mountpoint is writable, and
the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended
since 8.0 went end of life. The image also could not build at all: the frontend
stage never copied the plugin staging script it runs.

Adds a topology migration guide covering IIS to Docker and back.
2026-08-20 07:46:35 -04:00

203 lines
7.8 KiB
PowerShell

# Install-ShopdbPrinterDrivers.ps1
#
# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every
# printer driver it might need in one converging run. Wraps
# Install-ShopdbPrinterDriver.ps1, which does one driver.
#
# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than
# performing an install: a driver already present is skipped, so this is safe to
# run on a schedule and cheap when there is nothing to do. That is what lets a
# DSC Script resource call it from TestScript as well as SetScript.
#
# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by
# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and
# where its package lives. Paths are relative to this script, or absolute (a UNC
# path on a site's share is normal).
#
# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when
# one or more could not be installed. DSC needs a real answer here, unlike the
# single-driver script which never fails an enforcement run. The per-driver log
# says which and why.
#
# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share,
# which is mounted ONLY during the enforcement cycle. Run this as a manifest
# entry inside that cycle, not as its own scheduled task.
#
# WAVES, because a driver package is not a manifest check. GE-Enforce gives each
# PC a start offset of SHA256(hostname) % 5 MINUTES and then repeats every 5
# minutes, which was sized for reading a few KB of JSON. A driver set is 100 MB
# for the two universals and 226 MB for a full site, so the day an entry lands
# the whole fleet pulls it inside one 5-minute window: roughly 30 GB across 300
# bays, on the same share every bay needs for everything else. The failure is not
# slow drivers, it is a floor that stops converging.
#
# -WaveStart with -Waves spreads that out. Each PC derives its own wave from its
# hostname and does nothing until its turn, so there is no central coordination,
# no per-bay configuration and nothing to reconcile afterwards. The hash is the
# same idiom Register-GEEnforce.ps1 uses for its offset, SHA-256 rather than MD5
# because FIPS-enforced bays disable MD5 outright.
#
# A bay switched off during its wave installs on its next cycle instead. Late is
# a non-event here; the wave sets the earliest moment, not a deadline.
#
# Unparseable wave arguments FAIL CLOSED - nothing installs, and it says so every
# cycle. Failing open would restore precisely the stampede this exists to avoid,
# and 30 GB cannot be un-sent, whereas a typo that installs nothing is loud in
# the log and fixed in a minute.
param(
# Defaults to drivers.json beside this script.
[string]$Manifest = '',
# Install only these driver names; everything else in the manifest is
# ignored. For a bay that needs one driver out of a site-wide set.
[string[]]$Only = @(),
# Report what is missing and change nothing. This is what a DSC TestScript
# calls: exit 0 means compliant. A bay whose wave has not arrived is
# compliant BY DESIGN - not installed is its desired state today.
[switch]$TestOnly,
# Date the rollout opens, e.g. '2026-08-25' or '2026-08-25 22:00'. Empty
# means no gating at all, which is right for imaging time: a bay being built
# is one bay, and it should come off the line complete.
[string]$WaveStart = '',
# How many waves to spread the fleet across. 0 or 1 means everyone at once.
# 300 bays over 10 daily waves is ~30 bays and ~3 GB a day.
[int]$Waves = 0,
# Wave spacing. Hours finishes a 10-wave rollout inside a day; Days is the
# cautious setting when nobody is watching the share.
[ValidateSet('Days', 'Hours')]
[string]$WaveUnit = 'Days',
# Pilot bays skip the gate. Use this to prove one bay before the fleet.
[switch]$IgnoreWave
)
$ErrorActionPreference = 'Continue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' }
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# The gate runs BEFORE the manifest is read, because the manifest lives on the
# share too. A bay that is not due this wave must not touch the share at all -
# reading it is cheap, but 300 bays deciding to read it in the same 5 minutes is
# how this whole problem starts.
if ($WaveStart -and -not $IgnoreWave) {
$start = [datetime]::MinValue
if (-not [datetime]::TryParse($WaveStart, [ref]$start)) {
Log "ERROR -WaveStart '$WaveStart' is not a date. Refusing to install."
Log ' (failing closed on purpose: guessing here would release the whole fleet at once)'
exit 1
}
$waveCount = $Waves
if ($waveCount -lt 1) { $waveCount = 1 }
# Same hostname hash as the enforcer's own start offset, so a bay's wave is
# stable for the life of its name: it cannot drift between cycles, and a
# rerun never moves a PC into a different wave.
$hostHash = [System.BitConverter]::ToUInt32(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes([System.Environment]::MachineName)), 0)
$wave = [int]($hostHash % [uint32]$waveCount)
if ($WaveUnit -eq 'Hours') { $due = $start.AddHours($wave) }
else { $due = $start.AddDays($wave) }
if ((Get-Date) -lt $due) {
Log ("wave {0} of {1} for {2}; not due until {3}. Nothing to do." -f `
$wave, $waveCount, [System.Environment]::MachineName,
$due.ToString('yyyy-MM-dd HH:mm'))
exit 0
}
Log ("wave {0} of {1} opened {2}; proceeding" -f `
$wave, $waveCount, $due.ToString('yyyy-MM-dd HH:mm'))
}
if (-not (Test-Path $Manifest)) {
Log "ERROR manifest not found: $Manifest"
exit 1
}
try {
$config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json
} catch {
Log "ERROR manifest is not valid JSON: $($_.Exception.Message)"
exit 1
}
$wanted = @($config.drivers)
if ($Only.Count -gt 0) {
$wanted = @($wanted | Where-Object { $Only -contains $_.drivername })
}
if ($wanted.Count -eq 0) {
Log "nothing to do: the manifest selects no drivers"
exit 0
}
$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1'
if (-not (Test-Path $single)) {
Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script"
exit 1
}
$missing = @()
foreach ($driver in $wanted) {
$name = $driver.drivername
if (-not $name) { continue }
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
Log "present: $name"
continue
}
if ($TestOnly) {
Log "MISSING: $name"
$missing += $name
continue
}
# Relative paths are resolved against the package, so the whole thing can be
# copied anywhere - a share, C:\ProgramData, an Intune staging folder - and
# still find its own payloads.
$path = $driver.path
if ($path -and -not [System.IO.Path]::IsPathRooted($path)) {
$path = Join-Path $here $path
}
if (-not $path -or -not (Test-Path $path)) {
Log "ERROR package not found for '$name': $path"
$missing += $name
continue
}
Log "installing: $name"
& $single -DriverName $name -Source $path | Out-Null
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
Log "installed: $name"
} else {
Log "FAILED: $name (see the per-driver lines above)"
$missing += $name
}
}
if ($missing.Count -gt 0) {
Log ("not present: {0}" -f ($missing -join ', '))
exit 1
}
Log "all $($wanted.Count) driver(s) present"
exit 0