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 :
"${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
}
)