terraform

How to make terraform templatefile to ignore default bash variables?


This is my Bash Script :

#!/bin/bash

#Colors for terminal

#RED
R="\e[31m"

#GREEN
G="\e[32m"

#YELLOW
Y="\e[33m"

#NORMAL
N="\e[0m"

validation(){

    if [ $1 -eq 0 ];
    then 
        echo -e "${G}$2 is successful${N}"
    else 
        echo -e "${R}$2 has Failed${N}"
        exit 1;
    fi
}

echo -e "${Y}Configuring Cart Service${N}"

cart_config="/etc/systemd/system/cart.service"

cat << EOF >$cart_config

Environment=REDIS_HOST="${redis_ip}" #I want TF to only substitute these
Environment=CATALOGUE_HOST="${app_lb_dns}"

Environment=CATALOGUE_PORT=8082

EOF

validation $? "Configuring Cart Service"

I will receive my Redis-Ip address and load-balancer DNS after terraform creating them, and I want to pass a bash script as user data with these values substituted. The problem is terraform trying to substitute every variable ${} inside bash script, but I only want it to substitute ${redis_ip} and ${app_lb_dns} into user data. I tried escaping all variables using \${} and $${} but no use.

Error:

20: user_data = base64encode(templatefile("../userdata/cart.sh", { redis_ip = data.aws_ssm_parameter.redis_ip.value, app_lb_dns = data.aws_ssm_parameter.app_lb_dns.value })) while calling templatefile(path, vars) Invalid value for "vars" parameter: vars map does not contain key "G", referenced at ../userdata/cart.sh:16,21-22.

As per Error , TF is trying to substitute a variable that is related to bash script.

This is my Terraform Code:

user_data = base64encode(templatefile("../userdata/cart.sh", { redis_ip = data.aws_ssm_parameter.redis_ip.value, app_lb_dns = data.aws_ssm_parameter.app_lb_dns.value }))

Solution

  • Because Bash and Terraform both use the ${ ... } punctuation to represent interpolation, if you use Terraform's template syntax to generate a Bash script then you'll need to use Terraform's escaping syntax in any situation where an interpolation sequence should be interpreted by Bash instead of by Terraform.

    For example:

    echo -e "$${Y}Configuring Cart Service$${N}"
    

    Note that Terraform's template language only treats ${ as special, whereas Bash supports both $foo and ${foo}. You only need to escape the sequence ${ in particular; it's not necessary to escape an individual dollar sign that is not followed by an opening brace character.

    Everything after this is exploring some different ways to solve the problem that have some other tradeoffs, but if you're happy with just escaping everything then that's fine and you can stop reading here.


    You can avoid this sort of conflict by rearranging your script to have only a small templated part and to be mostly just a static file.

    For example, if you place most of your script in a static bash script file called cart.sh then you can generate the small dynamic part separately inside the user_data expression, while avoiding treating cart.sh as a Terraform template at all:

      user_data = <<-EOT
        #!/bin/bash
    
        declare -r cart_config=<<EOF
        Environment=REDIS_HOST="${data.aws_ssm_parameter.redis_ip.value}"
        Environment=CATALOGUE_HOST="${data.aws_ssm_parameter.app_lb_dns.value}"
        Environment=CATALOGUE_PORT=8082
        EOF
    
        ${file("${path.module}/../userdata/cart.sh")}
      EOT
    

    Notice that this uses file instead of templatefile to read the cart.sh file, and so Terraform will take the contents completely literally and not try to interpret it as a template.

    The code in cart.sh can be written to refer to the variable cart_config, which is declared using the templated declare command in the overall result. Since that's the only variable that needs to include dynamic data from elsewhere in the Terraform module, this avoids the need for extra escaping in the cart.sh file.

    echo >/etc/systemd/system/cart.service "$cart_config"
    

    The inline-template-based solution above should work functionally, but I personally found the shape of it annoying to maintain and so I wrote a utility provider apparentlymart/bash that knows how to generate suitable declare statements and insert them at the start of a Bash script.

    The previous example could be rewritten using my utility provider to look something like this:

    # Terraform requires that your module must declare a
    # dependency on this provider in order for its functions
    # to be available for use elsewhere in the module.
    terraform {
      required_providers {
        bash = {
          source = "apparentlymart/bash"
        }
      }
    }
    
    resource "aws_instance" "example" {
      # ...
      user_data = provider::bash::script(file("${path.module}/../userdata/cart.sh"), {
        cart_config = <<-EOT
          Environment=REDIS_HOST="${data.aws_ssm_parameter.redis_ip.value}"
          Environment=CATALOGUE_HOST="${data.aws_ssm_parameter.app_lb_dns.value}"
          Environment=CATALOGUE_PORT=8082
        EOT
      })
    }
    

    The call to provider::bash::script in this example should produce a similar result as the hand-written template in my previous example.


    This provider-contributed function can also support maps of strings and declare them as Bash "associative arrays", which allows for an alternative where the details of the "cart config" file's syntax can be dealt with inside the bash script, rather than inside the Terraform configuration:

    # (...again, a required_providers block must appear in your module...)
    
    resource "aws_instance" "example" {
      # ...
      user_data = provider::bash::script(file("${path.module}/../userdata/cart.sh"), {
        environment = tomap({
          REDIS_HOST     = data.aws_ssm_parameter.redis_ip.value
          CATALOGUE_HOST = data.aws_ssm_parameter.app_lb_dns.value
          CATALOGUE_PORT = "8082"
        })
      })
    }
    

    The above means that there will be a Bash variable called environment whose value is an associative array with the three given elements, which you can then iterate over inside the Bash script itself:

    cart_config="/etc/systemd/system/cart.service"
    # Create the file, or truncate it if it already exists
    > $cart_config
    # Append each pair from "environment" as a line in the file
    for k in "${!environment[@]}"; do
        echo >>$cart_config "Environment=${k}=\"${environment["$k"]}\""
    done
    

    Whether this additional complexity is worth it for this simple situation is debatable, but this technique can be handy when the keys of the map that will be used in the script are dynamically-chosen, since the script will just react to whatever key/value pairs it was given rather than expecting a fixed set.

    (The above technique is tricky if either the variable names or values could potentially include characters that Bash would interpret non-literally, such as "globbing" symbols. There are some ideas about that in Other Bash Robustness Tips in the documentation for my provider. Since this is using Bash to generate yet another language -- the systemd unit file language -- you'll also need to make sure that the key and value strings don't contain anything that would make this invalid from systemd's perspective.)