Octopus - Merge CaC Updates (S3 Backend)

Octopus.AwsRunScript exported 2023-11-17 by mcasperson belongs to ‘Octopus’ category.

This step queries each workspace in the Terraform state for downstream Octopus CaC enabled projects, extracts the Git repo associated with the CaC project, and merges any changes so long as there are no merge conflicts.

If there is a merge conflict between the upstream and downstream repos, instructions for manually resolving the conflict are provided.

Parameters

When steps based on the template are included in a project’s deployment process, the parameters below can be set.

Octopus Spaces

FindConflicts.Octopus.Spaces =

An optional newline-separated list of space names with projects to merge changes into. Leave this field blank to merge changes to projects in all spaces.

Octopus Projects

FindConflicts.Octopus.Projects =

A newline-separated list of projects to merge changes into. Leave this field blank to merge changes to all projects.

Git Username

FindConflicts.Git.Credentials.Username = x-access-token

The git repo username. When using GitHub with an access token, the value is x-access-token.

Git Password

FindConflicts.Git.Credentials.Password =

The git repo password or access token.

Git Protocol

FindConflicts.Git.Url.Protocol = https

The git repo protocol.

Git Hostname

FindConflicts.Git.Url.Host = github.com

The git repo host name.

Git Organization

FindConflicts.Git.Url.Organization =

The git repo owner or organization i.e. owner in the url https://github.com/owner/repo.

Git Template Repo

FindConflicts.Git.Url.Template =

The repo holding the upstream, or template, CaC project i.e. repo in the url https://github.com/owner/repo.

AWS Region

FindConflicts.Terraform.Backend.S3Region =

The AWS region hosting the S3 bucket persisting the Terraform state.

S3 Key

FindConflicts.Terraform.Backend.S3Key = Project_#{Octopus.Project.Name | Replace "[^A-Za-z0-9]" "_"}

The name of the file in the S3 bucket hosting the Terraform state.

S3 Bucket

FindConflicts.Terraform.Backend.S3Bucket =

The name of the S3 bucket hosting the Terraform state.

AWS Account

FindConflicts.Terraform.Aws.Account =

The AWS account used to access the S3 bucket.

Script body

Steps based on this template will execute the following PowerShell script.

# Check to see if $IsWindows is available
if ($null -eq $IsWindows)
{
    Write-Host "Determining Operating System..."
    $IsWindows = ([System.Environment]::OSVersion.Platform -eq "Win32NT")
    $IsLinux = ([System.Environment]::OSVersion.Platform -eq "Unix")
}

Function Get-GitExecutable
{
    # Define parameters
    param (
        $WorkingDirectory
    )

    # Define variables
    $gitExe = "PortableGit-2.41.0.3-64-bit.7z.exe"
    $gitDownloadUrl = "https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.3/$gitExe"
    $gitDownloadArguments = @{ }
    $gitDownloadArguments.Add("Uri", $gitDownloadUrl)
    $gitDownloadArguments.Add("OutFile", "$WorkingDirectory/git/$gitExe")

    # This makes downloading faster
    $ProgressPreference = 'SilentlyContinue'

    # Check to see if git subfolder exists
    if ((Test-Path -Path "$WorkingDirectory/git") -eq $false)
    {
        # Create subfolder
        New-Item -Path "$WorkingDirectory/git"  -ItemType Directory | Out-Null
    }

    # Check PowerShell version
    if ($PSVersionTable.PSVersion.Major -lt 6)
    {
        # Use basic parsing is required
        $gitDownloadArguments.Add("UseBasicParsing", $true)
    }

    # Download Git
    Write-Host "Downloading Git ..."
    Invoke-WebRequest @gitDownloadArguments

    # Extract Git
    $gitExtractArguments = @()
    $gitExtractArguments += "-o"
    $gitExtractArguments += "$WorkingDirectory\git"
    $gitExtractArguments += "-y"
    $gitExtractArguments += "-bd"

    Write-Host "Extracting Git download ..."
    & "$WorkingDirectory\git\$gitExe" $gitExtractArguments

    # Wait until unzip action is complete
    while ($null -ne (Get-Process | Where-Object { $_.ProcessName -eq ($gitExe.Substring(0,$gitExe.LastIndexOf("."))) }))
    {
        Start-Sleep 5
    }

    # Add bin folder to path
    $env:PATH = "$WorkingDirectory\git\bin$( [IO.Path]::PathSeparator )" + $env:PATH

    # Disable promopt for credential helper
    Invoke-CustomCommand "git" @("config", "--system", "--unset", "credential.helper") | Write-Results
}

Function Invoke-CustomCommand
{
    Param (
        $commandPath,
        $commandArguments,
        $workingDir = (Get-Location),
        $path = @(),
        $envVars = @{ }
    )

    $path += $env:PATH
    $newPath = $path -join [IO.Path]::PathSeparator

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    $pinfo.FileName = $commandPath
    $pinfo.WorkingDirectory = $workingDir
    $pinfo.RedirectStandardError = $true
    $pinfo.RedirectStandardOutput = $true
    $pinfo.UseShellExecute = $false
    $pinfo.Arguments = $commandArguments
    $pinfo.EnvironmentVariables["PATH"] = $newPath

    foreach ($env in $envVars.Keys)
    {
        Write-Verbose "Setting $env to $( $envVars.$env )"
        $pinfo.EnvironmentVariables.Add($env,$envVars.$env.ToString())
    }

    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo
    $p.Start() | Out-Null

    # Capture output during process execution so we don't hang
    # if there is too much output.
    # Microsoft documents a C# solution here:
    # https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.redirectstandardoutput?view=net-7.0&redirectedfrom=MSDN#remarks
    # This code is based on https://stackoverflow.com/a/74748844
    $stdOut = [System.Text.StringBuilder]::new()
    $stdErr = [System.Text.StringBuilder]::new()
    do
    {
        if (!$p.StandardOutput.EndOfStream)
        {
            $stdOut.AppendLine($p.StandardOutput.ReadLine())
        }
        if (!$p.StandardError.EndOfStream)
        {
            $stdErr.AppendLine($p.StandardError.ReadLine())
        }

        Start-Sleep -Milliseconds 10
    }
    while (-not $p.HasExited)

    # Capture any standard output generated between our last poll and process end.
    while (!$p.StandardOutput.EndOfStream)
    {
        $stdOut.AppendLine($p.StandardOutput.ReadLine())
    }

    # Capture any error output generated between our last poll and process end.
    while (!$p.StandardError.EndOfStream)
    {
        $stdErr.AppendLine($p.StandardError.ReadLine())
    }

    $p.WaitForExit()

    $executionResults = [pscustomobject]@{
        StdOut = $stdOut.ToString()
        StdErr = $stdErr.ToString()
        ExitCode = $p.ExitCode
    }

    return $executionResults

}

function Write-Results
{
    [cmdletbinding()]
    param (
        [Parameter(Mandatory = $True, ValuefromPipeline = $True)]
        $results
    )

    if (![String]::IsNullOrWhiteSpace($results.StdOut))
    {
        Write-Verbose $results.StdOut
    }

    if (![String]::IsNullOrWhiteSpace($results.StdErr))
    {
        Write-Verbose $results.StdErr
    }
}

function Format-StringAsNullOrTrimmed {
    [cmdletbinding()]
    param (
        [Parameter(ValuefromPipeline=$True)]
        $input
    )

    if ([string]::IsNullOrWhitespace($input)) {
        return $null
    }

    return $input.Trim()
}

function Write-TerraformBackend
{
    Set-Content -Path 'backend.tf' -Value @"
terraform {
        backend "s3" {}
        required_providers {
          octopusdeploy = { source = "OctopusDeployLabs/octopusdeploy", version = ">= 0.21.1" }
        }
    }
"@
}

function Set-GitContactDetails
{
    $gitEmail = Invoke-CustomCommand "git" @("config", "--global", "user.email", "octopus@octopus.com")
    Write-Results $gitEmail
    if (-not $gitEmail.ExitCode -eq 0)
    {
        Write-Error "Failed to set the git email address (exit code was $( $gitEmail.ExitCode ))."
    }

    $gitUser = Invoke-CustomCommand "git" @("config", "--global", "user.name", "Octopus Server")
    Write-Results $gitUser
    if (-not $gitUser.ExitCode -eq 0)
    {
        Write-Error "Failed to set the git name (exit code was $( $gitUser.ExitCode ))."
    }
}

$spaceFilter = if (-not [string]::IsNullOrWhitespace($OctopusParameters["FindConflicts.Octopus.Spaces"]))
{
    $OctopusParameters["FindConflicts.Octopus.Spaces"].Split("`n")
}
else
{
    @()
}
$projectFilter = if (-not [string]::IsNullOrWhitespace($OctopusParameters["FindConflicts.Octopus.Projects"]))
{
    $OctopusParameters["FindConflicts.Octopus.Projects"].Split("`n")
}
else
{
    @()
}
$username = $OctopusParameters["FindConflicts.Git.Credentials.Username"]
$password = $OctopusParameters["FindConflicts.Git.Credentials.Password"]
$protocol = $OctopusParameters["FindConflicts.Git.Url.Protocol"]
$gitHost = $OctopusParameters["FindConflicts.Git.Url.Host"]
$org = $OctopusParameters["FindConflicts.Git.Url.Organization"]
$repo = $OctopusParameters["FindConflicts.Git.Url.Template"]
$region = $OctopusParameters["FindConflicts.Terraform.Backend.S3Region"]
$key = $OctopusParameters["FindConflicts.Terraform.Backend.S3Key"]
$bucket = $OctopusParameters["FindConflicts.Terraform.Backend.S3Bucket"]

if ([string]::IsNullOrWhitespace($username))
{
    Write-Error "The FindConflicts.Git.Credentials.Username variable must be provided"
}

if ([string]::IsNullOrWhitespace($password))
{
    Write-Error "The FindConflicts.Git.Credentials.Password variable must be provided"
}

if ( [string]::IsNullOrWhitespace($protocol))
{
    Write-Error "The FindConflicts.Git.Url.Protocol variable must be defined."
}

if ( [string]::IsNullOrWhitespace($gitHost))
{
    Write-Error "The FindConflicts.Git.Url.Host variable must be defined."
}

if ( [string]::IsNullOrWhitespace($repo))
{
    Write-Error "The FindConflicts.Git.Url.Template variable must be defined."
}

if ( [string]::IsNullOrWhitespace($region))
{
    Write-Error "The FindConflicts.Terraform.Backend.S3Region variable must be defined."
}

if ( [string]::IsNullOrWhitespace($key))
{
    Write-Error "The FindConflicts.Terraform.Backend.S3Key variable must be defined."
}

if ( [string]::IsNullOrWhitespace($bucket))
{
    Write-Error "The FindConflicts.Terraform.Backend.S3Bucket variable must be defined."
}

$templateRepoUrl = $protocol + "://" + $gitHost + "/" + $org + "/" + $repo + ".git"
$templateRepo = $protocol + "://" + $username + ":" + $password + "@" + $gitHost + "/" + $org + "/" + $repo + ".git"
$branch = "main"

# Check to see if it's Windows
if ($IsWindows -and $OctopusParameters['Octopus.Workerpool.Name'] -eq "Hosted Windows")
{
    # Dynamic worker don't have git, download portable version and add to path for execution
    Write-Host "Detected usage of Windows Dynamic Worker ..."
    Get-GitExecutable -WorkingDirectory $PWD
}

Write-TerraformBackend
Set-GitContactDetails

Invoke-CustomCommand "terraform" @("init", "-no-color", "-backend-config=`"bucket=$bucket`"", "-backend-config=`"region=$region`"", "-backend-config=`"key=$key`"") | Write-Results

Write-Host "Verbose logs contain instructions for resolving merge conflicts."

$workspaces = Invoke-CustomCommand "terraform" @("workspace", "list")

Write-Results $workspaces

$parsedWorkspaces = $workspaces.StdOut.Replace("*", "").Split("`n")

foreach ($workspace in $parsedWorkspaces)
{
    $trimmedWorkspace = $workspace | Format-StringAsNullOrTrimmed

    if ($trimmedWorkspace -eq "default" -or [string]::IsNullOrWhitespace($trimmedWorkspace))
    {
        continue
    }

    Write-Verbose "Processing workspace $trimmedWorkspace"

    Invoke-CustomCommand "terraform" @("workspace", "select", $trimmedWorkspace) | Write-Results

    $state = Invoke-CustomCommand "terraform" @("show", "-json")

    # state might include sensitive values, so don't print it unless there was an error

    if (-not $state.ExitCode -eq 0)
    {
        Write-Results $state
        continue
    }

    $parsedState = $state.StdOut | ConvertFrom-Json

    $resources = $parsedState.values.root_module.resources | Where-Object {
        $_.type -eq "octopusdeploy_project"
    }

    # The outputs allow us to contact the downstream instance)
    $spaceName = (Invoke-CustomCommand "terraform" @("output", "-raw", "octopus_space_name")).StdOut | Format-StringAsNullOrTrimmed

    foreach ($resource in $resources)
    {
        $url = $resource.values.git_library_persistence_settings.url | Format-StringAsNullOrTrimmed
        $name = $resource.values.name | Format-StringAsNullOrTrimmed

        # Optional filtering
        if (-not($spaceFilter.Count -eq 0 -or $spaceFilter.Contains($spaceName)))
        {
            continue
        }

        if (-not($projectFilter.Count -eq 0 -or $projectFilter.Contains($name)))
        {
            continue
        }

        if (-not [string]::IsNullOrWhitespace($url))
        {
            mkdir $trimmedWorkspace | Out-Null

            $parsedUrl = [System.Uri]$url
            $urlWithCreds = $parsedUrl.Scheme + "://" + $username + ":" + $password + "@" + $parsedUrl.Host + ":" + $parsedUrl.Port + $parsedUrl.AbsolutePath

            Write-Verbose "Cloning repo"
            $cloneRepo = Invoke-CustomCommand "git" @("clone", $urlWithCreds, $trimmedWorkspace)
            Write-Results $cloneRepo
            if (-not $cloneRepo.ExitCode -eq 0)
            {
                Write-Error "Failed to clone repo (exit code was $( $cloneRepo.ExitCode ))."
            }

            Write-Verbose "Cloning upstream remote"
            $addRemote = Invoke-CustomCommand "git" @("remote", 'add', 'upstream', $templateRepo) $trimmedWorkspace
            Write-Results $addRemote
            if (-not $addRemote.ExitCode -eq 0)
            {
                Write-Error "Failed to clone repo (exit code was $( $addRemote.ExitCode ))."
            }

            Write-Verbose "Fetching all"
            $fetchAll = Invoke-CustomCommand "git" @("fetch", "--all") $trimmedWorkspace
            Write-Results $fetchAll
            if (-not $fetchAll.ExitCode -eq 0)
            {
                Write-Error "Failed to fetch all (exit code was $( $fetchAll.ExitCode ))."
            }

            Write-Verbose "Checking out upstream-$branch upstream/$branch"
            $checkoutUpstream = Invoke-CustomCommand "git" @("checkout", "-b", "upstream-$branch", "upstream/$branch") $trimmedWorkspace
            Write-Results $checkoutUpstream
            if (-not $checkoutUpstream.ExitCode -eq 0)
            {
                Write-Error "Failed to checkout upstream (exit code was $( $checkoutUpstream.ExitCode ))."
            }

            if (-not($branch -eq "master" -or $branch -eq "main"))
            {
                Write-Verbose "Checking out $branch origin/$branch"
                $checkoutDownstream = Invoke-CustomCommand "git" @("checkout", "-b", $branch, "origin/$branch") $trimmedWorkspace
            }
            else
            {
                Write-Verbose "Checking out $branch"
                $checkoutDownstream = Invoke-CustomCommand "git" @("checkout", $branch) $trimmedWorkspace
            }

            Write-Results $checkoutDownstream
            if (-not $checkoutDownstream.ExitCode -eq 0)
            {
                Write-Error "Failed to checkout downstream (exit code was $( $checkoutDownstream.ExitCode ))."
            }

            Write-Verbose "Merge base"
            $mergeBase = Invoke-CustomCommand "git" @("merge-base", $branch, "upstream-$branch") $trimmedWorkspace
            Write-Results $mergeBase
            if (-not $mergeBase.ExitCode -eq 0)
            {
                Write-Error "Failed to merge base (exit code was $( $mergeBase.ExitCode ))."
            }

            Write-Verbose "Rev parse"
            $mergeSourceCurrentCommit = Invoke-CustomCommand "git" @("rev-parse", "upstream-$branch") $trimmedWorkspace
            Write-Results $mergeSourceCurrentCommit
            if (-not $mergeSourceCurrentCommit.ExitCode -eq 0)
            {
                Write-Error "Failed to rev parse (exit code was $( $mergeSourceCurrentCommit.ExitCode ))."
            }

            Write-Verbose "Merge (no commit)"
            $mergeResult = Invoke-CustomCommand "git" @("merge", "--no-commit", "--no-ff", "upstream-$branch") $trimmedWorkspace
            Write-Results $mergeResult

            if ($mergeBase.StdOut -eq $mergeSourceCurrentCommit.StdOut)
            {
                Write-Host "No changes found in the upstream repo $templateRepoUrl that do not exist in the downstream repo $url for project `"$name`" in space $spaceName"
            }
            elseif (-not $mergeResult.ExitCode -eq 0)
            {
                Write-Warning "Changes between upstream repo $templateRepoUrl conflict with changes in downstream repo $url for project `"$name`" in space $spaceName."
                Write-Verbose "To resolve the conflicts, run the following commands:"
                Write-Verbose "mkdir cac"
                Write-Verbose "cd cac"
                Write-Verbose "git clone $url ."
                Write-Verbose "git remote add upstream $templateRepoUrl"
                Write-Verbose "git fetch --all"
                Write-Verbose "git checkout -b upstream-$branch upstream/$branch"
                if (-not($branch -eq "master" -or $branch -eq "main"))
                {
                    Write-Verbose "git checkout -b $branch origin/$branch"
                }
                else
                {
                    Write-Verbose "git checkout $branch"
                    Write-Verbose "git merge-base $branch upstream-$branch"
                    Write-Verbose "git merge --no-commit --no-ff upstream-$branch"
                }
            }
            else
            {
                # https://stackoverflow.com/a/76272919
                # How to commit a merge non-interactively
                Write-Verbose "Git commit"
                $mergeContinue = Invoke-CustomCommand "git" @("commit", "--no-edit") $trimmedWorkspace
                Write-Results $mergeContinue
                if (-not $mergeContinue.ExitCode -eq 0)
                {
                    Write-Error "Failed to merge continue (exit code was $( $mergeContinue.ExitCode ))."
                }

                $diffResult = Invoke-CustomCommand "git" @("diff", "--quiet", "--exit-code", "@{upstream}") $trimmedWorkspace
                Write-Results $diffResult

                if (-not $diffResult.ExitCode -eq 0)
                {
                    $pushResult = Invoke-CustomCommand "git" @("push", "origin") $trimmedWorkspace
                    Write-Results $pushResult

                    if ($pushResult.ExitCode -eq 0)
                    {
                        Write-Host "Changes were merged between upstream repo $templateRepoUrl and downstream repo $url for project `"$name`" in space $spaceName."
                    }
                    else
                    {
                        Write-Warning "Failed to push changes to downstream repo $url for project `"$name`" in space $spaceName (exit code $( $pushResult.ExitCode ))."
                    }
                }
                else
                {
                    Write-Host "No changes found in the upstream repo $templateRepoUrl that do not exist in the downstream repo $url for project `"$name`" in space $spaceName"
                }
            }
        }
        else
        {
            Write-Verbose "`"$name`" is not a CaC project"
        }
    }
}

Provided under the Apache License version 2.0.

Report an issue

To use this template in Octopus Deploy, copy the JSON below and paste it into the Library → Step templates → Import dialog.

{
  "Id": "c2536053-024f-499e-bf14-7e55c5a675d0",
  "Name": "Octopus - Merge CaC Updates (S3 Backend)",
  "Description": "This step queries each workspace in the Terraform state for downstream Octopus CaC enabled projects, extracts the Git repo associated with the CaC project, and merges any changes so long as there are no merge conflicts.\n\nIf there is a merge conflict between the upstream and downstream repos, instructions for manually resolving the conflict are provided.",
  "Version": 4,
  "ExportedAt": "2023-11-17T01:19:30.474Z",
  "ActionType": "Octopus.AwsRunScript",
  "Author": "mcasperson",
  "Packages": [],
  "Parameters": [
    {
      "Id": "5abfbf98-22f0-47a2-8dd8-f16e2227d4ba",
      "Name": "FindConflicts.Octopus.Spaces",
      "Label": "Octopus Spaces",
      "HelpText": "An optional newline-separated list of space names with projects to merge changes into. Leave this field blank to merge changes to projects in all spaces.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "MultiLineText"
      }
    },
    {
      "Id": "56b85050-7c06-4a84-9565-88730a4859dc",
      "Name": "FindConflicts.Octopus.Projects",
      "Label": "Octopus Projects",
      "HelpText": "A newline-separated list of projects to merge changes into. Leave this field blank to merge changes to all projects.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "MultiLineText"
      }
    },
    {
      "Id": "539dc165-d880-4bf2-99c2-7024f36f7593",
      "Name": "FindConflicts.Git.Credentials.Username",
      "Label": "Git Username",
      "HelpText": "The git repo username. When using GitHub with an access token, the value is `x-access-token`.",
      "DefaultValue": "x-access-token",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "7cf2352e-7381-485d-994c-ff128ee0fe8b",
      "Name": "FindConflicts.Git.Credentials.Password",
      "Label": "Git Password",
      "HelpText": "The git repo password or access token.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Sensitive"
      }
    },
    {
      "Id": "3887173c-a7f9-4311-848f-6f82736df2ce",
      "Name": "FindConflicts.Git.Url.Protocol",
      "Label": "Git Protocol",
      "HelpText": "The git repo protocol.",
      "DefaultValue": "https",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "https|HTTPS\nhttp|HTTP"
      }
    },
    {
      "Id": "0781245e-3f02-4d5b-a2ad-68c5f6270cb5",
      "Name": "FindConflicts.Git.Url.Host",
      "Label": "Git Hostname",
      "HelpText": "The git repo host name.",
      "DefaultValue": "github.com",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "7891f539-752e-4933-abcc-68db8ba9d114",
      "Name": "FindConflicts.Git.Url.Organization",
      "Label": "Git Organization",
      "HelpText": "The git repo owner or organization i.e. `owner` in the url `https://github.com/owner/repo`.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "e5a6f5a3-fec6-47dd-ad46-82ed7df409f7",
      "Name": "FindConflicts.Git.Url.Template",
      "Label": "Git Template Repo",
      "HelpText": "The repo holding the upstream, or template, CaC project i.e. `repo` in the url `https://github.com/owner/repo`.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "d8abeacf-b392-4a9e-b95f-50d0d033819f",
      "Name": "FindConflicts.Terraform.Backend.S3Region",
      "Label": "AWS Region",
      "HelpText": "The AWS region hosting the S3 bucket persisting the Terraform state.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "c9f76f2e-9c8b-4af2-9768-6449526d52ca",
      "Name": "FindConflicts.Terraform.Backend.S3Key",
      "Label": "S3 Key",
      "HelpText": "The name of the file in the S3 bucket hosting the Terraform state.",
      "DefaultValue": "Project_#{Octopus.Project.Name | Replace \"[^A-Za-z0-9]\" \"_\"}",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "fa261df0-01dc-4310-ad1e-1b07c3145e41",
      "Name": "FindConflicts.Terraform.Backend.S3Bucket",
      "Label": "S3 Bucket",
      "HelpText": "The name of the S3 bucket hosting the Terraform state.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "00d184f8-287f-46e4-8848-282138484cfa",
      "Name": "FindConflicts.Terraform.Aws.Account",
      "Label": "AWS Account",
      "HelpText": "The AWS account used to access the S3 bucket.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "AmazonWebServicesAccount"
      }
    }
  ],
  "Properties": {
    "Octopus.Action.Script.ScriptSource": "Inline",
    "Octopus.Action.Script.Syntax": "PowerShell",
    "Octopus.Action.Aws.AssumeRole": "False",
    "Octopus.Action.AwsAccount.UseInstanceRole": "False",
    "OctopusUseBundledTooling": "False",
    "Octopus.Action.Script.ScriptBody": "# Check to see if $IsWindows is available\nif ($null -eq $IsWindows)\n{\n    Write-Host \"Determining Operating System...\"\n    $IsWindows = ([System.Environment]::OSVersion.Platform -eq \"Win32NT\")\n    $IsLinux = ([System.Environment]::OSVersion.Platform -eq \"Unix\")\n}\n\nFunction Get-GitExecutable\n{\n    # Define parameters\n    param (\n        $WorkingDirectory\n    )\n\n    # Define variables\n    $gitExe = \"PortableGit-2.41.0.3-64-bit.7z.exe\"\n    $gitDownloadUrl = \"https://github.com/git-for-windows/git/releases/download/v2.41.0.windows.3/$gitExe\"\n    $gitDownloadArguments = @{ }\n    $gitDownloadArguments.Add(\"Uri\", $gitDownloadUrl)\n    $gitDownloadArguments.Add(\"OutFile\", \"$WorkingDirectory/git/$gitExe\")\n\n    # This makes downloading faster\n    $ProgressPreference = 'SilentlyContinue'\n\n    # Check to see if git subfolder exists\n    if ((Test-Path -Path \"$WorkingDirectory/git\") -eq $false)\n    {\n        # Create subfolder\n        New-Item -Path \"$WorkingDirectory/git\"  -ItemType Directory | Out-Null\n    }\n\n    # Check PowerShell version\n    if ($PSVersionTable.PSVersion.Major -lt 6)\n    {\n        # Use basic parsing is required\n        $gitDownloadArguments.Add(\"UseBasicParsing\", $true)\n    }\n\n    # Download Git\n    Write-Host \"Downloading Git ...\"\n    Invoke-WebRequest @gitDownloadArguments\n\n    # Extract Git\n    $gitExtractArguments = @()\n    $gitExtractArguments += \"-o\"\n    $gitExtractArguments += \"$WorkingDirectory\\git\"\n    $gitExtractArguments += \"-y\"\n    $gitExtractArguments += \"-bd\"\n\n    Write-Host \"Extracting Git download ...\"\n    & \"$WorkingDirectory\\git\\$gitExe\" $gitExtractArguments\n\n    # Wait until unzip action is complete\n    while ($null -ne (Get-Process | Where-Object { $_.ProcessName -eq ($gitExe.Substring(0,$gitExe.LastIndexOf(\".\"))) }))\n    {\n        Start-Sleep 5\n    }\n\n    # Add bin folder to path\n    $env:PATH = \"$WorkingDirectory\\git\\bin$( [IO.Path]::PathSeparator )\" + $env:PATH\n\n    # Disable promopt for credential helper\n    Invoke-CustomCommand \"git\" @(\"config\", \"--system\", \"--unset\", \"credential.helper\") | Write-Results\n}\n\nFunction Invoke-CustomCommand\n{\n    Param (\n        $commandPath,\n        $commandArguments,\n        $workingDir = (Get-Location),\n        $path = @(),\n        $envVars = @{ }\n    )\n\n    $path += $env:PATH\n    $newPath = $path -join [IO.Path]::PathSeparator\n\n    $pinfo = New-Object System.Diagnostics.ProcessStartInfo\n    $pinfo.FileName = $commandPath\n    $pinfo.WorkingDirectory = $workingDir\n    $pinfo.RedirectStandardError = $true\n    $pinfo.RedirectStandardOutput = $true\n    $pinfo.UseShellExecute = $false\n    $pinfo.Arguments = $commandArguments\n    $pinfo.EnvironmentVariables[\"PATH\"] = $newPath\n\n    foreach ($env in $envVars.Keys)\n    {\n        Write-Verbose \"Setting $env to $( $envVars.$env )\"\n        $pinfo.EnvironmentVariables.Add($env,$envVars.$env.ToString())\n    }\n\n    $p = New-Object System.Diagnostics.Process\n    $p.StartInfo = $pinfo\n    $p.Start() | Out-Null\n\n    # Capture output during process execution so we don't hang\n    # if there is too much output.\n    # Microsoft documents a C# solution here:\n    # https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.redirectstandardoutput?view=net-7.0&redirectedfrom=MSDN#remarks\n    # This code is based on https://stackoverflow.com/a/74748844\n    $stdOut = [System.Text.StringBuilder]::new()\n    $stdErr = [System.Text.StringBuilder]::new()\n    do\n    {\n        if (!$p.StandardOutput.EndOfStream)\n        {\n            $stdOut.AppendLine($p.StandardOutput.ReadLine())\n        }\n        if (!$p.StandardError.EndOfStream)\n        {\n            $stdErr.AppendLine($p.StandardError.ReadLine())\n        }\n\n        Start-Sleep -Milliseconds 10\n    }\n    while (-not $p.HasExited)\n\n    # Capture any standard output generated between our last poll and process end.\n    while (!$p.StandardOutput.EndOfStream)\n    {\n        $stdOut.AppendLine($p.StandardOutput.ReadLine())\n    }\n\n    # Capture any error output generated between our last poll and process end.\n    while (!$p.StandardError.EndOfStream)\n    {\n        $stdErr.AppendLine($p.StandardError.ReadLine())\n    }\n\n    $p.WaitForExit()\n\n    $executionResults = [pscustomobject]@{\n        StdOut = $stdOut.ToString()\n        StdErr = $stdErr.ToString()\n        ExitCode = $p.ExitCode\n    }\n\n    return $executionResults\n\n}\n\nfunction Write-Results\n{\n    [cmdletbinding()]\n    param (\n        [Parameter(Mandatory = $True, ValuefromPipeline = $True)]\n        $results\n    )\n\n    if (![String]::IsNullOrWhiteSpace($results.StdOut))\n    {\n        Write-Verbose $results.StdOut\n    }\n\n    if (![String]::IsNullOrWhiteSpace($results.StdErr))\n    {\n        Write-Verbose $results.StdErr\n    }\n}\n\nfunction Format-StringAsNullOrTrimmed {\n    [cmdletbinding()]\n    param (\n        [Parameter(ValuefromPipeline=$True)]\n        $input\n    )\n\n    if ([string]::IsNullOrWhitespace($input)) {\n        return $null\n    }\n\n    return $input.Trim()\n}\n\nfunction Write-TerraformBackend\n{\n    Set-Content -Path 'backend.tf' -Value @\"\nterraform {\n        backend \"s3\" {}\n        required_providers {\n          octopusdeploy = { source = \"OctopusDeployLabs/octopusdeploy\", version = \">= 0.21.1\" }\n        }\n    }\n\"@\n}\n\nfunction Set-GitContactDetails\n{\n    $gitEmail = Invoke-CustomCommand \"git\" @(\"config\", \"--global\", \"user.email\", \"octopus@octopus.com\")\n    Write-Results $gitEmail\n    if (-not $gitEmail.ExitCode -eq 0)\n    {\n        Write-Error \"Failed to set the git email address (exit code was $( $gitEmail.ExitCode )).\"\n    }\n\n    $gitUser = Invoke-CustomCommand \"git\" @(\"config\", \"--global\", \"user.name\", \"Octopus Server\")\n    Write-Results $gitUser\n    if (-not $gitUser.ExitCode -eq 0)\n    {\n        Write-Error \"Failed to set the git name (exit code was $( $gitUser.ExitCode )).\"\n    }\n}\n\n$spaceFilter = if (-not [string]::IsNullOrWhitespace($OctopusParameters[\"FindConflicts.Octopus.Spaces\"]))\n{\n    $OctopusParameters[\"FindConflicts.Octopus.Spaces\"].Split(\"`n\")\n}\nelse\n{\n    @()\n}\n$projectFilter = if (-not [string]::IsNullOrWhitespace($OctopusParameters[\"FindConflicts.Octopus.Projects\"]))\n{\n    $OctopusParameters[\"FindConflicts.Octopus.Projects\"].Split(\"`n\")\n}\nelse\n{\n    @()\n}\n$username = $OctopusParameters[\"FindConflicts.Git.Credentials.Username\"]\n$password = $OctopusParameters[\"FindConflicts.Git.Credentials.Password\"]\n$protocol = $OctopusParameters[\"FindConflicts.Git.Url.Protocol\"]\n$gitHost = $OctopusParameters[\"FindConflicts.Git.Url.Host\"]\n$org = $OctopusParameters[\"FindConflicts.Git.Url.Organization\"]\n$repo = $OctopusParameters[\"FindConflicts.Git.Url.Template\"]\n$region = $OctopusParameters[\"FindConflicts.Terraform.Backend.S3Region\"]\n$key = $OctopusParameters[\"FindConflicts.Terraform.Backend.S3Key\"]\n$bucket = $OctopusParameters[\"FindConflicts.Terraform.Backend.S3Bucket\"]\n\nif ([string]::IsNullOrWhitespace($username))\n{\n    Write-Error \"The FindConflicts.Git.Credentials.Username variable must be provided\"\n}\n\nif ([string]::IsNullOrWhitespace($password))\n{\n    Write-Error \"The FindConflicts.Git.Credentials.Password variable must be provided\"\n}\n\nif ( [string]::IsNullOrWhitespace($protocol))\n{\n    Write-Error \"The FindConflicts.Git.Url.Protocol variable must be defined.\"\n}\n\nif ( [string]::IsNullOrWhitespace($gitHost))\n{\n    Write-Error \"The FindConflicts.Git.Url.Host variable must be defined.\"\n}\n\nif ( [string]::IsNullOrWhitespace($repo))\n{\n    Write-Error \"The FindConflicts.Git.Url.Template variable must be defined.\"\n}\n\nif ( [string]::IsNullOrWhitespace($region))\n{\n    Write-Error \"The FindConflicts.Terraform.Backend.S3Region variable must be defined.\"\n}\n\nif ( [string]::IsNullOrWhitespace($key))\n{\n    Write-Error \"The FindConflicts.Terraform.Backend.S3Key variable must be defined.\"\n}\n\nif ( [string]::IsNullOrWhitespace($bucket))\n{\n    Write-Error \"The FindConflicts.Terraform.Backend.S3Bucket variable must be defined.\"\n}\n\n$templateRepoUrl = $protocol + \"://\" + $gitHost + \"/\" + $org + \"/\" + $repo + \".git\"\n$templateRepo = $protocol + \"://\" + $username + \":\" + $password + \"@\" + $gitHost + \"/\" + $org + \"/\" + $repo + \".git\"\n$branch = \"main\"\n\n# Check to see if it's Windows\nif ($IsWindows -and $OctopusParameters['Octopus.Workerpool.Name'] -eq \"Hosted Windows\")\n{\n    # Dynamic worker don't have git, download portable version and add to path for execution\n    Write-Host \"Detected usage of Windows Dynamic Worker ...\"\n    Get-GitExecutable -WorkingDirectory $PWD\n}\n\nWrite-TerraformBackend\nSet-GitContactDetails\n\nInvoke-CustomCommand \"terraform\" @(\"init\", \"-no-color\", \"-backend-config=`\"bucket=$bucket`\"\", \"-backend-config=`\"region=$region`\"\", \"-backend-config=`\"key=$key`\"\") | Write-Results\n\nWrite-Host \"Verbose logs contain instructions for resolving merge conflicts.\"\n\n$workspaces = Invoke-CustomCommand \"terraform\" @(\"workspace\", \"list\")\n\nWrite-Results $workspaces\n\n$parsedWorkspaces = $workspaces.StdOut.Replace(\"*\", \"\").Split(\"`n\")\n\nforeach ($workspace in $parsedWorkspaces)\n{\n    $trimmedWorkspace = $workspace | Format-StringAsNullOrTrimmed\n\n    if ($trimmedWorkspace -eq \"default\" -or [string]::IsNullOrWhitespace($trimmedWorkspace))\n    {\n        continue\n    }\n\n    Write-Verbose \"Processing workspace $trimmedWorkspace\"\n\n    Invoke-CustomCommand \"terraform\" @(\"workspace\", \"select\", $trimmedWorkspace) | Write-Results\n\n    $state = Invoke-CustomCommand \"terraform\" @(\"show\", \"-json\")\n\n    # state might include sensitive values, so don't print it unless there was an error\n\n    if (-not $state.ExitCode -eq 0)\n    {\n        Write-Results $state\n        continue\n    }\n\n    $parsedState = $state.StdOut | ConvertFrom-Json\n\n    $resources = $parsedState.values.root_module.resources | Where-Object {\n        $_.type -eq \"octopusdeploy_project\"\n    }\n\n    # The outputs allow us to contact the downstream instance)\n    $spaceName = (Invoke-CustomCommand \"terraform\" @(\"output\", \"-raw\", \"octopus_space_name\")).StdOut | Format-StringAsNullOrTrimmed\n\n    foreach ($resource in $resources)\n    {\n        $url = $resource.values.git_library_persistence_settings.url | Format-StringAsNullOrTrimmed\n        $name = $resource.values.name | Format-StringAsNullOrTrimmed\n\n        # Optional filtering\n        if (-not($spaceFilter.Count -eq 0 -or $spaceFilter.Contains($spaceName)))\n        {\n            continue\n        }\n\n        if (-not($projectFilter.Count -eq 0 -or $projectFilter.Contains($name)))\n        {\n            continue\n        }\n\n        if (-not [string]::IsNullOrWhitespace($url))\n        {\n            mkdir $trimmedWorkspace | Out-Null\n\n            $parsedUrl = [System.Uri]$url\n            $urlWithCreds = $parsedUrl.Scheme + \"://\" + $username + \":\" + $password + \"@\" + $parsedUrl.Host + \":\" + $parsedUrl.Port + $parsedUrl.AbsolutePath\n\n            Write-Verbose \"Cloning repo\"\n            $cloneRepo = Invoke-CustomCommand \"git\" @(\"clone\", $urlWithCreds, $trimmedWorkspace)\n            Write-Results $cloneRepo\n            if (-not $cloneRepo.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to clone repo (exit code was $( $cloneRepo.ExitCode )).\"\n            }\n\n            Write-Verbose \"Cloning upstream remote\"\n            $addRemote = Invoke-CustomCommand \"git\" @(\"remote\", 'add', 'upstream', $templateRepo) $trimmedWorkspace\n            Write-Results $addRemote\n            if (-not $addRemote.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to clone repo (exit code was $( $addRemote.ExitCode )).\"\n            }\n\n            Write-Verbose \"Fetching all\"\n            $fetchAll = Invoke-CustomCommand \"git\" @(\"fetch\", \"--all\") $trimmedWorkspace\n            Write-Results $fetchAll\n            if (-not $fetchAll.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to fetch all (exit code was $( $fetchAll.ExitCode )).\"\n            }\n\n            Write-Verbose \"Checking out upstream-$branch upstream/$branch\"\n            $checkoutUpstream = Invoke-CustomCommand \"git\" @(\"checkout\", \"-b\", \"upstream-$branch\", \"upstream/$branch\") $trimmedWorkspace\n            Write-Results $checkoutUpstream\n            if (-not $checkoutUpstream.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to checkout upstream (exit code was $( $checkoutUpstream.ExitCode )).\"\n            }\n\n            if (-not($branch -eq \"master\" -or $branch -eq \"main\"))\n            {\n                Write-Verbose \"Checking out $branch origin/$branch\"\n                $checkoutDownstream = Invoke-CustomCommand \"git\" @(\"checkout\", \"-b\", $branch, \"origin/$branch\") $trimmedWorkspace\n            }\n            else\n            {\n                Write-Verbose \"Checking out $branch\"\n                $checkoutDownstream = Invoke-CustomCommand \"git\" @(\"checkout\", $branch) $trimmedWorkspace\n            }\n\n            Write-Results $checkoutDownstream\n            if (-not $checkoutDownstream.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to checkout downstream (exit code was $( $checkoutDownstream.ExitCode )).\"\n            }\n\n            Write-Verbose \"Merge base\"\n            $mergeBase = Invoke-CustomCommand \"git\" @(\"merge-base\", $branch, \"upstream-$branch\") $trimmedWorkspace\n            Write-Results $mergeBase\n            if (-not $mergeBase.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to merge base (exit code was $( $mergeBase.ExitCode )).\"\n            }\n\n            Write-Verbose \"Rev parse\"\n            $mergeSourceCurrentCommit = Invoke-CustomCommand \"git\" @(\"rev-parse\", \"upstream-$branch\") $trimmedWorkspace\n            Write-Results $mergeSourceCurrentCommit\n            if (-not $mergeSourceCurrentCommit.ExitCode -eq 0)\n            {\n                Write-Error \"Failed to rev parse (exit code was $( $mergeSourceCurrentCommit.ExitCode )).\"\n            }\n\n            Write-Verbose \"Merge (no commit)\"\n            $mergeResult = Invoke-CustomCommand \"git\" @(\"merge\", \"--no-commit\", \"--no-ff\", \"upstream-$branch\") $trimmedWorkspace\n            Write-Results $mergeResult\n\n            if ($mergeBase.StdOut -eq $mergeSourceCurrentCommit.StdOut)\n            {\n                Write-Host \"No changes found in the upstream repo $templateRepoUrl that do not exist in the downstream repo $url for project `\"$name`\" in space $spaceName\"\n            }\n            elseif (-not $mergeResult.ExitCode -eq 0)\n            {\n                Write-Warning \"Changes between upstream repo $templateRepoUrl conflict with changes in downstream repo $url for project `\"$name`\" in space $spaceName.\"\n                Write-Verbose \"To resolve the conflicts, run the following commands:\"\n                Write-Verbose \"mkdir cac\"\n                Write-Verbose \"cd cac\"\n                Write-Verbose \"git clone $url .\"\n                Write-Verbose \"git remote add upstream $templateRepoUrl\"\n                Write-Verbose \"git fetch --all\"\n                Write-Verbose \"git checkout -b upstream-$branch upstream/$branch\"\n                if (-not($branch -eq \"master\" -or $branch -eq \"main\"))\n                {\n                    Write-Verbose \"git checkout -b $branch origin/$branch\"\n                }\n                else\n                {\n                    Write-Verbose \"git checkout $branch\"\n                    Write-Verbose \"git merge-base $branch upstream-$branch\"\n                    Write-Verbose \"git merge --no-commit --no-ff upstream-$branch\"\n                }\n            }\n            else\n            {\n                # https://stackoverflow.com/a/76272919\n                # How to commit a merge non-interactively\n                Write-Verbose \"Git commit\"\n                $mergeContinue = Invoke-CustomCommand \"git\" @(\"commit\", \"--no-edit\") $trimmedWorkspace\n                Write-Results $mergeContinue\n                if (-not $mergeContinue.ExitCode -eq 0)\n                {\n                    Write-Error \"Failed to merge continue (exit code was $( $mergeContinue.ExitCode )).\"\n                }\n\n                $diffResult = Invoke-CustomCommand \"git\" @(\"diff\", \"--quiet\", \"--exit-code\", \"@{upstream}\") $trimmedWorkspace\n                Write-Results $diffResult\n\n                if (-not $diffResult.ExitCode -eq 0)\n                {\n                    $pushResult = Invoke-CustomCommand \"git\" @(\"push\", \"origin\") $trimmedWorkspace\n                    Write-Results $pushResult\n\n                    if ($pushResult.ExitCode -eq 0)\n                    {\n                        Write-Host \"Changes were merged between upstream repo $templateRepoUrl and downstream repo $url for project `\"$name`\" in space $spaceName.\"\n                    }\n                    else\n                    {\n                        Write-Warning \"Failed to push changes to downstream repo $url for project `\"$name`\" in space $spaceName (exit code $( $pushResult.ExitCode )).\"\n                    }\n                }\n                else\n                {\n                    Write-Host \"No changes found in the upstream repo $templateRepoUrl that do not exist in the downstream repo $url for project `\"$name`\" in space $spaceName\"\n                }\n            }\n        }\n        else\n        {\n            Write-Verbose \"`\"$name`\" is not a CaC project\"\n        }\n    }\n}",
    "Octopus.Action.Aws.Region": "#{FindConflicts.Terraform.Backend.S3Region}",
    "Octopus.Action.AwsAccount.Variable": "#{FindConflicts.Terraform.Aws.Account}"
  },
  "Category": "Octopus",
  "HistoryUrl": "https://github.com/OctopusDeploy/Library/commits/master/step-templates//opt/buildagent/work/75443764cd38076d/step-templates/octopus-merge-cac-updates.json",
  "Website": "/step-templates/c2536053-024f-499e-bf14-7e55c5a675d0",
  "Logo": "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAC1QTFRFT6Tl////L5Pg8vj9Y67omsvwPJrisdfzfbzs5fL7y+T32Ov5isLucLXqvt31CJPHWwAABMJJREFUeNrs3deW4jAMAFDF3U75/89dlp0ZhiU4blJEjvQ8hYubLJsA00UCBCIQgQhEIAIRiEAEIhCBCEQgAhGIQAQiEIEIhD8kJm+t+QprfdKfB9HbYpx6CWfspj8HMi+gMgHL/AmQA8W3JTKH+ALFvzCeL0RbpyoCPE9IJeNOSQwh5Z3qd6yRGWQ2qi2cZQWxqj1WzQYSjeoJmJlAklOd4VlArOqPhQEkqBERToeMcfRJBkC0Uep8CfBpjz4JsHJ0zF3dkEWNje0kiB/sUC6eApndaIiCMyAa1PiwJ0AWhRGJHJJQHG2dC7h1rNbO1QOxSA7lNCkkKrQIpJCAB1GREILYIC1NAiwbpKFJgGWDNExcwGstfExcZBCHC6nOglshHtmhViLIig1RNBCN7qjtW8C0Z1UvJcC1Z9XmwMBzzvobmgAyEzgq91dtEEsBsQSQQAFZCSBAATEEEApHZbrVBIkkEIUPSVeB+KtALA0kXQUSrwKZBCIQBnk8Y4i5CsReBeKvkqLM+BCSDWJlrZFvGk9SRTHshkgjZCGAaArIxm3H3grhVzFlW2msfl1ca79UJ1bofYvsDHHlNdTZnlh5MghuPd5NdBDUNZHyCkfktIh03XzALGRPlBDPac7qgWjHZzWcmF5zmmkhidMQ6boKiDXcDTUEaylZqCGJ0Vjvu/fLJtHqhSANEvqb2OYqkOUqEHuVMbJcZdZCGiPhKhC4yjqiIjEE7XThMp8fAWII3mY3kUIQD+AMKQTzPiBhgQ63HlT/KSvgtoi0dq5mCPah1UIE0eh3sT0NhOByvKeAkFzi8PgQomumFhsyOxpIzZN4gLOj5plVwNpR0b2AuePWKBEHQu24pSsJA+LVCeHHQxZ1SiyDIdqok8IOhSSnTottHEQTdyt4ettAj4KkzA4dMikk2Dht2S5ptm1vswnPDxn0YyDZ5oDM3iToo2T5voWaYe+Q+vdjH80QyAzZhCgcDtLMI1Tmtz9w++XHgziHQHJJu/OZ3bs9Xn8gQ72NcP3dKqEfkp10F51xhoIi2I91R+LurXV/5q7pH+wx061CzO16oSQleMyr8fXvwMA0Pro8432DPD/ySx8XrHfSuDAM8n6UhnjQabaiXf5Bq/lREHvEeNtn1rJ08+C/uXkQZHeguxAPC3UvtcJYUogLzZX5hhZZvS6onG5lxXtzWGaygwb79vT/IXhdlNibwlKYOR6T8xjI7W8n+xV7T+GH4tMzWwR+lZhRkJYSsC0thpmCYqyngOz3rN2FLBZ2wZflBCggUHF0Vnp88JKienzIXLSEZCZqU7IKr/gQW9yx3pzV7Y9kvWZWTRRIqDmTtRUnU7b2lLcTYmoqHqnmiO1poER0SPkAeZMAZxaJx0Y3TCdAclsIqDz03ALcyxfTCZBsthoGXWmigGyVhWPLFJJfuuKQWycoEFdXbH4dJJoJxNR1eD/kshz6yn48cF8yW8sFoitflB1w6Q8n+/15Za7oA17/pYNmYgP5fmWm8L1NOHPWgK8kuFew1/JXtOA0yJCv7ah7X8ObUuT5kObU30+fDZm8+zqP+HTIpK0xQ796b5Kv2hSIQAQiEIEIRCACEYhABCIQgQhEIAIRiEAEIpBf8UeAAQAEjtYmlDTcCgAAAABJRU5ErkJggg==",
  "$Meta": {
    "Type": "ActionTemplate"
  }
}

History

Page updated on Friday, November 17, 2023