grate Database Migrations

Octopus.Script exported 2022-10-17 by farhanalam belongs to ‘Grate’ category.

Database migrations using grate. With this template you can either include grate with your package or use the Download grate? feature to download it at deploy time. If you’re downloading, you can choose the version by specifying it in the Version of grate.

NOTE:

  • AWS EC2 IAM Role authentication requires the AWS CLI be installed.
  • To run on Linux, the machine must have both PowerShell Core and .NET Core 3.1 installed.

Parameters

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

grate Package

gratePackage =

The package containing the scripts for grate to deploy.

Database Server Name

grateServerName =

Name or IP address of the server being deployed to.

Database Server Port

grateServerPort =

Port number for the database server. Uses default server port if left blank.

Authentication Method

grateAuthenticationMethod = usernamepassword

Method used to authenticate to the database server.

Database Name

grateDatabaseName =

Name of the database to deploy to.

Force SSL

grateSsl =

Check this box for force connection string to use SSL. Only applicable to MariaDB, MySQL, SQL Server, and PostgreSQL database types.

Database Version

grateVersion = #{Octopus.Action.Package[gratePackage].PackageVersion}

Version number of your database migration. Default value is the version of the grate package.

Database Username

grateUsername =

Username of the account with sufficient permissions to execute scripts. (Leave blank for Integrated Authentication.)

Database User Password

grateUserPassword =

Password for the Database Username account.

Database Server Type

grateDatabaseServerType =

The database technology being deployed to.

Use Transaction?

grateWithTransaction = False

Check this box if you want all scripts to be run within the same transaction.

Dry Run?

grateDryRun = False

Check this box if you want to perform a dry run. Results are recorded and attached as deployment artifacts if you check Record Output.

Record Output?

grateRecordOutput = False

Check this box to record the output of the run. Useful for gathering what would be changed for approval purposes.

Command Timeout

grateCommandTimeout = 60

Customizable command timeout (in seconds). Default is 60

Use Baseline

grateBaseline = False

Check this box if you want grate to mark the scripts as run, but not to actually run anything against the database. More information about this option can be found here

SQL Script folder

grateSqlScriptFolder =

Script location to use for grate (if not in the root of the package)

Log Level

grateLogVerbosity = information

Configure log level when running Grate.

Download grate?

grateDownloadNuget = False

Check this box if you want the template to download RoundhousE and use the downloaded version for deployment. Requires .NET Core be installed on the machine executing the deployment.

Version of grate

grateNugetVersion =

Version of grate to download (used with Download grate option), leave blank for latest.

Grate environment

grateEnvironment = #{Octopus.Environment.Name}

The environment specific scripts that grate will execute

Grate schema for migration tables

grateSchema = grate

Useful if migrating from RoundhousE.

Script body

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

# Configure template

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

if ($IsWindows)
{
	$ProgressPreference = 'SilentlyContinue'
}

# Define parameters
$grateExecutable = ""
$grateOutputPath = [System.IO.Path]::Combine($OctopusParameters["Octopus.Action.Package[gratePackage].ExtractedPath"], "output")
$grateSsl = [System.Convert]::ToBoolean($grateSsl)

Function Get-LatestVersionDownloadUrl {
    # Define parameters
    param(
        $Repository,
        $Version
    )
    
    # Define local variables
    $releases = "https://api.github.com/repos/$Repository/releases"
    
    # Get latest version
    Write-Host "Determining latest release of $Repository ..."
    
    $tags = (Invoke-WebRequest $releases -UseBasicParsing | ConvertFrom-Json)
    
    if ($null -ne $Version) {
        # Get specific version
        $tags = ($tags | Where-Object { $_.tag_name.EndsWith($Version) })

        # Check to see if nothing was returned
        if ($null -eq $tags) {
            # Not found
            Write-Host "No release found matching version $Version, getting highest version using Major.Minor syntax..."

            # Get the tags
            $tags = (Invoke-WebRequest $releases -UseBasicParsing | ConvertFrom-Json)

            # Parse the version number into a version object
            $parsedVersion = [System.Version]::Parse($Version)
            $partialVersion = "$($parsedVersion.Major).$($parsedVersion.Minor)"

            # Filter tags to ones matching only Major.Minor of version specified
            $tags = ($tags | Where-Object { $_.tag_name.Contains("$partialVersion.") -and $_.draft -eq $false })
            
            # Grab the latest
            if ($null -eq $tags)
            {
            	# decrement minor version
                $minorVersion = [int]$parsedVersion.Minor
                $minorVersion --
                
                # Check to make sure that minor version isn't negative
                if ($minorVersion -ge 0)
                {
                	# return the urls
                	return (Get-LatestVersionDownloadUrl -Repository $Repository -Version "$($parsedVersion.Major).$($minorVersion)")
                }
                else
                {
                	# Display error
                    Write-Error "Unable to find a version within the major version of $($parsedVersion.Major)!"
                }
            }
        }
    }

    # Find the latest version with a downloadable asset
    foreach ($tag in $tags) {
        if ($tag.assets.Count -gt 0) {
            return $tag.assets.browser_download_url
        }
    }

    # Return the version
    return $null
}

# Change the location to the extract path
Set-Location -Path $OctopusParameters["Octopus.Action.Package[gratePackage].ExtractedPath"]

# Check to see if download is specified
if ([System.Boolean]::Parse($grateDownloadNuget))
{
    # Set secure protocols
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls12
    $downloadUrls = @()

	# Check to see if version number specified
    if ([string]::IsNullOrWhitespace($grateNugetVersion))
    {
    	# Get the latest version number
        $downloadUrls = Get-LatestVersionDownloadUrl -Repository "erikbra/grate"
    }
    else
    {
    	# Get specific version
        $downloadUrls = Get-LatestVersionDownloadUrl -Repository "erikbra/grate" -Version $grateNugetVersion
    }

	# Check to make sure something was returned
    if ($null -ne $downloadUrls -and $downloadUrls.Length -gt 0)
	{
    
      # Check for download folder
      if ((Test-Path -Path "$PSSCriptRoot/grate") -eq $false)
      {
          # Create the folder
          New-Item -ItemType Directory -Path "$PSSCriptRoot/grate"
      }

      # Get URL of grate-dotnet-tool
      $downloadUrl = $downloadUrls | Where-Object {$_.Contains("grate-dotnet-tool")}
      
      # Check to see if something was returned
      if ($null -eq $downloadUrl)
      {
      	# Attempt to get nuget package
        Write-Host "An asset with grate-dotnet-tool was not found, attempting to locate nuget package ..."
        $downloadUrl = $downloadUrls | Where-Object {$_.Contains(".nupkg")}
        
        # Check to see if something was returned
        if ($null -eq $downloadUrl)
        {
        	Write-Error "Unable to find appropriate asset for download."
        }
      }

      # Download nuget package
      Write-Output "Downloading $downloadUrl ..."

      # Get download file name
      $downloadFile = $downloadUrl.Substring($downloadUrl.LastIndexOf("/") + 1)

      # Download the file
      Invoke-WebRequest -Uri $downloadUrl -OutFile "$PSSCriptRoot/grate/$downloadFile"

      # Check the extension
      if ($downloadFile.EndsWith(".zip"))
      {
          # Extract the file
          Write-Host "Extracting $downloadFile ..."
          Expand-Archive -Path "$PSSCriptRoot/grate/$downloadFile" -Destination "$PSSCriptRoot/grate"

          # Delete the downloaded .zip
          Remove-Item -Path "$PSSCriptRoot/grate/$downloadFile"

          # Get extracted files
          $extractedFiles = Get-ChildItem -Path "$PSSCriptRoot/grate"

          # Check to see if what was extracted was simply a nuget file
          if ($extractedFiles.Count -eq 1 -and $extractedFiles[0].Extension -eq ".nupkg")
          {
              # Zip file contained a nuget package </facepalm>
              Write-Host "Archive contained a NuGet package, extracting package ..."
              $nugetPackage = $extractedFiles[0]
              $nugetPackage | Rename-Item -NewName $nugetPackage.Name.Replace(".nupkg", ".zip")
              Expand-Archive -Path $nugetPackage.FullName.Replace(".nupkg", ".zip") -Destination "$PSSCriptRoot/grate"
          }
      }

      if ($downloadFile.EndsWith(".nupkg"))
      {
          # Zip file contained a nuget package </facepalm>
          $nugetPackage = Get-ChildItem -Path "$PSSCriptRoot/grate/$($downloadFile)"
          $nugetPackage | Rename-Item -NewName $nugetPackage.Name.Replace(".nupkg", ".zip")
          Expand-Archive -Path "$PSSCriptRoot/grate/$($downloadFile.Replace(".nupkg", ".zip"))" -Destination "$PSSCriptRoot/grate"    
      }
    }
    else
    {
    	Write-Error "No download url returned!"
    }
}



if ([string]::IsNullOrWhitespace($grateExecutable))
{
	# Look for just grate.dll
    $grateExecutable = Get-ChildItem -Path $PSSCriptRoot -Recurse | Where-Object {$_.Name -eq "grate.dll"}
    
    # Check for multiple results
    if ($grateExecutable -is [array])
    {
        # choose one that matches highest version of .net
		$dotnetVersions = (dotnet --list-runtimes) | Where-Object {$_ -like "*.NetCore*"}

		$maxVersion = $null
		foreach ($dotnetVersion in $dotnetVersions)
		{
    		$parsedVersion = $dotnetVersion.Split(" ")[1]
    		if ($null -eq $maxVersion -or [System.Version]::Parse($parsedVersion) -gt [System.Version]::Parse($maxVersion))
    		{
        		$maxVersion = $parsedVersion
    		}
		}
        
        $grateExecutable = $grateExecutable | Where-Object {$_.FullName -like "*net$(([System.Version]::Parse($maxVersion).Major))*"}
    }
}

if ([string]::IsNullOrWhitespace($grateExecutable))
{
    # Couldn't find grate
    Write-Error "Couldn't find the grate executable!"
}

# Build the arguments
$grateSwitches = @()

# Update the connection string based on authentication method
switch ($grateAuthenticationMethod)
{
    "awsiam"
    {
        # Region is part of the RDS endpoint, extract
        $region = ($grateServerName.Split("."))[2]

        Write-Host "Generating AWS IAM token ..."
        $grateUserPassword = (aws rds generate-db-auth-token --hostname $grateServerName --region $region --port $grateServerPort --username $grateUserName)       
        $grateUserInfo = "Uid=$grateUserName;Pwd=$grateUserPassword;"

        break
    }
	
    "azuremanagedidentity"
    {
    	# SQL Server driver doesn't assign password
        if ($grateDatabaseServerType -ne "sqlserver")
        {
          # Get login token
          Write-Host "Generating Azure Managed Identity token ..."
          $token = Invoke-RestMethod -Method GET -Uri "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://ossrdbms-aad.database.windows.net" -Headers @{"MetaData" = "true"}

          $grateUserPassword = $token.access_token
          $grateUserInfo = "Uid=$grateUserName;Pwd=$grateUserPassword;"
        }
        
        break
    }

    "gcpserviceaccount"
    {
        # Define header
        $header = @{ "Metadata-Flavor" = "Google"}

        # Retrieve service accounts
        $serviceAccounts = Invoke-RestMethod -Method Get -Uri "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/" -Headers $header

        # Results returned in plain text format, get into array and remove empty entries
        $serviceAccounts = $serviceAccounts.Split([Environment]::NewLine, [StringSplitOptions]::RemoveEmptyEntries)

        # Retreive the specific service account assigned to the VM
        $serviceAccount = $serviceAccounts | Where-Object {$_.Contains("iam.gserviceaccount.com") }

		Write-Host "Generating GCP IAM token ..."
        # Retrieve token for account
        $token = Invoke-RestMethod -Method Get -Uri "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/$serviceAccount/token" -Headers $header
        $grateUserPassword = $token.access_token
        
        # Append remaining portion of connection string
        $grateUserInfo = "Uid=$grateUserName;Pwd=$grateUserPassword;"
    }


    "usernamepassword"
    {
    	# Append remaining portion of connection string
        $grateUserInfo = "Uid=$grateUserName;Pwd=$grateUserPassword;"

		break    
	}

    "windowsauthentication"
    {
      # Append remaining portion of connection string
	  $grateUserInfo = "integrated security=true;"
      
      # Append username (required for non
      $grateUserInfo += "Uid=$grateUserName;"
    }
    
}

# Configure connnection string based on technology
switch ($grateDatabaseServerType)
{
    "sqlserver"
    {
        # Check to see if port has been defined
        if (![string]::IsNullOrEmpty($grateServerPort))
        {
            # Append to servername
            $grateServerName += ",$grateServerPort"

            # Empty the port
            $grateServerPort = [string]::Empty
        }
    }
    "mariadb"
    {
    	$grateServerPort = "Port=$grateServerPort;Allow User Variables=true;"
    }
    "mysql"
    {
    	# Use the MySQL client
        $grateDatabaseServerType = "mariadb"
        $grateServerPort = "Port=$grateServerPort;Allow User Variables=true;"
    }
    "oracle"
    {
    	# Oracle connection strings are built different than all others
        $grateServerConnectionString = "--connectionstring=`"Data source=$($grateServerName):$($grateServerPort)/$grateDatabaseName;$($grateUserInfo.Replace("Uid", "User Id").Replace("Pwd", "Password")) "
    }
    default
    {
        $grateServerPort = "Port=$grateServerPort;"
    }
}

# Build base connection string
if ([string]::IsNullOrWhitespace($grateServerConnectionString))
{
	$grateServerConnectionString = "--connectionstring=`"Server=$grateServerName;$grateServerPort $grateUserInfo Database=$grateDatabaseName;"
}

# Check for SQL Server and Azure Managed Identity
if (($grateDatabaseServerType -eq "sqlserver") -and ($grateAuthenticationMethod -eq "azuremanagedidentity"))
{
	# Append AD component to connection string
    $grateServerConnectionString += "Authentication=Active Directory Default;"
}

if ($grateSsl -eq $true)
{
	if (($grateDatabaseServerType -eq "mariadb") -or ($grateDatabaseServerType -eq "mysql") -or ($grateDatabaseServerType -eq "postgres"))
    {
    	# Add sslmode
        $grateServerConnectionString += "SslMode=Require;Trust Server Certificate=true;"
    }
    elseif ($grateDatabaseServerType -eq "sqlserver")
    {
    	$grateServerConnectionString += "Trust Server Certificate=true;"
    }
    else
    {
    	Write-Warning "Invalid Database Server Type selection for SSL, ignoring setting."
    }
}

# Add terminating double quote to connection string
$grateServerConnectionString += "`""

$grateSwitches += $grateServerConnectionString

$grateSwitches += "--databasetype=$grateDatabaseServerType"
$grateSwitches += "--silent"

if ([System.Boolean]::Parse($grateDryRun))
{
    $grateSwitches += "--dryrun"
}

if ([System.Boolean]::Parse($grateRecordOutput))
{
    $grateSwitches += "--outputPath=$grateOutputPath"
    
    # Check to see if path exists
    if ((Test-Path -Path $grateOutputPath) -eq $false)
    {
    	# Create folder
        New-Item -Path $grateOutputPath -ItemType "Directory"
    }
}

# Add transaction switch
$grateSwitches += "--transaction=$($grateWithTransaction.ToLower())"

# Add Command Timeout
if (![string]::IsNullOrEmpty($grateCommandTimeout)){
    $grateSwitches += "--commandtimeout=$([int]$grateCommandTimeout)"
}

# Add Baseline switch
if ([System.Boolean]::Parse($grateBaseline)) {
    $grateSwitches += "--baseline"
}

# Add SQL Files Directory parameter
if (![string]::IsNullOrEmpty($grateSqlScriptFolder)) {
    # Add up folder
    $grateSwitches += "--sqlfilesdirectory=$grateSqlScriptFolder"
}

# Add log verbosity flag
if (![string]::IsNullOrEmpty($grateLogVerbosity)) {
    # Add up folder
    $grateSwitches += "--verbosity=$grateLogVerbosity"
}


# Check for version
if (![string]::IsNullOrEmpty($grateVersion))
{
    # Add version
    $grateSwitches += "--version=$grateVersion"
}

# Set grate environment
if (![string]::IsNullOrEmpty($grateEnvironment))
{
    # Add environment
    $grateSwitches += "--environment=$grateEnvironment"
}

# Set grate schema. Especially useful when migrating from RoundhousE
if (![string]::IsNullOrEmpty($grateSchema))
{
    # Add schema
    $grateSwitches += "--schema=$grateSchema"
}

# Display what's going to be run
if (![string]::IsNullOrWhitespace($grateUserPassword))
{
	Write-Host "Executing $($grateExecutable.FullName) with $($grateSwitches.Replace($grateUserPassword, "****"))"
}
else
{
	Write-Host "Executing $($grateExecutable.FullName) with $($grateSwitches)"
}

# Execute grate
if ($grateExecutable.FullName.EndsWith(".dll"))
{
	& dotnet $grateExecutable.FullName $grateSwitches
}
else
{
	& $grateExecutable.FullName $grateSwitches
}

# If the output path was specified, attach artifacts
if ([System.Boolean]::Parse($grateRecordOutput))
{    
    # Zip up output folder content
    Add-Type -Assembly 'System.IO.Compression.FileSystem'
    
    $zipFile = "$($OctopusParameters["Octopus.Action.Package[gratePackage].ExtractedPath"])/output.zip"
    
	[System.IO.Compression.ZipFile]::CreateFromDirectory($grateOutputPath, $zipFile)
    New-OctopusArtifact -Path "$zipFile" -Name "output.zip"
}

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": "ca23d18f-ab03-403d-bfb8-3ff74d3ddab3",
  "Name": "grate Database Migrations",
  "Description": "Database migrations using [grate](https://github.com/erikbra/grate).\nWith this template you can either include grate with your package or use the `Download grate?` feature to download it at deploy time.  If you're downloading, you can choose the version by specifying it in the `Version of grate`.\n\nNOTE: \n - AWS EC2 IAM Role authentication requires the AWS CLI be installed.\n - To run on Linux, the machine must have both PowerShell Core and .NET Core 3.1 installed.",
  "Version": 9,
  "ExportedAt": "2022-10-17T22:47:54.861Z",
  "ActionType": "Octopus.Script",
  "Author": "farhanalam",
  "Packages": [
    {
      "Id": "e0d1bcb0-4a7e-41dc-ab53-2799d6f2b051",
      "Name": "gratePackage",
      "PackageId": null,
      "FeedId": null,
      "AcquisitionLocation": "Server",
      "Properties": {
        "Extract": "True",
        "SelectionMode": "deferred",
        "PackageParameterName": "gratePackage",
        "Purpose": ""
      }
    }
  ],
  "Parameters": [
    {
      "Id": "5d54012a-1f10-4b3c-bbfd-fe70bf843904",
      "Name": "gratePackage",
      "Label": "grate Package",
      "HelpText": "The package containing the scripts for grate to deploy.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Package"
      }
    },
    {
      "Id": "57a43e61-86c0-46a3-8446-aacbb67cf596",
      "Name": "grateServerName",
      "Label": "Database Server Name",
      "HelpText": "Name or IP address of the server being deployed to.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "ba3705a1-e620-4fb1-b494-2b9e65fbb194",
      "Name": "grateServerPort",
      "Label": "Database Server Port",
      "HelpText": "Port number for the database server.  Uses default server port if left blank.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "c3a7c802-babd-470b-8447-5f6159128b4c",
      "Name": "grateAuthenticationMethod",
      "Label": "Authentication Method",
      "HelpText": "Method used to authenticate to the database server.",
      "DefaultValue": "usernamepassword",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "awsiam|AWS EC2 IAM Role\nazuremanagedidentity|Azure Managed Identity\ngcpserviceaccount|GCP Service Account\nusernamepassword|Username\\Password\nwindowsauthentication|Windows Authentication"
      }
    },
    {
      "Id": "c955ad8c-9686-419c-a1cf-129ffbe0acb4",
      "Name": "grateDatabaseName",
      "Label": "Database Name",
      "HelpText": "Name of the database to deploy to.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "0438580a-8825-40db-a782-569a35144f4b",
      "Name": "grateSsl",
      "Label": "Force SSL",
      "HelpText": "Check this box for force connection string to use SSL.  Only applicable to MariaDB, MySQL, SQL Server, and PostgreSQL database types.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "e007d182-88f5-4e1f-906f-c85ff955c51e",
      "Name": "grateVersion",
      "Label": "Database Version",
      "HelpText": "Version number of your database migration.  Default value is the version of the grate package.",
      "DefaultValue": "#{Octopus.Action.Package[gratePackage].PackageVersion}",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "58ceec6b-f374-4c2e-83bb-fbd7ce26dc05",
      "Name": "grateUsername",
      "Label": "Database Username",
      "HelpText": "Username of the account with sufficient permissions to execute scripts.  (Leave blank for Integrated Authentication.)",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "29f6bb35-c076-4b5d-98cf-8ee8494cb38b",
      "Name": "grateUserPassword",
      "Label": "Database User Password",
      "HelpText": "Password for the Database Username account.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Sensitive"
      }
    },
    {
      "Id": "5e5d68f1-fdc8-41ce-8cd3-ab082278218d",
      "Name": "grateDatabaseServerType",
      "Label": "Database Server Type",
      "HelpText": "The database technology being deployed to.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "mariadb|MariaDB\nmysql|MySQL\noracle|Oracle\npostgresql|PostgreSQL\nsqlserver|SQL Server"
      }
    },
    {
      "Id": "67c016c3-d779-4300-b3d1-e11463bb9fc4",
      "Name": "grateWithTransaction",
      "Label": "Use Transaction?",
      "HelpText": "Check this box if you want all scripts to be run within the same transaction.",
      "DefaultValue": "False",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "29a4f568-23f7-4bb2-a5ba-910b922c5406",
      "Name": "grateDryRun",
      "Label": "Dry Run?",
      "HelpText": "Check this box if you want to perform a dry run.  Results are recorded and attached as deployment artifacts if you check Record Output.",
      "DefaultValue": "False",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "eaad7639-05d8-46ed-bab3-b72b8ad81d5a",
      "Name": "grateRecordOutput",
      "Label": "Record Output?",
      "HelpText": "Check this box to record the output of the run.  Useful for gathering what would be changed for approval purposes.",
      "DefaultValue": "False",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "84b46c25-6762-44c2-8ec6-4ba30ff599f5",
      "Name": "grateCommandTimeout",
      "Label": "Command Timeout",
      "HelpText": "Customizable command timeout (in seconds). Default is 60",
      "DefaultValue": "60",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "3382a212-f467-409e-950b-317f05f59ea5",
      "Name": "grateBaseline",
      "Label": "Use Baseline",
      "HelpText": "Check this box if you want grate to mark the scripts as run, but not to actually run anything against the database. [More information about this option can be found here](https://erikbra.github.io/grate/configuration-options/)",
      "DefaultValue": "False",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "60dc1e2a-93ec-4bc9-abde-aacf6aeba0a7",
      "Name": "grateSqlScriptFolder",
      "Label": "SQL Script folder",
      "HelpText": "Script location to use for grate (if not in the root of the package)",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "b792092e-e3c1-487b-8349-e17832506f12",
      "Name": "grateLogVerbosity",
      "Label": "Log Level",
      "HelpText": "Configure log level when running Grate.",
      "DefaultValue": "information",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "critical|Critical\ndebug|Debug\nerror|Error\ninformation|Information\nnone|None\ntrace|Trace\nwarning|Warning"
      }
    },
    {
      "Id": "b8a17064-900f-4d77-9354-e5de1d3057f0",
      "Name": "grateDownloadNuget",
      "Label": "Download grate?",
      "HelpText": "Check this box if you want the template to download RoundhousE and use the downloaded version for deployment.  Requires .NET Core be installed on the machine executing the deployment.",
      "DefaultValue": "False",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "409bd29d-79cf-4209-a005-ce768714eb64",
      "Name": "grateNugetVersion",
      "Label": "Version of grate",
      "HelpText": "Version of grate to download (used with Download grate option), leave blank for latest.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "d9660ee0-8376-4f9b-8d1d-99f3b4701a42",
      "Name": "grateEnvironment",
      "Label": "Grate environment",
      "HelpText": "The environment specific scripts that grate will execute",
      "DefaultValue": "#{Octopus.Environment.Name}",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "a93b61a7-74ae-471e-ace3-d09cdfab8858",
      "Name": "grateSchema",
      "Label": "Grate schema for migration tables",
      "HelpText": "Useful if migrating from RoundhousE.",
      "DefaultValue": "grate",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    }
  ],
  "Properties": {
    "Octopus.Action.Script.ScriptSource": "Inline",
    "Octopus.Action.Script.Syntax": "PowerShell",
    "Octopus.Action.Script.ScriptBody": "# Configure template\n\n# Check to see if $IsWindows is available\nif ($null -eq $IsWindows)\n{\n\tWrite-Host \"Determining Operating System...\"\n    switch ([System.Environment]::OSVersion.Platform)\n    {\n    \t\"Win32NT\"\n        {\n        \t# Set variable\n            $IsWindows = $true\n            $IsLinux = $false\n        }\n        \"Unix\"\n        {\n        \t$IsWindows = $false\n            $IsLinux = $true\n        }\n    }\n}\n\nif ($IsWindows)\n{\n\t$ProgressPreference = 'SilentlyContinue'\n}\n\n# Define parameters\n$grateExecutable = \"\"\n$grateOutputPath = [System.IO.Path]::Combine($OctopusParameters[\"Octopus.Action.Package[gratePackage].ExtractedPath\"], \"output\")\n$grateSsl = [System.Convert]::ToBoolean($grateSsl)\n\nFunction Get-LatestVersionDownloadUrl {\n    # Define parameters\n    param(\n        $Repository,\n        $Version\n    )\n    \n    # Define local variables\n    $releases = \"https://api.github.com/repos/$Repository/releases\"\n    \n    # Get latest version\n    Write-Host \"Determining latest release of $Repository ...\"\n    \n    $tags = (Invoke-WebRequest $releases -UseBasicParsing | ConvertFrom-Json)\n    \n    if ($null -ne $Version) {\n        # Get specific version\n        $tags = ($tags | Where-Object { $_.tag_name.EndsWith($Version) })\n\n        # Check to see if nothing was returned\n        if ($null -eq $tags) {\n            # Not found\n            Write-Host \"No release found matching version $Version, getting highest version using Major.Minor syntax...\"\n\n            # Get the tags\n            $tags = (Invoke-WebRequest $releases -UseBasicParsing | ConvertFrom-Json)\n\n            # Parse the version number into a version object\n            $parsedVersion = [System.Version]::Parse($Version)\n            $partialVersion = \"$($parsedVersion.Major).$($parsedVersion.Minor)\"\n\n            # Filter tags to ones matching only Major.Minor of version specified\n            $tags = ($tags | Where-Object { $_.tag_name.Contains(\"$partialVersion.\") -and $_.draft -eq $false })\n            \n            # Grab the latest\n            if ($null -eq $tags)\n            {\n            \t# decrement minor version\n                $minorVersion = [int]$parsedVersion.Minor\n                $minorVersion --\n                \n                # Check to make sure that minor version isn't negative\n                if ($minorVersion -ge 0)\n                {\n                \t# return the urls\n                \treturn (Get-LatestVersionDownloadUrl -Repository $Repository -Version \"$($parsedVersion.Major).$($minorVersion)\")\n                }\n                else\n                {\n                \t# Display error\n                    Write-Error \"Unable to find a version within the major version of $($parsedVersion.Major)!\"\n                }\n            }\n        }\n    }\n\n    # Find the latest version with a downloadable asset\n    foreach ($tag in $tags) {\n        if ($tag.assets.Count -gt 0) {\n            return $tag.assets.browser_download_url\n        }\n    }\n\n    # Return the version\n    return $null\n}\n\n# Change the location to the extract path\nSet-Location -Path $OctopusParameters[\"Octopus.Action.Package[gratePackage].ExtractedPath\"]\n\n# Check to see if download is specified\nif ([System.Boolean]::Parse($grateDownloadNuget))\n{\n    # Set secure protocols\n    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls12\n    $downloadUrls = @()\n\n\t# Check to see if version number specified\n    if ([string]::IsNullOrWhitespace($grateNugetVersion))\n    {\n    \t# Get the latest version number\n        $downloadUrls = Get-LatestVersionDownloadUrl -Repository \"erikbra/grate\"\n    }\n    else\n    {\n    \t# Get specific version\n        $downloadUrls = Get-LatestVersionDownloadUrl -Repository \"erikbra/grate\" -Version $grateNugetVersion\n    }\n\n\t# Check to make sure something was returned\n    if ($null -ne $downloadUrls -and $downloadUrls.Length -gt 0)\n\t{\n    \n      # Check for download folder\n      if ((Test-Path -Path \"$PSSCriptRoot/grate\") -eq $false)\n      {\n          # Create the folder\n          New-Item -ItemType Directory -Path \"$PSSCriptRoot/grate\"\n      }\n\n      # Get URL of grate-dotnet-tool\n      $downloadUrl = $downloadUrls | Where-Object {$_.Contains(\"grate-dotnet-tool\")}\n      \n      # Check to see if something was returned\n      if ($null -eq $downloadUrl)\n      {\n      \t# Attempt to get nuget package\n        Write-Host \"An asset with grate-dotnet-tool was not found, attempting to locate nuget package ...\"\n        $downloadUrl = $downloadUrls | Where-Object {$_.Contains(\".nupkg\")}\n        \n        # Check to see if something was returned\n        if ($null -eq $downloadUrl)\n        {\n        \tWrite-Error \"Unable to find appropriate asset for download.\"\n        }\n      }\n\n      # Download nuget package\n      Write-Output \"Downloading $downloadUrl ...\"\n\n      # Get download file name\n      $downloadFile = $downloadUrl.Substring($downloadUrl.LastIndexOf(\"/\") + 1)\n\n      # Download the file\n      Invoke-WebRequest -Uri $downloadUrl -OutFile \"$PSSCriptRoot/grate/$downloadFile\"\n\n      # Check the extension\n      if ($downloadFile.EndsWith(\".zip\"))\n      {\n          # Extract the file\n          Write-Host \"Extracting $downloadFile ...\"\n          Expand-Archive -Path \"$PSSCriptRoot/grate/$downloadFile\" -Destination \"$PSSCriptRoot/grate\"\n\n          # Delete the downloaded .zip\n          Remove-Item -Path \"$PSSCriptRoot/grate/$downloadFile\"\n\n          # Get extracted files\n          $extractedFiles = Get-ChildItem -Path \"$PSSCriptRoot/grate\"\n\n          # Check to see if what was extracted was simply a nuget file\n          if ($extractedFiles.Count -eq 1 -and $extractedFiles[0].Extension -eq \".nupkg\")\n          {\n              # Zip file contained a nuget package </facepalm>\n              Write-Host \"Archive contained a NuGet package, extracting package ...\"\n              $nugetPackage = $extractedFiles[0]\n              $nugetPackage | Rename-Item -NewName $nugetPackage.Name.Replace(\".nupkg\", \".zip\")\n              Expand-Archive -Path $nugetPackage.FullName.Replace(\".nupkg\", \".zip\") -Destination \"$PSSCriptRoot/grate\"\n          }\n      }\n\n      if ($downloadFile.EndsWith(\".nupkg\"))\n      {\n          # Zip file contained a nuget package </facepalm>\n          $nugetPackage = Get-ChildItem -Path \"$PSSCriptRoot/grate/$($downloadFile)\"\n          $nugetPackage | Rename-Item -NewName $nugetPackage.Name.Replace(\".nupkg\", \".zip\")\n          Expand-Archive -Path \"$PSSCriptRoot/grate/$($downloadFile.Replace(\".nupkg\", \".zip\"))\" -Destination \"$PSSCriptRoot/grate\"    \n      }\n    }\n    else\n    {\n    \tWrite-Error \"No download url returned!\"\n    }\n}\n\n\n\nif ([string]::IsNullOrWhitespace($grateExecutable))\n{\n\t# Look for just grate.dll\n    $grateExecutable = Get-ChildItem -Path $PSSCriptRoot -Recurse | Where-Object {$_.Name -eq \"grate.dll\"}\n    \n    # Check for multiple results\n    if ($grateExecutable -is [array])\n    {\n        # choose one that matches highest version of .net\n\t\t$dotnetVersions = (dotnet --list-runtimes) | Where-Object {$_ -like \"*.NetCore*\"}\n\n\t\t$maxVersion = $null\n\t\tforeach ($dotnetVersion in $dotnetVersions)\n\t\t{\n    \t\t$parsedVersion = $dotnetVersion.Split(\" \")[1]\n    \t\tif ($null -eq $maxVersion -or [System.Version]::Parse($parsedVersion) -gt [System.Version]::Parse($maxVersion))\n    \t\t{\n        \t\t$maxVersion = $parsedVersion\n    \t\t}\n\t\t}\n        \n        $grateExecutable = $grateExecutable | Where-Object {$_.FullName -like \"*net$(([System.Version]::Parse($maxVersion).Major))*\"}\n    }\n}\n\nif ([string]::IsNullOrWhitespace($grateExecutable))\n{\n    # Couldn't find grate\n    Write-Error \"Couldn't find the grate executable!\"\n}\n\n# Build the arguments\n$grateSwitches = @()\n\n# Update the connection string based on authentication method\nswitch ($grateAuthenticationMethod)\n{\n    \"awsiam\"\n    {\n        # Region is part of the RDS endpoint, extract\n        $region = ($grateServerName.Split(\".\"))[2]\n\n        Write-Host \"Generating AWS IAM token ...\"\n        $grateUserPassword = (aws rds generate-db-auth-token --hostname $grateServerName --region $region --port $grateServerPort --username $grateUserName)       \n        $grateUserInfo = \"Uid=$grateUserName;Pwd=$grateUserPassword;\"\n\n        break\n    }\n\t\n    \"azuremanagedidentity\"\n    {\n    \t# SQL Server driver doesn't assign password\n        if ($grateDatabaseServerType -ne \"sqlserver\")\n        {\n          # Get login token\n          Write-Host \"Generating Azure Managed Identity token ...\"\n          $token = Invoke-RestMethod -Method GET -Uri \"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://ossrdbms-aad.database.windows.net\" -Headers @{\"MetaData\" = \"true\"}\n\n          $grateUserPassword = $token.access_token\n          $grateUserInfo = \"Uid=$grateUserName;Pwd=$grateUserPassword;\"\n        }\n        \n        break\n    }\n\n    \"gcpserviceaccount\"\n    {\n        # Define header\n        $header = @{ \"Metadata-Flavor\" = \"Google\"}\n\n        # Retrieve service accounts\n        $serviceAccounts = Invoke-RestMethod -Method Get -Uri \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/\" -Headers $header\n\n        # Results returned in plain text format, get into array and remove empty entries\n        $serviceAccounts = $serviceAccounts.Split([Environment]::NewLine, [StringSplitOptions]::RemoveEmptyEntries)\n\n        # Retreive the specific service account assigned to the VM\n        $serviceAccount = $serviceAccounts | Where-Object {$_.Contains(\"iam.gserviceaccount.com\") }\n\n\t\tWrite-Host \"Generating GCP IAM token ...\"\n        # Retrieve token for account\n        $token = Invoke-RestMethod -Method Get -Uri \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/$serviceAccount/token\" -Headers $header\n        $grateUserPassword = $token.access_token\n        \n        # Append remaining portion of connection string\n        $grateUserInfo = \"Uid=$grateUserName;Pwd=$grateUserPassword;\"\n    }\n\n\n    \"usernamepassword\"\n    {\n    \t# Append remaining portion of connection string\n        $grateUserInfo = \"Uid=$grateUserName;Pwd=$grateUserPassword;\"\n\n\t\tbreak    \n\t}\n\n    \"windowsauthentication\"\n    {\n      # Append remaining portion of connection string\n\t  $grateUserInfo = \"integrated security=true;\"\n      \n      # Append username (required for non\n      $grateUserInfo += \"Uid=$grateUserName;\"\n    }\n    \n}\n\n# Configure connnection string based on technology\nswitch ($grateDatabaseServerType)\n{\n    \"sqlserver\"\n    {\n        # Check to see if port has been defined\n        if (![string]::IsNullOrEmpty($grateServerPort))\n        {\n            # Append to servername\n            $grateServerName += \",$grateServerPort\"\n\n            # Empty the port\n            $grateServerPort = [string]::Empty\n        }\n    }\n    \"mariadb\"\n    {\n    \t$grateServerPort = \"Port=$grateServerPort;Allow User Variables=true;\"\n    }\n    \"mysql\"\n    {\n    \t# Use the MySQL client\n        $grateDatabaseServerType = \"mariadb\"\n        $grateServerPort = \"Port=$grateServerPort;Allow User Variables=true;\"\n    }\n    \"oracle\"\n    {\n    \t# Oracle connection strings are built different than all others\n        $grateServerConnectionString = \"--connectionstring=`\"Data source=$($grateServerName):$($grateServerPort)/$grateDatabaseName;$($grateUserInfo.Replace(\"Uid\", \"User Id\").Replace(\"Pwd\", \"Password\")) \"\n    }\n    default\n    {\n        $grateServerPort = \"Port=$grateServerPort;\"\n    }\n}\n\n# Build base connection string\nif ([string]::IsNullOrWhitespace($grateServerConnectionString))\n{\n\t$grateServerConnectionString = \"--connectionstring=`\"Server=$grateServerName;$grateServerPort $grateUserInfo Database=$grateDatabaseName;\"\n}\n\n# Check for SQL Server and Azure Managed Identity\nif (($grateDatabaseServerType -eq \"sqlserver\") -and ($grateAuthenticationMethod -eq \"azuremanagedidentity\"))\n{\n\t# Append AD component to connection string\n    $grateServerConnectionString += \"Authentication=Active Directory Default;\"\n}\n\nif ($grateSsl -eq $true)\n{\n\tif (($grateDatabaseServerType -eq \"mariadb\") -or ($grateDatabaseServerType -eq \"mysql\") -or ($grateDatabaseServerType -eq \"postgres\"))\n    {\n    \t# Add sslmode\n        $grateServerConnectionString += \"SslMode=Require;Trust Server Certificate=true;\"\n    }\n    elseif ($grateDatabaseServerType -eq \"sqlserver\")\n    {\n    \t$grateServerConnectionString += \"Trust Server Certificate=true;\"\n    }\n    else\n    {\n    \tWrite-Warning \"Invalid Database Server Type selection for SSL, ignoring setting.\"\n    }\n}\n\n# Add terminating double quote to connection string\n$grateServerConnectionString += \"`\"\"\n\n$grateSwitches += $grateServerConnectionString\n\n$grateSwitches += \"--databasetype=$grateDatabaseServerType\"\n$grateSwitches += \"--silent\"\n\nif ([System.Boolean]::Parse($grateDryRun))\n{\n    $grateSwitches += \"--dryrun\"\n}\n\nif ([System.Boolean]::Parse($grateRecordOutput))\n{\n    $grateSwitches += \"--outputPath=$grateOutputPath\"\n    \n    # Check to see if path exists\n    if ((Test-Path -Path $grateOutputPath) -eq $false)\n    {\n    \t# Create folder\n        New-Item -Path $grateOutputPath -ItemType \"Directory\"\n    }\n}\n\n# Add transaction switch\n$grateSwitches += \"--transaction=$($grateWithTransaction.ToLower())\"\n\n# Add Command Timeout\nif (![string]::IsNullOrEmpty($grateCommandTimeout)){\n    $grateSwitches += \"--commandtimeout=$([int]$grateCommandTimeout)\"\n}\n\n# Add Baseline switch\nif ([System.Boolean]::Parse($grateBaseline)) {\n    $grateSwitches += \"--baseline\"\n}\n\n# Add SQL Files Directory parameter\nif (![string]::IsNullOrEmpty($grateSqlScriptFolder)) {\n    # Add up folder\n    $grateSwitches += \"--sqlfilesdirectory=$grateSqlScriptFolder\"\n}\n\n# Add log verbosity flag\nif (![string]::IsNullOrEmpty($grateLogVerbosity)) {\n    # Add up folder\n    $grateSwitches += \"--verbosity=$grateLogVerbosity\"\n}\n\n\n# Check for version\nif (![string]::IsNullOrEmpty($grateVersion))\n{\n    # Add version\n    $grateSwitches += \"--version=$grateVersion\"\n}\n\n# Set grate environment\nif (![string]::IsNullOrEmpty($grateEnvironment))\n{\n    # Add environment\n    $grateSwitches += \"--environment=$grateEnvironment\"\n}\n\n# Set grate schema. Especially useful when migrating from RoundhousE\nif (![string]::IsNullOrEmpty($grateSchema))\n{\n    # Add schema\n    $grateSwitches += \"--schema=$grateSchema\"\n}\n\n# Display what's going to be run\nif (![string]::IsNullOrWhitespace($grateUserPassword))\n{\n\tWrite-Host \"Executing $($grateExecutable.FullName) with $($grateSwitches.Replace($grateUserPassword, \"****\"))\"\n}\nelse\n{\n\tWrite-Host \"Executing $($grateExecutable.FullName) with $($grateSwitches)\"\n}\n\n# Execute grate\nif ($grateExecutable.FullName.EndsWith(\".dll\"))\n{\n\t& dotnet $grateExecutable.FullName $grateSwitches\n}\nelse\n{\n\t& $grateExecutable.FullName $grateSwitches\n}\n\n# If the output path was specified, attach artifacts\nif ([System.Boolean]::Parse($grateRecordOutput))\n{    \n    # Zip up output folder content\n    Add-Type -Assembly 'System.IO.Compression.FileSystem'\n    \n    $zipFile = \"$($OctopusParameters[\"Octopus.Action.Package[gratePackage].ExtractedPath\"])/output.zip\"\n    \n\t[System.IO.Compression.ZipFile]::CreateFromDirectory($grateOutputPath, $zipFile)\n    New-OctopusArtifact -Path \"$zipFile\" -Name \"output.zip\"\n}\n"
  },
  "Category": "Grate",
  "HistoryUrl": "https://github.com/OctopusDeploy/Library/commits/master/step-templates//opt/buildagent/work/75443764cd38076d/step-templates/grate-database-migration.json",
  "Website": "/step-templates/ca23d18f-ab03-403d-bfb8-3ff74d3ddab3",
  "Logo": "iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAMAAAAp4XiDAAAC91BMVEUAAAAAAAAAAABVVf9AQECAQP8zMzMrKyskJCQkJEkgIEAcHDkzGjMuLi50XegVK0AkJCQkNzciIjMgMEAbKDYoKCgmJiYkJDEjIy4rKysfMz0pKSknJycmJi8bLkAkJC4jIywpKSknJy4eLTwmJi0kJCwrIyspKSl1U+ooKCgnJy4mJi0kJCsqJCopKSknJywmJisgMDolJSsfLj0pJClzVetxU+smJisgLjsoJC0oKCxzUu9xVe90VOsnJytyVOweMTwmJilxU+wpJSwoJCwnJysmJipzVO4fMDsmJilyU+4lJSx0Uu4eLzxyVO4oJCsdLjsnJysmJiwoJSsnJysnJyomJixyUu0mJitxVO4oJSsfMDwnJypzVO4eLz0nJywmJisgMDsnJSonJywmJiseMD0mJiseMDwoJitzU+4oJSpyVO5xU+4nJyxyU+4mJitxUu4mJisoJitxU+weLz0nJSxyU+wnJyxzU+1yUu1zU+0fLzxxUu1xU+4nJysmJitzU+4oJipyUuwfLztyUu1yVO0nJysnJysmJiwnJitzVO0oJitxU+1yVO0nJSsnJypyU+1yVO4mJityU+5yU+4nJityU+wnJyxyUu0oJityU+0fLzxzUu0mJityU+xyUuwnJysmJityU+1zUu0nJionJSsnJytyVO1yU+5yU+5yVO4oJityU+1xU+0fLzwnJSsnJysmJisoJiwnJisoJiwfLzwnJisfLzwnJisnJytzUu0mJixyU+0fLzxxU+0fLjwnJywoJisnJisnJityU+0mJityU+1yU+0nJityU+1xU+1yU+4nJityU+wfLzwnJityU+0nJisnJisfLzwnJisnJysfLzwnJisnJityU+1yU+0nJisfLzwnJisnJisfLj0nJitzU+0nJisnJityU+0nJityU+0nJityU+0nJisnJysnJisfLzwnJisnJisfLzwnJityU+1yU+0nJisnJisnJityU+0fLzwnJisfLzwnJityU+3///8ZordNAAAA+XRSTlMAAQIDBAQFBgcHCAkKCwsMDg4PEBMTFBUWGBkZGhscHB0fISIiIyQlJSYnKCorLC4vMDAyMjM0Njg5Oj4/QEJDRERERUZISUlKSkpLS0xMTU5OUVNUVVdXWFhZWltbXFxeX2FiZGVlZmZmZ2doaWlqamtsbG1ubm9vcHFyc3V3eHh5eX1/gIOEhoiMjo6PkJGRkpOTlJWWmJianZ6en6Okpaamp6mrrK6vsLG0tba3uLm6u73Bw8PFxcbGx8fIyMvNztDR09TU1dbZ2tvc3N3d3d7f4ODh4+Xm5ufp6urr7Ozs7u/x8vLz8/T19vf3+Pn5+fr7/P39/v5vNae6AAAAAWJLR0T8PA6jfwAAAoRJREFUGBntwXdUTXEAwPHvC2XvrZS9s0f2yki2jGwZ2bPszUP23p69x8MzIyuUFdmbSJSerMu9v390XjfnvI7ueQd/9vmQ4m/lW/0o9uIoe5Io4Ehy8oZHLhq7/ecWHdaMJpKzMbYCMEVpjGvbjN41oWxPr+zgfinEyxlyd+7oRBIO0WuJl9bLFX2EKW6+3drvIU/fVOJwTGyYO82jnj6P6Yu1MkofVHplbgYKHepGhvCdYDRB1ojNqXXz4pywUl7pDK3DwsIWo/+WiXh2hRvdOQVGE3RQGhQtWl3phZU8ynQo5uv7aiN6M+CwMjLu8sNAMJrAT3kTEW8k1s5ftgccv05FbwaG/mxnz5FAMJrAW3HmD9oqO1wodTLSGb0ZmPMhEyUiA2HfjdTkjl6mo8XR0iQxNEYxKy8agd4MlIu6f/zhhUDoo0Q1wTvu3oXvm+1IKpe3X7uMgGt74rn4DHJyqwtU8SkOzv2Hu5Hi/yg5bKnBsHRISWy2RhIW0ipsJX4jy0fZ4seJbGi5KlRXoMcAizFyV7S0GrEn9PXr0N3DWpFIJ/dDiwhe7t+9u/+KYIFq4i352Vk0SEIlocpxTv7UBg2T3gmLtxNIlPNYS7Ts9fQYPWvWaA/PvdhKiM/Xg4KufxaCBJkb+s+e7V8vM8naJlRbsZj8Xli8m0Syqs3YtH//pmlVSSAJlYStngjVY7RUXp+ORLUXXpGEkIIX1EJDxZfywfSovuwa59G0qef43V/QcFeW5ZmoxG9o6SfrSLRKEhbSCrT0lpvVt6gBAXUGLzEYlgysHYCW/A/kBD+yIMTNAwbDgdtCoCldkQQFIUioTmOrNF02nLl27cy6TqlI8Q9+AUpiXLmlXI7qAAAAAElFTkSuQmCC",
  "$Meta": {
    "Type": "ActionTemplate"
  }
}

History

Page updated on Monday, October 17, 2022