How to use templatefile to pass a powershell script into CommandToExecute

Hi folks,

I am trying to load a PowerShell script as part of the deployment to avoid using external dependencies when deploying a custom script extension for a windows virtual machine but I’m facing this issue.

Error: "settings" contains an invalid JSON: invalid character '\n' in string literal

on compute.tf line 94, in resource "azurerm_virtual_machine_extension" "example":
94: resource "azurerm_virtual_machine_extension" "example" {

Here’s a part of my PowerShell script.

<#

.SYNOPSIS
Synopsis

.DESCRIPTION
Description

.PARAMETER FirstParameter
FirstParameter

.PARAMETER SecondParameter
SecondParameter

.EXAMPLE
.\script.ps1 -FirstParameter <value> -SecondParameter <value>
#>

Param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$FirstParameter

[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$SecondParameter

try 
{
    # Do stuff
    Write-Output "Enabling TLS 1.2."
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

    Write-Output "Doing stuff..."
    iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))

    Write-Output "Doing stuff..."
    choco install package --params "/PARAM:$FirstParameter" -y

    Write-Output "Doing stuff..."
    Add-LocalGroupMember -Group "Group" -Member $SecondParameter
}
catch
{
    Write-Error $_.Exception
    throw $_.Exception
}
finally
{
    Write-Host "Did stuff..."
    $LASTEXITCODE
}

Here’s the Terraform part.

resource "azurerm_virtual_machine_extension" "example" {
  for_each             = azurerm_windows_virtual_machine.machines
  name                 = "extension"
  virtual_machine_id   = azurerm_windows_virtual_machine.machines[each.key].id
  publisher            = "Microsoft.Compute"
  type                 = "CustomScriptExtension"
  type_handler_version = "1.10"

  settings = <<SETTINGS
{
  "commandToExecute": "powershell.exe -executionpolicy bypass -command ${templatefile("script.ps1", { FirstParameter = "Some value", SecondParameter = "${var.localadmin}" } ) }"
}
  SETTINGS
}

I’ve tried to use jsonencode(), convert my script line endings to unix style and many other things but didn’t found out how to solve this issue.

Appreciate your help on this.

Hi @shnabr,

Situations where you need to pass data from one language to another are tricky and so I would recommend trying to break this down into a series of clear steps where each step is going from one language directly to another language. That way if something goes wrong with one of the steps it’ll hopefully be easier to see where the problem is coming from.

In your case it seems like there are five different nested “languages” involved: Terraform values embedded a PowerShell command lines, embedded in the command line syntax expected for powershell.exe itself, embedded in a Windows command prompt command (launching powershell.exe), embedded in a JSON value (in commandToExecute).

Let’s first tackle the first step of going from Terraform to PowerShell. Terraform doesn’t have any PowerShell-specific encoding function, but from referring to the PowerShell quoting rules I understand that one way to encode a string so that PowerShell will take it literally is to write it in single quotes ' and double any single quotes already in the string, which we can do with Terraform’s replace function:

  Add-LocalGroupMember -Group "Group" -Member '${replace(SecondParameter, "'", "''")}'

Take care when reading the above because there are unfortunately several sequences where double quotes " and single quotes ' appear together, which may not be easy to distinguish in some typefaces.

If you change your script.ps1 template so that both of the references to template variables are using replace like the above, that should achieve the first level of required escaping so that PowerShell itself can understand the value.

I recommend factoring out the template rendering into a named local value, because doing too many operations in a single expression would be hard to read, and we still have several more levels of escaping to handle:

locals {
  powershell_script = templatefile("script.ps1", {
    FirstParameter = "Some value"
    SecondParameter = var.localadmin
  })
}

The next “language” may not seem like a language in its own right but is still important for correct escaping: the powershell.exe executable seems to parse its command line using the .NET framework function Environment.GetCommandLineArgs, whose documentation includes a “Remarks” section which explains some escaping rules which seem to suggest that we need to escape " as \" and \ as \\, so we can employ replace again, using regular expression matching syntax this time so that we can match both the quote and the backslash. This gets a bit messy to read because the backslashes are also significant to Terraform and to the regular expression engine :roll_eyes::

"${replace(local.powershell_script, "/([\"\\\\])/", "\\\\$1")}"

So this is, I think, the right way to encode the -command argument to powershell.exe, which is the result of rendering your template:

locals {
  powershell_command = <<-EOT
    powershell.exe -executionpolicy bypass -command "${
        replace(local.powershell_script, "/([\"\\\\])/", "\\\\$1")
    }"
  EOT
}

Again I’ve used a local value here so we can easily see this as a separate step. It uses the result of the previous local value and escapes it so it can be understood by powershell.exe.

The virtual machine extension in turn seems to be running the given command using the windows command interpreter (cmd.exe). That command interpreter has its own mini-language for dealing with things like I/O redirection (>, <, etc) and pipes (|), so we also need to escape that. I couldn’t find an official Microsoft document on the escaping syntax for cmd.exe, but I did find a third-party tutorial on command escaping which tells us that the special characters & \ < > ^ | must be escaped by adding a ^ symbol before them. Again, we can use replace:

locals {
  powershell_command_for_cmd = replace(local.powershell_command, "/([&\\\\<>^|])/", "^$1")
}

By this point local.powershell_command_for_cmd should hopefully be a valid command line ready to be run by the Windows command interpreter, and so our final step is to pass that into the "commandToExecute" property of the settings JSON object. This final step is thankfully decidedly easier than the others because Terraform has a built in jsonencode function which knows how to generate valid JSON string escaping automatically:

  settings = jsonencode({
    commandToExecute = local.powershell_command_for_cmd
  })

Putting that all together into a single example:

locals {
  powershell_script = templatefile("script.ps1", {
    FirstParameter = "Some value"
    SecondParameter = var.localadmin
  })
  powershell_command = <<-EOT
    powershell.exe -executionpolicy bypass -command "${
        replace(local.powershell_script, "/([\"\\\\])/", "\\\\$1")
    }"
  EOT
  powershell_command_for_cmd = replace(local.powershell_command, "/([&\\\\<>^|])/", "^$1")
}

resource "azurerm_virtual_machine_extension" "example" {
  for_each             = azurerm_windows_virtual_machine.machines

  # ...

  settings = jsonencode({
    commandToExecute = local.powershell_command_for_cmd
  })
}

When I tried this locally the final settings value was the following, which is very hard to read but should hopefully have all of the correct escaping to finally cause the script to run as you intended:

{"commandToExecute":"powershell.exe -executionpolicy bypass -command \"^\u003c#\n\n.SYNOPSIS\nSynopsis\n\n.DESCRIPTION\nDescription\n\n.PARAMETER FirstParameter\nFirstParameter\n\n.PARAMETER SecondParameter\nSecondParameter\n\n.EXAMPLE\n.^\\^\\^\\script.ps1 -FirstParameter ^\u003cvalue^\u003e -SecondParameter ^\u003cvalue^\u003e\n#^\u003e\n\nParam(\n[Parameter(Mandatory = $true)]\n[ValidateNotNullOrEmpty()]\n[string]$FirstParameter\n\n[Parameter(Mandatory = $true)]\n[ValidateNotNullOrEmpty()]\n[string]$SecondParameter\n\ntry \n{\n    # Do stuff\n    Write-Output ^\\^\\\"Enabling TLS 1.2.^\\^\\\"\n    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12\n\n    Write-Output ^\\^\\\"Doing stuff...^\\^\\\"\n    iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))\n\n    Write-Output ^\\^\\\"Doing stuff...^\\^\\\"\n    choco install package --params '/PARAM:Some value' -y\n\n    Write-Output ^\\^\\\"Doing stuff...^\\^\\\"\n    Add-LocalGroupMember -Group ^\\^\\\"Group^\\^\\\" -Member 'example'\n}\ncatch\n{\n    Write-Error $_.Exception\n    throw $_.Exception\n}\nfinally\n{\n    Write-Host ^\\^\\\"Did stuff...^\\^\\\"\n    $LASTEXITCODE\n}\n\"\n"}

Fortunately Terraform’s plan renderer has a special heuristic where it notices if a particular argument seems to be a JSON string and prints out the content using the normal plan diff syntax, so the result is a little easier to read in the plan:

  + settings = jsonencode(
        {
          + commandToExecute = <<-EOT
                powershell.exe -executionpolicy bypass -command "^<#
                
                .SYNOPSIS
                Synopsis
                
                .DESCRIPTION
                Description
                
                .PARAMETER FirstParameter
                FirstParameter
                
                .PARAMETER SecondParameter
                SecondParameter
                
                .EXAMPLE
                .^\^\^\script.ps1 -FirstParameter ^<value^> -SecondParameter ^<value^>
                #^>
                
                Param(
                [Parameter(Mandatory = $true)]
                [ValidateNotNullOrEmpty()]
                [string]$FirstParameter
                
                [Parameter(Mandatory = $true)]
                [ValidateNotNullOrEmpty()]
                [string]$SecondParameter
                
                try 
                {
                    # Do stuff
                    Write-Output ^\^\"Enabling TLS 1.2.^\^\"
                    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
                
                    Write-Output ^\^\"Doing stuff...^\^\"
                    iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
                
                    Write-Output ^\^\"Doing stuff...^\^\"
                    choco install package --params '/PARAM:Some value' -y
                
                    Write-Output ^\^\"Doing stuff...^\^\"
                    Add-LocalGroupMember -Group ^\^\"Group^\^\" -Member 'example'
                }
                catch
                {
                    Write-Error $_.Exception
                    throw $_.Exception
                }
                finally
                {
                    Write-Host ^\^\"Did stuff...^\^\"
                    $LASTEXITCODE
                }
                "
            EOT
        }
    )