Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,38 @@ All notable changes to this module are recorded here. Format follows

## [Unreleased]

Nothing yet.
### Added

- **The module detects which PowerShell it is running on, once, and uses what that one can
do.** Windows PowerShell 5.1 stays, because a freshly built domain controller has nothing else;
the REST providers are better served by PowerShell 7.4. `Get-TestRuntime` in Core reads the
edition at import and detects each capability on the cmdlet that has it - a parameter that
exists on `Invoke-WebRequest` is one that works, whatever the build - and everything that
differs by edition reads that object instead of testing `$PSVersionTable` for itself, which
seven places used to do. `Get-TestEnvironmentRuntime` shows the decision: Edition, Version,
Platform, the capabilities found, what the HTTP layer does with them, and which PowerShell is
preferred - 7.4 or later for every provider, with 5.1 supported for the domain controller -
said in the object so a run on the slower edition sees it without reading the help.

What differs today: on PowerShell 7 a failed response is asked for with `-SkipHttpErrorCheck`
and its body read like any other, and TLS is left to negotiate; on Windows PowerShell the
cmdlet throws and the body is read once from the exception's response stream, and TLS 1.2 is
added. HTTP/2 is not requested: `-HttpVersion 2.0` was tried on PowerShell 7 and a core-tier
PingOne seed measured 25 seconds with it and 25 without, twice each, and a capability that
changes nothing is noise. Either way a provider now receives one error shape - an integer
`Response.StatusCode`, a `Response.Headers` table with each value one string, the body in
`ErrorDetails` - so the four `Get-<Provider>ErrorDetail` helpers no longer read a stream and the
retry loops read one header table on both editions. A transport failure with no response
propagates untouched. Verified live against PingOne from both editions: the same 404 and the
same validation refusal, word for word, and the headers present on both.

What was tried and not kept: moving the Active Directory user and device steps from
`Start-Job` onto the runspace pool. Measured on the lab domain controller, the users were
created in 10 seconds instead of 28 and then 270 of 310 manager assignments failed with
"invalid enumeration context", and the device step took five and a half minutes instead of 33
seconds with 148 failures: the RSAT module keeps one ADWS session per process, and runspaces in
one process trample its enumeration contexts. The steps stay on a process per batch, and
`CLAUDE.md` says why.

## [1.3.0] - 2026-09-14

Expand Down
68 changes: 54 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ now proves every `$script:` variable a provider reads is assigned somewhere.
Every provider shares one session state, so two providers defining the same function name
means the second silently wins. The contract test checks for that too.

### The edition is read once, in `Core/Get-TestRuntime.ps1`, and nothing else tests `$PSVersionTable` for it

The module runs on Windows PowerShell 5.1, because a freshly built domain controller has nothing
else, and on PowerShell 7.4, which the REST providers are better served by. `Get-TestRuntime`
detects what the running PowerShell can do - on the cmdlet, never from a version number: a
parameter that exists on `Invoke-WebRequest` is one that works - caches the answer in
`$script:TestEnvironmentRuntime` at import, and `Get-TestEnvironmentRuntime` shows it. Everything
that differs by edition reads that object; a new `$PSVersionTable.PSEdition` test elsewhere is the
old scattering coming back.

What it decides today lives in `Invoke-TestWebRequest`: with `-SkipHttpErrorCheck` (PowerShell 7)
a failed response comes back as a response and its body is read like any other; without it
(Windows PowerShell) the cmdlet throws and the body is read once from the exception's response
stream. Either way the caller gets the one shape `New-TestWebRequestError` builds - an integer
`Response.StatusCode`, a hashtable `Response.Headers` with each value one string, the body in
`ErrorDetails` - so a provider's retry loop and its `Get-<Provider>ErrorDetail` read one thing and
nothing outside Core touches a response stream. TLS 1.2 is added only where the edition needs it.
HTTP/2 is deliberately not requested: `-HttpVersion 2.0` was tried on 7 and a core-tier PingOne seed
measured 25 seconds with it and 25 without, twice each, so it is not a decision the runtime makes. A
transport failure with no response propagates untouched. The two things that are the same on both editions - bodies as UTF-8 bytes, responses
decoded from raw bytes - are in the section below and are not optional on either.

Two lessons from building it, both in the suite: a `WebHeaderCollection` assigned from an `if`
expression arrives as an array of its key names, because a statement's output is enumerated on the
way out, so it is assigned inside the branch; and Windows PowerShell rewraps a bare thrown
exception and loses a member added to it, so a test fake that needs `Response` on its exception
throws an `ErrorRecord`.

### FreeIPA sends in batches; Authentik works on a runspace pool; both keep the row as the unit

The FreeIPA seed steps for users, hosts and DNS records decide each row one at a time - the
Expand All @@ -70,13 +98,23 @@ block inline; a suite that forgets to would call the real instance URL from the
DNS rather than pass. The groups sweep asks for one worker, because a group is deleted before the
group it nests under and that order has to hold.

### Never read module scope inside `Start-Job`
The Active Directory user and device steps stay on `Start-Job`, a process per batch, and that is
not an oversight. Moving them onto the pool was tried on 2026-09-16 and measured on the lab domain
controller: the users were created in 10 seconds instead of 28, and then 270 of 310 manager
assignments failed with "invalid enumeration context" and "a connection to the directory was
unavailable", and the device step took five and a half minutes instead of 33 seconds with 148
failures. The RSAT `ActiveDirectory` module keeps one ADWS session per process, and runspaces in
one process trample its enumeration contexts; a process per batch is what keeps them apart. The
jobs cost the marshalling through `-ArgumentList`, and they are worth it.

### Never read module scope inside `Start-Job` or a worker block

A job runs in a fresh runspace where `$script:Anything` is empty and module functions are
undefined, and neither fails loudly. The AD provider once did this for its prefix: a live
run created 688 computers with no prefix and silently failed to create all 296 users. Pass
values through `-ArgumentList`. A contract test walks every `Start-Job` body for `$script:`
reads and module-function calls.
A job and a worker runspace both start without this session's state: `$script:Anything` is empty
there, and in a job the module's functions are undefined as well, and neither fails loudly. The AD
provider once did this for its prefix: a live run created 688 computers with no prefix and silently
failed to create all 296 users. Pass values through `-ArgumentList` on a job and `-Parameter` on
the pool. A contract test walks every `Start-Job` body for `$script:` reads and module-function
calls, and every `Invoke-TestParallel` block for `$script:` reads.

### The AD commands keep a `Test` infix; the others do not

Expand Down Expand Up @@ -184,7 +222,8 @@ and its error and retry handling:
`Invoke-RestMethod`. 5.1 decodes by the declared charset and falls back to Latin-1; Okta declares
none, which turned every accented name into mojibake. A service that declares UTF-8 today is not a
reason to trust it, because the header is not this module's to control.
- **TLS 1.2 is added on the Desktop edition**, only ever adding to the enabled set.
- **TLS 1.2 is added on the Desktop edition**, only ever adding to the enabled set. PowerShell 7
negotiates on its own, and `Get-TestRuntime` is what says which this is.
- **The progress bar is suppressed** around `Invoke-WebRequest`, which on 5.1 costs more than the calls.

FreeIPA reaches the same result through `HttpClient`: `StringContent` with UTF-8 out, and
Expand Down Expand Up @@ -436,13 +475,14 @@ then a SecretManagement secret named `PSGallery-ApiKey`.
## Targeting

Windows PowerShell 5.1 and PowerShell 7, `CompatiblePSEditions = Desktop, Core`. That rules
out the ternary and null-coalescing operators, `ForEach-Object -Parallel`, and anything else
7-only, anywhere in `Core/`, `Providers/` or `Public/`. The `desktop` job in
`quality-gates.yml` imports the module under 5.1 to catch it, and then runs the whole suite
there, because 5.1 also differs at run time in ways a suite run on 7 cannot see: string bodies
sent as Latin-1, responses decoded by their declared charset, a name above the basic plane
measured one longer. The build script under
`Build/` is 7.4-only, which is fine: it never ships.
out the ternary and null-coalescing operators, `ForEach-Object -Parallel`, `Sort-Object -Stable`,
and anything else 7-only, anywhere in `Core/`, `Providers/` or `Public/`; a 7-only *parameter* is
used only behind a capability `Get-TestRuntime` detected, the way `Invoke-TestWebRequest` uses
`-SkipHttpErrorCheck`. The `desktop` job in `quality-gates.yml` imports the
module under 5.1 to catch it, and then runs the whole suite there, because 5.1 also differs at run
time in ways a suite run on 7 cannot see: string bodies sent as Latin-1, responses decoded by their
declared charset, a name above the basic plane measured one longer, a parameter the 7 suite passed
that 5.1 does not have. The build script under `Build/` is 7.4-only, which is fine: it never ships.

## Checks

Expand Down
128 changes: 128 additions & 0 deletions Core/Get-TestRuntime.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
function Get-TestRuntime {
<#
.SYNOPSIS
Detects the PowerShell the module is running on and decides, once, which methods it uses

.DESCRIPTION
The module targets Windows PowerShell 5.1, because a freshly built domain controller has
nothing else, and PowerShell 7.4, which the REST providers are better served by. The two
differ in what Invoke-WebRequest can do and in what it gets wrong, and every place that
cares used to test $PSVersionTable for itself. This is now the one place the edition is
read, and what it returns is what the rest of the module acts on.

Each capability is detected on the cmdlet, never inferred from a version number: a
parameter that exists is one that works on whatever build this is, and a version check
would be wrong the day a backport or a preview moved one. The result is computed on
first use and cached in module scope, because it cannot change for the life of the
session.

What is decided here:

- Http.ErrorBody: how the body of a failed response is read. 'SkipHttpErrorCheck' where
Invoke-WebRequest has that switch (PowerShell 7), so a 4xx or 5xx comes back as a
response and the body is read like any other; 'ResponseStream' on Windows PowerShell,
where the cmdlet throws and the body has to be pulled from the exception's response
stream, once, because the stream cannot be read twice.
- Not HTTP/2, deliberately. -HttpVersion exists from PowerShell 7.3 and was tried: a
core-tier PingOne seed measured 25 seconds with it and 25 without, twice each, so it
is not requested and not listed. A capability that changes nothing is noise.
- Http.Tls: 'Default' on PowerShell 7, whose HttpClient negotiates TLS 1.2 and 1.3 on
its own; 'Tls12Added' on Windows PowerShell, which can still default to TLS 1.0 and
has TLS 1.2 added to the enabled set, never removing any.
- Http.Encoding and Http.Progress are the same on both editions and are listed so the
object says everything the HTTP layer does: the body always goes out as UTF-8 bytes
and the response is always decoded from its raw bytes, because Windows PowerShell
corrupts both silently and PowerShell 7 merely hides that; and the progress bar is
suppressed around every call, which on Windows PowerShell costs more than the call.
- Parallel: 'RunspacePool' on both. Invoke-TestParallel runs a block over many items on
a pool of runspaces with the module loaded, which is the mechanism ForEach-Object
-Parallel is built on and is available on Windows PowerShell 5.1.
- Preferred and Recommendation: whether this is the PowerShell the module is best run
on, and one sentence saying so. PowerShell 7.4 or later is preferred for every
provider; Windows PowerShell 5.1 is supported because a freshly built domain
controller has nothing else, and the Active Directory provider is at home there.
Said here, in the object, so a run on the slower edition can see it without reading
the help.

.PARAMETER Refresh
Detect again rather than returning the cached result. For tests.

.EXAMPLE
PS> (Get-TestRuntime).Http.ErrorBody

DESCRIPTION: Which way a failed response's body is read on this host
OUTPUT: SkipHttpErrorCheck on PowerShell 7, ResponseStream on Windows PowerShell
USE CASE: Called by Invoke-TestWebRequest on every call, from the cache

.OUTPUTS
PSCustomObject of type TestEnvironmentRuntime: Edition, Version, Platform, Preferred,
Recommendation, Capability (one boolean per detected feature), Http, Parallel.

.NOTES
Author: Jeffrey Stuhr
Blog: https://www.techbyjeff.net
LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter()]
[switch]$Refresh
)

if ($script:TestEnvironmentRuntime -and -not $Refresh) { return $script:TestEnvironmentRuntime }

# $PSVersionTable is a hashtable: a key it lacks reads as null, and Windows PowerShell 5.0
# lacks PSEdition altogether.
$edition = if ($PSVersionTable['PSEdition']) { [string]$PSVersionTable['PSEdition'] } else { 'Desktop' }
$version = [version]$PSVersionTable['PSVersion']
$platform = if ($edition -eq 'Desktop') { 'Windows' }
elseif ($PSVersionTable['Platform'] -eq 'Win32NT') { 'Windows' }
elseif ([string]$PSVersionTable['OS'] -match 'Darwin') { 'macOS' }
elseif ($PSVersionTable['Platform'] -eq 'Unix') { 'Linux' }
else { 'Unknown' }

$webRequest = @((Get-Command -Name Invoke-WebRequest -CommandType Cmdlet -ErrorAction Stop).Parameters.Keys)
$fromJson = @((Get-Command -Name ConvertFrom-Json -CommandType Cmdlet -ErrorAction Stop).Parameters.Keys)

$capability = [PSCustomObject]@{
SkipHttpErrorCheck = ($webRequest -contains 'SkipHttpErrorCheck')
HttpTimeouts = ($webRequest -contains 'ConnectionTimeoutSeconds')
JsonAsHashtable = ($fromJson -contains 'AsHashtable')
ModernTls = ($edition -eq 'Core')
NativeUtf8 = ($edition -eq 'Core')
}

$http = [PSCustomObject]@{
ErrorBody = if ($capability.SkipHttpErrorCheck) { 'SkipHttpErrorCheck' } else { 'ResponseStream' }
Tls = if ($capability.ModernTls) { 'Default' } else { 'Tls12Added' }
Encoding = 'Utf8Bytes'
Progress = 'Suppressed'
}

$preferred = ($edition -eq 'Core' -and $version -ge [version]'7.4')
$recommendation = if ($preferred) {
'PowerShell 7.4 or later: the preferred PowerShell for every provider.'
}
elseif ($edition -eq 'Core') {
'PowerShell 7.4 or later is preferred; this build has the same HTTP paths and is supported.'
}
else {
'Windows PowerShell 5.1 is supported and is what a freshly built domain controller has; the Entra, Okta, Authentik, FreeIPA and PingOne providers are better served by PowerShell 7.4 or later.'
}

$script:TestEnvironmentRuntime = [PSCustomObject]@{
PSTypeName = 'TestEnvironmentRuntime'
Edition = $edition
Version = $version
Platform = $platform
Preferred = $preferred
Recommendation = $recommendation
Capability = $capability
Http = $http
Parallel = 'RunspacePool'
}
Write-Verbose ("Running on PowerShell $version ($edition, $platform): failed HTTP bodies read by " +
"$($http.ErrorBody), TLS $($http.Tls)")
return $script:TestEnvironmentRuntime
}
Loading