Escaping dollar sign in Terraform - terraform

In an attempt to create a route key named $disconnect for an API Gateway, I'm running the snippet below, while var.route_name should receive the string "disconnect":
resource "aws_apigatewayv2_route" "route" {
api_id = var.apigw_api.id
route_key = "$${var.route_name}"
# more stuff...
}
But it's not escaping it correctly. I coulnd't find a proper way to emit a $, followed by var.route_name's content.
How to do that?

In Terraform's template language, the sequence $${ is the escape sequence for literal ${, and so unfortunately in your example Terraform will understand $${var.route_name} as literally ${var.route_name}, and not as a string interpolation at all.
To avoid this, you can use any strategy that causes the initial $ to be separate from the following ${, so that Terraform will understand the first $ as a literal and the remainder as an interpolation sequence.
One way to do that would be to present that initial literal $ via an interpolation sequence itself:
"${"$"}${var.route_name}"
The above uses an interpolation sequence that would typically be redundant -- its value is a literal string itself -- but in this case it's grammatically useful to change Terraform's interpretation of that initial dollar sign.
Some other permutations:
join("", ["$", var.route_name])
format("$%s", var.route_name)
locals {
dollar = "$"
}
resource "aws_apigatewayv2_route" "route" {
route_key = "${local.dollar}${var.route_name}"
# ...
}
Again, all of these are just serving to present the literal $ in various ways that avoid it being followed by either { or ${ and thus avoid Terraform's parser treating it as a template sequence or template escape.

There probably exists an easier way to escape a $ in hcl2 string interpolation, but the format function will also assist you here:
resource "aws_apigatewayv2_route" "route" {
api_id = var.apigw_api.id
route_key = format("$%s", var.route_name)
# more stuff...
}

If you are trying to dynamically set a variable name (ie the variable name depends on another variable), this is not possible. Otherwise you could do this:
resource "aws_apigatewayv2_route" "route" {
api_id = var.apigw_api.id
route_key = "$$${var.route_name}"
# more stuff...
}
Instead, create a map route_keys and choose the key based on the name:
locals {
route_keys = {
route_name1 = ...
route_name2 = ...
}
}
resource "aws_apigatewayv2_route" "route" {
api_id = var.apigw_api.id
route_key = local.route_keys[var.route_name]
# more stuff...
}

Related

Trouble passing variable in terraform apply

I am using Terraform Cloud for my Backend with version 1.1
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
}
}
cloud {
hostname = "app.terraform.io"
organization = "MyOrg"
workspaces {
name = "MyWorkspace"
}
}
}
I have a variable in my HCL
variable "app_version" {
description = "The application version to deploy"
type = string
}
I am attempting to set it when I call terraform apply like so:-
terraform apply -var="app_version=v0.0.1"
I get the following error though.
1 error occurred:
* Invalid HCL in variable "app_version": At 1:15: Unknown token: 1:15 IDENT v0.0.1
What does this mean?
I think the Terraform docs for command line variables do a poor job of explaining that, but you basically need to wrap the value in double quotes. As the value passed after = is interpreted as Terraform expression, not constant (which opens up for other possibilities).
So, try:
terraform apply -var 'app_version="v0.0.1"'
Also notice there should be no = after -var
Grzegorz was correct but if you are on a windows machine you will need to escape the double quotes but adding a \ before each double quote.

Terraform extensible user_data in aws_instance

Good day,
Our team utilizes a module that creates Linux instances with a standard configuration in user_data as defined below.
resource "aws_instance" "this" {
...
user_data = templatefile("${path.module}/user_data.tp", { hostname = upper("${local.prefix}${count.index + 1}"), domain = local.domain })
...
}
Contents of the user_data.tp:
#cloud-config
repo_update: true
repo_upgrade: all
preserve_hostname: false
hostname: ${hostname}
fqdn: ${hostname}.${domain}
manage_etc_hosts: false
runcmd:
- 'echo "preserve_hostname: true" >> /etc/cloud/cloud.cfg.d/99_hostname.cfg'
What is the best way to modify this module such that the contents of user_data.tp are always executed and optionally another block could be passed to install certain packages or execute certain shell scripts?
I'm assuming it involves using cloudinit_config and a multipart mime configuration, but would appreciate any suggestions.
Thank you.
Since you showed a cloud-config template I'm assuming here that you're preparing a user_data for an AMI that runs cloud-init on boot. That means this is perhaps more of a cloud-init question than a Terraform question, but I understand that you also want to know how to translate the cloud-init-specific answer into a workable Terraform configuration.
The User-data Formats documentation describes various possible ways to format user_data for cloud-init to consume. You mentioned multipart MIME in your question and indeed that could be a viable answer here if you want cloud-init to interpret the two payloads separately, rather than as a single artifact. The cloud-init docs talk about the tool make-mime, but the Terraform equivalent of that is the cloudinit_config data source belonging to the hashicorp/cloudinit provider:
variable "extra_cloudinit" {
type = object({
content_type = string
content = string
})
# This makes the variable optional to set,
# and var.extra_cloudinit will be null if not set.
default = null
}
data "cloudinit_config" "user_data" {
# set "count" to be whatever your aws_instance count is set to
count = ...
part {
content_type = "text/cloud-config"
content = templatefile(
"${path.module}/user_data.tp",
{
hostname = upper("${local.prefix}${count.index + 1}")
domain = local.domain
}
)
}
dynamic "part" {
# If var.extra_cloud_init is null then this
# will produce a zero-element list, or otherwise
# it'll produce a one-element list.
for_each = var.extra_cloudinit[*]
content {
content_type = part.value.content_type
content = part.value.content
# NOTE: should probably also set merge_type
# here to tell cloud-init how to merge these
# two:
# https://cloudinit.readthedocs.io/en/latest/topics/merging.html
}
}
}
resource "aws_instance" "example" {
count = length(data.cloudinit_config.user_data)
# ...
user_data = data.cloudinit_config.user_data[count.index].rendered
}
If you expect that the extra cloud-init configuration will always come in the form of extra cloud-config YAML values then an alternative approach would be to merge the two data structures together within Terraform and then yamlencode the merged result:
variable "extra_cloudinit" {
type = any
# This makes the variable optional to set,
# and var.extra_cloudinit will be null if not set.
default = {}
validation {
condition = can(merge(var.extra_cloudinit, {}))
error_message = "Must be an object to merge with the built-in cloud-init settings."
}
}
locals {
cloudinit_config = merge(
var.extra_cloudinit,
{
repo_update = true
repo_upgrade = "all"
# etc, etc
},
)
}
resource "aws_instance" "example" {
count = length(data.cloudinit_config.user_data)
# ...
user_data = <<EOT
#!cloud-config
${yamlencode(local.cloudinit_config)}
EOT
}
A disadvantage of this approach is that Terraform's merge function is always a shallow merge only, whereas cloud-init itself has various other merging options. However, an advantage is that the resulting single YAML document will generally be simpler than a multipart MIME payload and thus probably easier to review for correctness in the terraform plan output.

How do I apply a CRD from github to a cluster with terraform?

I want to install a CRD with terraform, I was hoping it would be easy as doing this:
data "http" "crd" {
url = "https://raw.githubusercontent.com/kubernetes-sigs/application/master/deploy/kube-app-manager-aio.yaml"
request_headers = {
Accept = "text/plain"
}
}
resource "kubernetes_manifest" "install-crd" {
manifest = data.http.crd.body
}
But I get this error:
can't unmarshal tftypes.String into *map[string]tftypes.Value, expected
map[string]tftypes.Value
Trying to convert it to yaml with yamldecode also doesn't work because yamldecode doesn't support multi-doc yaml files.
I could use exec, but I was already doing that while waiting for the kubernetes_manifest resource to be released. Does kubernetes_manifest only support a single resource or can it be used to create several from a raw text manifest file?
kubernetes_manifest (emphasis mine)
Represents one Kubernetes resource by supplying a manifest attribute
That sounds to me like it does not support multiple resources / a multi doc yaml file.
However you can manually split the incoming document and yamldecode the parts of it:
locals {
yamls = [for data in split("---", data.http.crd.body): yamldecode(data)]
}
resource "kubernetes_manifest" "install-crd" {
count = length(local.yamls)
manifest = local.yamls[count.index]
}
Unfortunately on my machine this then complains about
'status' attribute key is not allowed in manifest configuration
for exactly one of the 11 manifests.
And since I have no clue of kubernetes I have no idea what that means or wether or not it needs fixing.
Alternatively you can always use a null_resource with a script that fetches the yaml document and uses bash tools or python or whatever is installed to convert and split and filter the incoming yaml.
I got this to work using kubectl provider. Eventually kubernetes_manifest should work as well, but it is currently (v2.5.0) still beta and has some bugs. This example only uses kind+name, but for full uniqueness, it should also include the API and the namespace params.
resource "kubectl_manifest" "cdr" {
# Create a map { "kind--name" => yaml_doc } from the multi-document yaml text.
# Each element is a separate kubernetes resource.
# Must use \n---\n to avoid splitting on strings and comments containing "---".
# YAML allows "---" to be the first and last line of a file, so make sure
# raw yaml begins and ends with a newline.
# The "---" can be followed by spaces, so need to remove those too.
# Skip blocks that are empty or comments-only in case yaml began with a comment before "---".
for_each = {
for pair in [
for yaml in split(
"\n---\n",
"\n${replace(data.http.crd.body, "/(?m)^---[[:blank:]]*(#.*)?$/", "---")}\n"
) :
[yamldecode(yaml), yaml]
if trimspace(replace(yaml, "/(?m)(^[[:blank:]]*(#.*)?$)+/", "")) != ""
] : "${pair.0["kind"]}--${pair.0["metadata"]["name"]}" => pair.1
}
yaml_body = each.value
}
Once Hashicorp fixes kubernetes_manifest, I would recommend using the same approach. Do not use count+element() because if the ordering of the elements change, Terraform will delete/recreate many resources without needed it.
resource "kubernetes_manifest" "crd" {
for_each = {
for value in [
for yaml in split(
"\n---\n",
"\n${replace(data.http.crd.body, "/(?m)^---[[:blank:]]*(#.*)?$/", "---")}\n"
) :
yamldecode(yaml)
if trimspace(replace(yaml, "/(?m)(^[[:blank:]]*(#.*)?$)+/", "")) != ""
] : "${value["kind"]}--${value["metadata"]["name"]}" => value
}
manifest = each.value
}
P.S. Please support Terraform feature request for multi-document yamldecode. Will make things far easier than the above regex.
Terraform can split a multi-resource yaml (---) for you (docs):
# fetch a raw multi-resource yaml
data "http" "knative_serving_crds" {
url = "https://github.com/knative/serving/releases/download/knative-v1.7.1/serving-crds.yaml"
}
# split raw yaml into individual resources
data "kubectl_file_documents" "knative_serving_crds" {
content = data.http.knative_serving_crds.body
}
# apply each resource from the yaml one by one
resource "kubectl_manifest" "knative_serving_crds" {
depends_on = [kops_cluster_updater.updater]
for_each = data.kubectl_file_documents.knative_serving_crds.manifests
yaml_body = each.value
}

Terraform variable to assign using function

variable "cidr" {
type = map(string)
default = {
development = "x.1.0.0/16"
qa = "x.1.0.0/16"
default = "x.1.0.0/16"
}
}
variable "network_address_space" {
default = lookup(var.cidr, var.environment_name,"default")
}
Am getting error that "Error: Function calls not allowed"
variable "subnet_address_space": cidr_subnet2_address_space = cidrsubnet(var.network_address_space,8,1)
A Terraform Input Variable is analogous to a function argument in a general-purpose programming language: its value comes from an expression in the calling module, not from the current module.
The default mechanism allows us to substitute a value for when the caller doesn't specify one, but because variables are intended for getting data into a module from the outside, it doesn't make sense to set the default to something from inside that module: that would cause the result to potentially be something the caller of the module could never actually specify, because they don't have access to the necessary data.
Terraform has another concept Local Values which are roughly analogous to a local variable within a function in a general-purpose programming language. These can draw from function results and other objects in the current module to produce their value, and so we can use input variables and local values together to provide fallback behaviors like you've illustrated in your question:
var "environment_name" {
type = string
}
var "environment_default_cidr_blocks" {
type = map(string)
default = {
development = "10.1.0.0/16"
qa = "10.2.0.0/16"
}
}
var "override_network_range" {
type = string
default = null # If not set by caller, will be null
}
locals {
subnet_cidr_block = (
var.override_network_range != null ?
var.override_network_range :
var.environment_default_cidr_blocks[var.environment_name]
)
}
Elsewhere in the module you can use local.subnet_cidr_block to refer to the final CIDR block selection, regardless of whether it was set explicitly by the caller or by lookup into the table of defaults.
When a module uses computation to make a decision like this, it is sometimes useful for the module to export its result as an Output Value so that the calling module can make use of it too, similar to how Terraform resources also export additional attributes recording decisions made by the provider or by the remote API:
output "subnet_cidr_block" {
value = local.subnet_cidr_block
}
As stated in Interpolate variables inside .tfvars to define another variable by the Hashicorp person, it is intended to be constant by design.
Input variables are constant values passed into the root module, and so they cannot contain interpolations or other expressions that do not yield a constant value.
We cannot use variables in backend either as in Using variables in terraform backend config block.
These are the things we Terraform users tripped on at some point, I suppose.

Pass complex, non-primitive data types to Terraform template provider

Having a more complex list object like this
variable "proxy" {
type = list(object({
enabled = bool
host = string
port = number
user = string
password = string
}))
default = [
{
enabled = false
host = ""
port = 0
user = ""
password = ""
}
]
}
I want to use this in a external template (cloudinit in my case). The template_file directive allows passing variables to a template. Sadly, not for more complex types:
Note that variables must all be primitives. Direct references to lists or maps will cause a validation error.
So something like this
data "template_file" "cloudinit_data" {
template = file("cloudinit.cfg")
vars = {
proxy = var.proxy
}
}
cause the error
Inappropriate value for attribute "vars": element "proxy": string required.
This leads me to two questions:
How can I pass the variable to the template? I assume that I need to convert it to a primitive type like this:
vars = {
proxy_host = var.proxy.host
}
This doesn't work:
This value does not have any attributes.
Is there an alternative way to pass this object directly to the template?
I'm using v0.12.17.
The template_file data source continues to exist only for compatibility with configurations written for Terraform 0.11. Since you are using Terraform 0.12, you should use the templatefile function instead, which is a built-in part of the language and supports all value types.
Because templatefile is a function, you can call it from anywhere expressions are expected. If you want to use the rendered result multiple times then you could define it as a named local value, for example:
locals {
cloudinit_data = templatefile("${path.module}/cloudinit.cfg", {
proxy = var.proxy
})
}
If you only need this result once -- for example, if you're using it just to populate the user_data of a single aws_instance resource -- then you can just write this expression inline in the resource block, to keep everything together and make the configuration (subjectively) easier to read:
resource "aws_instance" "example" {
# ...
user_data = templatefile("${path.module}/cloudinit.cfg", {
proxy = var.proxy
})
}

Resources