Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a65c6e3
docs(fusion): spec for Phase 0 context-support prerequisites
juemerson-at-purestorage Aug 4, 2026
0779ef6
docs(fusion): coordinate Phase 0 schemaVersion bump with #84
juemerson-at-purestorage Aug 4, 2026
0ac0eb5
docs(fusion): dissolve the #84 pairing, Phase 0 takes schemaVersion 2
juemerson-at-purestorage Aug 4, 2026
74ada89
feat(private): add Resolve-PfbParameterComponent for capability-map c…
juemerson-at-purestorage Aug 4, 2026
b5bd095
fix(private): resolve component keys on dictionary maps, not just PSC…
juemerson-at-purestorage Aug 5, 2026
d5a9a69
refactor(tools): consume Private/ resolver and constants instead of i…
juemerson-at-purestorage Aug 5, 2026
15bd96b
feat(tools): read Fusion remote-execution annotations from the spec
juemerson-at-purestorage Aug 5, 2026
867a727
feat(tools): emit per-endpoint contextScope, capability map schemaVer…
juemerson-at-purestorage Aug 5, 2026
b5de365
test(tools): drift guard on the contextScope curated table
juemerson-at-purestorage Aug 5, 2026
9c0e6ce
feat(connection): warn once when the array outruns the bundled capabi…
juemerson-at-purestorage Aug 5, 2026
dd6041a
feat(replication): add top-level MemberName/FleetName to Get-PfbFleet…
juemerson-at-purestorage Aug 5, 2026
4241778
fix(tools): close four review gaps in the contextScope work
juemerson-at-purestorage Aug 5, 2026
33fbd32
fix(connection): remove the duplicate version parse and close 3 mutat…
juemerson-at-purestorage Aug 5, 2026
6ae04e8
test(tools): run the contextScope synthetic suite on 5.1 and pin flag…
juemerson-at-purestorage Aug 5, 2026
4f07080
docs(tools): document contextScope and schemaVersion 2 in the tools R…
juemerson-at-purestorage Aug 5, 2026
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
3,196 changes: 2,862 additions & 334 deletions Data/PfbCapabilityMap.json

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions Private/PfbContextConstants.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Fusion context parameter names, shared by the runtime injection path (Phase 1) and the
# maintainer drift check in tools/lib/PfbContextRuleTools.ps1.
#
# These live in Private/ rather than tools/ because tools/ may depend on Private/ and never
# the reverse -- the same dependency direction issue #74 exists to fix. tools/ dot-sources
# this file explicitly, since tools/ is not on the module's load path.
#
# The 'Context_names_get' component literal is deliberately NOT here: it is the cardinality
# rule's own business and already lives inside Test-PfbContextMultiValueCapable, which is
# THE single declared home of that rule. A second definition here would be exactly the
# duplication #74 is about.
$script:PfbContextParameterName = 'context_names'
$script:PfbAllowErrorsParameterName = 'allow_errors'
102 changes: 102 additions & 0 deletions Private/Resolve-PfbParameterComponent.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
function Resolve-PfbParameterComponent {
<#
.SYNOPSIS
Resolves which OpenAPI component backs a named parameter on one capability-map
endpoint entry, per the map's three-step resolution contract.
.DESCRIPTION
THE single runtime home of the contract documented in
tools/Build-PfbCapabilityMap.ps1's second pass. Three ordered steps:

1. If the endpoint's parameterComponentOverrides CONTAINS THE KEY for this
parameter, that value is authoritative -- EVEN WHEN IT IS JSON null, which
means "this endpoint's parameter has no component". Defaults are not consulted.
2. Otherwise, if the map's top-level parameterComponentDefaults contains the
parameter name, use that.
3. Otherwise, there is no known component.

WHY THIS IS ITS OWN FUNCTION. Steps 1-with-null and 3 both return $null, so the
distinction is NOT observable in the return value -- it is entirely about whether
step 2 is allowed to run. An implementation that tests the override's VALUE
(`if ($overrides.$name)`) rather than the KEY's PRESENCE silently falls through to
the default for a null override, yielding a component name the endpoint does not
actually have. Only 7 of 4109 parameter declarations in fb2.27 are inline/no-$ref,
so a wrong implementation is right almost everywhere -- and wrong precisely on the
endpoints the Fusion cardinality rule (Test-PfbContextMultiValueCapable) depends on.

Extracted from tools/lib/PfbContextRuleTools.ps1 per issue #74: the module could not
feed its own declared cardinality rule, because the code producing the rule's
-ContextComponent input lived in tools/, which the module never loads. tools/ may
depend on Private/; never the reverse.

Deliberately parameterised on -ParameterName rather than hardcoded to
'context_names': the contract is a property of the capability map's shape, not of
Fusion, and allow_errors needs the same resolution in Phase 2.

PURE -- no map loading, no file I/O. The caller supplies the entry and the defaults
table, exactly like Test-PfbContextMultiValueCapable.
.PARAMETER EndpointEntry
One capability-map endpoints.<key> object, as parsed from
Data/PfbCapabilityMap.json.
.PARAMETER ParameterName
The API parameter name to resolve, e.g. 'context_names'.
.PARAMETER ParameterComponentDefaults
The map's top-level parameterComponentDefaults object. Pass $null when unavailable;
resolution then stops after step 1.
.OUTPUTS
[string] the resolved component name, or $null when the endpoint's parameter has no
component (step 1 with a null override) or no component is known (step 3). These two
outcomes are deliberately indistinguishable to the caller.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
$EndpointEntry,

[Parameter(Mandatory)]
[string]$ParameterName,

[AllowNull()]
$ParameterComponentDefaults
)

# Key PRESENCE, never the value's truthiness -- see .DESCRIPTION. A present key whose
# value is null returns null here and must NOT reach the defaults lookup below.
#
# Both container shapes must be handled, and they need DIFFERENT operators:
# * PSCustomObject -- what ConvertFrom-Json yields reading Data/PfbCapabilityMap.json.
# * IDictionary ([ordered]@{}) -- what tools/Build-PfbCapabilityMap.ps1 builds IN
# MEMORY before serializing, so any tools/ caller resolving against the map it just
# built passes this shape.
# For a Hashtable/OrderedDictionary, .PSObject.Properties.Name does NOT return the keys
# -- it returns the adapted CLR surface (Keys, Values, Count, IsReadOnly, ...). A
# presence test written only against .PSObject.Properties.Name is therefore ALWAYS FALSE
# for dictionary input and silently falls through to the defaults, reproducing the exact
# wrong-component defect this function exists to prevent. It is also a false POSITIVE
# for an intrinsic name ('Count' would "resolve" to the item count).
#
# Empty containers are covered by key absence, not truthiness: an empty PSCustomObject
# AND an empty hashtable are both truthy, so truthiness carries no information here.
$overrides = $EndpointEntry.parameterComponentOverrides
if ($null -ne $overrides) {
if ($overrides -is [System.Collections.IDictionary]) {
if ($overrides.Contains($ParameterName)) { return $overrides[$ParameterName] }
}
elseif ($overrides.PSObject.Properties.Name -contains $ParameterName) {
return $overrides.$ParameterName
}
}

if ($null -ne $ParameterComponentDefaults) {
if ($ParameterComponentDefaults -is [System.Collections.IDictionary]) {
if ($ParameterComponentDefaults.Contains($ParameterName)) {
return $ParameterComponentDefaults[$ParameterName]
}
}
elseif ($ParameterComponentDefaults.PSObject.Properties.Name -contains $ParameterName) {
return $ParameterComponentDefaults.$ParameterName
}
}

return $null
}
85 changes: 85 additions & 0 deletions Private/Test-PfbCapabilityMapCoverage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
function Test-PfbCapabilityMapCoverage {
<#
.SYNOPSIS
Does the connected array's negotiated REST version exceed the range the bundled
capability map was generated from?
.DESCRIPTION
The honesty half of Assert-PfbApiCapability's permissive fallback. When the array is
newer than the map, the module has no evidence either way and deliberately proceeds
rather than blocking -- a cmdlet caller never chose the negotiated version, so
refusing a call because the BUNDLED MAP is behind punishes a packaging lag they
cannot see. Telling them is what makes that trade honest.

This function contributes POLICY, not arithmetic. The comparison is delegated to
Test-PfbVersionAtLeast -- the same comparator Assert-PfbApiCapability uses -- so the
warning and the capability check can never disagree about what is in range. Finding
the highest scanned version is delegated to ConvertTo-PfbVersionObject, which
returns its results sorted Major,Minor DESCENDING, so element [0] is the maximum.
Do not reimplement either; a naive string compare ranks '2.9' above '2.26', a bug
already found twice in this repo.

The policy: return $false whenever the answer cannot be established -- a missing
map, a generatedFrom that is absent/null/empty, or an unparseable version string.
"Nothing to check against" must never become a warning, exactly as it never becomes a
hard failure in Get-PfbCapabilityMap.

Note which guard catches what: the .Count check below only short-circuits a literal
empty array. An ABSENT or $null generatedFrom becomes @($null), whose Count is 1, so
it falls through to the try and is caught there. Both routes return $false; the
guards are defence in depth rather than the only cover, and neither is individually
load-bearing.

-HighestScanned exists so the CALLER never has to re-parse. The warning text needs
the maximum, and computing it a second time at the call site would put an unguarded
copy of this function's own throwing expression outside this try/catch -- safe only
by an invariant living in a different file. Handing it back keeps one parse, inside
one guard. This mirrors Assert-PfbConnection's existing [ref] idiom.
.PARAMETER NegotiatedVersion
The REST version Connect-PfbArray negotiated with the array, e.g. '2.28'.
.PARAMETER CapabilityMap
The parsed capability map, or $null when it could not be loaded.
.PARAMETER HighestScanned
Optional [ref]. Receives the highest version in generatedFrom when that could be
determined. Only meaningful when this function returns $true; left untouched
otherwise.
.OUTPUTS
[bool]
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory)]
[string]$NegotiatedVersion,

[AllowNull()]
$CapabilityMap,

[AllowNull()]
[ref]$HighestScanned
)

if ($null -eq $CapabilityMap) { return $false }

$scanned = @($CapabilityMap.generatedFrom)
if ($scanned.Count -eq 0) { return $false }

# Both helpers cast their split parts with [int] and do not guard the input, so a
# malformed version string throws rather than returning a verdict. Per the policy above
# that must be silence, not a warning and not a failed Connect-PfbArray.
try {
$highest = (ConvertTo-PfbVersionObject -Versions $scanned)[0].Version

# "At least" is >=, and an exact match must NOT warn. So the question is whether the
# map fails to reach the array: NOT (highest >= negotiated).
$exceeds = -not (Test-PfbVersionAtLeast -Have $highest -Need $NegotiatedVersion)

# Publish the max only once the comparison has succeeded, so the caller can never
# read a value produced by a half-completed parse.
if ($exceeds -and $null -ne $HighestScanned) { $HighestScanned.Value = $highest }
return $exceeds
}
catch {
Write-Verbose "Test-PfbCapabilityMapCoverage: could not compare '$NegotiatedVersion' against the map's generatedFrom ($($scanned -join ', ')): $($_.Exception.Message)"
return $false
}
}
32 changes: 32 additions & 0 deletions Public/Connection/Connect-PfbArray.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,35 @@ function Connect-PfbArray {
throw "Authentication failed: No x-auth-token received from FlashBlade '${Endpoint}'."
}

# Capability-map coverage check: run ONCE here, unconditionally, so the result is
# cached on the connection for its whole life rather than recomputed per call. Firing
# at connect universally also means Phase 1's context cmdlets need no trigger of their
# own. Deliberately no Gallery lookup -- this module runs against air-gapped arrays, so
# point at the remedy rather than probing for it.
# Get-PfbCapabilityMap returns $null for a MISSING file, but it does not guard
# ConvertFrom-Json -- a corrupt or truncated shipped map THROWS. Today that surfaces at
# request time inside Assert-PfbApiCapability; calling it here unguarded would newly
# break Connect-PfbArray itself, so you could not even establish a connection to
# diagnose it. A diagnostic warning must never be able to fail the connect path.
$capabilityMap = $null
try { $capabilityMap = Get-PfbCapabilityMap }
catch { Write-Verbose "Connect-PfbArray: capability map could not be loaded for the coverage check: $($_.Exception.Message)" }

# The helper hands back the highest scanned version it already computed, so this path
# never re-parses generatedFrom. Recomputing here would place an unguarded copy of the
# helper's own throwing expression OUTSIDE its try/catch -- safe only by an invariant
# living in another file, which is exactly the kind of non-local coupling this phase is
# removing. One parse, inside one guard.
$highestScanned = $null
$exceedsCapabilityMapCoverage = Test-PfbCapabilityMapCoverage -NegotiatedVersion $negotiatedVersion -CapabilityMap $capabilityMap -HighestScanned ([ref]$highestScanned)
if ($exceedsCapabilityMapCoverage) {
Write-Warning ("Connected array is running REST $negotiatedVersion; this module's " +
"capability map only covers through REST $highestScanned -- capability checks " +
'for anything newer, including context scoping, cannot be fully verified and ' +
'may not error even if unsupported. Check the PowerShell Gallery for a newer ' +
'release (Update-Module).')
}

# Build connection object — properties align with PureRestClientBase (Pfa2)
$connection = [PSCustomObject]@{
PSTypeName = 'PureStorage.FlashBlade.Connection'
Expand All @@ -386,6 +415,9 @@ function Connect-PfbArray {
SkipCertificateCheck = [bool]$IgnoreCertificateError
HttpTimeoutMs = $HttpTimeout
ConnectedAt = [datetime]::UtcNow
# Diagnostic: array outruns the bundled capability map's scanned range. Deliberately
# NOT in $defaultProps below -- diagnostic state, not default-display material.
ExceedsCapabilityMapCoverage = $exceedsCapabilityMapCoverage
# Certificate/OAuth2 refresh state — only populated when AuthMethod is 'Certificate'
ClientId = $ClientId
Issuer = $Issuer
Expand Down
29 changes: 28 additions & 1 deletion Public/Replication/Get-PfbFleetMember.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,32 @@ function Get-PfbFleetMember {
if ($FleetName) { $queryParams['fleet_names'] = $FleetName -join ',' }
if ($MemberName) { $queryParams['member_names'] = $MemberName -join ',' }

Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'fleets/members' -QueryParams $queryParams -AutoPaginate
# Decorate with top-level MemberName/FleetName. ValueFromPipelineByPropertyName matches
# only TOP-LEVEL names, and this endpoint nests the array name at .member.name -- so
# without this, piping into a context-scoping cmdlet binds nothing and silently no-ops.
# Additive by design: the raw nested member/fleet objects stay, so existing callers
# reaching .member.name keep working.
#
# Runs per emitted item, so -AutoPaginate's later pages are decorated too.
#
# Deliberately no IsLocal: is_local is relative to the CALL'S CONTEXT rather than the
# connection, so a Where-Object { -not $_.IsLocal } idiom would silently select a
# different array once a context is active.
Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'fleets/members' -QueryParams $queryParams -AutoPaginate |
ForEach-Object {
# Invoke-PfbApiRequest returns a bare { total_item_count = N } sentinel -- not a
# member -- when the API reports a count but no items (see its trailing return).
# A filter matching nothing takes that path. Decorating it would produce an
# object that LOOKS like a fleet member whose name is $null, which is the exact
# silent-wrong-binding failure this decoration exists to prevent: piping it into
# a context-scoping cmdlet would bind a $null name. Pass it through undecorated
# so the absence of MemberName fails loudly instead.
if (($_.PSObject.Properties.Name -contains 'total_item_count') -and
($_.PSObject.Properties.Name -notcontains 'member')) {
return $_
}

Add-Member -InputObject $_ -MemberType NoteProperty -Name 'MemberName' -Value $_.member.name -Force -PassThru |
Add-Member -MemberType NoteProperty -Name 'FleetName' -Value $_.fleet.name -Force -PassThru
}
}
Loading