From 4368577f860bf9b33108e770cbae0b6189b1cc76 Mon Sep 17 00:00:00 2001 From: dmann000 Date: Wed, 22 Jul 2026 11:20:31 -0400 Subject: [PATCH 1/2] docs(design): add Fusion context_names injection design spec Draft proposal for how to expose the Fusion fleet context_names query param without adding a parameter to ~520 cmdlets: a connection-level default on Connect-PfbArray, an ambient per-call override (Invoke-PfbInContext), and central capability-map-gated injection at the single Invoke-PfbApiRequest choke point. For review. Co-Authored-By: Claude Opus 4.8 --- docs/design/fusion-context-injection.md | 124 ++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/design/fusion-context-injection.md diff --git a/docs/design/fusion-context-injection.md b/docs/design/fusion-context-injection.md new file mode 100644 index 0000000..e36aeb3 --- /dev/null +++ b/docs/design/fusion-context-injection.md @@ -0,0 +1,124 @@ +# Design: Fusion context injection (`context_names`) + +Status: **Draft / for review** +Author: Don Mann +Related: Fusion / fleet support, `Private/Invoke-PfbApiRequest.ps1`, capability map (`Data/PfbCapabilityMap.json`) + +## Problem + +Many FlashBlade REST endpoints accept a `context_names` query parameter (Fusion +fleet context) so a single management connection can target a specific fleet +member instead of the local array. Today the module has no first-class way to set +that: a caller would have to drop to raw REST or hand-build query params. + +The naive fix -- add a `-Context` parameter to every affected cmdlet -- is a +non-starter: + +- The module exports ~520 cmdlets. Threading a new parameter through all of them + (and keeping it in sync as endpoints change) is a large, error-prone surface. +- Not every endpoint accepts `context_names`. A blanket parameter would let + callers send it to endpoints that reject it, producing confusing 400s. +- `context_names` arrived at a specific REST version per endpoint. We already have + that information in the capability map; we should reuse it rather than re-encode + it by hand. + +## Goals + +- Let a caller pick a fleet context once and have it apply to subsequent calls. +- Allow a per-call override without a session-wide change. +- Never send `context_names` to an endpoint (or array version) that does not + support it. +- Zero change to the ~520 public cmdlet signatures. + +## Non-goals + +- Modelling every Fusion concept (realms, presets, fleet membership management). + This spec covers only injecting `context_names` into outbound requests. +- `context_ids` (id-based targeting). Names first; ids can follow the same shape + if there is demand. + +## Design + +The module already funnels every request through a single choke point, +`Invoke-PfbApiRequest`. That is the one place to inject context, so no public +cmdlet needs to change. + +### 1. Connection-level default + +`Connect-PfbArray` gains an optional `-Context ` parameter. When +supplied, the resolved value is stored on the connection object as +`.DefaultContext`. This is the "target this fleet member for the whole session" +case. + +```powershell +$fb = Connect-PfbArray -Endpoint mgmt-fb -ApiToken $t -Context 'member-a' +Get-PfbFileSystem -Array $fb # implicitly scoped to member-a +``` + +### 2. Per-call override (ambient scope) + +Rather than a parameter on every cmdlet, an ambient scope helper sets the context +for a block of calls and restores it on exit: + +```powershell +Invoke-PfbInContext -Array $fb -Context 'member-b' { + Get-PfbFileSystem -Array $fb # scoped to member-b + Get-PfbNetworkInterface -Array $fb # also member-b +} +Get-PfbFileSystem -Array $fb # back to the session default +``` + +Internally this sets a script-scoped override that `Invoke-PfbApiRequest` reads. +The scriptblock form guarantees the override is always cleared, even on error. +(A `Set-PfbContext` / `Clear-PfbContext` pair could be offered too, but the +scriptblock is the safe default and should ship first.) + +### 3. Central injection, gated by the capability map + +In `Invoke-PfbApiRequest`, immediately before the request is built: + +1. Resolve the effective context: per-call override, else connection + `.DefaultContext`, else none. If none -> do nothing. +2. If the caller already set `context_names` explicitly in `-QueryParams`, leave + it untouched (explicit wins). +3. Look up the target `Method` + `Endpoint` in the capability map. Inject + `context_names` **only if** the map says this endpoint accepts it. If the map + has no entry for the endpoint, or the endpoint does not list `context_names`, + skip injection silently -- fail safe, same philosophy as + `Assert-PfbApiCapability`. +4. `Assert-PfbApiCapability` (already wired in from the capability-map work) then + catches the case where the connected array's version predates `context_names` + for that endpoint, so we do not need a second version check here. + +### Precedence + +``` +explicit -QueryParams['context_names'] > Invoke-PfbInContext override > connection .DefaultContext > (none) +``` + +## Why this is the right shape + +- One insertion point (`Invoke-PfbApiRequest`), not ~520 edits. +- Reuses the capability map as the single source of truth for which endpoints + accept `context_names`, so it stays correct as the map regenerates. +- Fail-safe: unknown endpoint or unsupported version -> no injection / clear error, + never a silently-wrong request. +- Backwards compatible: callers who never set a context see identical behaviour. + +## Open questions + +1. Parameter name: `-Context` vs `-ContextNames` vs `-FleetMember`. Leaning + `-Context` (short, matches the REST concept) with `-ContextNames` alias. +2. Does the capability map already record `context_names` per endpoint, or do we + need to add that field when it regenerates? (This design assumes it can.) +3. Multiple contexts: the REST param is plural. Do we support fan-out to several + members in one call, or constrain to one for now? +4. Should `Get-PfbArrayConnection` / display surface the active default context? + +## Phasing + +- **Phase 1**: `-Context` on `Connect-PfbArray` + central capability-gated + injection in `Invoke-PfbApiRequest`. Covers the common session-default case. +- **Phase 2**: `Invoke-PfbInContext` ambient override. +- **Phase 3**: optional `Set-PfbContext`/`Clear-PfbContext`, `context_ids`, + and display surfacing -- only if asked for. From 75375a1146108e2d6d14ccf31318344cd2128ce8 Mon Sep 17 00:00:00 2001 From: dmann000 Date: Fri, 24 Jul 2026 14:08:29 -0400 Subject: [PATCH 2/2] docs(design): revise context_names spec per review (rev 2) Incorporates Justin's live-tested review of rev 1: - Hard-throw instead of silent skip when a non-empty context can't be honored (silent skip was proven to land calls on the local array). - Context state moves to the connection object (.DefaultContext copy-on-write, .ContextOverride ambient) instead of module script scope, fixing nesting / multi-connection / parallel-runspace leakage. - Set-/Clear-PfbContext pulled into Phase 1; Invoke-PfbInContext nests correctly via captured $previous + try/finally. - Injection ordering (before Assert-PfbApiCapability, mutate QueryParams for AutoPaginate), tri-state none, connect-time staleness warning. - Multi-value GET vs size-1 write split documented; 3 open questions flagged for Don to settle. Co-Authored-By: Claude Opus 4.8 --- docs/design/fusion-context-injection.md | 273 +++++++++++++++++++----- 1 file changed, 217 insertions(+), 56 deletions(-) diff --git a/docs/design/fusion-context-injection.md b/docs/design/fusion-context-injection.md index e36aeb3..492aedf 100644 --- a/docs/design/fusion-context-injection.md +++ b/docs/design/fusion-context-injection.md @@ -1,8 +1,9 @@ # Design: Fusion context injection (`context_names`) -Status: **Draft / for review** +Status: **Draft / for review** (rev 2, incorporates Justin Emerson's live-tested review of rev 1) Author: Don Mann -Related: Fusion / fleet support, `Private/Invoke-PfbApiRequest.ps1`, capability map (`Data/PfbCapabilityMap.json`) +Related: Fusion / fleet support, `Private/Invoke-PfbApiRequest.ps1`, capability map +(`Data/PfbCapabilityMap.json`), `Private/Assert-PfbApiCapability.ps1` ## Problem @@ -26,6 +27,9 @@ non-starter: - Let a caller pick a fleet context once and have it apply to subsequent calls. - Allow a per-call override without a session-wide change. +- **Never let a set context be silently dropped.** If a caller asks for a context + and it cannot be honored, the call must fail loudly, not silently execute against + the wrong target. (This is the central lesson from the rev-1 review; see below.) - Never send `context_names` to an endpoint (or array version) that does not support it. - Zero change to the ~520 public cmdlet signatures. @@ -36,89 +40,246 @@ non-starter: This spec covers only injecting `context_names` into outbound requests. - `context_ids` (id-based targeting). Names first; ids can follow the same shape if there is demand. +- Client-side write fan-out across multiple contexts (see the multi-value section). + +## What changed since rev 1 + +Rev 1 proposed storing the ambient override in module script scope and *silently +skipping* injection when the target endpoint did not support `context_names`. +Justin's review (live-tested against real arrays, including one on Purity//FB +4.6.5 / REST 2.22) showed both of those choices reintroduce the exact +silent-wrong-scope failure this feature exists to prevent: + +- A `context_names` sent to an endpoint that never supported it is **silently + accepted** by the array (HTTP 200, mutation applied), so "let the array reject + it" does not work as a safety net. +- Script-scoped state does not survive nesting, a second connection, or a + parallel runspace, and would silently fall back to "no context." + +Rev 2 fixes both: **hard-throw instead of silent skip**, and **connection-object +state instead of module scope**. ## Design -The module already funnels every request through a single choke point, -`Invoke-PfbApiRequest`. That is the one place to inject context, so no public -cmdlet needs to change. +Every request funnels through one choke point, `Invoke-PfbApiRequest`, so that is +the one place context is resolved and injected. No public cmdlet changes. -### 1. Connection-level default +### 1. Context state lives on the connection object -`Connect-PfbArray` gains an optional `-Context ` parameter. When -supplied, the resolved value is stored on the connection object as -`.DefaultContext`. This is the "target this fleet member for the whole session" -case. +Two properties on the `$Array` connection object, with two different mutation +policies: + +- **`.DefaultContext`** -- durable session default. Set at connect time or via + `Set-PfbContext` / `Clear-PfbContext`, which are **copy-on-write**: they return + a *new* connection object and never mutate the one the caller passed in. This + prevents a context change leaking into other code holding the same reference. +- **`.ContextOverride`** -- the ambient, block-scoped value `Invoke-PfbInContext` + sets. Mutable, but scoped by construction and restored via `try`/`finally`. + +Storing this on the object (rather than in `$script:` module scope) is what makes +nesting, multiple connections, and `$using:`-passed parallel work behave correctly +instead of silently falling back to no context. It also matches how the module +already writes `AuthToken`/`TokenExpiresAt` back onto `$Array` during +auto-reconnect. (Distinction: token refresh is a *transparent* mutation; context +is a *targeting* mutation that changes where a write lands -- hence the stricter +copy-on-write policy on the durable default.) + +### 2. Setting a context ```powershell +# At connect (durable session default) $fb = Connect-PfbArray -Endpoint mgmt-fb -ApiToken $t -Context 'member-a' -Get-PfbFileSystem -Array $fb # implicitly scoped to member-a + +# Change / clear the durable default later (copy-on-write: capture the return value) +$fb = Set-PfbContext -Array $fb -Context 'member-b' +$fb = Clear-PfbContext -Array $fb + +# Ambient per-call override (auto-restores, nests correctly) +Invoke-PfbInContext -Array $fb -Context 'member-c' { + Get-PfbFileSystem -Array $fb # member-c + Get-PfbNetworkInterface -Array $fb # member-c +} +Get-PfbFileSystem -Array $fb # back to the session default ``` -### 2. Per-call override (ambient scope) +`Set-`/`Clear-PfbContext` are **Phase 1** (moved up from rev 1's Phase 3): without +them a session context set at connect can only be changed by reconnecting. They +must also update the module's connection cache (`$script:PfbArrays` / +`$script:PfbDefaultArray`) to point at the new copy, or callers relying on the +implicit default connection would keep hitting the old object. -Rather than a parameter on every cmdlet, an ambient scope helper sets the context -for a block of calls and restores it on exit: +`Invoke-PfbInContext` needs no explicit stack -- each invocation captures the +prior value in a local and restores it in `finally`, so the PowerShell call stack +provides push/pop discipline and nesting restores the *outer* value (not "none"): ```powershell -Invoke-PfbInContext -Array $fb -Context 'member-b' { - Get-PfbFileSystem -Array $fb # scoped to member-b - Get-PfbNetworkInterface -Array $fb # also member-b +function Invoke-PfbInContext { + param( + [Parameter(Mandatory)][PSCustomObject]$Array, + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Context, + [Parameter(Mandatory, Position=0)][scriptblock]$ScriptBlock + ) + $previous = $Array.ContextOverride + $Array.ContextOverride = $Context + try { & $ScriptBlock } + finally { $Array.ContextOverride = $previous } } -Get-PfbFileSystem -Array $fb # back to the session default ``` -Internally this sets a script-scoped override that `Invoke-PfbApiRequest` reads. -The scriptblock form guarantees the override is always cleared, even on error. -(A `Set-PfbContext` / `Clear-PfbContext` pair could be offered too, but the -scriptblock is the safe default and should ship first.) +### 3. Resolving and injecting context in `Invoke-PfbApiRequest` + +Resolve the effective context once, at the top of the choke point: + +``` +explicit -QueryParams['context_names'] > $Array.ContextOverride > $Array.DefaultContext > none +``` + +**Tri-state "none":** distinguish *unset* (`$null`) from *explicit no-context* +(`[string[]]@()`). Both resolve to "nothing to inject," but the empty-array form is +a deliberate "run this one call locally" (hence `[AllowEmptyCollection()]` and a +`-ne $null` check, not a truthiness check). Only a **non-empty** resolved context +is subject to the hard-throw gate below -- so `Invoke-PfbInContext -Context @()` +is the escape hatch for running one call locally even on an endpoint that does not +support `context_names`. + +Then, **before the existing `Assert-PfbApiCapability` call and before the +`-AutoPaginate` loop** (ordering matters -- see below), and only when the resolved +context is non-empty: + +1. If the endpoint's capability-map entry **lists `context_names`** (any version): + inject it into `$QueryParams` and let the existing `Assert-PfbApiCapability` + version check do its job (unchanged -- it already throws a clean, readable + error when the array's version predates `context_names` for that endpoint). +2. If the map has **no entry for `Method Endpoint`, or the entry does not list + `context_names`**, AND the connected array's version is **within the map's + scanned range** (`generatedFrom`): **throw**, naming the endpoint and telling + the caller their context could not be honored. Do not send the request. This + absence is *confirmed* -- we scanned that version's spec and the param is not + there. +3. Same as (2) but the array's version **exceeds** anything the map scanned: + proceed permissively (no inject, no throw). We have no evidence either way, and + the module's founding rule is "a capability check must never block a call that + would otherwise succeed." Backed by the connect-time staleness warning below. +4. No context set -> unchanged; no injection, no check. + +Apply the throw **uniformly across all verbs, including `GET`**. A silently +wrong-scoped read is not safer than a blocked write: warnings are routinely +suppressed in automation, a wrong-scope read inside a fleet-wide loop corrupts a +result set invisibly, and a `GET`'s output routinely feeds a later mutating call. +`Invoke-PfbInContext -Context @()` (or `Clear-PfbContext`) is the explicit opt-out. -### 3. Central injection, gated by the capability map +**Implementation ordering (two non-obvious details):** -In `Invoke-PfbApiRequest`, immediately before the request is built: +- Inject **before** the existing `Assert-PfbApiCapability` call, not "immediately + before the request is built." If `context_names` lands in `$QueryParams` after + Assert runs, Assert never sees it and the version check in step 1 never fires. +- Inject by **mutating the `$QueryParams` hashtable**, not the built query string. + `-AutoPaginate` rebuilds the query string from `$QueryParams` on every page, so + mutating the hashtable carries `context_names` forward for free on every page; + appending to the first page's URI would drop it from page 2 on. (`Get-PfbArraySpace` + is a live example of a paginated read that would be affected.) -1. Resolve the effective context: per-call override, else connection - `.DefaultContext`, else none. If none -> do nothing. -2. If the caller already set `context_names` explicitly in `-QueryParams`, leave - it untouched (explicit wins). -3. Look up the target `Method` + `Endpoint` in the capability map. Inject - `context_names` **only if** the map says this endpoint accepts it. If the map - has no entry for the endpoint, or the endpoint does not list `context_names`, - skip injection silently -- fail safe, same philosophy as - `Assert-PfbApiCapability`. -4. `Assert-PfbApiCapability` (already wired in from the capability-map work) then - catches the case where the connected array's version predates `context_names` - for that endpoint, so we do not need a second version check here. +### 4. Connect-time capability-map staleness warning + +Independent of `context_names`, the module's capability knowledge can lag a +customer's actual firmware. Surface it once, at `Connect-PfbArray`, +unconditionally (not gated on `-Context`): compare the negotiated `$Array.ApiVersion` +against the map's `generatedFrom` maximum; if the array is newer, cache a flag on +the connection and emit a one-shot warning naming the concrete case, e.g.: + +> Connected array is running REST 2.28; this module's capability map only covers +> through REST 2.27 -- 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`). + +No live `Find-Module` lookup (this module runs against air-gapped arrays); just +point at `Update-Module`. ### Precedence ``` -explicit -QueryParams['context_names'] > Invoke-PfbInContext override > connection .DefaultContext > (none) +explicit -QueryParams['context_names'] > $Array.ContextOverride > $Array.DefaultContext > (none) ``` -## Why this is the right shape +## Multi-value `context_names`: a real API split, not a free choice + +The OpenAPI spec defines two components that both surface as `context_names`: + +- **`Context_names_get`** (`GET`, plus one `DELETE`): true multi-target fan-out, + comma-separated names, resolved atomically server-side. +- **`Context_names`** (`POST`, `PATCH`, and almost every `DELETE`): "must be an + array of size 1." The size-1 limit is free-text only, not a structured + `maxItems`, so it is not mechanically detectable from the schema. + +The split tracks verb almost perfectly. The one exception is +`DELETE /management-access-policies`, which uses the multi-value variant and was +also added at exactly REST 2.26 -- possibly a spec/doc error. **Open question for +Don (below).** + +Interim design pending confirmation: treat `GET` as multi-context-capable and +`POST`/`PATCH`/`DELETE` as single-context-only, uniformly, with the one known +`DELETE` exception called out in code comments. This also exposes a gap in the +capability map: it records only `paramName: introducedVersion`, with no cardinality +concept -- closing it properly means teaching `tools/Build-PfbCapabilityMap.ps1` +which of the two spec components an endpoint references (or hardcoding the +verb heuristic with the documented exception). + +## Open questions for Don / Justin to settle + +1. **Multi-value on a size-1 write endpoint: throw or client-side fan-out?** + Leaning **throw** (Justin's recommendation, and mine): client-side fan-out + silently changes the return shape (scalar -> N objects) based on session state + the calling code may not see, has no clean partial-failure story, and is not + equivalent to the server's atomic read-side fan-out. If ergonomics later + demand it, add a separate, explicitly-named opt-in cmdlet that returns + per-context-annotated results and surfaces partial failure loudly -- not + automatic behavior triggered by cmdlet-plus-context-shape. + +2. **Is `DELETE /management-access-policies`'s multi-value `context_names` real + or a spec bug?** Cannot be safely settled by testing (can't tell "honored 2 + names" from "silently used the first"). Needs a direct confirmation. + +3. **"Explicit `-QueryParams['context_names']` wins" -- wins over what, + concretely?** Today nothing in the public API can populate that top precedence + tier: every public cmdlet builds its own fixed `QueryParams`, and + `Invoke-PfbApiRequest` is private. Is this reserved for a specific future + fleet-aware cmdlet, or is it defensive layering? If the latter, the doc should + say so rather than imply a path that does not exist yet. + +## Notes for a later pass (non-blocking) -- One insertion point (`Invoke-PfbApiRequest`), not ~520 edits. -- Reuses the capability map as the single source of truth for which endpoints - accept `context_names`, so it stays correct as the map regenerates. -- Fail-safe: unknown endpoint or unsupported version -> no injection / clear error, - never a silently-wrong request. -- Backwards compatible: callers who never set a context see identical behaviour. +- **Annotate fleet-membership failures with the active context name.** A bad + context name returns `code 42 "Cannot find array in fleet"`, which currently + surfaces bare -- no indication of which context (possibly a session default set + and forgotten) caused it. The injection layer should annotate it. +- **State the security model explicitly.** `context_names` is not itself an auth + boundary; any valid management connection can *attempt* to target any fleet + member, and enforcement is entirely server-side (RBAC). The client enforces + nothing about which contexts a caller may target -- say so, so no reader assumes + otherwise. +- **Pipeline caveat.** `Invoke-PfbInContext`'s scriptblock form is not + pipeline-friendly by nature; the durable path is + (`$fb = $fb | Set-PfbContext -Context 'b'`). -## Open questions +## Test plan -1. Parameter name: `-Context` vs `-ContextNames` vs `-FleetMember`. Leaning - `-Context` (short, matches the REST concept) with `-ContextNames` alias. -2. Does the capability map already record `context_names` per endpoint, or do we - need to add that field when it regenerates? (This design assumes it can.) -3. Multiple contexts: the REST param is plural. Do we support fan-out to several - members in one call, or constrain to one for now? -4. Should `Get-PfbArrayConnection` / display surface the active default context? +- Three-level precedence resolution, including the empty-array "explicit none" case. +- Capability-gated injection on both the allow path and the hard-throw path. +- `Assert-PfbApiCapability`'s version check for `context_names` specifically. +- Nested `Invoke-PfbInContext` restoring the *outer* value, including when the + inner block throws (exception safety, not just the happy path). +- Cross-connection isolation: an override set via `$fb1` must not affect calls + made with `$fb2` in the same block. +- `Set-PfbContext` leaving the original object's `.DefaultContext` untouched while + the cache/default pointer moves to the new copy. +- Staleness warning fires once per connection when the array outranges the map. ## Phasing -- **Phase 1**: `-Context` on `Connect-PfbArray` + central capability-gated - injection in `Invoke-PfbApiRequest`. Covers the common session-default case. +- **Phase 1**: connection-object state (`.DefaultContext` / `.ContextOverride`), + `Connect-PfbArray -Context`, `Set-`/`Clear-PfbContext` (copy-on-write), + capability-gated **hard-throw** injection in `Invoke-PfbApiRequest`, and the + connect-time staleness warning. - **Phase 2**: `Invoke-PfbInContext` ambient override. -- **Phase 3**: optional `Set-PfbContext`/`Clear-PfbContext`, `context_ids`, - and display surfacing -- only if asked for. +- **Phase 3**: `context_ids`, and -- only if confirmed wanted -- an explicit + multi-context write fan-out cmdlet.