NetBoxing.psm1

function AddToHash ([hashtable] $Hash, [string[]] $Key, $Value)
{
    $k, $Key = $Key
    if ($Key)
    {
        if ($Hash[$K] -isnot [hashtable]) {$Hash[$K] = @{}}
        AddToHash -Hash $Hash[$K] -Key $Key -Value $Value
    }
    else
    {
        $Hash[$K] = $Value
    }
}

function ChangesOnly ([PSObject] $Item, [hashtable] $Changes)
{
    function NotIdentical ($Item, $Changes)
    {
        ($Changes -is [hashtable] -and (ChangesOnly -Item $Item -Changes $Changes).Count) -or
        ($Changes -isnot [hashtable] -and $Item -cne $Changes)
    }

    $expand = $null
    $Changes = $Changes.Clone()
    foreach ($key in @($Changes.Keys))
    {
        if ($key -ceq '___EXPAND')
        {
            $expand = $Changes.$key
            $null = $Changes.Remove($key)
        }
        elseif ($Changes.$key -is [hashtable])
        {
            if (-not ($Changes.$key = ChangesOnly -Item $Item.$key -Changes $Changes.$key).Count)
            {
                $Changes.Remove($key)
            }
        }
        elseif ($Changes.$key -is [array])
        {
            if ($Changes.$key.Count -and $Changes.$key[0] -is [hashtable] -and $Changes.$key[0].___APPEND)
            {
                $append, $Changes.$key = $Changes.$key
                if (($ik = $Item.$key) -isnot [array])
                {
                    $ik = @()
                }
                $identical = $true
                $combined = @($ik | Select-Object -Property $append.___APPEND)
                foreach ($c in $Changes.$key)
                {
                    $exist = $false
                    foreach ($i in $ik)
                    {
                        if (-not (NotIdentical -Item $i -Changes $c))
                        {
                            $exist = $true
                            break
                        }
                    }
                    if (-not $exist)
                    {
                        $combined += $c
                        $identical = $false
                    }
                }
                if ($identical)
                {
                    $Changes.Remove($key)
                }
                else
                {
                    $Changes.$key = $combined
                }
            }
            else
            {
                if ($Item.$key -is [array] -and $Item.$key.Count -eq $Changes.$key.Count)
                {
                    $identical = $true
                    for ($i=0; $i -lt $Changes.$key.Count; $i++)
                    {
                        if (NotIdentical -Item $Item.$key[$i] -Changes $Changes.$key[$i])
                        {
                            $identical = $false
                            break
                        }
                    }
                    if ($identical)
                    {
                        $Changes.Remove($key)
                    }
                }
                elseif ($Item.$key -eq $null -and $Changes.$key.Count -eq 0)
                {
                    $Changes.Remove($key)
                }
            }
        }
        else
        {
            if ($Item.$key -ceq $Changes.$key)
            {
                $null = $Changes.Remove($key)
            }
        }
    }
    if ($expand)
    {
        $Changes.$expand
    }
    else
    {
        $Changes
    }
}

function FlattenHash ([hashtable] $Hash, [string] $Prefix)
{
    $return = @{}
    @($Hash.Keys) | ForEach-Object -Process {
        $n = if ($Prefix) {$Prefix + '.' + $_} else {$_}
        if ($Hash[$_] -is [hashtable])
        {
            $return += FlattenHash -Hash $Hash[$_] -Prefix $n
        }
        else
        {

            $return[$n] = $Hash[$_]
        }
    }
    $return
}

# Override Write-Verbose in this module so calling function is added to the message
function script:Write-Verbose
{
    [CmdletBinding()]
    param
    (
       [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Message
    )

    begin
    {}

    process
    {
        try
        {
            $PSBoundParameters['Message'] = $((Get-PSCallStack)[1].Command) + ': ' + $PSBoundParameters['Message']
        }
        catch
        {}

        Microsoft.PowerShell.Utility\Write-Verbose @PSBoundParameters
    }

    end
    {}
}

function Connect-Netbox
{
    <#
        .SYNOPSIS
            Connect to NetBox

        .DESCRIPTION
            Connect to Netbox.
            Or that is, tell the PowerShell module URI and token - so the other functions in the module know what to connect to.
            This function doesn't actually connect to anything.

        .PARAMETER Uri
            Uri. Eg. https://netbox.yourdomain.tld

        .PARAMETER Token
            API token created in NetBox

        .EXAMPLE
            Connect-Netbox -Uri https://netbox.yourdomain.tld -Token abcabcabc
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $Uri,

        [Parameter(Mandatory = $true)]
        [String]
        $Token
    )

    $origErrorActionPreference = $ErrorActionPreference

    try
    {
        $ErrorActionPreference = 'Stop'

        $script:baseUri = $Uri -replace '/$'
        $script:apiToken = $Token
    }
    catch
    {
        $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString()
        Write-Verbose -Message "Encountered an error: $msg"
        Write-Error -ErrorAction $origErrorActionPreference -Message $msg
    }
}

function Find-NetboxObject
{
    <#
        .SYNOPSIS
            Find object(s) in NetBox

        .DESCRIPTION
            Find object(s) in NetBox

        .PARAMETER Uri
            Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/")

        .PARAMETER Properties
            Hashtable with properties

        .PARAMETER FindBy
            Which properties should be used to find object

        .EXAMPLE
            Find-NetboxObject -Uri ipam/prefixes/ -Properties @{vlan = @{vid = 3999}}
            Find all prefixes attaced to VLAN 3999.

        .EXAMPLE
            Find-NetboxObject -Uri ipam/prefixes/ -FindBy 'vlan.vid' -Properties @{vlan = @{vid = 3999}; otherproperty='foobar'}
            Find all prefixes attaced to VLAN 3999. "otherproperty" is ignored in search.

        .EXAMPLE
            Find-NetboxObject ipam/vlans/ -Properties @{group=@{slug='test'}} -FindBy 'group=group.slug'
            Find all VLANs belonging to VLAN group "test".
            Sometimes the NetBox API want queries "different". It's not "?group_slug=test" but "?group=test"
            If the "Findby" is omitted in this example, then NetBox will return all VLAN objects back, and the filtering will be done only on client side.
            Stuff like "Got 18 objects back from server and returned 2" can be seen in verbose output.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $Uri,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Properties,

        [Parameter()]
        [string[]]
        $FindBy
    )

    begin
    {
        Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
        $origErrorActionPreference = $ErrorActionPreference
        $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue')
    }

    process
    {
        Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)"

        try
        {
            # Make sure that we don't continue on error, and that we catches the error
            $ErrorActionPreference = 'Stop'

            if (-not $FindBy)
            {
                $FindBy = (FlattenHash -Hash $Properties).Keys
            }

            $queryProperties = @{}
            $queryData = [System.Web.HttpUtility]::ParseQueryString([String]::Empty)
            foreach ($f in $Findby)
            {
                $key = $f -replace '^custom_fields.','cf.' -replace '\.','_'
                if (($a, $b = $f -split '=') -and $b) {
                    $f   = $b
                    $key = $a
                }

                # Is it insecure? Yes! Is it quick and dirty? Yes! Does it do the job? Yes!
                $val = .([scriptblock]::Create("`$Properties.$f"))
                AddToHash -Hash $queryProperties -Key ($f -split '\.') -Value $val
                $queryData.Add($key, $val)
            }
            $findUri = '{0}?{1}' -f $uri, $queryData.ToString()

            # Return (sometimes we risk getting more data back from Netbox than we wanted - that's why we also check locally)
            $cAll = $cReturned = 0
            Invoke-NetboxRequest -Uri $findUri -Follow | Where-Object -FilterScript {
                ++$cAll
                -not (ChangesOnly -Item $_ -Changes $queryProperties).Count -and ++$cReturned
            }
            Write-Verbose -Message "Got $cAll objects back from server and returned $cReturned"
        }
        catch
        {
            Write-Verbose -Message "Encountered an error: $_"
            Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception
        }
        finally
        {
            $ErrorActionPreference = $origErrorActionPreference
        }

        Write-Verbose -Message 'Process end'
    }

    end
    {
        Write-Verbose -Message 'End'
    }
}

function Invoke-NetboxPatch
{
    <#
        .SYNOPSIS
            Patch object in Netbox

        .DESCRIPTION
            Patch object in Netbox

        .PARAMETER Uri
            Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/")

        .PARAMETER Item
            Original unpatched object

        .PARAMETER Changes
            Hashtable with changes to be made to object

        .PARAMETER NoUpdate
            Don't update object, only show what would be sent to server (as a warning)

        .PARAMETER Wait
            After patch is sent to NetBox, wait with a "Press enter to continue" prompt

        .EXAMPLE
            Invoke-NetboxPatch -Uri tenancy/tenants/3/ -Changes @{description = 'example'}
            Patch tenant 3 with description.
            This is always sent to Netbox, even if description hasn't changes.
            The function doesn't know the previous state of the properties.

        .EXAMPLE
            $v = Invoke-NetboxRequest ipam/vlans/1/ ; Invoke-NetboxPatch -Item $v -Changes @{description = 'example'}
            Fetch VLAN object with id 1 and change description.
            If description is already correct, then a patch request isn't sent to Netbox.
            Old versions of Netbox didn't have an "url" property in objects. If that's the case, then this should be added:
             -Uri "ipam/vlans/$($v.id)/"
    #>


    [CmdletBinding()]
    param
    (
        [Parameter()]
        [string]
        $Uri,

        [Parameter()]
        [PSObject]
        $Item,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Changes,

        [Parameter()]
        [switch]
        $NoUpdate,

        [Parameter()]
        [switch]
        $Wait
    )

    begin
    {
        Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
        $origErrorActionPreference = $ErrorActionPreference
        $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue')
    }

    process
    {
        Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)"

        try
        {
            # Make sure that we don't continue on error, and that we catches the error
            $ErrorActionPreference = 'Stop'

            if (-not $Uri)
            {
                $Uri = $Item.url
            }
            if (-not $Uri)
            {
                throw 'Cannot find URI for Netbox object'
            }

            $body = ChangesOnly -Item $Item -Changes $Changes

            if ($body.Count)
            {
                if ($NoUpdate)
                {
                    Write-Warning -Message "Skipping changes on $Uri"
                    Write-Warning -Message ($body | ConvertTo-Json -Depth 9)
                }
                else
                {
                    Invoke-NetboxRequest -Uri $Uri -FullResponse -Method Patch -Body $body
                    if ($Wait)
                    {
                        Read-Host -Prompt 'Press enter to continue'
                    }
                }
            }
        }
        catch
        {
            Write-Verbose -Message "Encountered an error: $_"
            Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception
        }
        finally
        {
            $ErrorActionPreference = $origErrorActionPreference
        }

        Write-Verbose -Message 'Process end'
    }

    end
    {
        Write-Verbose -Message 'End'
    }
}

function Invoke-NetboxRequest
{
    <#
        .SYNOPSIS
            Send HTTP request to NetBox

        .DESCRIPTION
            Send HTTP request to NetBox

        .PARAMETER Uri
            Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/")

        .PARAMETER Method
            HTTP method
            Get, Post, ...

        .PARAMETER Body
            Object (or hashtable) that should be sent if Method is POST or PATCH

        .PARAMETER FullResponse
            Return the full object returned from Netbox - and not only the "relevant" part

        .PARAMETER Follow
            If result from NetBox contains more than 50 objects, then follow next-page links and get it all

        .EXAMPLE
            Invoke-NetboxRequest dcim/sites/ -Follow
            Fetch all sites from NetBox

        .EXAMPLE
            Invoke-NetboxRequest -Uri https://netbox.yourdomain.tld/api/dcim/sites/1/
            Fetch site with ID 1 from Netbox

        .EXAMPLE
            Invoke-NetboxRequest -Uri tenancy/tenants/ -Method Post -Body @{name='Example Tenant'; slug='example-tenant'}
            Create new tenant
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string]
        $Uri,

        [Parameter()]
        [Microsoft.PowerShell.Commands.WebRequestMethod]
        $Method,

        [Parameter()]
        [PSObject]
        $Body,

        [Parameter()]
        [switch]
        $FullResponse,

        [Parameter()]
        [switch]
        $Follow
    )

    begin
    {
        Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
        $origErrorActionPreference = $ErrorActionPreference
        $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue')
        $origSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol

        if (-not $script:baseUri -or -not $script:apiToken)
        {
            throw 'Please login with Connect-Netbox first'
        }
    }

    process
    {
        Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)"

        try
        {
            # Make sure that we don't continue on error, and that we catches the error
            $ErrorActionPreference = 'Stop'

            # Why isn't this default!?
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

            $null = $PSBoundParameters.Remove('FullResponse')
            $null = $PSBoundParameters.Remove('Follow')
            $PSBoundParameters['Uri']             = $Uri
            $PSBoundParameters['Headers']         = @{Authorization = "Token $($script:apiToken)"}
            $PSBoundParameters['ContentType']     = 'application/json; charset=utf-8'
            $PSBoundParameters['UseBasicParsing'] = $true
            if ($Body)
            {
                $PSBoundParameters['Body'] = $Body | ConvertTo-Json -Depth 99
                Write-Verbose -Message $PSBoundParameters['Body']
            }
            if ($PSBoundParameters['Uri'] -notmatch '^http(s)?://')
            {
                $PSBoundParameters['Uri'] = "$($script:baseUri)/api/$($PSBoundParameters['Uri'] -replace '^/')"
            }

            do
            {
                # Server send UTF8 back but does not send info about it in header
                #$response = Invoke-RestMethod @PSBoundParameters
                'Sending request to {0}' -f $PSBoundParameters['Uri'] | Write-Verbose
                $resp = Invoke-WebRequest @PSBoundParameters
                $response = [system.Text.Encoding]::UTF8.GetString($resp.RawContentStream.ToArray()) | ConvertFrom-Json

                if ($FullResponse -or $response.results -isnot [array])
                {
                    $response
                }
                else
                {
                    $response.results
                }
            }
            while ($Follow -and ($PSBoundParameters['Uri'] = $response.next))
        }
        catch
        {
            # If error was encountered inside this function then stop doing more
            # But still respect the ErrorAction that comes when calling this function
            # And also return the line number where the original error occured
            $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString()
            Write-Verbose -Message "Encountered an error: $msg"
            Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception -Message $msg
        }
        finally
        {
            $ErrorActionPreference = $origErrorActionPreference
            [Net.ServicePointManager]::SecurityProtocol = $origSecurityProtocol
        }

        Write-Verbose -Message 'Process end'
    }

    end
    {
        Write-Verbose -Message 'End'
    }
}

function Invoke-NetboxUpsert
{
    <#
        .SYNOPSIS
            Update (patch) or create NetBox object

        .DESCRIPTION
            Update (patch) or create NetBox object
            If existing object is found

        .PARAMETER Uri
            Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/")

        .PARAMETER Properties
            Properties that should be set when updating or creating object

        .PARAMETER PropertiesNew
            Properties that should only be set when creating object - not when updating

        .PARAMETER FindBy
            Which properties should be used to find existing object

        .PARAMETER Item
            Existing NetBox object can be passed (normally not used).

        .PARAMETER Multi
            Changes to multiple objects is allowed.
            Normally only changes to one object is allowed.
            If this is set, no new objects will be created, only existing will be updated.

        .PARAMETER NoCreate
            Don't create object, only show what would be sent to server (as a warning)

        .PARAMETER NoUpdate
            Don't update object, only show what would be sent to server (as a warning)

        .PARAMETER Wait
            After post/patch is sent to NetBox, wait with a "Press enter to continue" prompt

        .EXAMPLE
            Invoke-NetboxUpsert -Uri ipam/prefixes/ -FindBy 'prefix' -Properties @{prefix='10.0.0.0/30'; description='example'}
            If prefix 10.0.0.0/30 already exist, then set description. If it doesn't exist, then create it.

        .EXAMPLE
            Invoke-NetboxUpsert -Uri ipam/prefixes/ -FindBy 'vlan.vid' -Properties @{vlan=@{vid=3999}; description='example'} -Multi -NoUpdate
            Find all prefixes attached to VLAN 3999 and show which changes that should be made (as warning).
            Remove -NoUpdate to send patch requests to NetBox
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $Uri,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Properties,

        [Parameter()]
        [hashtable]
        $PropertiesNew = @{},

        [Parameter(Mandatory = $true)]
        [string[]]
        $FindBy,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [PSObject[]]
        $Item,

        [Parameter()]
        [switch]
        $Multi,

        [Parameter()]
        [switch]
        $NoCreate,

        [Parameter()]
        [switch]
        $NoUpdate,

        [Parameter()]
        [switch]
        $Wait
    )

    begin
    {
        Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
        $origErrorActionPreference = $ErrorActionPreference
        $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue')
    }

    process
    {
        Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)"

        try
        {
            # Make sure that we don't continue on error, and that we catches the error
            $ErrorActionPreference = 'Stop'

            if ($Item -or ($Item = @(Find-NetboxObject -Uri $Uri -Properties $Properties -FindBy $FindBy)))
            {
                if ($Item.Count -eq 1 -or $Multi)
                {
                    foreach ($itemObj in $Item)
                    {
                        if (-not ($itemUri = $itemObj.url))
                        {
                            if (-not $itemObj.id) {throw 'No ID found on NetBox object'}
                            $itemUri = '{0}{1}/' -f $Uri, $itemObj.id
                        }
                        if ($updatedItem = Invoke-NetboxPatch -Uri $itemUri -Item $itemObj -Changes $Properties -NoUpdate:$NoUpdate -Wait:$Wait)
                        {
                            $updatedItem
                        }
                        else
                        {
                            $itemObj
                        }
                    }
                }
                else
                {
                    throw "$($findUri) matched more than one item - matched $($Item.Count)"
                }
            }
            elseif (-not $Multi)
            {
                $body = ChangesOnly -Item @{} -Changes ($Properties + $PropertiesNew)
                if ($NoCreate)
                {
                    Write-Warning -Message "Not creating $Uri"
                    Write-Warning -Message ($body | ConvertTo-Json -Depth 9)
                }
                else
                {
                    Invoke-NetboxRequest -Uri $Uri -Method Post -FullResponse -Body $body
                    if ($Wait)
                    {
                        Read-Host -Prompt 'Press enter to continue'
                    }
                }
            }
            else
            {
                Write-Verbose -Message 'Multi=true, but zero objects found'
            }
        }
        catch
        {
            Write-Verbose -Message "Encountered an error: $_"
            Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception
        }
        finally
        {
            $ErrorActionPreference = $origErrorActionPreference
        }

        Write-Verbose -Message 'Process end'
    }

    end
    {
        Write-Verbose -Message 'End'
    }
}

Export-ModuleMember -Function Invoke-NetboxRequest
Export-ModuleMember -Function Invoke-NetboxPatch
Export-ModuleMember -Function Find-NetboxObject
Export-ModuleMember -Function Invoke-NetboxUpsert
Export-ModuleMember -Function Connect-Netbox