From 1518c85979b7721dd1e247c021ec3b3125853e46 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Tue, 25 Aug 2026 16:54:04 -0700 Subject: [PATCH 1/3] feat(fleet): add -FleetKey to New-PfbFleetMember with self-identification Joining a fleet needed the caller to hand-build the request body and to know the joining array's own id: New-PfbFleetMember -FleetName f -Members @{ key = $k; member = @{ id = $selfId } } Both halves are recoverable from context. The key comes from New-PfbFleetKey, and POST /fleets/members must be called from the array that is joining, so that array is by definition the one the connection already points at. -FleetKey takes the key and resolves the id with Get-PfbArray over the same connection. -Members stays as the passthrough for what the convenience path cannot express -- several members in one call, or a member other than the array being talked to. The two are separate parameter sets so the engine rejects supplying both, rather than one silently winning. -FleetId is untouched and is not made redundant by this. The spec has fleet_ids and fleet_names as the query-parameter selector and the key as a body field: the selector says which fleet, the key authorises the join. A test pins that the two compose. The wire shape is unchanged -- a test asserts the -FleetKey body is byte-equal to the equivalent explicit -Members body -- so this is an ergonomics layer over the contract fixed in #38, not a change to it. Get-PfbArray is called before ShouldProcess, so it runs under -WhatIf too. It is a read, and without it there is no body to describe. If it yields no id the cmdlet throws rather than POSTing a member reference with an empty id. Co-Authored-By: Claude Opus 5 (1M context) --- Public/Replication/New-PfbFleetMember.ps1 | 64 +++++++++++++---- Tests/New-PfbFleetMember.Tests.ps1 | 87 +++++++++++++++++++++++ 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/Public/Replication/New-PfbFleetMember.ps1 b/Public/Replication/New-PfbFleetMember.ps1 index 8605e284..0e4123dd 100644 --- a/Public/Replication/New-PfbFleetMember.ps1 +++ b/Public/Replication/New-PfbFleetMember.ps1 @@ -10,23 +10,38 @@ function New-PfbFleetMember { (generated on any array already in the fleet) and a reference to the joining array itself. + There are two ways to supply that body: + + -FleetKey takes the key straight from New-PfbFleetKey and builds the whole body for + you, resolving the joining array's own id with Get-PfbArray against the connection you + are already using. This is the common case, because the array running the cmdlet is by + definition the array that is joining. + + -Members passes the request body through unaltered, for the cases the convenience path + does not cover -- enrolling several members in one call, or naming a member other than + the array the connection points at. + + The two are mutually exclusive parameter sets, so the engine rejects supplying both + rather than having to pick one silently. + CONFIRMED WIRE-CONTRACT BUG (issue #38): this cmdlet previously sent `fleet_names` and `member_names` as bare query parameters with no request body at all. `member_names` is not a valid query parameter for this endpoint (only `fleet_ids`/`fleet_names` are, per the OpenAPI spec's parameter list for POST /fleets/members) and there was no way to supply the required fleet key or self-identification, so this cmdlet could never have - succeeded against a real array. -MemberName has been removed; -Members now exposes the - actual request body so a caller can supply the correct shape, e.g.: - `-Members @{ key = $fleetKey; member = @{ id = $thisArrayId } }`. - - The fix is verified against the OpenAPI spec (FleetMemberPost schema and the POST - operation's parameter list) but has NOT been live-tested against a real fleet -- that - happens in a later task against a lab array. See docs in issue #38's - issue38-fleetmember-bug-comment.md for the full context. + succeeded against a real array. -MemberName has been removed; -Members and -FleetKey + are what expose the actual request body. .PARAMETER FleetName The fleet name to add the member to. Sent as the `fleet_names` query parameter. .PARAMETER FleetId - The fleet ID to add the member to. Sent as the `fleet_ids` query parameter. + The fleet ID to add the member to. Sent as the `fleet_ids` query parameter. This is the + id form of the same fleet selector as -FleetName, and is unrelated to -FleetKey: the + selector says which fleet, the key authorises the join. + .PARAMETER FleetKey + The fleet key generated on an array already in the fleet, as returned by + New-PfbFleetKey. The joining array's own id is resolved with Get-PfbArray over the same + connection and the `members` body is built from the two, so this call is issued even + under -WhatIf -- it is a read, and without it there is no body to describe. .PARAMETER Members Info about the members being added to the fleet, as a hashtable or array of hashtables -- for example @{ key = ""; member = @{ id = "" } }. @@ -34,20 +49,27 @@ function New-PfbFleetMember { reference to the array joining the fleet. .PARAMETER Array The FlashBlade connection object. If not specified, the default connection is used. + .EXAMPLE + $key = New-PfbFleetKey -Array $existingMember + New-PfbFleetMember -FleetName "fleet-prod" -FleetKey $key.fleet_key -Array $joiningArray + + Joins $joiningArray to "fleet-prod". The joining array identifies itself, so only the + fleet and the key have to be supplied. .EXAMPLE New-PfbFleetMember -FleetName "fleet-prod" -Members @{ key = "1fc6297a-5183-4b7a-8d58-0182af1a2b64"; member = @{ id = "10314f42-020d-7080-8013-000ddt400012" } } - Adds this array to "fleet-prod" using the fleet key generated by an existing fleet member. + Adds a member by explicit id, for the cases -FleetKey does not cover. .EXAMPLE New-PfbFleetMember -FleetId "10314f42-020d-7080-8013-000ddt400099" -Members @{ key = "key-456"; member = @{ id = "this-array-id" } } -WhatIf Shows what would happen without actually adding the member, identifying the fleet by ID. #> - [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Members')] param( [Parameter()] [string]$FleetName, [Parameter()] [string]$FleetId, - [Parameter()] [hashtable[]]$Members, + [Parameter(Mandatory, ParameterSetName = 'FleetKey')] [string]$FleetKey, + [Parameter(ParameterSetName = 'Members')] [hashtable[]]$Members, [Parameter()] [PSCustomObject]$Array ) @@ -61,9 +83,23 @@ function New-PfbFleetMember { # outside {id, name, resource_type} -- this makes the array COMPOSITE, not an array of # references, so it is passed straight through rather than projected into @{ name = ... }. $body = @{} - if ($PSBoundParameters.ContainsKey('Members')) { $body['members'] = @($Members) } + $self = $null + + if ($PSCmdlet.ParameterSetName -eq 'FleetKey') { + $self = Get-PfbArray -Array $Array | Select-Object -First 1 + if (-not $self -or -not $self.id) { + throw ("Could not determine this array's own id from Get-PfbArray, so the fleet " + + 'member body cannot be built. Supply the member reference explicitly with ' + + '-Members instead.') + } + $body['members'] = @(@{ key = $FleetKey; member = @{ id = $self.id } }) + } + elseif ($PSBoundParameters.ContainsKey('Members')) { + $body['members'] = @($Members) + } - $target = if ($FleetName) { $FleetName } elseif ($FleetId) { $FleetId } else { 'fleet member' } + $fleet = if ($FleetName) { $FleetName } elseif ($FleetId) { $FleetId } else { 'fleet member' } + $target = if ($self -and $self.name) { "$($self.name) into $fleet" } else { $fleet } if ($PSCmdlet.ShouldProcess($target, 'Add fleet member')) { Invoke-PfbApiRequest -Array $Array -Method POST -Endpoint 'fleets/members' -Body $body -QueryParams $queryParams diff --git a/Tests/New-PfbFleetMember.Tests.ps1 b/Tests/New-PfbFleetMember.Tests.ps1 index b6af0c7d..b2a01596 100644 --- a/Tests/New-PfbFleetMember.Tests.ps1 +++ b/Tests/New-PfbFleetMember.Tests.ps1 @@ -95,10 +95,97 @@ Describe 'New-PfbFleetMember - typed body/query parameters (#31, confirmed wire- } } + Context '-FleetKey convenience path (build the body from a New-PfbFleetKey key plus self-identification)' { + BeforeEach { + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbArray { + [PSCustomObject]@{ id = 'self-array-id'; name = 'fb-a' } + } + } + + It 'builds the members body from -FleetKey and the joining array own id' { + New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'fleet-key-abc' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Endpoint -eq 'fleets/members' -and + $QueryParams['fleet_names'] -eq 'fleet-prod' -and + @($Body['members']).Count -eq 1 -and + @($Body['members'])[0]['key'] -eq 'fleet-key-abc' -and + @($Body['members'])[0]['member']['id'] -eq 'self-array-id' + } + } + + It 'sends the same wire shape as the equivalent explicit -Members call, so the convenience path is a pure ergonomics layer' { + $script:captured = @() + Mock -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest { + $script:captured += , ($Body | ConvertTo-Json -Depth 6 -Compress) + } + + New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'k' -Confirm:$false -Array $fakeArray + New-PfbFleetMember -FleetName 'fleet-prod' ` + -Members @{ key = 'k'; member = @{ id = 'self-array-id' } } ` + -Confirm:$false -Array $fakeArray + + $script:captured.Count | Should -Be 2 + $script:captured[0] | Should -Be $script:captured[1] + } + + It 'resolves the joining array over the connection it was given, not the default connection' { + New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'k' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Get-PfbArray -Times 1 -Exactly -ParameterFilter { + $Array.Endpoint -eq 'fb.example.test' + } + } + + It 'takes the first array record, since GET /arrays returns a list of one for the array being talked to' { + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbArray { + @([PSCustomObject]@{ id = 'first'; name = 'fb-a' }, [PSCustomObject]@{ id = 'second'; name = 'fb-b' }) + } + + New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'k' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + @($Body['members'])[0]['member']['id'] -eq 'first' + } + } + + It 'throws rather than POSTing a body with an empty member id when the array id cannot be resolved' { + Mock -ModuleName PureStorageFlashBladePowerShell Get-PfbArray { } + + { New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'k' -Confirm:$false -Array $fakeArray } | + Should -Throw -ExpectedMessage '*-Members*' + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 0 -Exactly + } + + It 'accepts -FleetId as the selector alongside -FleetKey, because the selector says WHICH fleet and the key only authorises the join' { + New-PfbFleetMember -FleetId 'fleet-1' -FleetKey 'k' -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Invoke-PfbApiRequest -Times 1 -Exactly -ParameterFilter { + $QueryParams['fleet_ids'] -eq 'fleet-1' -and -not $QueryParams.ContainsKey('fleet_names') -and + @($Body['members'])[0]['key'] -eq 'k' + } + } + + It 'rejects -FleetKey and -Members together at bind time rather than silently preferring one' { + { New-PfbFleetMember -FleetName 'fleet-prod' -FleetKey 'k' ` + -Members @{ key = 'k'; member = @{ id = 'i' } } -Confirm:$false -Array $fakeArray } | + Should -Throw + } + + It 'never calls Get-PfbArray on the -Members path, so the passthrough shape stays exactly what the caller wrote' { + New-PfbFleetMember -FleetName 'fleet-prod' -Members @{ key = 'k'; member = @{ id = 'i' } } ` + -Confirm:$false -Array $fakeArray + + Should -Invoke -ModuleName PureStorageFlashBladePowerShell Get-PfbArray -Times 0 -Exactly + } + } + Context 'constraint compliance' { It 'puts no ValidateSet on - (constraint 3, no spec enum)' -ForEach @( @{ Parameter = 'FleetName' } @{ Parameter = 'FleetId' } + @{ Parameter = 'FleetKey' } @{ Parameter = 'Members' } ) { $attrs = (Get-Command New-PfbFleetMember).Parameters[$Parameter].Attributes From 2709150484b340f78d2622fc1625434daf92c8bf Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Tue, 25 Aug 2026 16:54:04 -0700 Subject: [PATCH 2/3] chore(reports): regenerate for the new -FleetKey parameter -FleetKey is a typed parameter whose wire name cannot be resolved by the field/cmdlet mapper, because it lands in the request body nested under members[].key rather than as a parameter the spec names directly. So it joins the "typed but unresolved wire name" list, 51 to 52, and the dead-key report's parametersInventoried rises 2167 to 2168 with the same +1 under its "wire name unresolved" skip reason. No dead key appears or disappears. Regenerated through scripts/Assert-PfbDerivedArtifacts.ps1 -UpdateCommitted so the output is the one the CI gate compares against, then normalised to LF -- the generators emit CRLF on Windows and the committed reports are LF-only. Co-Authored-By: Claude Opus 5 (1M context) --- Reports/PfbDeadKeyReport.json | 4 ++-- Reports/PfbFieldCmdletMap.json | 4 ++++ Reports/PfbFieldCmdletMapping.md | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Reports/PfbDeadKeyReport.json b/Reports/PfbDeadKeyReport.json index 9d17494b..84eb0438 100644 --- a/Reports/PfbDeadKeyReport.json +++ b/Reports/PfbDeadKeyReport.json @@ -1,12 +1,12 @@ { "specVersion": "2.28", "counts": { - "parametersInventoried": 2167, + "parametersInventoried": 2168, "keysEvaluated": 1747, "ok": 1664, "deadKey": 83, "skipReasons": { - "wire name unresolved": 126, + "wire name unresolved": 127, "body property": 280, "endpoint/method ambiguous": 14, "endpoint/verb absent from spec": 0 diff --git a/Reports/PfbFieldCmdletMap.json b/Reports/PfbFieldCmdletMap.json index 3794bb0c..5cb5e742 100644 --- a/Reports/PfbFieldCmdletMap.json +++ b/Reports/PfbFieldCmdletMap.json @@ -20519,6 +20519,10 @@ "cmdlet": "New-PfbFileSystemSnapshot", "parameter": "SourceName" }, + { + "cmdlet": "New-PfbFleetMember", + "parameter": "FleetKey" + }, { "cmdlet": "New-PfbLocalGroupMember", "parameter": "Member" diff --git a/Reports/PfbFieldCmdletMapping.md b/Reports/PfbFieldCmdletMapping.md index a0e3e999..516aac59 100644 --- a/Reports/PfbFieldCmdletMapping.md +++ b/Reports/PfbFieldCmdletMapping.md @@ -124,7 +124,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Update-PfbSmbSharePolicy -Enabled` - `Update-PfbUserGroupQuotaPolicy -Enabled` -## Typed but unresolved wire name (needs manual inspection): 51 +## Typed but unresolved wire name (needs manual inspection): 52 - `Connect-PfbArray -AllArrays` - `Connect-PfbArray -ApiToken` @@ -155,6 +155,7 @@ Reporting only -- no `Public/` cmdlet is edited by this script. Every `matched` - `Invoke-PfbInContext -ScriptBlock` - `New-PfbDataEvictionPolicy -Disabled` - `New-PfbFileSystemSnapshot -SourceName` +- `New-PfbFleetMember -FleetKey` - `New-PfbLocalGroupMember -Member` - `New-PfbWorkloadPlacementRecommendation -Inputs` - `Remove-PfbBucket -Eradicate` From 23ced38160c95725efd5437e5ffc73580577b169 Mon Sep 17 00:00:00 2001 From: Justin Emerson Date: Tue, 25 Aug 2026 17:58:49 -0700 Subject: [PATCH 3/3] test(dead-key): raise the 'wire name unresolved' ceiling for FleetKey The dead-key gate ceilings each skip reason, because a key the generator cannot evaluate hides a dead key just as effectively as one it evaluates and passes. -FleetKey pushes 'wire name unresolved' from 126 to 127 and reds it. This is the same case the existing 126 was set for, and the comment there already names the test: the parameter is NEW, so nothing that was evaluable stopped being evaluated, and it was never evaluable as a query key in the first place -- its value goes into the request body at members[].key, which the AST resolver does not follow into. Neither condition is a coverage regression, which is what the ceiling exists to catch. The route to the wire is evidenced rather than assumed: POST /fleets/members returns 200 naming the member the key was sent for, on all three lab arrays. The comment now states both conditions a future raise has to meet, so the next person hitting this red has to establish them rather than bumping the number to clear it. Co-Authored-By: Claude Opus 5 (1M context) --- Tests/CommittedDeadKeyReport.Tests.ps1 | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Tests/CommittedDeadKeyReport.Tests.ps1 b/Tests/CommittedDeadKeyReport.Tests.ps1 index f79983c8..241dbadd 100644 --- a/Tests/CommittedDeadKeyReport.Tests.ps1 +++ b/Tests/CommittedDeadKeyReport.Tests.ps1 @@ -141,7 +141,20 @@ BeforeAll { # and `$queryParams['names'] = $filterNames -join ','`; it is unresolvable only because # the AST resolver cannot trace that conditional. This is the `body property` case, # not the coverage-loss case this ceiling guards against. - 'wire name unresolved' = 126 + # + # 126 -> 127 for New-PfbFleetMember|FleetKey, the same case again and for the same + # reason. It is a NEW parameter, so nothing that was evaluable stopped being evaluated, + # and it was never evaluable as a query key: its value is placed inside the request body + # at `members[].key`, which the resolver does not follow into. Live-verified against the + # lab fleet -- POST /fleets/members returns 200 for the member it names -- so the key + # demonstrably reaches the wire, exactly as with the entry above. + # + # A raise here needs that pair of facts, not just a passing test: the parameter is new + # (so no coverage was lost) AND its route to the wire is evidenced (so 'unresolved' + # means the resolver cannot see it, not that it goes nowhere). Absent either, a growing + # count is the coverage regression this ceiling exists to catch -- do not bump it to + # clear a red. + 'wire name unresolved' = 127 'endpoint/method ambiguous' = 14 'endpoint/verb absent from spec' = 0 }