Public/Set-ProjectTenantVariable.ps1

function Set-ProjectTenantVariable {
    <#
    .SYNOPSIS
        Sets a project tenant variable in Octopus Deploy, either unscoped or scoped to one or more environments.
    .DESCRIPTION
        This function sets a project tenant variable in Octopus Deploy. It takes in a TenantResource object, a ProjectResource object,
        optionally one or more EnvironmentResource objects, and either a hashtable of variable names and values or a name-value pair of a single variable.
 
        Without -Environment the unscoped value is created or updated. Scoped values are not touched.
 
        With -Environment the value is set for exactly these environments. As an environment can only be in one scope per variable,
        existing scoped values are adjusted:
          - scope doesn't overlap: kept unchanged
          - same scope: value updated
          - scope fully covered: removed, the new value covers it
          - scope partially covered: the given environments are removed from its scope, its value is kept
        The unscoped value is never touched when setting a scoped value.
    .PARAMETER Tenant
        The tenant to modify.
    .PARAMETER Project
        The project of the variable.
    .PARAMETER Environment
        The environment(s) to scope the variable to. If omitted, the unscoped value is set.
    .PARAMETER Name
        The name of the variable to modify.
    .PARAMETER Value
        The new value for the variable.
    .PARAMETER VariableHash
        A hashtable of variable names and values to set multiple variables at once.
    .EXAMPLE
        Set-ProjectTenantVariable -Tenant $tenant -Project $project -Name "VariableName" -Value "VariableValue"
        Sets the unscoped value of "VariableName" to "VariableValue" for the specified tenant and project.
    .EXAMPLE
        Set-ProjectTenantVariable -Tenant $tenant -Project $project -Environment $environment -Name "VariableName" -Value "VariableValue"
        Sets the variable "VariableName" to "VariableValue" for the specified tenant, project, and environment.
    .EXAMPLE
        Set-ProjectTenantVariable -Tenant XXROM001 -Project 'Install RS' -Environment Development, Test -VariableHash @{ Var1 = 'a'; Var2 = 'b' }
        Sets Var1 and Var2 to a single value each, scoped to Development and Test. Existing values scoped to these environments are adjusted.
    .INPUTS
        TenantSingleTransformation: Accepts a single TenantResource object.
        ProjectSingleTransformation: Accepts a single ProjectResource object.
        EnvironmentTransformation: Accepts one or more EnvironmentResource objects.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Parameter Set 1',
        SupportsShouldProcess = $true,
        PositionalBinding = $false,
        HelpUri = 'http://www.microsoft.com/',
        ConfirmImpact = 'Medium')]
    [Alias()]
    [OutputType([String])]
    param (
        [parameter(Mandatory = $true,
            ParameterSetName = 'Hash')]
        [parameter(Mandatory = $true,
            ParameterSetName = 'Value')]
        [TenantSingleTransformation()]
        [Octopus.Client.Model.TenantResource]$Tenant,

        [parameter(Mandatory = $true,
            ParameterSetName = 'Hash')]
        [parameter(Mandatory = $true,
            ParameterSetName = 'Value')]
        [ProjectSingleTransformation()]
        [Octopus.Client.Model.ProjectResource]$Project,

        [parameter(Mandatory = $false,
            ParameterSetName = 'Hash')]
        [parameter(Mandatory = $false,
            ParameterSetName = 'Value')]
        [EnvironmentTransformation()]
        [Octopus.Client.Model.EnvironmentResource[]]$Environment,

        [parameter(Mandatory = $true,
            ParameterSetName = 'Value')]
        [String]$Name,

        [parameter(Mandatory = $true,
            ParameterSetName = 'Value')]
        [AllowEmptyString()]
        [String]$Value,

        [parameter(Mandatory = $true,
            ParameterSetName = 'Hash')]
        [hashtable]$VariableHash
    )

    begin {
        # testing connection to octopus
        try {
            ValidateConnection
        } catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    process {
        try {
            # Fix the parameter set name comparison (case sensitive)
            if ($PSCmdlet.ParameterSetName -eq "Value") {
                $VariableHash = @{}
                $VariableHash[$Name] = $Value
            }
            Write-Verbose "Processing variables: $($VariableHash | ConvertTo-Json -Compress)"

            # Target environments. No environment means the unscoped value is set
            $targetEnvs = @($Environment | Where-Object { $_ } | Sort-Object Id -Unique)
            $targetIds = @(foreach ($env in $targetEnvs) { $env.Id })
            $scopeText = if ($targetIds) { "scoped to: $($targetEnvs.Name -join ', ')" } else { 'unscoped' }

            # Check that the project and environments are connected to the tenant
            if (-not $Tenant.ProjectEnvironments.ContainsKey($Project.Id)) {
                throw "Project '$($Project.Name)' is not connected to tenant '$($Tenant.Name)'."
            }
            $connectedEnvIds = $Tenant.ProjectEnvironments[$Project.Id]
            foreach ($env in $targetEnvs) {
                if ($connectedEnvIds -notcontains $env.Id) {
                    throw "Environment '$($env.Name)' is not connected to tenant '$($Tenant.Name)' for project '$($Project.Name)'."
                }
            }

            # Get ALL existing project variables for this tenant
            $getRequest = [Octopus.Client.Model.TenantVariables.GetProjectVariablesByTenantIdRequest]::new($Tenant.Id, $Tenant.SpaceId)
            $allExistingVariables = $repo._repository.TenantVariables.Get($getRequest).Variables


            $variableTemplate = $repo._repository.Tenants.GetVariables($Tenant).ProjectVariables[$Project.Id].Templates
            # Check that all the variables are defined in template
            foreach ($h in $VariableHash.GetEnumerator()) {
                $template = $variableTemplate | Where-Object { $_.Name -eq $h.Name }
                if (-not $template) {
                    $message = "Couldn't find variable '$($h.Name)' in project '$($Project.Name)' template. Please define it first."
                    throw $message
                } else {
                    $message = "Found variable '$($h.Name)' in project '$($Project.Name)' template."
                    Write-Verbose $message
                }
            }

            # Creates a payload for a value of this project with the given scope. No environment ids means unscoped
            function NewPayload ([string]$Id, [string]$TemplateId, $Value, [string[]]$EnvironmentIds) {
                $envIds = [Octopus.Client.Model.ReferenceCollection]::new([string[]]@($EnvironmentIds))
                $scope = [Octopus.Client.Model.TenantVariables.ProjectVariableScope]::new($envIds)
                $payload = [Octopus.Client.Model.TenantVariables.TenantProjectVariablePayload]::new($Project.Id, $TemplateId, $Value, $scope)
                $payload.Id = if ($Id) { $Id } else { [string]::Empty }
                $payload
            }

            # Create payloads for all variables
            $payloads = @()

            # Add all existing variables of other templates and projects (unchanged) to preserve them
            $targetTemplateIds = @(foreach ($t in $variableTemplate | Where-Object Name -In $VariableHash.Keys) { $t.Id })
            foreach ($existingVar in $allExistingVariables) {
                if ($existingVar.ProjectId -ne $Project.Id -or $targetTemplateIds -notcontains $existingVar.TemplateId) {
                    $payload = [Octopus.Client.Model.TenantVariables.TenantProjectVariablePayload]::new(
                        $existingVar.ProjectId,
                        $existingVar.TemplateId,
                        $existingVar.Value,
                        $existingVar.Scope
                    )
                    $payload.Id = $existingVar.Id
                    $payloads += $payload
                }
            }

            # Now add/update each variable from the hash. An environment can only be in one scope per template,
            # so existing scoped values that overlap the target environments are adjusted:
            # - no overlap: kept unchanged
            # - same scope: updated (keeps its Id)
            # - scope fully covered: removed, the new value covers it
            # - scope partially covered: target environments are removed from its scope, its value is kept
            # Without target environments only the unscoped value is created or updated
            foreach ($h in $VariableHash.GetEnumerator()) {
                # Get the template object. Id is needed to identify and set variable
                $varTemplate = $variableTemplate | Where-Object Name -EQ $h.Name
                $existingVars = @($allExistingVariables | Where-Object { $_.ProjectId -eq $Project.Id -and $_.TemplateId -eq $varTemplate.Id })
                $targetVariableId = $null

                foreach ($existingVar in $existingVars) {
                    $scopeIds = @($existingVar.Scope.EnvironmentIds)
                    $overlapIds = @($scopeIds | Where-Object { $targetIds -contains $_ })
                    $remainingIds = @($scopeIds | Where-Object { $targetIds -notcontains $_ })

                    if ($scopeIds.Count -eq 0 -and $targetIds.Count -eq 0) {
                        # existing unscoped value is the target
                        $targetVariableId = $existingVar.Id
                    } elseif ($overlapIds.Count -eq 0) {
                        # unscoped or no overlap - keep unchanged
                        $payloads += NewPayload $existingVar.Id $existingVar.TemplateId $existingVar.Value $scopeIds
                    } elseif ($remainingIds.Count -eq 0 -and $scopeIds.Count -eq $targetIds.Count) {
                        # same scope - this is the target
                        $targetVariableId = $existingVar.Id
                    } elseif ($remainingIds.Count -eq 0) {
                        # scope fully covered by the new value - drop it
                        Write-Verbose "Removing value of '$($h.Name)' (ID: $($existingVar.Id)), its scope is covered by the new value"
                    } else {
                        # scope partially covered - remove the target environments from its scope
                        Write-Verbose "Removing environments '$($overlapIds -join ', ')' from scope of '$($h.Name)' (ID: $($existingVar.Id))"
                        $payloads += NewPayload $existingVar.Id $existingVar.TemplateId $existingVar.Value $remainingIds
                    }
                }

                $newValue = [Octopus.Client.Model.PropertyValueResource]::new($h.Value, $($varTemplate.DefaultValue.IsSensitive))
                $payloads += NewPayload $targetVariableId $varTemplate.Id $newValue $targetIds

                $action = if ($targetVariableId) { "updated ID: $targetVariableId" } else { "created new" }
                Write-Verbose "Successfully processed '$($h.Name)' for tenant '$($Tenant.Name)' ($scopeText, $action)"
            }

            # Execute update using new API with all variables - any excluded variables are deleted
            if ($PSCmdlet.ShouldProcess("$($Tenant.Name) / $($Project.Name)", "Set variables '$($VariableHash.Keys -join "', '")' ($scopeText)")) {
                $command = [Octopus.Client.Model.TenantVariables.ModifyProjectVariablesByTenantIdCommand]::new($Tenant.Id, $Tenant.SpaceId, $payloads)
                $repo._repository.TenantVariables.Modify($command) | Out-Null
            }

        } catch {
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }

    end {}
}