Octopus Deploy Powershell Scripts

Update variables in all releases

$headers = @{
    'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY
}

# Update variables in all releases
$releases = Invoke-RestMethod -Uri "https://robota.octopus.app/api/releases?take=1000" -Headers $headers | Select-Object -ExpandProperty Items
foreach ($release in $releases) {
    $message = "[$(1 +[Array]::IndexOf($releases, $release)) of $($releases.Count)] $($release.Id) - $($release.Version)"
    try {
        Invoke-RestMethod -Method Post -Uri "https://robota.octopus.app/api/releases/$($release.Id)/snapshot-variables" -Headers $headers | Out-Null
        Write-Host $message -ForegroundColor Green
    }
    catch {
        Write-Host $message -ForegroundColor Red
    }
}

Redeploy all IIS sites to dev1

$headers = @{
    'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY
}


$target = "dev1"


$environments = Invoke-RestMethod "https://robota.octopus.app/api/environments" -Headers $headers | Select-Object -ExpandProperty Items
$environment = $environments | Where-Object Name -EQ $target
Write-Host "Environment for $target has id $($environment.Id)"


$projects = Invoke-RestMethod -Uri "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items
$projects = $projects | Where-Object IsDisabled -EQ $false
Write-Host "Got $($projects.Count) projects"

# Optional: filter frontend projects
$projects = $projects | Where-Object Name -NotLike "alliance-*"


# Optional: filter only IIS projects
$iisProjects = @()
foreach ($project in $projects) {
    $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
    $hasIisStep = $false
    foreach ($step in $process.Steps) {
        if (($step.Properties.'Octopus.Action.TargetRoles' -eq 'iis') -or ($step.Properties.'Octopus.Action.TargetRoles' -eq 'admin')) {
            foreach ($action in $step.Actions) {
                if ($action.IsDisabled -eq $false) {
                    $hasIisStep = $true
                    break
                }
            }
        }
    }
    if ($hasIisStep -eq $true) {
        $iisProjects += $project
    }
}
Write-Host "Filtered $($iisProjects.Count) IIS projects"
$projects = $iisProjects


$releases = @()
foreach($project in $projects) {
    $progression = Invoke-RestMethod "https://robota.octopus.app/api/progression/$($project.Id)" -Headers $headers
    foreach($release in $progression.Releases) {
        if ($release.Deployments.$($environment.Id)) {
            $releases += $release
            break
        }
    }
}
Write-Host "Got $($releases.Count) releases"


Write-Host "Going to update release variables for $($releases.Count) releases"
foreach($release in $releases) {
    $projectName = $projects | Where-Object Id -EQ $release.Release.ProjectId | Select-Object -First 1 -ExpandProperty Name
    try {
        Invoke-RestMethod -Method Post -Uri "https://robota.octopus.app/api/releases/$($release.Release.Id)/snapshot-variables" -Headers $headers | Out-Null
        Write-Host "$($projectName): $($release.Release.Version)" -ForegroundColor Green
    }
    catch {
        Write-Host "$($projectName): $($release.Release.Version)" -ForegroundColor Red
    }
}

$counter = 0
$errors = @()
Write-Host "Going to redeploy $($releases.Count) releases"
foreach($release in $releases) {
    $projectName = $projects | Where-Object Id -EQ $release.Release.ProjectId | Select-Object -First 1 -ExpandProperty Name
    try {
        $deployment = Invoke-RestMethod -Method Post "https://robota.octopus.app/api/deployments" -Headers $headers -ContentType 'application/json' -Body (@{ ReleaseId = $release.Release.Id; EnvironmentId = $environment.Id } | ConvertTo-Json)

        Write-Host "$($projectName): $($release.Release.Version)" -ForegroundColor Cyan
        $task = Invoke-RestMethod "https://robota.octopus.app/api/tasks/$($deployment.TaskId)" -Headers $headers
        while($task.IsCompleted -ne $true) {
            $task = Invoke-RestMethod "https://robota.octopus.app/api/tasks/$($deployment.TaskId)" -Headers $headers
            if ($task.State -eq "Success") {
                Write-Host "$($task.State) $($task.Duration)" -ForegroundColor Green
                break
            } else {
                Write-Host "$($task.State) $($task.Duration)"
            }

            Start-Sleep -Seconds 30
        }
        if ($task.FinishedSuccessfully -ne $true) {
            Write-Host "$($projectName): $($release.Release.Version) - unsuccessfull deployment" -ForegroundColor Red
            $errors += New-Object PSObject -Property @{
                project = $projectName
                version = $release.Release.Version
            }
        }

    }
    catch {
        Write-Host "$($projectName): $($release.Release.Version) - unable to create deployment" -ForegroundColor Red
        $errors += New-Object PSObject -Property @{
            project = $projectName
            version = $release.Release.Version
        }
    }

    <#
    $counter += 1
    if ($counter -ge 3) {
        break
    }
    #>

}

if ($errors.Count) {
    Write-Host "Take a look at failed projects"
    $errors | Out-Host
} else {
    Write-Host "Everything is OK, go take your 🍺" -ForegroundColor Yellow
}

PowerShell Octopus Check All Kubernetes Deployment Have Revision History Limit

$headers = @{
    'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY
}

$projects = Invoke-RestMethod "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items
foreach($project in $projects) {
    $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
    foreach($step in $process.Steps) {
        foreach($action in $step.Actions) {
            if ($action.ActionType -ne 'Octopus.KubernetesDeployContainers') {
                continue
            }
            $limit = $action.Properties.'Octopus.Action.KubernetesContainers.RevisionHistoryLimit'
            if ($limit -ne '#{RevisionHistoryLimit}') {
                Write-Host "project: $($project.Name), step: $($step.Name), url: https://robota.octopus.app/app#/Spaces-1/projects/$($project.Slug)/deployments/process"
            }
        }
    }
}

PowerShell Octopus set deployment setting in all projects

$headers = @{
    'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY
}

$projects = Invoke-RestMethod "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items
$projects = $projects | Sort-Object -Property Name
# $project = $projects |? Name -EQ 'adjwt'
foreach($project in $projects) {
    $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
    $shouldUpdate = $false
    foreach($step in $process.Steps) {
        foreach($action in $step.Actions) {
            if ($action.ActionType -ne 'Octopus.KubernetesDeployContainers') {
                continue
            }
            if ($action.Properties.'Octopus.Action.KubernetesContainers.RevisionHistoryLimit' -ne '#{RevisionHistoryLimit}') {
                if (-not $action.Properties.'Octopus.Action.KubernetesContainers.RevisionHistoryLimit') {
                    $action.Properties | Add-Member -NotePropertyName 'Octopus.Action.KubernetesContainers.RevisionHistoryLimit' -NotePropertyValue '#{RevisionHistoryLimit}'
                } else {
                    $action.Properties.'Octopus.Action.KubernetesContainers.RevisionHistoryLimit' = '#{RevisionHistoryLimit}'
                }
                $shouldUpdate = $true
            }
        }
    }
    $status = 'unknown'
    if ($shouldUpdate) {
        try {
            Invoke-RestMethod -Method Put "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers -Body ($process | ConvertTo-Json -Depth 100) | Out-Null
            Write-Host "$($project.Name) - updated" -ForegroundColor Green
            $status = 'updated'
        } catch {
            Write-Host "$($project.Name) - failed" -ForegroundColor Red
            $status = 'failed'
        }
    } else {
        Write-Host "$($project.Name) - skipped" -ForegroundColor Cyan
        $status = 'skipped'
    }
    Write-Progress -Activity $project.Name -Status $status -PercentComplete ( [Array]::IndexOf($projects, $project) / $projects.Count * 100 )
}

PowerShell Octopus Create and Deploy Release (sync dev environments with production)

<#
Problem: after releasing new version engineers are often forget to sync dev environments
Fact: deployment to production happens only after deployment to staging, so there is no need to sync them, only devX environments should be synced
Idea: after releasing to production, take its selected versions, create release for dev channel with same version, deploy
Inputs: project name to sync
Outputs: released deployments to all dev environments
Improvements for v2: ability to sync all projects
Links: https://github.com/OctopusDeploy/OctopusDeploy-Api/blob/master/REST/PowerShell/Deployments/CreateReleaseAndDeployment.ps1
#>

$name = 'adjwt'

$headers = @{
    'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY
}

$projects = Invoke-RestMethod "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items | Select-Object Id, Name
$project = $projects |? Name -EQ $name

$environments = Invoke-RestMethod "https://robota.octopus.app/api/environments" -Headers $headers | Select-Object -ExpandProperty Items | Select-Object Id, Name
$productionEnvironmentId = $environments | Where-Object Name -EQ "Production" | Select-Object -ExpandProperty Id

$idOfLastSuccessfullDeploymentToProduction = Invoke-RestMethod -Uri "https://robota.octopus.app/api/tasks?take=1&environment=$($productionEnvironmentId)&state=Success&name=Deploy&project=$($project.Id)" -Headers $headers | Select-Object -ExpandProperty Items | Select-Object -ExpandProperty Arguments -First 1 | Select-Object -ExpandProperty DeploymentId
$idOfRelease = Invoke-RestMethod -Uri "https://robota.octopus.app/api/deployments/$idOfLastSuccessfullDeploymentToProduction" -Headers $headers | Select-Object -ExpandProperty ReleaseId
$lastRelease = Invoke-RestMethod -Uri "https://robota.octopus.app/api/releases/$idOfRelease" -Headers $headers | Select-Object Version, SelectedPackages

$channels = Invoke-RestMethod "https://robota.octopus.app/api/projects/$($project.Id)/channels" -Headers $headers | Select-Object -ExpandProperty Items | Where-Object Id -NE $releaseOfLastSuccessfullDeploymentToProduction.ChannelId | Select-Object Id, Name
foreach($channel in $channels) {
    $release = Invoke-RestMethod -Method Post -Uri "https://robota.octopus.app/api/releases" -Headers $headers -Body (@{
        ChannelId        = $channel.Id
        ProjectId        = $project.Id
        Version          = $lastRelease.Version + "-sync"
        SelectedPackages = $lastRelease.SelectedPackages
    } | ConvertTo-Json)

    foreach($environment in $environments | Where-Object Name -NotIn @('Production', 'Staging')) {
        $deployment = Invoke-RestMethod -Method Post -Uri "https://robota.octopus.app/api/deployments " -Headers $headers -Body (@{
            ReleaseId     = $release.Id
            EnvironmentId = $environment.Id
        } | ConvertTo-Json)

        # optional
        Write-Host "$($project.Name)@$($environment.Name): created" -ForegroundColor Cyan
        do {
            $task = Invoke-RestMethod -Uri "https://robota.octopus.app/api/tasks/$($deployment.TaskId)" -Headers $headers
            if ($task.State -eq "Success") {
                Write-Host "$($task.State) $($task.Duration)" -ForegroundColor Green
                break
            } else {
                Write-Host "$($task.State) $($task.Duration)"
            }
            Start-Sleep -Seconds 30
        } while($task.IsCompleted -ne $true)
        if ($task.FinishedSuccessfully -ne $true) {
            Write-Host "$($project.Name)@$($environment.Name): failed" -ForegroundColor Red
        } else {
            Write-Host "$($project.Name)@$($environment.Name): deployed" -ForegroundColor Green
        }
    }
}

Mass fix for HPA tab issue

$headers = @{'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY}

$projects = Invoke-RestMethod -Uri "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items
$projects = $projects | Where-Object IsDisabled -EQ $false
Write-Host "Got $($projects.Count) projects"

foreach($project in $projects) {
    # $project = $projects | Where-Object Name -EQ 'ApplyApi'
    Write-Host "Project: $($project.Name)"
    $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
    $shouldUpdate = $false
    foreach($step in $process.Steps) {
        # $process.Steps | Select-Object name
        # $step = $process.Steps[2]
        Write-Host "Step: $($step.Name)"
        if ($step.Properties.'Octopus.Action.TargetRoles' -ne 'kube-azure') {
            Write-Host "skipping '$($step.Properties.'Octopus.Action.TargetRoles')' non kubernetes step..." -ForegroundColor Yellow
            continue
        }
        foreach($action in $step.Actions) {
            # $step.Actions | Select-Object name
            # $action = $step.Actions[0]
            Write-Host "Action: $($action.Name)"
            if ($action.IsDisabled) {
                Write-Host "skipping disabled action..." -ForegroundColor Yellow
                continue
            }
            if ($action.ActionType -ne 'Octopus.KubernetesDeployContainers') {
                Write-Host "skipping '$($action.ActionType)' non kubernetes action..." -ForegroundColor Yellow
                continue
            }
            if (-not $action.Properties.'Octopus.Action.KubernetesContainers.CustomResourceYaml') {
                Write-Host "skipping action without custom resource yaml..." -ForegroundColor Yellow
                continue
            }
            if ($action.Properties.'Octopus.Action.KubernetesContainers.CustomResourceYaml' -notmatch 'HorizontalPodAutoscaler') {
                Write-Host "skipping action without hpa in custom resource yaml..." -ForegroundColor Yellow
                continue
            }
            if ($action.Properties.'Octopus.Action.KubernetesContainers.CustomResourceYaml' -notmatch "`t") {
                Write-Host "skipping action tabs not found..." -ForegroundColor Yellow
                continue
            }

            $action.Properties.'Octopus.Action.KubernetesContainers.CustomResourceYaml' = ($action.Properties.'Octopus.Action.KubernetesContainers.CustomResourceYaml' -replace "`t", '  ')
            $shouldUpdate = $true
            Write-Host "should be updated..." -ForegroundColor Yellow

        }
    }
    if ($shouldUpdate) {
        try {
            Invoke-RestMethod -Method Put "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers -Body ($process | ConvertTo-Json -Depth 100) | Out-Null
            Write-Host $project.Name -ForegroundColor Green
        } catch {
            Write-Host $project.Name -ForegroundColor Red
        }
    } else {
        Write-Host $project.Name -ForegroundColor Cyan
    }
}

Project variables CRUD

$headers = @{'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY}

# Invoke-RestMethod -Uri "https://robota.octopus.app/api/environments" -Headers $headers | Select-Object -ExpandProperty Items | Select-Object Id, Name
# Environments-3  Production
# Environments-2  Staging
# Environments-1  dev

$projects = Invoke-RestMethod -Uri "https://robota.octopus.app/api/projects?skip=0&take=1000" -Headers $headers | Select-Object -ExpandProperty Items

$project = $projects | Where-Object Name -EQ 'adjwt'

# Add global variable
$variables = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers

$variables.Variables += [PSCustomObject]@{
    Name = 'Demo'
    Value = 'Hello from PowerShell'
    IsEditable = $true
    IsSensitive = $false
    Type = 'String'
}

Invoke-RestMethod -Method Put -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers -Body ($variables | ConvertTo-Json -Depth 100)

# Add environment variable
$variables = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers

$variables.Variables += [PSCustomObject]@{
    Name = 'Demo'
    Value = 'default'
    IsEditable = $true
    IsSensitive = $false
    Type = 'String'
}

$variables.Variables += [PSCustomObject]@{
    Name = 'Demo'
    Value = 'test2'
    IsEditable = $true
    IsSensitive = $false
    Type = 'String'
    Scope = @{
        Environment = @('Environments-2')
    }
}

$variables.Variables += [PSCustomObject]@{
    Name = 'Demo'
    Value = 'prod'
    IsEditable = $true
    IsSensitive = $false
    Type = 'String'
    Scope = @{
        Environment = @('Environments-3')
    }
}

Invoke-RestMethod -Method Put -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers -Body ($variables | ConvertTo-Json -Depth 100)

# Delete variable by its name

$variables = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers

$variables.Variables = $variables.Variables | Where-Object Name -NE 'Demo'

Invoke-RestMethod -Method Put -Uri "https://robota.octopus.app$($project.Links.Variables)" -Headers $headers -Body ($variables | ConvertTo-Json -Depth 100)

Mass add TZ environment variable to all octopus projects, powershell

$headers = @{'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY }

$projects = Invoke-RestMethod -Uri "https://robota.octopus.app/api/projects/all" -Headers $headers
$projects = $projects | Where-Object IsDisabled -EQ $false
Write-Host "Got $($projects.Count) projects"

foreach ($project in $projects) {
  <#
  $project = $projects | Where-Object Name -EQ 'banner-api'
  #>
  Write-Host "Project: $($project.Name)"
  $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
  $shouldUpdate = $false
  foreach ($step in $process.Steps) {
    <#
    $process.Steps | Select-Object name
    $step = $process.Steps[1]
    #>
    Write-Host "Step: $($step.Name)"
    if ($step.Properties.'Octopus.Action.TargetRoles' -ne 'kube-azure') {
      Write-Host "skipping '$($step.Properties.'Octopus.Action.TargetRoles')' non kubernetes step..." -ForegroundColor Yellow
      continue
    }
    foreach ($action in $step.Actions) {
      <#
      $step.Actions | Select-Object name
      $action = $step.Actions[0]
      #>
      Write-Host "Action: $($action.Name)"
      if ($action.IsDisabled) {
        Write-Host "skipping disabled action..." -ForegroundColor Yellow
        continue
      }
      if ($action.ActionType -ne 'Octopus.KubernetesDeployContainers') {
        Write-Host "skipping '$($action.ActionType)' non kubernetes action..." -ForegroundColor Yellow
        continue
      }

      $containers = $action.Properties.'Octopus.Action.KubernetesContainers.Containers' | ConvertFrom-Json
      foreach ($container in $containers) {
        <#
        $containers | Select-Object name
        $container = $containers[0]
        #>
        if (-not ($container.EnvironmentVariables | Where-Object key -EQ 'TZ' | Where-Object value -In @('Europe/Kiev', 'UTC'))) {
          Write-Host "Container: $($container.Name) - has no TZ env variable - should be updated..." -ForegroundColor Yellow
          $container.EnvironmentVariables += [PSCustomObject]@{
            key   = 'TZ'
            value = 'Europe/Kiev' # TODO: chose 'Europe/Kiev' or 'UTC'
          }
          $shouldUpdate = $true
        }
      }

      # WTF powershell?! Powershell always messes up with single item arrays! Keep that in mind
      $action.Properties.'Octopus.Action.KubernetesContainers.Containers' = ConvertTo-Json -Depth 100 -InputObject @($containers)
    }
  }
  if ($shouldUpdate) {
    try {
      # # Uncomment me when you ready
      # Invoke-RestMethod -Method Put "https://robota.octopus.app$($project.Links.DeploymentProcess)" -ContentType "application/json" -Headers $headers -Body ($process | ConvertTo-Json -Depth 100) | Out-Null
      Write-Host $project.Name -ForegroundColor Green
    }
    catch {
      Write-Host $project.Name -ForegroundColor Red
    }
  }
  else {
    Write-Host $project.Name -ForegroundColor Cyan
  }
}

Mass owners sync from Octopus to Kubernetes

$octopusHeaders = @{ "X-Octopus-ApiKey" = $env:OCTOPUS_TOKEN; "Content-Type" = "application/json" }

$items = kubectl get "deployment,statefulset,cronjob" -o json | ConvertFrom-Json | Select-Object -ExpandProperty items
<#
    $item = $items | Where-Object { $_.metadata.name -eq 'candidates-auto-screening-answer-stats-cronjob' }
#>

foreach ($item in $items) {
    $projectId = $item.metadata.labels.'Octopus.Project.Id'
    if ([string]::IsNullOrEmpty($projectId)) {
        #Write-Host "$($project.Name) | Octopus.Project.Id label not found for deployment" -ForegroundColor Red
        continue
    }

    if (-not $item.metadata.annotations.owner) {
        #Write-Host "$($project.Name) | Owner annotation not found for deployment" -ForegroundColor Red
        continue
    }

    $variables = Invoke-RestMethod -Uri "https://robota.octopus.app/api/Spaces-1/projects/$($projectId)/variables" -Headers $octopusHeaders
    $octo_owner = $variables.Variables | Where-Object Name -eq "Owner" | Select-Object -ExpandProperty Value -First 1
    if (-not $octo_owner) {
        #Write-Host "$($project.Name) | Owner repository variable not found for project" -ForegroundColor Red
        continue
    }

    $octo_owner_normalized = $octo_owner.Trim().ToLower()
    if ($item.metadata.annotations.owner.Trim().ToLower() -eq $octo_owner_normalized) {
        #Write-Host "$($item.metadata.name) | Owner is up to date" -ForegroundColor Green
        continue
    }

    if ($item.kind -eq "CronJob") {
        $patch = ConvertTo-Json -Compress -Depth 100 -InputObject @{
            metadata = @{
                annotations = @{
                    owner = $octo_owner_normalized
                }
            }
            spec     = @{
                jobTemplate = @{
                    metadata = @{
                        annotations = @{
                            owner = $octo_owner_normalized
                        }
                    }
                    spec     = @{
                        template = @{
                            metadata = @{
                                annotations = @{
                                    owner = $octo_owner_normalized
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    else {
        $patch = ConvertTo-Json -Compress -Depth 100 -InputObject @{
            metadata = @{
                annotations = @{
                    owner = $octo_owner_normalized
                }
            }
            spec     = @{
                template = @{
                    metadata = @{
                        annotations = @{
                            owner = $octo_owner_normalized
                        }
                    }
                }
            }
        }
    }

    Write-Host "kubectl patch $($item.kind.ToLower()) $($item.metadata.name) -p '$patch'"

    if ($item.kind -ne "cronjob") {
        Write-Host "kubectl rollout status $($item.kind.ToLower()) $($item.metadata.name) --timeout=60s"
    }
}

Octopus Powershell List Variable Usage by Projects

find all projects that use some variable

$headers = @{'X-Octopus-ApiKey' = $env:OCTOPUS_CLI_API_KEY }

$projects = Invoke-RestMethod -Uri "https://robota.octopus.app/api/projects/all" -Headers $headers
$projects = $projects | Where-Object IsDisabled -EQ $false

$variables = @()
foreach ($project in $projects) {
  <#
  $project = $projects | Where-Object Name -EQ 'RbacApi'
  #>
  $process = Invoke-RestMethod -Uri "https://robota.octopus.app$($project.Links.DeploymentProcess)" -Headers $headers
  foreach ($step in $process.Steps) {
    <#
    $process.Steps | Select-Object name
    $step = $process.Steps[1]
    #>
    foreach ($action in $step.Actions) {
      <#
      $step.Actions | Select-Object name
      $action = $step.Actions[0]
      #>
      if ($action.IsDisabled) { continue }

      $json = $action | ConvertTo-Json -Depth 100 # trick: instead of iteratin over all possible step kinds, serialize it as json and search for #{SomeVariable}
      foreach ($variable in [regex]::Matches($json, '#\{[^\}]+\}')) {
        $variables += [PSCustomObject]@{
          project  = $project.Name
          step     = $step.Name
          variable = $variable.Value
        }
      }
    }
  }
}

$variables = $variables | Select-Object project, step, variable -Unique

$variables