Wrong indent with multiline content to cloud-init (write_files + content directives)


I’m trying to render a terraform template and write its content in a configuration file (which happens to be consul) using cloud-init. The issue I’m currently facing is that I want to write this as part of the userdata of cloud-init using write_files and -content: |, so that it interprets this literally.
This is what the cloud-init config file looks like:

  - name: root
    groups: users
      - ${root_ssh_key}
    shell: /bin/bash

  - content: |

root_ssh_key is rendered correctly, because it’s just one line.
But consul-config is the entire hcl configuration. After rendering this all, I get the following:

Content-Type: multipart/mixed; boundary="MIMEBOUNDARY"
MIME-Version: 1.0^M
Content-Disposition: attachment; filename="cloud-init.cfg"^M
Content-Transfer-Encoding: 7bit^M
Content-Type: text/cloud-config^M
Mime-Version: 1.0^M
  - name: root
    groups: users
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDrlFuvnkVqt/3Nin6i7nWSelEddwqfBWQOKnD8P85vG vincentiu.iacob@ejobsgroup.ro

    shell: /bin/bash

  - content: |
      # Fullconfiguration options can be found at https://www.consul.io/docs/agent/options.html

# datacenter
# This flag controls the datacenter in which the agent is running. If not provided,
# it defaults to "dc1". Consul has first-class support for multiple datacenters, but
# it relies on proper configuration. Nodes in the same datacenter should be on a
# single LAN.
datacenter = "${consul_datacenter}"

So I would have expected that all the rows following “# Fullconfiguration options…” to have the same indentation, because that’s obviously important for the yaml I’m using here.

So my question is: is there any way I can achieve this by using cloud-init like this? If not, what is the proper way of writing multiline content to a file on the deployed virtual machine, starting from terraform templates?


Hi @lethargosapatheia,

What you’ve encountered here is a classic problem when trying to generate structured configuration formats like YAML using string concatenation (which is, essentially, what template interpolation does).

The templatefile function’s documentation includes a section Generating JSON or YAML from a template which describes our recommended approach to this sort of requirement.

In summary, the recommendation is to make the separate template file consist only of one big interpolation sequence whose expression is a call to yamlencode, and thus Terraform can be responsible for converting the given data structure to valid YAML, using that function’s knowledge of what is valid YAML syntax.

The extra required #cloud-config comment for cloud-init does require a small modification, to include that prior to the rendered YAML. For example:

  users = [
      name   = "root"
      groups = "users"
      ssh_authorized_keys = [
      shell = "/bin/bash"
  write_files = [
      content = consul-config

Note that because the content of an interpolation sequence ${ ... } is Terraform expression syntax rather than template syntax, it’s valid and expected to just refer directly to the variables that are in the template scope (root_ssh_key and consul-config in your case), without any need for further interpolation markers. The quotes around the literal values is what differentiates them from references to variables in this context, just as for expressions written in your .tf files.

However, if these symbol names are under your control then I would recommend renaming consul-config to consul_config (with an underscore) so that future readers who might be bringing experience from other programming languages won’t suspect that this is consul minus config. (The Terraform language does allow - in identifiers, but many other languages don’t.)

1 Like

Thank you for the reply. Unfortunately, when adding the yaml encoding, I get the following error:

│ Call to function "templatefile" failed: ./files/userdata.tpl:7,9-10: Invalid character; This
│ character is not used within the language., and 3 other diagnostic(s).

I should also paste the terraform code part that actually deals with interpreting the cloud-init configuration file:

data "template_cloudinit_config" "consul" {
        for_each = var.consul-servers
        # split in parts - 1st is cloud-init cfg as such; from 2nd onwards, shell scripts.
        # default gzip is true + base64 encoded (for proxmox don't encode or zip the cloud-init)
        gzip = false
        base64_encode = false
        part {
                filename = "cloud-init.cfg"
                content_type = "text/cloud-config"
                content = templatefile("${path.module}/files/userdata.tpl", {
                        root_ssh_key = file("${path.module}/files/id_ed25519.pub")
                        consul_config = file("${path.module}/files/consul-config.tftpl")
                #       consul_datacenter = var.consul_datacenter
                #       ui_config = var.ui_config
                #       server_role = var.server_role
                #       retry_join = var.retry_join
                #       consul_domain = var.consul_domain
                #       ip_address = each.value["ip_address"]

Maybe this doesn’t work well with content_type text/cloud-config or something to that effect? I worry that changing this might not be correctly interpreted by cloud-init, but I’m willing go try if there are other ways of doing this.

I already changed consul-config the moment I saw it :slight_smile: It did stand out, yes.

[later edit:]
I tested it without actually making all the necessary changes to the yaml. I didn’t see all the changes that you had made to the cloud-init yaml file :slight_smile:

It does work as expected! I appreciate it :slight_smile:

I have another issue with using templating within templating (so the consul config, but I’m going to create another thread, as that is a topic in itself).

I encountered the same issue and found a solution using base64encode() from a GH Issues thread in 2016. Pasting here, courtesy of @paultyng.

Instead of dealing with indentation in the cloud config, you can base64 encode the content via terraform interpolation and then the indentation doesn’t matter

  - path: /etc/kubernetes/ssl/ca.pem
    encoding: base64
    content: ${base64encode(ca_pem)}
1 Like

@michaelstepner This could actually be a pretty nice solution! As much as I appreciate @apparentlymart 's solution, I’m a little bit afraid that it transforms the whole thing so much, whereas with the base64 encoding you can still see the cloud-init format as is. It could actually be a nice trade-off.

I’ll keep that in mind when restart my setup :slight_smile:
Thank you!

Hi @michaelstepner,

I suppose that depends on whether you consider the YAML format here to be an important part of the definition or just an implementation detail of sending a data structure over to cloud-init.

I tend to be more concerned with making sure the data structure I wrote gets sent exactly as I presented it, and don’t really care about how it gets serialised as YAML as long as an equivalent data structure emerges at the other end.

You can generate YAML with string concatenation in templates if you find the result easier to read and maintain, of course. It does mean, though, that you need to do custom work at each interpolation to make sure it will always insert something in a way that doesn’t break the overall YAML document.

A more general compromise in that direction is to pass values through jsonencode when you interpolate them into YAML values. YAML is a superset of JSON and so jsonencode results should always be valid flow-style YAML:

  content: ${jsonencode(ca_pem)}

The result for this version would be a quoted string containing the PEM content, which is also valid YAML syntax but might be more readable than the base64 version when you see it in a plan diff. Of course, you’d need to remove the other property that says the value is base64 encoded, because it isn’t in this case.

(The comment you found on GitHub about this technique predates Terraform having a yamlencode function. Amusingly, the author of that comment was one of the proponents of us adding the yamlencode and yamldecode functions once it became technically possible in Terraform v0.12, presumably because of situations like that one!)

1 Like

My initial impression was that yamlencode is pretty complicated, even if it does solve the parsing problem. Once you get the hang of it though, it becomes pretty easy. So I do appreciate your suggestion, I used it then and I’m continued to use it and it seems to be working correctly.