From 50675a3756984944f8e87fb04f0b7d363876ca6e Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Wed, 1 Jul 2026 17:49:30 -0400 Subject: [PATCH 1/8] feat: add subnet-based pinning and asset health check bypass Add the ByTargetSubnet parameter set so operators can resolve and pin/unpin all monitored assets within an IPv4 CIDR range in one call, instead of enumerating individual asset IDs. Add -SkipAssetHealthValidation so a transient or stale unhealthy status in the portal doesn't block a pin/unpin operation, while every other validation (segment server check, monitored-by-Segment-Server check, applicability, and already-pinned/not-pinned state) stays enforced. Ignore local planning and test artifacts (test-plan.md, plan.md, testing/) that shouldn't be tracked in the repo. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Nox4wjcx2VFvMwn9Wup7t --- .../Pin Assets To Clusters/.gitignore | 5 + .../Pin Assets To Clusters/CLAUDE.md | 107 ++++++++ .../Pin-AssetsToClusters.ps1 | 258 ++++++++++++++++-- 3 files changed, 341 insertions(+), 29 deletions(-) create mode 100644 Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore index efaab03..1a9f27c 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore @@ -2,3 +2,8 @@ .env .env.ps1 AssetDetailsFieldMappings.json +subnet-pinning.md +plan.md +test-plan.md +ZeroNetworksApi.yaml +testing/ \ No newline at end of file diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md new file mode 100644 index 0000000..3c25362 --- /dev/null +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is a standalone PowerShell 7+ utility, `Pin-AssetsToClusters.ps1`, that pins/unpins Zero Networks Segment assets to deployment clusters via the Zero Networks REST API (`/api/v1`). It is one script in a much larger multi-project monorepo (`Community`); treat this directory as its own self-contained unit — there is no shared build system, package manifest, or test runner across the monorepo. + +There are no automated tests, linter config, or build step in this project. Validation is done by running the script directly (see below). + +## Running the script + +```powershell +# List deployment clusters (also initializes cluster/segment-server lookups) +./Pin-AssetsToClusters.ps1 -ApiKey -PortalUrl https://-admin.zeronetworks.com -ListDeploymentClusters + +# Export a CSV template for bulk operations +./Pin-AssetsToClusters.ps1 -ExportCsvTemplate + +# Pin/unpin a single asset (-DryRun previews without calling the mutation API) +./Pin-AssetsToClusters.ps1 -ApiKey -AssetId -DeploymentClusterId [-Unpin] [-DryRun] [-EnableDebug] + +# Bulk via CSV or AD OU path +./Pin-AssetsToClusters.ps1 -ApiKey -CsvPath ./assets.csv [-Unpin] [-DryRun] +./Pin-AssetsToClusters.ps1 -ApiKey -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterId [-DisableNestedOuResolution] [-StopOnAssetValidationError] + +# Bulk via IPv4 subnet (CIDR) +./Pin-AssetsToClusters.ps1 -ApiKey -TargetSubnet "10.200.200.0/24" -DeploymentClusterId [-Unpin] [-DryRun] [-StopOnAssetValidationError] +``` + +`-EnableDebug` sets `$DebugPreference = "Continue"` for verbose API/flow tracing. There is no `-WhatIf`/Pester test suite — use `-DryRun` against a real (or test) tenant to validate changes before committing. + +## Architecture + +Single-file script driven by a `[CmdletBinding]` parameter-set switch (`ByAssetId`, `ByOuPath`, `ByCsvPath`, `ByTargetSubnet`, `ListDeploymentClusters`, `ExportCsvTemplate`). The final `switch ($PSCmdlet.ParameterSetName)` block at the bottom of the file is the entry point — read it first to see how each mode wires the helper functions together. + +Key flow shared by all mutating parameter sets: +1. `Initialize-ApiContext` — sets script-scoped `$script:Headers` / `$script:ApiBaseUrl` from `-ApiKey` / `-PortalUrl`. +2. `Invoke-ValidateDeploymentClusterId` → `Get-DeploymentClusters` (lazily populates `$script:DeploymentClusterHashtable` and `$script:SegmentServerHashtable`, keyed by cluster ID and segment-server asset ID respectively, for O(1) lookups used throughout validation). +3. Asset resolution differs per mode: `Get-AssetDetails` (single asset), `Get-OUInfoFromApi` + `Get-AssetsFromOU` (OU mode), `Get-CsvData` (CSV mode, normalizes CSV rows into the same shape as API asset objects so downstream code is mode-agnostic), or `Get-SubnetHostAddresses` + `Get-AssetsByHostAddresses` (subnet mode, expands a CIDR range into host addresses and resolves them to monitored assets via a `lastIpAddress` filter). +4. `Test-AssetCanBePinned` / `Test-ValidateProvidedAssetsCanBePinned` — enforces the pin/unpin prerequisites (not a segment server, monitored by Segment Server, healthy, applicable, correct current pin state). Validation order matters — see comments in `Test-AssetCanBePinned`. +5. `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` — batches assets in groups of 50 and calls the `PUT /assets/actions/deployments-cluster` endpoint (or prints the would-be request body under `-DryRun`). + +API plumbing: `Invoke-ApiRequest` (single request + status-code validation via `Test-ApiResponseStatusCode`) is wrapped by `Invoke-PaginatedApiRequest`, which transparently follows both cursor-based (`nextCursor`) and offset-based (`nextOffset`/`count`) pagination and merges `items` across pages. + +`$script:DeploymentClusterFieldMappings` (near the top of the script) decodes numeric enum codes returned by the API (cluster strategy, deployment status/state, service IDs) into human-readable strings via `Invoke-DecodeDeploymentClusterIDFields`. Note: `DeploymentClusterFieldMappings.json` in this directory holds the same mapping data as a standalone reference file but is not read by the script — the script keeps its own inline copy in `$script:DeploymentClusterFieldMappings`. If one is updated, update the other to keep them in sync. + +`deploymentsClusterSource` on an asset (values 0–6) is the key field driving pin-state validation — see the block comment above `$AssetIsPinnedDeploymentClusterSource` in `Test-AssetCanBePinned` for the meaning of each code. + +### Per-Parameter-Set Workflow Sequences + +The bottom-of-file `switch ($PSCmdlet.ParameterSetName)` block dispatches to one of these sequences. Each is self-contained — read the relevant `case` directly for exact call order. + +**`ByAssetId`** (single asset, no batching): +1. `Initialize-ApiContext` +2. `Invoke-ValidateDeploymentClusterId` — validates `-DeploymentClusterId` exists (and has an online segment server, unless `-SkipSegmentServerValidation`). +3. `Test-AssetCanBePinned` — validates the single asset directly (not via `Test-ValidateProvidedAssetsCanBePinned`, since there's only one asset and no need to continue-on-error across a list). +4. Wraps `-AssetId` in a one-element `PSCustomObject` ArrayList. +5. `Set-AssetsToDeploymentCluster` is called directly — `Invoke-BatchBasedClusterPinning` is skipped because a single asset never needs batching. + +**`ByOuPath`** (bulk via AD OU, single cluster): +1. `Initialize-ApiContext` +2. `Invoke-ValidateDeploymentClusterId` for the one `-DeploymentClusterId` supplied. +3. `Get-OUInfoFromApi` — resolves `-OUPath` to an OU entity ID. +4. `Get-AssetsFromOU` — fetches OU members (nested, unless `-DisableNestedOuResolution`), filters to assets only, wrapped in `[System.Collections.ArrayList]@(...)` to guard against PowerShell unwrapping single-item results. +5. `Test-ValidateProvidedAssetsCanBePinned` — validates the whole asset list at once, honoring `-StopOnAssetValidationError`. +6. `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` in batches of 50, all against the single validated cluster. + +**`ByTargetSubnet`** (bulk via IPv4 CIDR subnet, single cluster): +1. `Initialize-ApiContext` +2. `Invoke-ValidateDeploymentClusterId` for the one `-DeploymentClusterId` supplied. +3. `Get-SubnetHostAddresses` — expands `-TargetSubnet` into every individual host address in the range (including network/broadcast addresses). Warns and requires interactive confirmation above /24 (256 addresses), and hard-stops above /16 (65,536 addresses). +4. `Get-AssetsByHostAddresses` — queries `/assets/monitored` in batches of `$script:SUBNET_BATCH_SIZE` (default 100, not a script parameter) using a `lastIpAddress` filter, accumulating `.items` across batches (empty batches are expected, not an error). +5. `Test-ValidateProvidedAssetsCanBePinned` — validates the whole asset list at once, honoring `-StopOnAssetValidationError`. Assets from `/assets/monitored` already carry `.id`/`.name` in the shape expected, so no normalization step is needed (unlike `ByCsvPath`). +6. `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` in batches of 50, all against the single validated cluster. + +**`ByCsvPath`** (bulk via CSV, potentially multiple clusters): +1. `Initialize-ApiContext` +2. `Get-CsvData` — reads and validates the CSV (required columns, non-empty rows). +3. Extracts the **unique** `DeploymentClusterId` values present in the CSV. +4. `Invoke-ValidateDeploymentClusterId` is run once per unique cluster ID (not once per row). +5. CSV rows are normalized into asset-shaped `PSCustomObject`s (`id`, `name`, `DeploymentClusterId`) so downstream validation/pinning functions are mode-agnostic with the OU/AssetId paths. +6. `Test-ValidateProvidedAssetsCanBePinned` validates the full normalized asset list in one pass (across all clusters at once), honoring `-StopOnAssetValidationError`. +7. For each unique cluster ID, the validated assets are filtered by `DeploymentClusterId` and passed to `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` (batches of 50) — so each cluster gets its own batched mutation call(s). + +## Coding Standards + +### Function Documentation + +Every function written must include a powershell block comment proceeding it, documenting the function. The comment should include a .SYNOPSIS, .PARAMETER (for every parameter), .OUTPUTS, .NOTES. + +For example: +```powershell +<# +.SYNOPSIS + Retrieves detailed information about an asset from the Zero Networks API. +.PARAMETER AssetId + The asset ID to retrieve details for. +.OUTPUTS + Returns the asset entity object from the API response. +.NOTES + Throws an exception if the asset is not found or if the API response is malformed. +#> +``` +### Git Commit Message + +Every git commit message must follow the widely practiced **Conventional Commits** guidelines. Invoke the `/conventional-commits` skill. diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 index fb2e5be..96e2f06 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 @@ -22,20 +22,26 @@ .PARAMETER DisableNestedOuResolution Disables nested OU resolution when pinning/unpinning assets by OU path. Defaults to false. +.PARAMETER TargetSubnet + IPv4 CIDR subnet (e.g., "10.200.200.0/24") to pin or unpin all matching assets within. Required for ByTargetSubnet parameter set. + .PARAMETER DeploymentClusterId - Deployment cluster ID to pin/unpin assets to. Required for ByAssetId and ByOuPath parameter sets. + Deployment cluster ID to pin/unpin assets to. Required for ByAssetId, ByOuPath, and ByTargetSubnet parameter sets. .PARAMETER Unpin - Switch to unpin assets instead of pinning them. Available for ByAssetId, ByOuPath, and ByCsvPath parameter sets. + Switch to unpin assets instead of pinning them. Available for ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. .PARAMETER SkipSegmentServerValidation - Skip validation that deployment clusters have online segment servers. Available for ByAssetId, ByOuPath, and ByCsvPath parameter sets. + Skip validation that deployment clusters have online segment servers. Available for ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. + +.PARAMETER SkipAssetHealthValidation + Skip validation that an asset is healthy before pinning/unpinning it. Available for ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. .PARAMETER DryRun - Preview changes without applying them. Available for ByAssetId, ByOuPath, and ByCsvPath parameter sets. + Preview changes without applying them. Available for ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. .PARAMETER StopOnAssetValidationError - Stop processing and throw an error when asset validation fails. Available for ByOuPath and ByCsvPath parameter sets. + Stop processing and throw an error when asset validation fails. Available for ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. .PARAMETER ListDeploymentClusters Switch to list all deployment clusters with detailed information. @@ -68,6 +74,10 @@ .EXAMPLE .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterId "C:d:00fd409f" Pins all assets within the specified OU path to a deployment cluster. + +.EXAMPLE + .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -TargetSubnet "10.200.200.0/24" -DeploymentClusterId "C:d:00fd409f" + Pins all monitored assets whose last known IP address falls within the specified subnet to a deployment cluster. #> <#PSScriptInfo @@ -85,70 +95,90 @@ param( [Parameter(ParameterSetName = "ByOuPath", Mandatory = $true)] [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $true)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $true)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] [string]$ApiKey, [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $false)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [string]$PortalUrl = "https://portal.zeronetworks.com", - + # ParameterSet 1: Pin by Asset ID and Deployment Cluster ID [Parameter(ParameterSetName = "ByAssetId", Mandatory = $true)] [string]$AssetId, - + # ParameterSet: Pin by OU Path and Deployment Cluster ID [Parameter(ParameterSetName = "ByOuPath", Mandatory = $true)] [string]$OUPath, - + [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [switch]$DisableNestedOuResolution = $false, - + + # ParameterSet: Pin by target subnet (CIDR) and Deployment Cluster ID + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] + [ValidatePattern('^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\/(3[0-2]|[1-2]?\d)$')] + [string]$TargetSubnet, + [Parameter(ParameterSetName = "ByAssetId", Mandatory = $true)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $true)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] [string]$DeploymentClusterId, - - # Shared switch parameter for unpinning (available in ByAssetId, ByOuPath, and ByCsvPath sets) + + # Shared switch parameter for unpinning (available in ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet sets) [Parameter(ParameterSetName = "ByAssetId")] [Parameter(ParameterSetName = "ByOuPath")] [Parameter(ParameterSetName = "ByCsvPath")] + [Parameter(ParameterSetName = "ByTargetSubnet")] [switch]$Unpin, - + # Shared switch parameter to skip segment server validation (available in all sets with ApiKey) [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [switch]$SkipSegmentServerValidation, - + + # Shared switch parameter to skip asset health validation (available in all sets with ApiKey) + [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] + [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] + [switch]$SkipAssetHealthValidation, + # Shared switch parameter for dry run mode (available in all sets with ApiKey) [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [switch]$DryRun, - - # Switch parameter to stop on asset validation error (available in ByOuPath and ByCsvPath sets) + + # Switch parameter to stop on asset validation error (available in ByOuPath, ByCsvPath, and ByTargetSubnet sets) [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [switch]$StopOnAssetValidationError, - + # ParameterSet 2: List Deployment Clusters [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $true)] [switch]$ListDeploymentClusters, - + # ParameterSet 3: Pin from CSV file [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $true)] [string]$CsvPath, - + # ParameterSet 4: Export CSV Template [Parameter(ParameterSetName = "ExportCsvTemplate", Mandatory = $true)] [switch]$ExportCsvTemplate, - + # Shared switch parameter for debug output (available in all parameter sets) [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $false)] [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] [Parameter(ParameterSetName = "ExportCsvTemplate", Mandatory = $false)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [switch]$EnableDebug ) $ErrorActionPreference = "Stop" @@ -259,6 +289,10 @@ $script:DeploymentClusterFieldMappings = @{ $script:SegmentServerHashtable = $null $script:DeploymentClusterHashtable = $null +# Number of host addresses to include per /assets/monitored filter query when resolving +# assets by subnet (-TargetSubnet). Not exposed as a script parameter - internal tunable only. +$script:SUBNET_BATCH_SIZE = 100 + <# This section of the script contains all of the asset related functions in the script @@ -274,6 +308,8 @@ asset related functions in the script If specified, validates that assets are already pinned (for unpinning operations). .PARAMETER StopOnAssetValidationError If specified, throws an error and terminates the script if any asset fails validation. + .PARAMETER SkipAssetHealthValidation + If specified, skips the asset health check when validating each asset. .OUTPUTS Returns an ArrayList of asset objects that passed validation. .NOTES @@ -286,7 +322,9 @@ function Test-ValidateProvidedAssetsCanBePinned { [Parameter(Mandatory = $false)] [switch]$AssetMustBePinned, [Parameter(Mandatory = $false)] - [switch]$StopOnAssetValidationError + [switch]$StopOnAssetValidationError, + [Parameter(Mandatory = $false)] + [switch]$SkipAssetHealthValidation ) # Validate each asset can be pinned/unpinned. Keep a track of any assets that pass or fail validation. # If the -StopOnAssetValidationError switch is provided, throw an error if any asset fails validation, @@ -300,7 +338,7 @@ function Test-ValidateProvidedAssetsCanBePinned { } try { - Test-AssetCanBePinned -AssetId $asset.id -AssetMustBePinned:$AssetMustBePinned + Test-AssetCanBePinned -AssetId $asset.id -AssetMustBePinned:$AssetMustBePinned -SkipAssetHealthValidation:$SkipAssetHealthValidation $AssetsPassedValidation.Add($asset) | Out-Null } catch { @@ -337,6 +375,8 @@ function Test-ValidateProvidedAssetsCanBePinned { The asset ID to validate. .PARAMETER AssetMustBePinned If specified, validates that the asset is already pinned (for unpinning operations). + .PARAMETER SkipAssetHealthValidation + If specified, skips the asset health check (healthState.healthStatus) below. .OUTPUTS None. Throws an exception if validation fails. .NOTES @@ -347,7 +387,9 @@ function Test-AssetCanBePinned { [Parameter(Mandatory = $true)] [string]$AssetId, [Parameter(Mandatory = $false)] - [switch]$AssetMustBePinned + [switch]$AssetMustBePinned, + [Parameter(Mandatory = $false)] + [switch]$SkipAssetHealthValidation ) # Get asset details from portal API $AssetDetails = Get-AssetDetails -AssetId $AssetId @@ -379,9 +421,9 @@ function Test-AssetCanBePinned { throw "Asset $($AssetDetails.name) ($($AssetDetails.id)) is not monitored by a Segment Server (e.g uses Cloud Connector, Lightweight Agent). Only hosts monitored by a Segment Server can be pinned to a deployment cluster." } - # 3rd: Check if asset is healthy (healthStatus = 1) - if ($AssetDetails.healthState.healthStatus -ne 1) { - throw "Asset $($AssetDetails.name) ($($AssetDetails.id)) is not healthy! Please check the asset health in the portaland try again." + # 3rd: Check if asset is healthy (healthStatus = 1), unless skipped via -SkipAssetHealthValidation + if (-not $SkipAssetHealthValidation -and $AssetDetails.healthState.healthStatus -ne 1) { + throw "Asset $($AssetDetails.name) ($($AssetDetails.id)) is not healthy! Please check the asset health in the portal and try again." } # 4th: Check if asset is applicable for pinning (deploymentsClusterSource != 6) @@ -689,7 +731,141 @@ function Set-AssetsToDeploymentCluster { } <# -This section of the script contains functions related to +This section of the script contains functions related to +subnet-based asset discovery for pinning/unpinning by CIDR range. +#> + +<# + .SYNOPSIS + Expands an IPv4 CIDR subnet into an array of individual host address strings. + .PARAMETER TargetSubnet + The CIDR subnet to expand (e.g., "10.200.200.0/24"). + .OUTPUTS + Returns an ArrayList of every dotted-quad host address in the subnet range (including network/broadcast addresses). + .NOTES + Subnets larger than /24 (256 addresses) require interactive confirmation before proceeding. + Subnets larger than /16 (65,536 addresses) are rejected outright, since resolving them would require + an impractical number of batched API calls. Throws an exception if the subnet is malformed, too large, + or if the user declines the confirmation prompt. + #> +function Get-SubnetHostAddresses { + param( + [Parameter(Mandatory = $true)] + [string]$TargetSubnet + ) + Write-Host "Expanding subnet $TargetSubnet into individual host addresses" + + $parts = $TargetSubnet -split '/' + $networkIp = [ipaddress]::Parse($parts[0]) + $prefixLength = [int]$parts[1] + + if ($prefixLength -lt 0 -or $prefixLength -gt 32) { + throw "Invalid CIDR prefix length in subnet $TargetSubnet : must be between 0 and 32" + } + + # Convert the network IP to a uint32 using network byte order (big-endian) + $networkBytes = $networkIp.GetAddressBytes() + if ([BitConverter]::IsLittleEndian) { + [Array]::Reverse($networkBytes) + } + $networkInt = [BitConverter]::ToUInt32($networkBytes, 0) + + # Compute the subnet mask - special-cased for /0 to avoid undefined shift-by-32 behavior on a uint32 + $maskInt = if ($prefixLength -eq 0) { [uint32]0 } else { [uint32]::MaxValue -shl (32 - $prefixLength) } + $networkBaseInt = $networkInt -band $maskInt + + # numAddresses is computed as a uint64 so that shifting by up to 32 bits is well-defined + [uint64]$numAddresses = [uint64]1 -shl (32 - $prefixLength) + + # Large-subnet guardrail: hard-stop above /16, warn + require confirmation above /24 + if ($numAddresses -gt 65536) { + throw "Subnet $TargetSubnet contains $numAddresses addresses, which exceeds the maximum supported size of 65,536 (/16). Please provide a smaller subnet." + } + elseif ($numAddresses -gt 256) { + Write-Warning "Subnet $TargetSubnet contains $numAddresses addresses, which will require $([math]::Ceiling($numAddresses / $script:SUBNET_BATCH_SIZE)) batched API call(s) to resolve assets.`nThis may take a long time to complete. Please confirm you want to proceed with this subnet size." + $confirmation = Read-Host "Type 'y' to confirm you want to proceed with this subnet size" + if ($confirmation -ne 'y') { + throw "Aborted subnet expansion for $TargetSubnet - user did not confirm proceeding with a subnet larger than /24" + } + } + + # Enumerate every address in the range, converting each back to a dotted-quad string + [System.Collections.ArrayList]$AssetSubnetHostAddresses = @() + for ([uint64]$i = 0; $i -lt $numAddresses; $i++) { + $currentInt = [uint32]([uint64]$networkBaseInt + $i) + $currentBytes = [BitConverter]::GetBytes($currentInt) + if ([BitConverter]::IsLittleEndian) { + [Array]::Reverse($currentBytes) + } + $currentIp = [ipaddress]::new($currentBytes) + $AssetSubnetHostAddresses.Add($currentIp.ToString()) | Out-Null + } + + Write-Host "Expanded subnet $TargetSubnet into $($AssetSubnetHostAddresses.Count) host addresses" + return $AssetSubnetHostAddresses +} + +<# + .SYNOPSIS + Retrieves monitored assets whose last known IP address falls within a set of subnet host addresses. + .PARAMETER AssetSubnetHostAddresses + ArrayList of dotted-quad host address strings to search for (as produced by Get-SubnetHostAddresses). + .OUTPUTS + Returns an ArrayList of asset entity objects whose lastIpAddress matched any of the provided addresses. + .NOTES + Addresses are queried in batches of $script:SUBNET_BATCH_SIZE, since the API has no native subnet-range filter. + It is expected that some (or all) batches may return zero matching assets - this is not treated as an error. + #> +function Get-AssetsByHostAddresses { + param( + [Parameter(Mandatory = $true)] + [System.Collections.ArrayList]$AssetSubnetHostAddresses + ) + Write-Host "Retrieving assets matching $($AssetSubnetHostAddresses.Count) subnet host addresses" + + [System.Collections.ArrayList]$Assets = @() + $batchSize = $script:SUBNET_BATCH_SIZE + $totalAddresses = $AssetSubnetHostAddresses.Count + $totalBatches = [math]::Ceiling($totalAddresses / $batchSize) + $batchNumber = 1 + + for ($i = 0; $i -lt $totalAddresses; $i += $batchSize) { + $endIndex = [math]::Min($i + $batchSize - 1, $totalAddresses - 1) + $batch = @($AssetSubnetHostAddresses[$i..$endIndex]) + Write-Host "Querying batch $batchNumber of $totalBatches ($($batch.Count) addresses)..." + + # Build filter array for API query - filters assets by lastIpAddress matching any address in this batch + $FilterArray = @( + @{ + id = "lastIpAddress" + includeValues = @($batch) + } + ) + $FilterJson = $FilterArray | ConvertTo-Json -Compress -AsArray -Depth 10 + + $QueryParams = @{ + _limit = 100 + showInactive = $false + _filters = $FilterJson + } + + $response = Invoke-PaginatedApiRequest -Method "GET" -ApiEndpoint "/assets/monitored" -QueryParams $QueryParams + + Write-Debug "Batch $batchNumber response body: $($response | ConvertTo-Json -Compress -Depth 10)" + + if ($null -ne $response.items -and $response.items.Count -gt 0) { + $Assets.AddRange(@($response.items)) + } + + $batchNumber++ + } + + Write-Host "Retrieved $($Assets.Count) assets across $totalBatches batch(es) matching subnet host addresses" + return $Assets +} + +<# +This section of the script contains functions related to deployment cluster operations. #> @@ -1323,7 +1499,7 @@ switch ($PSCmdlet.ParameterSetName) { Invoke-ValidateDeploymentClusterId -DeploymentClusterId $DeploymentClusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation # Validate asset can be pinned/unpinned - Test-AssetCanBePinned -AssetId $AssetId -AssetMustBePinned:$Unpin + Test-AssetCanBePinned -AssetId $AssetId -AssetMustBePinned:$Unpin -SkipAssetHealthValidation:$SkipAssetHealthValidation # Create asset object for the function $asset = [PSCustomObject]@{ @@ -1355,13 +1531,37 @@ switch ($PSCmdlet.ParameterSetName) { [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsFromOU -OUPath $OUPath -EntityId $OUInformation.id -DisableNestedOuResolution:$DisableNestedOuResolution) # Validate each asset can be pinned/unpinned - [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError) + [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError -SkipAssetHealthValidation:$SkipAssetHealthValidation) # Finally, call function to perform the batch based cluster pinning/unpinning operation Invoke-BatchBasedClusterPinning -AssetsPassedValidation $AssetsPassedValidation -DeploymentClusterId $DeploymentClusterId -Unpin:$Unpin -DryRun:$DryRun Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in OU path $OUPath to deployment cluster $DeploymentClusterId" } + "ByTargetSubnet" { + Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterId" + Initialize-ApiContext + + # Validate deployment cluster exists and has online segment servers + Invoke-ValidateDeploymentClusterId -DeploymentClusterId $DeploymentClusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation + + # Expand the subnet into individual host addresses, then resolve them to monitored assets + $AssetSubnetHostAddresses = Get-SubnetHostAddresses -TargetSubnet $TargetSubnet + [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsByHostAddresses -AssetSubnetHostAddresses $AssetSubnetHostAddresses) + + if ($Assets.Count -eq 0) { + Write-Host "No assets found matching subnet $TargetSubnet. Exiting..." + exit 0 + } + + # Validate each asset can be pinned/unpinned + [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError -SkipAssetHealthValidation:$SkipAssetHealthValidation) + + # Finally, call function to perform the batch based cluster pinning/unpinning operation + Invoke-BatchBasedClusterPinning -AssetsPassedValidation $AssetsPassedValidation -DeploymentClusterId $DeploymentClusterId -Unpin:$Unpin -DryRun:$DryRun + + Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterId" + } "ByCsvPath" { Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets from CSV file $CsvPath" Initialize-ApiContext @@ -1390,7 +1590,7 @@ switch ($PSCmdlet.ParameterSetName) { } # Validate each asset can be pinned/unpinned - [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError) + [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError -SkipAssetHealthValidation:$SkipAssetHealthValidation) # Process each cluster's assets foreach ($clusterId in $UniqueClusterIds) { From e2e9249499fe11a9e0cbb550e114faacf69e7b54 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 08:13:52 -0400 Subject: [PATCH 2/8] feat: add concurrent subnet batching and 429 retry backoff Run subnet-batch asset resolution requests concurrently via ForEach-Object -Parallel, throttled by the new -MaxConcurrentBatches parameter (default 5), to speed up resolution for large subnets. Add exponential backoff retry (up to 3 attempts) in Invoke-ApiRequest for HTTP 429 responses, since the added concurrency can trigger rate limiting that a single sequential request stream would not. Sync CLAUDE.md workflow notes to describe the new concurrent batching and retry behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Nox4wjcx2VFvMwn9Wup7t --- .../Pin Assets To Clusters/CLAUDE.md | 2 +- .../Pin-AssetsToClusters.ps1 | 86 ++++++++++++++++--- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md index 3c25362..32a728e 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md @@ -70,7 +70,7 @@ The bottom-of-file `switch ($PSCmdlet.ParameterSetName)` block dispatches to one 1. `Initialize-ApiContext` 2. `Invoke-ValidateDeploymentClusterId` for the one `-DeploymentClusterId` supplied. 3. `Get-SubnetHostAddresses` — expands `-TargetSubnet` into every individual host address in the range (including network/broadcast addresses). Warns and requires interactive confirmation above /24 (256 addresses), and hard-stops above /16 (65,536 addresses). -4. `Get-AssetsByHostAddresses` — queries `/assets/monitored` in batches of `$script:SUBNET_BATCH_SIZE` (default 100, not a script parameter) using a `lastIpAddress` filter, accumulating `.items` across batches (empty batches are expected, not an error). +4. `Get-AssetsByHostAddresses` — queries `/assets/monitored` in batches of `$script:SUBNET_BATCH_SIZE` (default 100, not a script parameter) using a `lastIpAddress` filter. Batches run concurrently via `ForEach-Object -Parallel`, up to `-MaxConcurrentBatches` (default 5; set to 1 for sequential behavior) — results are merged into a single list after the parallel block completes (empty batches are expected, not an error). `Invoke-ApiRequest` retries up to 3 times with exponential backoff on HTTP 429 to absorb any rate limiting the added concurrency triggers. 5. `Test-ValidateProvidedAssetsCanBePinned` — validates the whole asset list at once, honoring `-StopOnAssetValidationError`. Assets from `/assets/monitored` already carry `.id`/`.name` in the shape expected, so no normalization step is needed (unlike `ByCsvPath`). 6. `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` in batches of 50, all against the single validated cluster. diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 index 96e2f06..e3b3f70 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 @@ -43,6 +43,9 @@ .PARAMETER StopOnAssetValidationError Stop processing and throw an error when asset validation fails. Available for ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. +.PARAMETER MaxConcurrentBatches + Maximum number of subnet-batch asset resolution requests to run concurrently. Defaults to 5. Set to 1 to force fully sequential behavior. Available for ByTargetSubnet parameter set only. No documented Zero Networks API rate limit exists, so raise cautiously. + .PARAMETER ListDeploymentClusters Switch to list all deployment clusters with detailed information. @@ -160,6 +163,11 @@ param( [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] [switch]$StopOnAssetValidationError, + # Maximum number of subnet-batch asset resolution requests to run concurrently (ByTargetSubnet only) + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] + [ValidateRange(1, 20)] + [int]$MaxConcurrentBatches = 5, + # ParameterSet 2: List Deployment Clusters [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $true)] [switch]$ListDeploymentClusters, @@ -810,35 +818,69 @@ function Get-SubnetHostAddresses { Retrieves monitored assets whose last known IP address falls within a set of subnet host addresses. .PARAMETER AssetSubnetHostAddresses ArrayList of dotted-quad host address strings to search for (as produced by Get-SubnetHostAddresses). + .PARAMETER MaxConcurrentBatches + Maximum number of batch queries to run concurrently. Defaults to 5. Set to 1 to force sequential behavior. .OUTPUTS Returns an ArrayList of asset entity objects whose lastIpAddress matched any of the provided addresses. .NOTES Addresses are queried in batches of $script:SUBNET_BATCH_SIZE, since the API has no native subnet-range filter. It is expected that some (or all) batches may return zero matching assets - this is not treated as an error. + Batches are queried concurrently (via ForEach-Object -Parallel), so "Querying batch N of M..." progress + messages may print out of order - this is cosmetic only and does not affect the assets returned. #> function Get-AssetsByHostAddresses { param( [Parameter(Mandatory = $true)] - [System.Collections.ArrayList]$AssetSubnetHostAddresses + [System.Collections.ArrayList]$AssetSubnetHostAddresses, + + [Parameter(Mandatory = $false)] + [int]$MaxConcurrentBatches = 5 ) Write-Host "Retrieving assets matching $($AssetSubnetHostAddresses.Count) subnet host addresses" - [System.Collections.ArrayList]$Assets = @() $batchSize = $script:SUBNET_BATCH_SIZE $totalAddresses = $AssetSubnetHostAddresses.Count $totalBatches = [math]::Ceiling($totalAddresses / $batchSize) - $batchNumber = 1 + # Build batch descriptors up-front (cheap, no I/O) so the API calls themselves can run concurrently + [System.Collections.ArrayList]$Batches = @() + $batchNumber = 1 for ($i = 0; $i -lt $totalAddresses; $i += $batchSize) { $endIndex = [math]::Min($i + $batchSize - 1, $totalAddresses - 1) - $batch = @($AssetSubnetHostAddresses[$i..$endIndex]) - Write-Host "Querying batch $batchNumber of $totalBatches ($($batch.Count) addresses)..." + $Batches.Add([PSCustomObject]@{ + BatchNumber = $batchNumber + Addresses = @($AssetSubnetHostAddresses[$i..$endIndex]) + }) | Out-Null + $batchNumber++ + } + + # ForEach-Object -Parallel runspaces do not inherit $script: variables or functions from the + # calling scope - capture what each batch's API call needs here and re-hydrate it via $using: + # inside the parallel block. + $ApiBaseUrl = $script:ApiBaseUrl + $Headers = $script:Headers + $CapturedDebugPreference = $DebugPreference + $InvokeApiRequestDef = ${function:Invoke-ApiRequest}.ToString() + $InvokePaginatedApiRequestDef = ${function:Invoke-PaginatedApiRequest}.ToString() + $TestApiResponseStatusCodeDef = ${function:Test-ApiResponseStatusCode}.ToString() + + $BatchResults = $Batches | ForEach-Object -ThrottleLimit $MaxConcurrentBatches -Parallel { + # Re-hydrate script-scope state and functions inside this runspace + ${function:Test-ApiResponseStatusCode} = $using:TestApiResponseStatusCodeDef + ${function:Invoke-ApiRequest} = $using:InvokeApiRequestDef + ${function:Invoke-PaginatedApiRequest} = $using:InvokePaginatedApiRequestDef + $script:ApiBaseUrl = $using:ApiBaseUrl + $script:Headers = $using:Headers + $DebugPreference = $using:CapturedDebugPreference + + $batch = $_ + Write-Host "Querying batch $($batch.BatchNumber) of $($using:totalBatches) ($($batch.Addresses.Count) addresses)..." # Build filter array for API query - filters assets by lastIpAddress matching any address in this batch $FilterArray = @( @{ id = "lastIpAddress" - includeValues = @($batch) + includeValues = @($batch.Addresses) } ) $FilterJson = $FilterArray | ConvertTo-Json -Compress -AsArray -Depth 10 @@ -851,13 +893,18 @@ function Get-AssetsByHostAddresses { $response = Invoke-PaginatedApiRequest -Method "GET" -ApiEndpoint "/assets/monitored" -QueryParams $QueryParams - Write-Debug "Batch $batchNumber response body: $($response | ConvertTo-Json -Compress -Depth 10)" + Write-Debug "Batch $($batch.BatchNumber) response body: $($response | ConvertTo-Json -Compress -Depth 10)" if ($null -ne $response.items -and $response.items.Count -gt 0) { - $Assets.AddRange(@($response.items)) + $response.items } + } - $batchNumber++ + # Merge results back into a single accumulator - this happens single-threaded after + # ForEach-Object -Parallel completes, so no thread-safety concern here. + [System.Collections.ArrayList]$Assets = @() + if ($null -ne $BatchResults) { + $Assets.AddRange(@($BatchResults)) } Write-Host "Retrieved $($Assets.Count) assets across $totalBatches batch(es) matching subnet host addresses" @@ -1415,6 +1462,9 @@ function Invoke-PaginatedApiRequest { Returns the API response object. .NOTES Automatically validates status codes and throws exceptions for errors. + Retries up to 3 times with a short exponential backoff (1s, 2s, 4s) if the API responds with + HTTP 429 (rate limited), since concurrent subnet-batch requests (-MaxConcurrentBatches) can + trigger rate limiting that a single sequential request stream would not have. #> function Invoke-ApiRequest { param( @@ -1468,8 +1518,20 @@ function Invoke-ApiRequest { } # Execute request and capture status code (SkipHttpErrorCheck allows manual error handling) - $statusCode = $null - $response = Invoke-RestMethod @requestParams -SkipHttpErrorCheck -StatusCodeVariable statusCode + # Retry on 429 (rate limited) with a short exponential backoff before giving up + $maxAttempts = 3 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + $statusCode = $null + $response = Invoke-RestMethod @requestParams -SkipHttpErrorCheck -StatusCodeVariable statusCode + + if ($statusCode -eq 429 -and $attempt -lt $maxAttempts) { + $backoffSeconds = [math]::Pow(2, $attempt - 1) + Write-Warning "Received HTTP 429 (rate limited) from $($requestParams['Uri']). Retrying in $backoffSeconds second(s) (attempt $attempt of $maxAttempts)..." + Start-Sleep -Seconds $backoffSeconds + continue + } + break + } # Validate status code (throws exception for non-2XX codes) Test-ApiResponseStatusCode -StatusCode $statusCode -Response $response | Out-Null @@ -1547,7 +1609,7 @@ switch ($PSCmdlet.ParameterSetName) { # Expand the subnet into individual host addresses, then resolve them to monitored assets $AssetSubnetHostAddresses = Get-SubnetHostAddresses -TargetSubnet $TargetSubnet - [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsByHostAddresses -AssetSubnetHostAddresses $AssetSubnetHostAddresses) + [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsByHostAddresses -AssetSubnetHostAddresses $AssetSubnetHostAddresses -MaxConcurrentBatches $MaxConcurrentBatches) if ($Assets.Count -eq 0) { Write-Host "No assets found matching subnet $TargetSubnet. Exiting..." From 7e4860e13e8f3e8ed6e6c6db5d0524900ee75a10 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 08:13:58 -0400 Subject: [PATCH 3/8] docs: document subnet pinning and health-check bypass in README Add the IPv4 subnet bulk-pin use case, -SkipAssetHealthValidation, and -MaxConcurrentBatches to the README so it matches the ByTargetSubnet parameter set and health-check bypass already present in the script. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Nox4wjcx2VFvMwn9Wup7t --- .../Pin Assets To Clusters/README.md | 116 ++++++++++++++++-- 1 file changed, 108 insertions(+), 8 deletions(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md index 22c02a2..9c1a32f 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md @@ -1,6 +1,6 @@ # Pin Assets To Clusters -A PowerShell script for pinning (assigning) or unpinning (unassigning) assets to a particular deployment cluster in Zero Networks. This script allows you to pin/unpin individual assets, or in bulk via CSV or AD OU path. +A PowerShell script for pinning (assigning) or unpinning (unassigning) assets to a particular deployment cluster in Zero Networks. This script allows you to pin/unpin individual assets, or in bulk via CSV, AD OU path, or IPv4 subnet (CIDR). ## Requirements @@ -9,13 +9,15 @@ A PowerShell script for pinning (assigning) or unpinning (unassigning) assets to ## Features -- Pin or unpin assets to deployment clusters via asset ID, CSV file, or Active Directory OU path -- Bulk operations via CSV file or AD OU path +- Pin or unpin assets to deployment clusters via asset ID, CSV file, Active Directory OU path, or IPv4 subnet (CIDR) +- Bulk operations via CSV file, AD OU path, or IPv4 subnet - Automatic batching for large asset lists (50 assets per batch) - Dry run mode to preview changes without applying them (`-DryRun`) - Comprehensive validation before making changes - Stop on validation error option (`-StopOnAssetValidationError`) for strict validation enforcement - Nested OU resolution control (`-DisableNestedOuResolution`) for OU-based operations +- Asset health check bypass (`-SkipAssetHealthValidation`) for cases where a transient/stale unhealthy status shouldn't block pinning +- Concurrent subnet asset resolution with tunable throttling (`-MaxConcurrentBatches`) for subnet-based operations - List all deployment clusters with detailed information (`-ListDeploymentClusters`) - Export CSV template for bulk operations (`-ExportCsvTemplate`) - Debug output mode (`-EnableDebug`) for troubleshooting @@ -23,14 +25,14 @@ A PowerShell script for pinning (assigning) or unpinning (unassigning) assets to ## Prerequisites for pinning an asset to a cluster You can only pin assets that meet the following criterion: - **Must be monitored by a segment server** - You cannot pin assets monitored by Cloud Connector, Segment Connector, etc. -- **Must have a health status of *Healthy*** - The asset cannot have any health issues listed. +- **Must have a health status of *Healthy*** - The asset cannot have any health issues listed. *You can skip this check by adding the `-SkipAssetHealthValidation` parameter at run-time*. - **Target cluster must have at least one online and active segment server.** *You can skip this check by adding the `-SkipSegmentServerValidation` parameter at run-time*. - **Obviously, an asset cannot already be pinned** (The script checks for this) ## Script use cases -The script supports five different use cases: +The script supports six different use cases: ### 1. Pinning/Unpinning Asset by Asset ID & Deployment ID (Default) Pin or unpin a single asset to a deployment cluster. You can use the `-ListDeploymentClusters` use case to get the Deployment Cluster ID. @@ -55,6 +57,7 @@ See the [Quick start](#quick-start) section for more examples. - `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online +- `-SkipAssetHealthValidation` - Skip validation that the asset is healthy - `-DryRun` - Preview changes without applying them - `-EnableDebug` - Enable debug output @@ -82,6 +85,7 @@ See the [Quick start](#quick-start) section for more examples. - `-DisableNestedOuResolution` - Disable nested OU resolution (default: `false`). When set to `true`, only direct members of the OU are processed, not nested OUs. - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online +- `-SkipAssetHealthValidation` - Skip validation that assets are healthy - `-DryRun` - Preview changes without applying them - `-StopOnAssetValidationError` - Stop processing and throw an error when asset validation fails. If not specified, the script continues processing and only operates on assets that passed validation. - `-EnableDebug` - Enable debug output @@ -97,11 +101,45 @@ Pin or unpin multiple assets from a CSV file. Use `-ExportCsvTemplate` and `-Lis - `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online +- `-SkipAssetHealthValidation` - Skip validation that assets are healthy - `-DryRun` - Preview changes without applying them - `-StopOnAssetValidationError` - Stop processing and throw an error when asset validation fails. If not specified, the script continues processing and only operates on assets that passed validation. - `-EnableDebug` - Enable debug output -### 4. List your deployment clusters +### 4. IPv4 Subnet (CIDR) Bulk Operations +Pin or unpin all monitored assets whose last known IP address falls within a specified IPv4 subnet (CIDR range) to a deployment cluster. + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -TargetSubnet "10.200.200.0/24" ` + -DeploymentClusterId "your-deployment-cluster-id" ` + -PortalUrl "https://yourportal-admin.zeronetworks.com" +``` + +The script expands the CIDR range into individual host addresses (including network/broadcast addresses) and resolves them to monitored assets via the API. Host address batches are queried concurrently (see `-MaxConcurrentBatches`) to speed up resolution for large subnets. + +**Subnet size limits:** +- Subnets larger than `/24` (more than 256 addresses) require interactive confirmation before proceeding, since resolution will require multiple batched API calls. +- Subnets larger than `/16` (more than 65,536 addresses) are rejected outright. + +#### Supported Parameters +**Required Parameters:** +- `-ApiKey` - Your Zero Networks API key +- `-TargetSubnet` - IPv4 CIDR subnet to pin/unpin assets within (e.g., `10.200.200.0/24`) +- `-DeploymentClusterId` - The deployment cluster ID + +**Optional Parameters:** +- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) +- `-Unpin` - Switch to unpin instead of pin +- `-SkipSegmentServerValidation` - Skip validation that segment servers are online +- `-SkipAssetHealthValidation` - Skip validation that assets are healthy +- `-DryRun` - Preview changes without applying them +- `-StopOnAssetValidationError` - Stop processing and throw an error when asset validation fails. If not specified, the script continues processing and only operates on assets that passed validation. +- `-MaxConcurrentBatches` - Maximum number of subnet-batch asset resolution requests to run concurrently (default: `5`, range: `1`-`20`). Set to `1` to force fully sequential behavior. +- `-EnableDebug` - Enable debug output + +### 5. List your deployment clusters List all deployment clusters with detailed information. #### Supported Parameters **Required Parameters:** @@ -112,7 +150,7 @@ List all deployment clusters with detailed information. - `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-EnableDebug` - Enable debug output -### 5. Export CSV Template to use with CSV Bulk Operations +### 6. Export CSV Template to use with CSV Bulk Operations Export a CSV template file for bulk operations. #### Supported Parameters **Required Parameters:** @@ -231,6 +269,26 @@ To process only direct members of the OU (excluding nested OUs), use the `-Disab **Note:** The script automatically filters out segment servers and any non-computer entity, processing only valid assets from the OU. +### Pinning Assets by IPv4 Subnet (Bulk Ops) +#### 1. Determine the target subnet +- Determine the IPv4 CIDR subnet (e.g., `10.200.200.0/24`) covering the assets you want to pin/unpin. Assets are matched based on their last known IP address in the portal. + +#### 2. List deployment clusters +Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster ID](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-id) and [3. Extract cluster ID(s) from output](#3-extract-cluster-ids-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain the Cluster ID. + +#### 3. Pin assets in the subnet to the cluster +Run the script with the target subnet and cluster ID. + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -TargetSubnet "10.200.200.0/24" ` + -DeploymentClusterId "C:d:1234569f" ` + -PortalUrl "https://yourportal-admin.zeronetworks.com" +``` + +**Note:** Subnets larger than `/24` will prompt for interactive confirmation before proceeding, since resolving assets requires multiple batched API calls. Subnets larger than `/16` are rejected. Use `-MaxConcurrentBatches` to control how many resolution batches run in parallel. + ### Pinning assets from a CSV (Bulk Ops) #### 1. Export applicable assets to CSV within the portal - From within the Zero Networks portal, go to *Entities -> Assets -> Monitored* and add the filter **Monitored By --> Segment Server** and **Health Status --> Healthy** (Prerequisites). @@ -338,6 +396,48 @@ Finally, run the script, passing it the path to your CSV. -DisableNestedOuResolution $true ``` +### Pin Assets by IPv4 Subnet + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -TargetSubnet "10.200.200.0/24" ` + -DeploymentClusterId "C:d:1234569f" ` + -PortalUrl "https://yourportal-admin.zeronetworks.com" +``` + +### Unpin Assets by IPv4 Subnet + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -TargetSubnet "10.200.200.0/24" ` + -DeploymentClusterId "C:d:1234569f" ` + -Unpin +``` + +### Pin Assets by IPv4 Subnet with Custom Concurrency + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -TargetSubnet "10.200.0.0/20" ` + -DeploymentClusterId "C:d:1234569f" ` + -MaxConcurrentBatches 10 +``` + +### Skip Asset Health Validation + +Use `-SkipAssetHealthValidation` if a transient or stale unhealthy status in the portal shouldn't block a pin/unpin operation. All other validations (segment server check, monitored-by-Segment-Server check, applicability, and pin state) are still enforced. + +```powershell +.\Pin-AssetsToClusters.ps1 ` + -ApiKey "your-api-key" ` + -AssetId "a:a:123456tn" ` + -DeploymentClusterId "C:d:1234569f" ` + -SkipAssetHealthValidation +``` + ### Stop on Asset Validation Error When using `-StopOnAssetValidationError`, the script will stop and throw an error if any asset fails validation, rather than continuing with only the validated assets: @@ -396,7 +496,7 @@ The script performs comprehensive validation before making any changes: ### Asset Validation - Asset must not be a segment server (segment servers cannot be pinned to deployment clusters) - Asset must be monitored by a Segment Server (not Cloud Connector or Lightweight Agent) -- Asset must be healthy +- Asset must be healthy (unless `-SkipAssetHealthValidation` is used) - Asset must be applicable to be pinned to a deployment cluster (An asset's deploymentSource attribute cannot be set to "Not Applicable") - For pinning: Asset must not already be pinned to a deployment cluster - For unpinning: Asset must already be pinned to a deployment cluster From d819ddd63cad8ecfb4ff7b504349d2a5a622add6 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 08:16:58 -0400 Subject: [PATCH 4/8] chore: ignore DeploymentClusterFieldMappings.json Untrack the standalone reference JSON alongside AssetDetailsFieldMappings.json; the script keeps its own inline copy of this mapping data and never reads the file, so it doesn't need to be version controlled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019Nox4wjcx2VFvMwn9Wup7t --- .../Pin Assets To Clusters/.gitignore | 1 + .../DeploymentClusterFieldMappings.json | 95 ------------------- 2 files changed, 1 insertion(+), 95 deletions(-) delete mode 100644 Segment/Segment/Asset Management/Pin Assets To Clusters/DeploymentClusterFieldMappings.json diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore index 1a9f27c..bf2985a 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore @@ -2,6 +2,7 @@ .env .env.ps1 AssetDetailsFieldMappings.json +DeploymentClusterFieldMappings.json subnet-pinning.md plan.md test-plan.md diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/DeploymentClusterFieldMappings.json b/Segment/Segment/Asset Management/Pin Assets To Clusters/DeploymentClusterFieldMappings.json deleted file mode 100644 index 06f56b0..0000000 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/DeploymentClusterFieldMappings.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "strategy": { - "byId": { - "0": "CLUSTER_STRATEGY_UNSPECIFIED", - "1": "Active / Passive", - "2": "Active / Active" - }, - "byName": { - "CLUSTER_STRATEGY_UNSPECIFIED": 0, - "Active / Passive": 1, - "Active / Active": 2, - "ACTIVE_PASSIVE": 1, - "ACTIVE_ACTIVE": 2 - } - }, - "assignedDeployments.status": { - "byId": { - "0": "DEPLOYMENT_STATUS_UNSPECIFIED", - "1": "Offline", - "2": "Online", - "3": "Network disconnected" - }, - "byName": { - "DEPLOYMENT_STATUS_UNSPECIFIED": 0, - "Offline": 1, - "Online": 2, - "Network disconnected": 3, - "DISCONNECTED": 1, - "ONLINE": 2, - "NETWORK_DISCONNECTED": 3 - } - }, - "assignedDeployments.state": { - "byId": { - "0": "DEPLOYMENT_STATE_UNSPECIFIED", - "1": "Primary", - "2": "Secondary" - }, - "byName": { - "DEPLOYMENT_STATE_UNSPECIFIED": 0, - "Primary": 1, - "Secondary": 2, - "DEPLOYMENT_STATE_PRIMARY": 1, - "DEPLOYMENT_STATE_SECONDARY": 2 - } - }, - "assignedDeployments.servicesInfo.serviceId": { - "byId": { - "0": "SERVICE_ID_UNSPECIFIED", - "1": "ad", - "2": "winrm", - "3": "ansible-manager" - }, - "byName": { - "SERVICE_ID_UNSPECIFIED": 0, - "ad": 1, - "winrm": 2, - "ansible-manager": 3, - "SERVICE_ID_AD": 1, - "SERVICE_ID_WINRM": 2, - "SERVICE_ID_ANSIBLE_MANAGER": 3 - } - }, - "assignedDeployments.servicesInfo.status": { - "byId": { - "0": "DEPLOYMENT_STATUS_UNSPECIFIED", - "1": "Offline", - "2": "Online", - "3": "Network disconnected" - }, - "byName": { - "DEPLOYMENT_STATUS_UNSPECIFIED": 0, - "Offline": 1, - "Online": 2, - "Network disconnected": 3, - "DISCONNECTED": 1, - "ONLINE": 2, - "NETWORK_DISCONNECTED": 3 - } - }, - "assignedDeployments.servicesInfo.state": { - "byId": { - "0": "DEPLOYMENT_STATE_UNSPECIFIED", - "1": "Primary", - "2": "Secondary" - }, - "byName": { - "DEPLOYMENT_STATE_UNSPECIFIED": 0, - "Primary": 1, - "Secondary": 2, - "DEPLOYMENT_STATE_PRIMARY": 1, - "DEPLOYMENT_STATE_SECONDARY": 2 - } - } - } \ No newline at end of file From 800f704b14df0b93728937b837873363c029bc81 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 08:44:06 -0400 Subject: [PATCH 5/8] fix(subnet): dedupe cross-batch assets and untruncate validation errors Concurrent subnet batches querying overlapping IP ranges can resolve the same asset more than once; dedupe by id before returning so downstream validation and pinning never see duplicates. Also stop Format-Table from truncating long ErrorMessage text in the failure summary, so the full reason for each validation failure is visible. --- .../Pin Assets To Clusters/Pin-AssetsToClusters.ps1 | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 index e3b3f70..62818b3 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 @@ -360,7 +360,7 @@ function Test-ValidateProvidedAssetsCanBePinned { if ($AssetsFailedValidation.Count -gt 0) { Write-Warning "Failed to validate $($AssetsFailedValidation.Count)/$($Assets.Count) assets. Check list below for details." - $AssetsFailedValidation | Format-Table -Property name, id, ErrorMessage | Out-String | Write-Warning + $AssetsFailedValidation | Format-Table -Property name, id, ErrorMessage -Wrap | Out-String -Width 4096 | Write-Warning if ($StopOnAssetValidationError) { throw "At least one asset failed validation! Terminating script due to -StopOnAssetValidationError being set" } @@ -907,8 +907,13 @@ function Get-AssetsByHostAddresses { $Assets.AddRange(@($BatchResults)) } - Write-Host "Retrieved $($Assets.Count) assets across $totalBatches batch(es) matching subnet host addresses" - return $Assets + # An asset can match more than one queried host address (e.g. its lastIpAddress changed + # between batches), so different batches can independently resolve the same asset. Dedupe + # by id here so downstream validation/pinning never receives the same asset twice. + [System.Collections.ArrayList]$UniqueAssets = @($Assets | Sort-Object -Property id -Unique) + + Write-Host "Retrieved $($UniqueAssets.Count) unique assets across $totalBatches batch(es) matching subnet host addresses" + return $UniqueAssets } <# From 664a073c5813b963d07ec26af2e2921dad25e291 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 08:57:19 -0400 Subject: [PATCH 6/8] feat(pin-assets-to-clusters)!: require explicit PortalUrl BREAKING CHANGE: -PortalUrl no longer defaults to https://portal.zeronetworks.com and must be supplied explicitly on every invocation (except -ExportCsvTemplate). The prior default pointed at the generic SaaS URL rather than a tenant-specific admin URL, risking silent use of the wrong endpoint. Co-Authored-By: Claude Sonnet 5 --- .../Pin-AssetsToClusters.ps1 | 14 +++++++------- .../Pin Assets To Clusters/README.md | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 index 62818b3..ff5fb5a 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 @@ -11,7 +11,7 @@ Zero Networks API key with appropriate permissions. Required for all operations except ExportCsvTemplate. .PARAMETER PortalUrl - Base URL for the Zero Networks portal. Defaults to https://portal.zeronetworks.com. + Base URL for the Zero Networks portal (e.g., https://-admin.zeronetworks.com). Required for all parameter sets except ExportCsvTemplate. .PARAMETER AssetId Asset ID to pin or unpin. Required for ByAssetId parameter set. @@ -101,12 +101,12 @@ param( [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] [string]$ApiKey, - [Parameter(ParameterSetName = "ByAssetId", Mandatory = $false)] - [Parameter(ParameterSetName = "ByOuPath", Mandatory = $false)] - [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $false)] - [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $false)] - [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $false)] - [string]$PortalUrl = "https://portal.zeronetworks.com", + [Parameter(ParameterSetName = "ByAssetId", Mandatory = $true)] + [Parameter(ParameterSetName = "ByOuPath", Mandatory = $true)] + [Parameter(ParameterSetName = "ListDeploymentClusters", Mandatory = $true)] + [Parameter(ParameterSetName = "ByCsvPath", Mandatory = $true)] + [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] + [string]$PortalUrl, # ParameterSet 1: Pin by Asset ID and Deployment Cluster ID [Parameter(ParameterSetName = "ByAssetId", Mandatory = $true)] diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md index 9c1a32f..7992463 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md @@ -52,9 +52,9 @@ See the [Quick start](#quick-start) section for more examples. - `-ApiKey` - Your Zero Networks API key - `-AssetId` - The asset ID to pin/unpin - `-DeploymentClusterId` - The deployment cluster ID +- `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** -- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online - `-SkipAssetHealthValidation` - Skip validation that the asset is healthy @@ -79,9 +79,9 @@ See the [Quick start](#quick-start) section for more examples. - `-ApiKey` - Your Zero Networks API key - `-OUPath` - The OU path (e.g., "OU=Computers,DC=domain,DC=com") - `-DeploymentClusterId` - The deployment cluster ID +- `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** -- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-DisableNestedOuResolution` - Disable nested OU resolution (default: `false`). When set to `true`, only direct members of the OU are processed, not nested OUs. - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online @@ -96,9 +96,9 @@ Pin or unpin multiple assets from a CSV file. Use `-ExportCsvTemplate` and `-Lis **Required Parameters:** - `-ApiKey` - Your Zero Networks API key - `-CsvPath` - Path to the CSV file +- `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** -- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online - `-SkipAssetHealthValidation` - Skip validation that assets are healthy @@ -128,9 +128,9 @@ The script expands the CIDR range into individual host addresses (including netw - `-ApiKey` - Your Zero Networks API key - `-TargetSubnet` - IPv4 CIDR subnet to pin/unpin assets within (e.g., `10.200.200.0/24`) - `-DeploymentClusterId` - The deployment cluster ID +- `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** -- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-Unpin` - Switch to unpin instead of pin - `-SkipSegmentServerValidation` - Skip validation that segment servers are online - `-SkipAssetHealthValidation` - Skip validation that assets are healthy @@ -145,9 +145,9 @@ List all deployment clusters with detailed information. **Required Parameters:** - `-ApiKey` - Your Zero Networks API key - `-ListDeploymentClusters` - Switch to enable listing mode +- `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** -- `-PortalUrl` - Portal URL (default: `https://portal.zeronetworks.com`) - `-EnableDebug` - Enable debug output ### 6. Export CSV Template to use with CSV Bulk Operations From a3c597fe8142fce97086612baed0499b07ee9450 Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 09:27:06 -0400 Subject: [PATCH 7/8] feat(pin-assets-to-clusters)!: resolve deployment clusters by name BREAKING CHANGE: -DeploymentClusterId is removed everywhere (script parameter and the CSV DeploymentClusterId column) and replaced by -DeploymentClusterName / a DeploymentClusterName CSV column. Names are resolved to cluster IDs via a local -DeploymentClusters.json cache file kept next to the script, auto-created on first use and refreshed by -ListDeploymentClusters. This removes the previous workflow of running -ListDeploymentClusters separately to copy an opaque cluster ID into a follow-up command. Also fixes a pre-existing latent bug in Test-AssetCanBePinned that indexed the deployment cluster hashtable with an unset $DeploymentClusterId during CSV-based validation (ByCsvPath never bound that variable), which threw on every asset and silently failed all CSV validation. Co-Authored-By: Claude Sonnet 5 --- .../Pin Assets To Clusters/.gitignore | 1 + .../Pin Assets To Clusters/CLAUDE.md | 33 +-- .../Pin-AssetsToClusters.ps1 | 213 ++++++++++++++---- .../Pin Assets To Clusters/README.md | 95 ++++---- 4 files changed, 238 insertions(+), 104 deletions(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore index bf2985a..b0d149b 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore @@ -1,4 +1,5 @@ *.csv +*-DeploymentClusters.json .env .env.ps1 AssetDetailsFieldMappings.json diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md index 32a728e..a80af10 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/CLAUDE.md @@ -11,32 +11,34 @@ There are no automated tests, linter config, or build step in this project. Vali ## Running the script ```powershell -# List deployment clusters (also initializes cluster/segment-server lookups) +# List deployment clusters (also initializes cluster/segment-server lookups, and refreshes the name cache file) ./Pin-AssetsToClusters.ps1 -ApiKey -PortalUrl https://-admin.zeronetworks.com -ListDeploymentClusters # Export a CSV template for bulk operations ./Pin-AssetsToClusters.ps1 -ExportCsvTemplate # Pin/unpin a single asset (-DryRun previews without calling the mutation API) -./Pin-AssetsToClusters.ps1 -ApiKey -AssetId -DeploymentClusterId [-Unpin] [-DryRun] [-EnableDebug] +./Pin-AssetsToClusters.ps1 -ApiKey -AssetId -DeploymentClusterName [-Unpin] [-DryRun] [-EnableDebug] # Bulk via CSV or AD OU path ./Pin-AssetsToClusters.ps1 -ApiKey -CsvPath ./assets.csv [-Unpin] [-DryRun] -./Pin-AssetsToClusters.ps1 -ApiKey -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterId [-DisableNestedOuResolution] [-StopOnAssetValidationError] +./Pin-AssetsToClusters.ps1 -ApiKey -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterName [-DisableNestedOuResolution] [-StopOnAssetValidationError] # Bulk via IPv4 subnet (CIDR) -./Pin-AssetsToClusters.ps1 -ApiKey -TargetSubnet "10.200.200.0/24" -DeploymentClusterId [-Unpin] [-DryRun] [-StopOnAssetValidationError] +./Pin-AssetsToClusters.ps1 -ApiKey -TargetSubnet "10.200.200.0/24" -DeploymentClusterName [-Unpin] [-DryRun] [-StopOnAssetValidationError] ``` `-EnableDebug` sets `$DebugPreference = "Continue"` for verbose API/flow tracing. There is no `-WhatIf`/Pester test suite — use `-DryRun` against a real (or test) tenant to validate changes before committing. +All parameter sets that target a cluster take `-DeploymentClusterName`/a CSV `DeploymentClusterName` column, not a raw cluster ID — `-DeploymentClusterId` was removed entirely. Names are resolved to IDs via a local `-DeploymentClusters.json` cache file kept next to the script (`$PSScriptRoot`), where `envName` is derived from the `-PortalUrl` host. See `Resolve-DeploymentClusterName` and `Initialize-DeploymentClusterCache` below. + ## Architecture Single-file script driven by a `[CmdletBinding]` parameter-set switch (`ByAssetId`, `ByOuPath`, `ByCsvPath`, `ByTargetSubnet`, `ListDeploymentClusters`, `ExportCsvTemplate`). The final `switch ($PSCmdlet.ParameterSetName)` block at the bottom of the file is the entry point — read it first to see how each mode wires the helper functions together. Key flow shared by all mutating parameter sets: -1. `Initialize-ApiContext` — sets script-scoped `$script:Headers` / `$script:ApiBaseUrl` from `-ApiKey` / `-PortalUrl`. -2. `Invoke-ValidateDeploymentClusterId` → `Get-DeploymentClusters` (lazily populates `$script:DeploymentClusterHashtable` and `$script:SegmentServerHashtable`, keyed by cluster ID and segment-server asset ID respectively, for O(1) lookups used throughout validation). +1. `Initialize-ApiContext` — sets script-scoped `$script:Headers` / `$script:ApiBaseUrl` from `-ApiKey` / `-PortalUrl`, then calls `Initialize-DeploymentClusterCache` to ensure the local name cache file exists (creates it from the API if missing; does not refresh an existing one). +2. `Resolve-DeploymentClusterName` → `Invoke-ValidateDeploymentClusterId` — resolves the user-supplied `-DeploymentClusterName` to a cluster ID via the cache file (throws immediately, listing known names, on a cache miss), then validates that ID exists and has an online segment server via `Get-DeploymentClusters` (lazily populates `$script:DeploymentClusterHashtable` and `$script:SegmentServerHashtable`, keyed by cluster ID and segment-server asset ID respectively, for O(1) lookups used throughout validation). Everything downstream of this step (batching/pinning functions) still operates on the resolved cluster ID, not the name. 3. Asset resolution differs per mode: `Get-AssetDetails` (single asset), `Get-OUInfoFromApi` + `Get-AssetsFromOU` (OU mode), `Get-CsvData` (CSV mode, normalizes CSV rows into the same shape as API asset objects so downstream code is mode-agnostic), or `Get-SubnetHostAddresses` + `Get-AssetsByHostAddresses` (subnet mode, expands a CIDR range into host addresses and resolves them to monitored assets via a `lastIpAddress` filter). 4. `Test-AssetCanBePinned` / `Test-ValidateProvidedAssetsCanBePinned` — enforces the pin/unpin prerequisites (not a segment server, monitored by Segment Server, healthy, applicable, correct current pin state). Validation order matters — see comments in `Test-AssetCanBePinned`. 5. `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` — batches assets in groups of 50 and calls the `PUT /assets/actions/deployments-cluster` endpoint (or prints the would-be request body under `-DryRun`). @@ -47,20 +49,22 @@ API plumbing: `Invoke-ApiRequest` (single request + status-code validation via ` `deploymentsClusterSource` on an asset (values 0–6) is the key field driving pin-state validation — see the block comment above `$AssetIsPinnedDeploymentClusterSource` in `Test-AssetCanBePinned` for the meaning of each code. +**Deployment cluster name resolution:** `Get-DeploymentClusterCachePath` derives `$envName` from the `-PortalUrl` host (stripping a trailing `.zeronetworks.com`) and returns `$PSScriptRoot/-DeploymentClusters.json`. `Save-DeploymentClusterCache` writes a flat `name -> id` JSON map to that path from a `Get-DeploymentClusters` result. `Initialize-DeploymentClusterCache` (called from `Initialize-ApiContext`) creates the file only if it's missing — it does not refresh an existing file. `Resolve-DeploymentClusterName` reads the cache file and resolves a name to an ID, throwing (and listing known names) on a miss. The `ListDeploymentClusters` parameter set is the one exception that always overwrites the cache via `Save-DeploymentClusterCache` after its own `Get-DeploymentClusters` call, since it already fetches fresh data — this is the documented way to refresh the cache after clusters are added/renamed. The cache file (`*-DeploymentClusters.json`) is gitignored. + ### Per-Parameter-Set Workflow Sequences The bottom-of-file `switch ($PSCmdlet.ParameterSetName)` block dispatches to one of these sequences. Each is self-contained — read the relevant `case` directly for exact call order. **`ByAssetId`** (single asset, no batching): 1. `Initialize-ApiContext` -2. `Invoke-ValidateDeploymentClusterId` — validates `-DeploymentClusterId` exists (and has an online segment server, unless `-SkipSegmentServerValidation`). +2. `Resolve-DeploymentClusterName` resolves `-DeploymentClusterName` to a cluster ID (local var `$DeploymentClusterId`), then `Invoke-ValidateDeploymentClusterId` validates that ID exists (and has an online segment server, unless `-SkipSegmentServerValidation`). 3. `Test-AssetCanBePinned` — validates the single asset directly (not via `Test-ValidateProvidedAssetsCanBePinned`, since there's only one asset and no need to continue-on-error across a list). 4. Wraps `-AssetId` in a one-element `PSCustomObject` ArrayList. 5. `Set-AssetsToDeploymentCluster` is called directly — `Invoke-BatchBasedClusterPinning` is skipped because a single asset never needs batching. **`ByOuPath`** (bulk via AD OU, single cluster): 1. `Initialize-ApiContext` -2. `Invoke-ValidateDeploymentClusterId` for the one `-DeploymentClusterId` supplied. +2. `Resolve-DeploymentClusterName` resolves `-DeploymentClusterName` to a cluster ID, then `Invoke-ValidateDeploymentClusterId` for that ID. 3. `Get-OUInfoFromApi` — resolves `-OUPath` to an OU entity ID. 4. `Get-AssetsFromOU` — fetches OU members (nested, unless `-DisableNestedOuResolution`), filters to assets only, wrapped in `[System.Collections.ArrayList]@(...)` to guard against PowerShell unwrapping single-item results. 5. `Test-ValidateProvidedAssetsCanBePinned` — validates the whole asset list at once, honoring `-StopOnAssetValidationError`. @@ -68,7 +72,7 @@ The bottom-of-file `switch ($PSCmdlet.ParameterSetName)` block dispatches to one **`ByTargetSubnet`** (bulk via IPv4 CIDR subnet, single cluster): 1. `Initialize-ApiContext` -2. `Invoke-ValidateDeploymentClusterId` for the one `-DeploymentClusterId` supplied. +2. `Resolve-DeploymentClusterName` resolves `-DeploymentClusterName` to a cluster ID, then `Invoke-ValidateDeploymentClusterId` for that ID. 3. `Get-SubnetHostAddresses` — expands `-TargetSubnet` into every individual host address in the range (including network/broadcast addresses). Warns and requires interactive confirmation above /24 (256 addresses), and hard-stops above /16 (65,536 addresses). 4. `Get-AssetsByHostAddresses` — queries `/assets/monitored` in batches of `$script:SUBNET_BATCH_SIZE` (default 100, not a script parameter) using a `lastIpAddress` filter. Batches run concurrently via `ForEach-Object -Parallel`, up to `-MaxConcurrentBatches` (default 5; set to 1 for sequential behavior) — results are merged into a single list after the parallel block completes (empty batches are expected, not an error). `Invoke-ApiRequest` retries up to 3 times with exponential backoff on HTTP 429 to absorb any rate limiting the added concurrency triggers. 5. `Test-ValidateProvidedAssetsCanBePinned` — validates the whole asset list at once, honoring `-StopOnAssetValidationError`. Assets from `/assets/monitored` already carry `.id`/`.name` in the shape expected, so no normalization step is needed (unlike `ByCsvPath`). @@ -76,12 +80,11 @@ The bottom-of-file `switch ($PSCmdlet.ParameterSetName)` block dispatches to one **`ByCsvPath`** (bulk via CSV, potentially multiple clusters): 1. `Initialize-ApiContext` -2. `Get-CsvData` — reads and validates the CSV (required columns, non-empty rows). -3. Extracts the **unique** `DeploymentClusterId` values present in the CSV. -4. `Invoke-ValidateDeploymentClusterId` is run once per unique cluster ID (not once per row). -5. CSV rows are normalized into asset-shaped `PSCustomObject`s (`id`, `name`, `DeploymentClusterId`) so downstream validation/pinning functions are mode-agnostic with the OU/AssetId paths. -6. `Test-ValidateProvidedAssetsCanBePinned` validates the full normalized asset list in one pass (across all clusters at once), honoring `-StopOnAssetValidationError`. -7. For each unique cluster ID, the validated assets are filtered by `DeploymentClusterId` and passed to `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` (batches of 50) — so each cluster gets its own batched mutation call(s). +2. `Get-CsvData` — reads and validates the CSV (required columns, non-empty rows). The cluster column is `DeploymentClusterName`, not an ID. +3. Extracts the **unique** `DeploymentClusterName` values present in the CSV, resolves each via `Resolve-DeploymentClusterName`, and runs `Invoke-ValidateDeploymentClusterId` once per resolved cluster ID (not once per row) — building a `$ClusterNameToIdMap` hashtable along the way. +4. CSV rows are normalized into asset-shaped `PSCustomObject`s (`id`, `name`, `DeploymentClusterId`) using `$ClusterNameToIdMap` to look up each row's resolved ID, so downstream validation/pinning functions are mode-agnostic with the OU/AssetId paths (they still key off `DeploymentClusterId`, never the name). +5. `Test-ValidateProvidedAssetsCanBePinned` validates the full normalized asset list in one pass (across all clusters at once), honoring `-StopOnAssetValidationError`. +6. For each unique resolved cluster ID (`$ClusterNameToIdMap.Values`), the validated assets are filtered by `DeploymentClusterId` and passed to `Invoke-BatchBasedClusterPinning` → `Set-AssetsToDeploymentCluster` (batches of 50) — so each cluster gets its own batched mutation call(s). ## Coding Standards diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 index ff5fb5a..ca8f40e 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/Pin-AssetsToClusters.ps1 @@ -25,8 +25,11 @@ .PARAMETER TargetSubnet IPv4 CIDR subnet (e.g., "10.200.200.0/24") to pin or unpin all matching assets within. Required for ByTargetSubnet parameter set. -.PARAMETER DeploymentClusterId - Deployment cluster ID to pin/unpin assets to. Required for ByAssetId, ByOuPath, and ByTargetSubnet parameter sets. +.PARAMETER DeploymentClusterName + Deployment cluster name to pin/unpin assets to. Resolved to a cluster ID via a local -DeploymentClusters.json + cache file (stored next to the script, named after the -PortalUrl tenant). The cache file is created automatically + if missing; run -ListDeploymentClusters to refresh it if a cluster was recently added or renamed. + Required for ByAssetId, ByOuPath, and ByTargetSubnet parameter sets. .PARAMETER Unpin Switch to unpin assets instead of pinning them. Available for ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet parameter sets. @@ -61,9 +64,12 @@ .NOTES Requires PowerShell 7.0 or higher. Large CSV files are automatically processed in batches of 50 assets. + Deployment cluster names are resolved via a local -DeploymentClusters.json cache file stored next to + this script (envName is derived from -PortalUrl). The cache is created automatically the first time it's needed; + run -ListDeploymentClusters to refresh it after clusters are added or renamed. .EXAMPLE - .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -AssetId "a:a:qvI6tVtn" -DeploymentClusterId "C:d:00fd409f" + .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -AssetId "a:a:qvI6tVtn" -DeploymentClusterName "Production Cluster" Pins a single asset to a deployment cluster. .EXAMPLE @@ -75,11 +81,11 @@ Previews what changes would be made without actually applying them. .EXAMPLE - .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterId "C:d:00fd409f" + .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -OUPath "OU=Computers,DC=domain,DC=com" -DeploymentClusterName "Production Cluster" Pins all assets within the specified OU path to a deployment cluster. .EXAMPLE - .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -TargetSubnet "10.200.200.0/24" -DeploymentClusterId "C:d:00fd409f" + .\Pin-AssetsToClusters.ps1 -ApiKey "your-api-key" -TargetSubnet "10.200.200.0/24" -DeploymentClusterName "Production Cluster" Pins all monitored assets whose last known IP address falls within the specified subnet to a deployment cluster. #> @@ -127,7 +133,7 @@ param( [Parameter(ParameterSetName = "ByAssetId", Mandatory = $true)] [Parameter(ParameterSetName = "ByOuPath", Mandatory = $true)] [Parameter(ParameterSetName = "ByTargetSubnet", Mandatory = $true)] - [string]$DeploymentClusterId, + [string]$DeploymentClusterName, # Shared switch parameter for unpinning (available in ByAssetId, ByOuPath, ByCsvPath, and ByTargetSubnet sets) [Parameter(ParameterSetName = "ByAssetId")] @@ -452,7 +458,11 @@ function Test-AssetCanBePinned { } } - Write-Host "Validated that asset $($AssetDetails.name) ($($AssetDetails.id)) can be $($Unpin ? "unpinned" : "pinned") to deployment cluster: $($script:DeploymentClusterHashtable[$DeploymentClusterId].name)" + # $DeploymentClusterId may be unset here (e.g. ByCsvPath validates assets across multiple clusters + # at once, with no single ambient cluster ID in scope) - guard the lookup so this stays a status + # message and never throws. + $DeploymentClusterNameForMessage = (-not [string]::IsNullOrEmpty($DeploymentClusterId) -and $script:DeploymentClusterHashtable.ContainsKey($DeploymentClusterId)) ? $script:DeploymentClusterHashtable[$DeploymentClusterId].name : "N/A" + Write-Host "Validated that asset $($AssetDetails.name) ($($AssetDetails.id)) can be $($Unpin ? "unpinned" : "pinned") to deployment cluster: $DeploymentClusterNameForMessage" } <# @@ -1172,11 +1182,105 @@ function Write-DeploymentClusters { Write-Host $("="*(($Host.UI.RawUI.WindowSize.Width)/2)) } Write-Host "Finished writing deployment clusters information to console" - + } <# -This section of the script is responsible for + .SYNOPSIS + Computes the local deployment-cluster name-to-ID cache file path for the current tenant. + .OUTPUTS + Returns the full path to the -DeploymentClusters.json cache file located next to the script. + .NOTES + envName is derived from the -PortalUrl host, stripping a trailing ".zeronetworks.com" suffix if present; + otherwise the full host is used as-is. + #> +function Get-DeploymentClusterCachePath { + $envName = ([Uri]$PortalUrl).Host -replace '\.zeronetworks\.com$', '' + return Join-Path -Path $PSScriptRoot -ChildPath "$envName-DeploymentClusters.json" +} + +<# + .SYNOPSIS + Writes the deployment cluster name-to-ID cache file to disk, overwriting any existing file. + .PARAMETER DeploymentClusters + Array of deployment cluster objects (as returned by Get-DeploymentClusters) to cache. + .OUTPUTS + None. Writes the cache file to $script:DeploymentClusterCachePath. + .NOTES + The cache file is a flat JSON object mapping cluster name to cluster ID. + #> +function Save-DeploymentClusterCache { + param( + [Parameter(Mandatory = $true)] + [System.Array]$DeploymentClusters + ) + $NameToIdMap = @{} + foreach ($cluster in $DeploymentClusters) { + $NameToIdMap[$cluster.name] = $cluster.id + } + + try { + $NameToIdMap | ConvertTo-Json | Set-Content -Path $script:DeploymentClusterCachePath + } + catch { + throw "Failed to write deployment cluster cache file '$($script:DeploymentClusterCachePath)': $_" + } + Write-Host "Deployment cluster name cache file written to $($script:DeploymentClusterCachePath) with $($NameToIdMap.Count) cluster(s)" +} + +<# + .SYNOPSIS + Ensures the local deployment-cluster name-to-ID cache file exists, creating it from the API if missing. + .OUTPUTS + None. Sets $script:DeploymentClusterCachePath. Creates the cache file via Save-DeploymentClusterCache if it does not already exist. + .NOTES + Does not refresh an existing cache file - run -ListDeploymentClusters to force a refresh. + #> +function Initialize-DeploymentClusterCache { + $script:DeploymentClusterCachePath = Get-DeploymentClusterCachePath + + if (Test-Path -Path $script:DeploymentClusterCachePath) { + Write-Debug "Deployment cluster cache file already exists at $($script:DeploymentClusterCachePath)" + return + } + + Write-Host "Deployment cluster cache file not found - creating $($script:DeploymentClusterCachePath)" + $DeploymentClusters = Get-DeploymentClusters + Save-DeploymentClusterCache -DeploymentClusters $DeploymentClusters +} + +<# + .SYNOPSIS + Resolves a deployment cluster name to its cluster ID using the local name cache file. + .PARAMETER DeploymentClusterName + The human-readable deployment cluster name to resolve. + .OUTPUTS + Returns the deployment cluster ID string matching the provided name. + .NOTES + Throws an exception listing the known cluster names in the cache file if the name is not found. + Run -ListDeploymentClusters to refresh the cache file if a cluster was recently added or renamed. + #> +function Resolve-DeploymentClusterName { + param( + [Parameter(Mandatory = $true)] + [string]$DeploymentClusterName + ) + try { + $CachedClusters = Get-Content -Path $script:DeploymentClusterCachePath -Raw | ConvertFrom-Json -AsHashtable + } + catch { + throw "Failed to read deployment cluster cache file '$($script:DeploymentClusterCachePath)': $_" + } + + if (-not $CachedClusters.ContainsKey($DeploymentClusterName)) { + throw "Deployment cluster name '$DeploymentClusterName' not found in cache file '$($script:DeploymentClusterCachePath)'. Known deployment cluster names: $($CachedClusters.Keys -join ', '). Run -ListDeploymentClusters to refresh the cache if this cluster was recently added or renamed." + } + + return $CachedClusters[$DeploymentClusterName] +} + +<# +This section of the script is responsible for creating and exporting the CSV template. #> @@ -1192,11 +1296,11 @@ function Export-CsvTemplate { $template = [PSCustomObject]@{ AssetName = $null AssetId = $null - DeploymentClusterId = $null + DeploymentClusterName = $null } $template | Export-Csv -Path ".\pin-assets-to-clusters-template.csv" -NoTypeInformation Write-Host "CSV Template exported to .\pin-assets-to-clusters-template.csv" - Write-Host "Please fill in AT LEAST the AssetId, AssetName and DeploymentClusterId columns, and then run the script again with the -CsvPath parameter to pin the assets to the clusters." + Write-Host "Please fill in AT LEAST the AssetId, AssetName and DeploymentClusterName columns, and then run the script again with the -CsvPath parameter to pin the assets to the clusters." Write-Host "Example: .\Pin-AssetsToClusters.ps1 -CsvPath '.\pin-assets-to-clusters-template.csv' -ApiKey 'your-api-key'" } @@ -1208,7 +1312,7 @@ function Export-CsvTemplate { .OUTPUTS Returns an array of PSCustomObject representing the validated CSV rows. .NOTES - Required columns: AssetId, DeploymentClusterId. AssetName is optional. + Required columns: AssetId, DeploymentClusterName. AssetName is optional. #> function Get-CsvData { param( @@ -1235,32 +1339,32 @@ function Get-CsvData { } # Validate required columns exist - $requiredColumns = @('AssetId', 'AssetName', 'DeploymentClusterId') + $requiredColumns = @('AssetId', 'AssetName', 'DeploymentClusterName') $firstRow = $csvData[0] $actualColumns = $firstRow.PSObject.Properties.Name $missingColumns = @() - + foreach ($column in $requiredColumns) { if ($actualColumns -notcontains $column) { $missingColumns += $column } } - + if ($missingColumns.Count -gt 0) { - throw "CSV validation failed: The CSV file needs at least AssetId and DeploymentClusterId columns. Actual columns found in CSV: $($actualColumns -join ', ')" + throw "CSV validation failed: The CSV file needs at least AssetId and DeploymentClusterName columns. Actual columns found in CSV: $($actualColumns -join ', ')" } - + # Validate each row has required values (Import-Csv excludes header from data array) for ($i = 0; $i -lt $csvData.Count; $i++) { $row = $csvData[$i] $csvRowNumber = $i + 2 # +2: row 1 is header, arrays are 0-indexed - + if ($null -eq $row.AssetId) { throw "CSV validation failed: AssetId is null at row $csvRowNumber (index $i)" } - - if ($null -eq $row.DeploymentClusterId) { - throw "CSV validation failed: DeploymentClusterId is null at row $csvRowNumber (index $i)" + + if ($null -eq $row.DeploymentClusterName) { + throw "CSV validation failed: DeploymentClusterName is null at row $csvRowNumber (index $i)" } } @@ -1281,6 +1385,7 @@ initializing the API context and making API requests. None. Sets $script:Headers and $script:ApiBaseUrl for use by Invoke-ApiRequest function. .NOTES Sets $script:Headers and $script:ApiBaseUrl for use by Invoke-ApiRequest function. + Also ensures the local deployment cluster name cache file exists via Initialize-DeploymentClusterCache. #> function Initialize-ApiContext { $script:Headers = @{ @@ -1288,6 +1393,7 @@ function Initialize-ApiContext { Authorization = $ApiKey } $script:ApiBaseUrl = "$PortalUrl/api/v1" + Initialize-DeploymentClusterCache } <# @@ -1559,31 +1665,33 @@ workflow to execute based on the parameter set matched. #> switch ($PSCmdlet.ParameterSetName) { "ByAssetId" { - Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") asset $AssetId to deployment cluster $DeploymentClusterId" + Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") asset $AssetId to deployment cluster $DeploymentClusterName" Initialize-ApiContext - - # Validate deployment cluster exists and has online segment servers + + # Resolve the deployment cluster name to an ID, then validate it exists and has online segment servers + $DeploymentClusterId = Resolve-DeploymentClusterName -DeploymentClusterName $DeploymentClusterName Invoke-ValidateDeploymentClusterId -DeploymentClusterId $DeploymentClusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation - + # Validate asset can be pinned/unpinned Test-AssetCanBePinned -AssetId $AssetId -AssetMustBePinned:$Unpin -SkipAssetHealthValidation:$SkipAssetHealthValidation - + # Create asset object for the function $asset = [PSCustomObject]@{ id = $AssetId } $Assets = [System.Collections.ArrayList]@($asset) - + # Execute pin/unpin operation Set-AssetsToDeploymentCluster -Assets $Assets -DeploymentClusterId $DeploymentClusterId -Unpin:$Unpin -DryRun:$DryRun - - Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") asset $AssetId to deployment cluster $DeploymentClusterId" + + Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") asset $AssetId to deployment cluster $DeploymentClusterName" } "ByOuPath" { - Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets in OU path $OUPath to deployment cluster $DeploymentClusterId" + Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets in OU path $OUPath to deployment cluster $DeploymentClusterName" Initialize-ApiContext - - # Validate deployment cluster exists and has online segment servers + + # Resolve the deployment cluster name to an ID, then validate it exists and has online segment servers + $DeploymentClusterId = Resolve-DeploymentClusterName -DeploymentClusterName $DeploymentClusterName Invoke-ValidateDeploymentClusterId -DeploymentClusterId $DeploymentClusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation # Get OU Information from API @@ -1595,21 +1703,22 @@ switch ($PSCmdlet.ParameterSetName) { Wrapping the return value in an array ensures that the value is always returned as an array, regardless of the number of objects returned. #> # Get members of OU - [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsFromOU -OUPath $OUPath -EntityId $OUInformation.id -DisableNestedOuResolution:$DisableNestedOuResolution) - + [System.Collections.ArrayList]$Assets = [System.Collections.ArrayList]@(Get-AssetsFromOU -OUPath $OUPath -EntityId $OUInformation.id -DisableNestedOuResolution:$DisableNestedOuResolution) + # Validate each asset can be pinned/unpinned [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError -SkipAssetHealthValidation:$SkipAssetHealthValidation) # Finally, call function to perform the batch based cluster pinning/unpinning operation Invoke-BatchBasedClusterPinning -AssetsPassedValidation $AssetsPassedValidation -DeploymentClusterId $DeploymentClusterId -Unpin:$Unpin -DryRun:$DryRun - - Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in OU path $OUPath to deployment cluster $DeploymentClusterId" + + Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in OU path $OUPath to deployment cluster $DeploymentClusterName" } "ByTargetSubnet" { - Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterId" + Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterName" Initialize-ApiContext - # Validate deployment cluster exists and has online segment servers + # Resolve the deployment cluster name to an ID, then validate it exists and has online segment servers + $DeploymentClusterId = Resolve-DeploymentClusterName -DeploymentClusterName $DeploymentClusterName Invoke-ValidateDeploymentClusterId -DeploymentClusterId $DeploymentClusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation # Expand the subnet into individual host addresses, then resolve them to monitored assets @@ -1627,21 +1736,24 @@ switch ($PSCmdlet.ParameterSetName) { # Finally, call function to perform the batch based cluster pinning/unpinning operation Invoke-BatchBasedClusterPinning -AssetsPassedValidation $AssetsPassedValidation -DeploymentClusterId $DeploymentClusterId -Unpin:$Unpin -DryRun:$DryRun - Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterId" + Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets in subnet $TargetSubnet to deployment cluster $DeploymentClusterName" } "ByCsvPath" { Write-Host "$($DryRun ? "[DRY RUN] " : '')Starting workflow to $($Unpin ? "unpin" : "pin") assets from CSV file $CsvPath" Initialize-ApiContext - + # Read and validate CSV data $csvData = Get-CsvData -CsvPath $CsvPath - - # Get unique deployment cluster IDs from CSV - $UniqueClusterIds = @($csvData.DeploymentClusterId | Select-Object -Unique) - + + # Get unique deployment cluster names from CSV, then resolve each to a cluster ID + $UniqueClusterNames = @($csvData.DeploymentClusterName | Select-Object -Unique) + # Validate all deployment clusters exist and have online segment servers - foreach ($clusterId in $UniqueClusterIds) { + $ClusterNameToIdMap = @{} + foreach ($clusterName in $UniqueClusterNames) { + $clusterId = Resolve-DeploymentClusterName -DeploymentClusterName $clusterName Invoke-ValidateDeploymentClusterId -DeploymentClusterId $clusterId -SkipSegmentServerValidation:$SkipSegmentServerValidation + $ClusterNameToIdMap[$clusterName] = $clusterId } # Since the CSV data template does not have 1:1 field names as assets returned from API @@ -1652,26 +1764,29 @@ switch ($PSCmdlet.ParameterSetName) { $Assets.Add([pscustomobject]@{ id = $row.AssetId name = $row.AssetName - DeploymentClusterId = $row.DeploymentClusterId + DeploymentClusterId = $ClusterNameToIdMap[$row.DeploymentClusterName] }) | Out-Null } # Validate each asset can be pinned/unpinned [System.Collections.ArrayList]$AssetsPassedValidation = [System.Collections.ArrayList]@(Test-ValidateProvidedAssetsCanBePinned -Assets $Assets -AssetMustBePinned:$Unpin -StopOnAssetValidationError:$StopOnAssetValidationError -SkipAssetHealthValidation:$SkipAssetHealthValidation) - + # Process each cluster's assets - foreach ($clusterId in $UniqueClusterIds) { + foreach ($clusterId in @($ClusterNameToIdMap.Values | Select-Object -Unique)) { $AssetsToProcess = [System.Collections.ArrayList]@($AssetsPassedValidation | Where-Object { $_.DeploymentClusterId -eq $clusterId }) Write-Host "Processing $($Unpin ? "unpinning" : "pinning") operation against $($clusterId) for $($AssetsToProcess.Count) assets" Invoke-BatchBasedClusterPinning -AssetsPassedValidation $AssetsToProcess -DeploymentClusterId $clusterId -Unpin:$Unpin -DryRun:$DryRun } - + Write-Host "$($DryRun ? "[DRY RUN] " : '')Finished workflow to $($Unpin ? "unpin" : "pin") assets from CSV file $CsvPath" } "ListDeploymentClusters" { Initialize-ApiContext $DeploymentClusters = Get-DeploymentClusters Write-DeploymentClusters -DeploymentClusters $DeploymentClusters + # ListDeploymentClusters already fetches fresh cluster data above, so always refresh + # (not just create-if-missing) the local name cache file here. + Save-DeploymentClusterCache -DeploymentClusters $DeploymentClusters "" } "ExportCsvTemplate" { diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md index 7992463..16b691d 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/README.md @@ -10,6 +10,7 @@ A PowerShell script for pinning (assigning) or unpinning (unassigning) assets to ## Features - Pin or unpin assets to deployment clusters via asset ID, CSV file, Active Directory OU path, or IPv4 subnet (CIDR) +- Target clusters by human-readable name (`-DeploymentClusterName`), resolved via a local per-tenant name cache file (see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)) - Bulk operations via CSV file, AD OU path, or IPv4 subnet - Automatic batching for large asset lists (50 assets per batch) - Dry run mode to preview changes without applying them (`-DryRun`) @@ -34,14 +35,14 @@ You can only pin assets that meet the following criterion: The script supports six different use cases: -### 1. Pinning/Unpinning Asset by Asset ID & Deployment ID (Default) -Pin or unpin a single asset to a deployment cluster. You can use the `-ListDeploymentClusters` use case to get the Deployment Cluster ID. +### 1. Pinning/Unpinning Asset by Asset ID & Deployment Cluster Name (Default) +Pin or unpin a single asset to a deployment cluster. You can use the `-ListDeploymentClusters` use case to get the deployment cluster name. ```powershell .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -AssetId "your-asset-id" ` - -DeploymentClusterId "your-deployment-cluster-id" ` + -DeploymentClusterName "your-deployment-cluster-name" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -51,7 +52,7 @@ See the [Quick start](#quick-start) section for more examples. **Required Parameters:** - `-ApiKey` - Your Zero Networks API key - `-AssetId` - The asset ID to pin/unpin -- `-DeploymentClusterId` - The deployment cluster ID +- `-DeploymentClusterName` - The deployment cluster name (resolved to a cluster ID via the local name cache file - see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)) - `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** @@ -68,7 +69,7 @@ Pin or unpin all assets within a specified Active Directory Organizational Unit .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Your,DC=Company,DC=com" ` - -DeploymentClusterId "your-deployment-cluster-id" ` + -DeploymentClusterName "your-deployment-cluster-name" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -78,7 +79,7 @@ See the [Quick start](#quick-start) section for more examples. **Required Parameters:** - `-ApiKey` - Your Zero Networks API key - `-OUPath` - The OU path (e.g., "OU=Computers,DC=domain,DC=com") -- `-DeploymentClusterId` - The deployment cluster ID +- `-DeploymentClusterName` - The deployment cluster name (resolved to a cluster ID via the local name cache file - see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)) - `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** @@ -113,7 +114,7 @@ Pin or unpin all monitored assets whose last known IP address falls within a spe .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -TargetSubnet "10.200.200.0/24" ` - -DeploymentClusterId "your-deployment-cluster-id" ` + -DeploymentClusterName "your-deployment-cluster-name" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -127,7 +128,7 @@ The script expands the CIDR range into individual host addresses (including netw **Required Parameters:** - `-ApiKey` - Your Zero Networks API key - `-TargetSubnet` - IPv4 CIDR subnet to pin/unpin assets within (e.g., `10.200.200.0/24`) -- `-DeploymentClusterId` - The deployment cluster ID +- `-DeploymentClusterName` - The deployment cluster name (resolved to a cluster ID via the local name cache file - see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)) - `-PortalUrl` - Portal URL (e.g., `https://yourportal-admin.zeronetworks.com`) **Optional Parameters:** @@ -166,8 +167,8 @@ Export a CSV template file for bulk operations. - Enable the **Asset ID** column in the table of assets - Copy the **Asset ID** to a text document to be referenced later -#### 2. Run script with -ListDeploymentClusters parameter to get Cluster ID -Run the script with the `-ListDeploymentClusters` parameter. This will output information about all clusters in the tenant to your console. +#### 2. Run script with -ListDeploymentClusters parameter to get Cluster Name +Run the script with the `-ListDeploymentClusters` parameter. This will output information about all clusters in the tenant to your console, and will also create/refresh the local `-DeploymentClusters.json` name cache file next to the script (see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)). *The data below is actual output, but specific identifying information has been changed for secrecy.* ```powershell @@ -223,17 +224,17 @@ Segment server deployments assigned to this cluster: Finished writing deployment clusters information to console ``` -#### 3. Extract cluster ID(s) from output -Analyze the output from the script ran with `-ListDeploymentClusters`. Make note of each **Cluster ID(s)** you wish to pin the asset(s) to. E.g ```C:d:8PghlCty``` +#### 3. Extract cluster name(s) from output +Analyze the output from the script ran with `-ListDeploymentClusters`. Make note of each **Cluster Name** (the `Deployment cluster: ...` line) you wish to pin the asset(s) to. E.g ```EU Cluster``` #### 4. Pin the asset to the cluster -Run the script in single asset mode, specifying a particular asset and cluster ID. This will pin this asset to the specified cluster. +Run the script in single asset mode, specifying a particular asset and cluster name. This will pin this asset to the specified cluster. ```powershell .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -AssetId "a:a:123456tn" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -244,7 +245,7 @@ Run the script in single asset mode, specifying a particular asset and cluster I - You can find OU paths using Active Directory tools or PowerShell #### 2. List deployment clusters -Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster ID](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-id) and [3. Extract cluster ID(s) from output](#3-extract-cluster-ids-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain the Cluster ID. +Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster Name](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-name) and [3. Extract cluster name(s) from output](#3-extract-cluster-names-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain the Cluster Name. #### 3. Pin assets in the OU to the cluster Run the script with the OU path and cluster ID. By default, the script will process assets in nested OUs as well. @@ -253,7 +254,7 @@ Run the script with the OU path and cluster ID. By default, the script will proc .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Computers,DC=domain,DC=com" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -263,7 +264,7 @@ To process only direct members of the OU (excluding nested OUs), use the `-Disab .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Computers,DC=domain,DC=com" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -DisableNestedOuResolution $true ``` @@ -274,7 +275,7 @@ To process only direct members of the OU (excluding nested OUs), use the `-Disab - Determine the IPv4 CIDR subnet (e.g., `10.200.200.0/24`) covering the assets you want to pin/unpin. Assets are matched based on their last known IP address in the portal. #### 2. List deployment clusters -Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster ID](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-id) and [3. Extract cluster ID(s) from output](#3-extract-cluster-ids-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain the Cluster ID. +Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster Name](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-name) and [3. Extract cluster name(s) from output](#3-extract-cluster-names-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain the Cluster Name. #### 3. Pin assets in the subnet to the cluster Run the script with the target subnet and cluster ID. @@ -283,7 +284,7 @@ Run the script with the target subnet and cluster ID. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -TargetSubnet "10.200.200.0/24" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -299,19 +300,19 @@ Run the script with the target subnet and cluster ID. To facilitate ease of use, the script, when ran with the `-ExportCsvTemplate` parameter, will export a CSV template at `./pin-assets-to-clusters-template.csv` #### 3. List deployment clusters -Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster ID](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-id) and [3. Extract cluster ID(s) from output](#3-extract-cluster-ids-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain a list of relevant Cluster IDs. +Follow the steps [2. Run script with -ListDeploymentClusters parameter to get Cluster Name](#2-run-script-with--listdeploymentclusters-parameter-to-get-cluster-name) and [3. Extract cluster name(s) from output](#3-extract-cluster-names-from-output) from the [Single asset pinning](#single-asset-pinning) section above to obtain a list of relevant Cluster names. #### 4. Populate CSV template -Copy the asset IDs and asset names into the CSV template previously generated. Copy and paste the cluster ID (for which you wish to pin that asset to) in the *DeploymentClusterId* column of the CSV for each asset. +Copy the asset IDs and asset names into the CSV template previously generated. Copy and paste the cluster name (for which you wish to pin that asset to) in the *DeploymentClusterName* column of the CSV for each asset. -**Required CSV Columns:** AssetName, AssetId, DeploymentClusterId +**Required CSV Columns:** AssetName, AssetId, DeploymentClusterName Your CSV should look similar to: ```csv -AssetName,AssetId,DeploymentClusterId -Server-01,a:a:123456tn,C:d:1234569f -Server-02,a:a:abc123,C:d:1234569f -Server-03,a:a:def456,C:d:00fd409g +AssetName,AssetId,DeploymentClusterName +Server-01,a:a:123456tn,Production Cluster +Server-02,a:a:abc123,Production Cluster +Server-03,a:a:def456,EU Cluster ``` To review the required columns in the CSV, please read [CSV file format](#csv-file-format). @@ -334,7 +335,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -AssetId "a:a:123456tn" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -344,7 +345,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -AssetId "a:a:123456tn" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -Unpin ``` @@ -372,7 +373,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Computers,DC=domain,DC=com" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -382,7 +383,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Computers,DC=domain,DC=com" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -Unpin ``` @@ -392,7 +393,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -OUPath "OU=Computers,DC=domain,DC=com" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -DisableNestedOuResolution $true ``` @@ -402,7 +403,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -TargetSubnet "10.200.200.0/24" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -PortalUrl "https://yourportal-admin.zeronetworks.com" ``` @@ -412,7 +413,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -TargetSubnet "10.200.200.0/24" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -Unpin ``` @@ -422,7 +423,7 @@ Finally, run the script, passing it the path to your CSV. .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -TargetSubnet "10.200.0.0/20" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -MaxConcurrentBatches 10 ``` @@ -434,7 +435,7 @@ Use `-SkipAssetHealthValidation` if a transient or stale unhealthy status in the .\Pin-AssetsToClusters.ps1 ` -ApiKey "your-api-key" ` -AssetId "a:a:123456tn" ` - -DeploymentClusterId "C:d:1234569f" ` + -DeploymentClusterName "Production Cluster" ` -SkipAssetHealthValidation ``` @@ -478,17 +479,28 @@ The CSV file must contain the following columns: - **AssetId** (required) - The asset ID to pin/unpin - **AssetName** (required) - Asset name for reference and validation -- **DeploymentClusterId** (required) - The deployment cluster ID +- **DeploymentClusterName** (required) - The deployment cluster name (resolved to a cluster ID via the local name cache file - see [Deployment Cluster Name Resolution](#deployment-cluster-name-resolution)) Example CSV: ```csv -AssetName,AssetId,DeploymentClusterId -Server-01,a:a:123456tn,C:d:1234569f -Server-02,a:a:abc123,C:d:1234569f -Server-03,a:a:def456,C:d:00fd409g +AssetName,AssetId,DeploymentClusterName +Server-01,a:a:123456tn,Production Cluster +Server-02,a:a:abc123,Production Cluster +Server-03,a:a:def456,EU Cluster ``` +## Deployment Cluster Name Resolution + +All parameter sets that target a deployment cluster (`-AssetId`, `-OUPath`, `-TargetSubnet`, and CSV rows) take a `-DeploymentClusterName`/`DeploymentClusterName` value instead of a raw cluster ID. Names are resolved to cluster IDs using a local JSON cache file: + +- The cache file is named `-DeploymentClusters.json`, where `envName` is derived from the `-PortalUrl` host (e.g. `https://mycompany-admin.zeronetworks.com` → `mycompany-admin-DeploymentClusters.json`). +- The cache file is stored **next to the script** (not the current working directory), and is a flat JSON object mapping cluster name to cluster ID. +- If the cache file doesn't exist, the script creates it automatically the first time it's needed - this happens on every run that has `-ApiKey`/`-PortalUrl` (i.e. every parameter set except `-ExportCsvTemplate`). +- The cache file is **not** refreshed automatically on every run. If a cluster is renamed or a new cluster is added, run `-ListDeploymentClusters` to refresh the cache file (it always overwrites the cache with fresh data from the API). +- If a supplied `-DeploymentClusterName` isn't found in the cache file, the script fails immediately with an error listing the known cluster names from the file. +- The cache file is gitignored (`*-DeploymentClusters.json`) since it contains tenant-specific cluster IDs and shouldn't be committed. + ## Validation The script performs comprehensive validation before making any changes: @@ -540,6 +552,9 @@ Use the `-DryRun` switch to preview what changes would be made without actually ## Troubleshooting +### "Deployment cluster name '...' not found in cache file '...'" +The cluster name doesn't match any entry in the local `-DeploymentClusters.json` cache file (the error message lists the known names). Run `-ListDeploymentClusters` to refresh the cache if the cluster was recently added or renamed, then double-check the spelling. + ### "Asset is not monitored by a Segment Server" The asset must be using a Segment Server, not Cloud Connector or Lightweight Agent. From 4c72a56594a435721dd5cf228f5bfef825373a7c Mon Sep 17 00:00:00 2001 From: Thomas Obarowski Date: Thu, 2 Jul 2026 15:03:13 -0400 Subject: [PATCH 8/8] chore: ignore generated deployment cluster json exports Prevent DeploymentClusters.json files produced by -ListDeploymentClusters from being committed alongside the mapping reference file. --- .../Segment/Asset Management/Pin Assets To Clusters/.gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore index bf2985a..42f1aad 100644 --- a/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore +++ b/Segment/Segment/Asset Management/Pin Assets To Clusters/.gitignore @@ -7,4 +7,5 @@ subnet-pinning.md plan.md test-plan.md ZeroNetworksApi.yaml -testing/ \ No newline at end of file +testing/ +*-DeploymentClusters.json \ No newline at end of file