Files
2026-08-02 19:43:02 -04:00

392 lines
22 KiB
PowerShell

#region Sync-RackpadObject
Function Sync-RackpadObject
{
<#
.SYNOPSIS
Performs an idempotent create or update of a single Rackpad object and records ownership within the managed state document.
.DESCRIPTION
This is the reconciliation primitive that every entity type flows through. The caller supplies the desired property set and
the matching object from the pre-loaded inventory, if one exists. The function then decides between three outcomes:
Created - No matching object exists, so the desired property set is posted to the collection route.
Updated - A matching object exists but one or more desired properties differ, so only the differing properties are patched.
Unchanged - A matching object exists and every desired property already matches, so no request is issued.
Because only differing properties are transmitted, repeated runs against an unchanged environment produce no write traffic
at all, which is what makes the overall process safe to schedule.
Objects that this function creates are recorded within the managed state document. Objects that already existed are observed
but never recorded, which is what keeps hand created objects outside the scope of pruning.
.PARAMETER Session
The session object produced by Connect-RackpadInstance.
.PARAMETER EntityType
The logical entity name used for state tracking and log messages, for example 'device' or 'port'.
.PARAMETER CollectionRoute
The collection route relative to /api, for example 'devices'.
.PARAMETER NaturalKey
The stable natural key that identifies this object within its entity type, for example a lab scoped hostname.
.PARAMETER DesiredProperty
An ordered dictionary describing the desired state of the object.
.PARAMETER ExistingObject
The matching object from the pre-loaded inventory, or null when no match exists.
.PARAMETER ManagedState
The state object produced by Get-RackpadManagedState.
.PARAMETER UpdateMethod
The method used to modify an existing object. PATCH transmits only the differing properties. PUT transmits the full payload.
.PARAMETER UpdateRoute
An explicit route used for update operations. Required for routes such as 'wifi/access-points/{deviceId}' where the update
identifier is not the object identifier.
.PARAMETER ImmutablePropertyName
Property names that are only valid during creation and that must never participate in the difference calculation.
.EXAMPLE
$SyncRackpadObjectParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$SyncRackpadObjectParameters.Session = $RackpadSession
$SyncRackpadObjectParameters.EntityType = 'room'
$SyncRackpadObjectParameters.CollectionRoute = 'rooms'
$SyncRackpadObjectParameters.NaturalKey = 'server room'
$SyncRackpadObjectParameters.DesiredProperty = $RoomProperty
$SyncRackpadObjectParameters.ExistingObject = $ExistingRoom
$SyncRackpadObjectParameters.ManagedState = $ManagedState
$Result = Sync-RackpadObject @SyncRackpadObjectParameters
.NOTES
Author: Grace Solutions
Version: 2026.08.02.0000
#>
[CmdletBinding(SupportsShouldProcess=$True)]
Param
(
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[PSObject]$Session,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[String]$EntityType,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[String]$CollectionRoute,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[String]$NaturalKey,
[Parameter(Mandatory=$True)]
[ValidateNotNullOrEmpty()]
[System.Collections.Specialized.OrderedDictionary]$DesiredProperty,
[Parameter(Mandatory=$False)]
[AllowNull()]
[PSObject]$ExistingObject,
[Parameter(Mandatory=$False)]
[AllowNull()]
[PSObject]$ManagedState,
[Parameter(Mandatory=$False)]
[ValidateSet('PATCH', 'PUT')]
[String]$UpdateMethod = 'PATCH',
[Parameter(Mandatory=$False)]
[AllowEmptyString()]
[String]$UpdateRoute = '',
[Parameter(Mandatory=$False)]
[AllowNull()]
[String[]]$ImmutablePropertyName
)
Try
{
#region Define the value normalization logic used by the difference calculation
[ScriptBlock]$ConvertToComparableValue = {
Param
(
[System.Object]$Value
)
$ComparableValue = ''
Switch ($True)
{
{($Null -ieq $Value)}
{
$ComparableValue = ''
}
{($Value -is [System.Boolean])}
{
$ComparableValue = "$($Value)".ToLowerInvariant()
}
{($Value -is [System.Array]) -or ($Value -is [System.Collections.IList])}
{
$ValueSegmentList = New-Object -TypeName 'System.Collections.Generic.List[String]'
ForEach ($ValueSegment In $Value)
{
$ValueSegmentList.Add("$($ValueSegment)".Trim())
}
$Null = $ValueSegmentList.Sort()
$ComparableValue = "$($ValueSegmentList -Join '|')"
}
{($Null -ine $Value) -and ($Value -isnot [System.Boolean]) -and ($Value -isnot [System.Array]) -and ($Value -isnot [System.Collections.IList])}
{
$ComparableValue = "$($Value)".Trim()
}
}
Write-Output -InputObject ("$($ComparableValue)")
}
#endregion
#region Register the natural key as observed so that it is excluded from pruning
$EntityTypeKey = "$($EntityType)".ToLowerInvariant()
$NaturalKeyValue = "$($NaturalKey)".ToLowerInvariant()
Switch ($Null -ine $ManagedState)
{
{($_ -eq $True)}
{
Switch ($ManagedState.ObservedTable.ContainsKey($EntityTypeKey))
{
{($_ -eq $False)}
{
$ManagedState.ObservedTable.Add($EntityTypeKey, (New-Object -TypeName 'System.Collections.Generic.HashSet[System.String]'))
}
}
$Null = $ManagedState.ObservedTable[$EntityTypeKey].Add($NaturalKeyValue)
}
}
#endregion
#region Establish the result object
$ResultProperties = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
$ResultProperties.EntityType = "$($EntityType)"
$ResultProperties.NaturalKey = "$($NaturalKey)"
$ResultProperties.Action = 'Unchanged'
$ResultProperties.Identifier = "$($ExistingObject.id)"
$ResultProperties.ChangedPropertyList = New-Object -TypeName 'System.Collections.Generic.List[String]'
$ResultProperties.Object = $ExistingObject
$ResultProperties.ErrorMessage = $Null
#endregion
#region Create the object when no match exists
Switch ($Null -ieq $ExistingObject)
{
{($_ -eq $True)}
{
$CreationRoute = "$($CollectionRoute)"
$CreationMethod = 'POST'
Switch (([String]::IsNullOrWhiteSpace($UpdateRoute) -eq $False) -and ($UpdateMethod -ieq 'PUT'))
{
{($_ -eq $True)}
{
$CreationRoute = "$($UpdateRoute)"
$CreationMethod = 'PUT'
}
}
Switch ($PSCmdlet.ShouldProcess("$($EntityType) `"$($NaturalKey)`"", "Create using $($CreationMethod) /api/$($CreationRoute)"))
{
{($_ -eq $False)}
{
$ResultProperties.Action = 'WhatIf'
ForEach ($DesiredPropertyEntry In $DesiredProperty.GetEnumerator())
{
$ResultProperties.ChangedPropertyList.Add("$($DesiredPropertyEntry.Key)")
}
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
Return
}
}
$CreationResponse = Invoke-RackpadRequest -Session ($Session) -Route ($CreationRoute) -Method ($CreationMethod) -Body ($DesiredProperty)
Switch ($CreationResponse.IsSuccess)
{
{($_ -eq $True)}
{
$ResultProperties.Action = 'Created'
$ResultProperties.Object = $CreationResponse.Content
$ResultProperties.Identifier = "$($CreationResponse.Content.id)"
ForEach ($DesiredPropertyEntry In $DesiredProperty.GetEnumerator())
{
$ResultProperties.ChangedPropertyList.Add("$($DesiredPropertyEntry.Key)")
}
#region Record ownership so that this object becomes eligible for pruning later
Switch (($Null -ine $ManagedState) -and ([String]::IsNullOrWhiteSpace($ResultProperties.Identifier) -eq $False))
{
{($_ -eq $True)}
{
Switch ($ManagedState.EntryTable.ContainsKey($EntityTypeKey))
{
{($_ -eq $False)}
{
$ManagedState.EntryTable.Add($EntityTypeKey, (New-Object -TypeName 'System.Collections.Generic.Dictionary[[System.String], [System.String]]'))
}
}
$ManagedState.EntryTable[$EntityTypeKey][$NaturalKeyValue] = "$($ResultProperties.Identifier)"
}
}
#endregion
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Created $($EntityType) `"$($NaturalKey)`". [Identifier: $($ResultProperties.Identifier)]"
}
{($_ -eq $False)}
{
$ResultProperties.Action = 'Failed'
$ResultProperties.ErrorMessage = "$($CreationResponse.StatusDescription) $($CreationResponse.ErrorMessage)".Trim()
Write-Warning -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - Failed to create $($EntityType) `"$($NaturalKey)`". [Status Code: $($CreationResponse.StatusCode)] [$($ResultProperties.ErrorMessage)]"
}
}
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
Return
}
}
#endregion
#region Calculate the difference between the desired and current property sets
$ChangedProperty = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary'
ForEach ($DesiredPropertyEntry In $DesiredProperty.GetEnumerator())
{
$PropertyName = "$($DesiredPropertyEntry.Key)"
$PropertyIsComparable = -Not (($Null -ine $ImmutablePropertyName) -and ($PropertyName -iin $ImmutablePropertyName))
Switch ($PropertyIsComparable)
{
{($_ -eq $True)}
{
$DesiredComparableValue = $ConvertToComparableValue.InvokeReturnAsIs($DesiredPropertyEntry.Value)
$CurrentComparableValue = $ConvertToComparableValue.InvokeReturnAsIs($ExistingObject.$($PropertyName))
Switch ($DesiredComparableValue -ine $CurrentComparableValue)
{
{($_ -eq $True)}
{
$ChangedProperty.$($PropertyName) = $DesiredPropertyEntry.Value
$ResultProperties.ChangedPropertyList.Add("$($PropertyName)")
}
}
}
}
}
#endregion
#region Update the object when at least one property differs
Switch ($ChangedProperty.Count -gt 0)
{
{($_ -eq $False)}
{
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - No changes are required for $($EntityType) `"$($NaturalKey)`". [Identifier: $($ResultProperties.Identifier)]"
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
Return
}
}
$ResolvedUpdateRoute = "$($CollectionRoute)/$($ExistingObject.id)"
Switch ([String]::IsNullOrWhiteSpace($UpdateRoute) -eq $False)
{
{($_ -eq $True)}
{
$ResolvedUpdateRoute = "$($UpdateRoute)"
}
}
$UpdatePayload = $ChangedProperty
Switch ($UpdateMethod -ieq 'PUT')
{
{($_ -eq $True)}
{
$UpdatePayload = $DesiredProperty
}
}
Switch ($PSCmdlet.ShouldProcess("$($EntityType) `"$($NaturalKey)`"", "Update $($ResultProperties.ChangedPropertyList -Join ', ') using $($UpdateMethod) /api/$($ResolvedUpdateRoute)"))
{
{($_ -eq $False)}
{
$ResultProperties.Action = 'WhatIf'
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
Return
}
}
$UpdateResponse = Invoke-RackpadRequest -Session ($Session) -Route ($ResolvedUpdateRoute) -Method ($UpdateMethod) -Body ($UpdatePayload)
Switch ($UpdateResponse.IsSuccess)
{
{($_ -eq $True)}
{
$ResultProperties.Action = 'Updated'
$ResultProperties.Object = $UpdateResponse.Content
Switch ([String]::IsNullOrWhiteSpace("$($UpdateResponse.Content.id)") -eq $False)
{
{($_ -eq $True)}
{
$ResultProperties.Identifier = "$($UpdateResponse.Content.id)"
}
}
Write-Verbose -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [INFO] - Updated $($EntityType) `"$($NaturalKey)`". [Identifier: $($ResultProperties.Identifier)] [Changed: $($ResultProperties.ChangedPropertyList -Join ', ')]"
}
{($_ -eq $False)}
{
$ResultProperties.Action = 'Failed'
$ResultProperties.ErrorMessage = "$($UpdateResponse.StatusDescription) $($UpdateResponse.ErrorMessage)".Trim()
Write-Warning -Message "[$([System.DateTime]::UtcNow.ToString('yyyy/MM/dd HH:mm:ss.FFF'))] - [WARNING] - Failed to update $($EntityType) `"$($NaturalKey)`". [Status Code: $($UpdateResponse.StatusCode)] [$($ResultProperties.ErrorMessage)]"
}
}
#endregion
Write-Output -InputObject (New-Object -TypeName 'System.Management.Automation.PSObject' -Property ($ResultProperties))
}
Catch
{
Throw
}
}
#endregion