Deploying an Ubuntu VM with Terraform and cloud-init

I recently encountered a scenario where I would like to deploy an Ubuntu VM unattended, with the least amount of user interaction possible.

DISCLAIMER: I am not very proficient with cloud-init at all.

Quite a lot has already been written on using cloud-init within VMware, unfortunately, a lot of this seems to be fairly old, incomplete, or not really fit for my usecase. A lot of information out there refers to modifying your base template, which is great, but unfortunately not something that fits this particular scenario. Quite a few other sources refer to using vApp properties, which work great until you want to pass metadata. Similarly, quite a lot of people use a seed ISO for deployment, which is also a step I’d prefer to leave out for my usecase.

Requirements:

- a vSphere environment (duh)
- an OVF template based of the Ubuntu cloud image
- Terraform 

Steps:

Preparing the template

  1. Download and install your base template You can find Ubuntu’s cloud images right here. Find your preferred version and download the OVA. You can deploy this OVA through Terraform, or manually. It’s a one time action.

  2. Update the OVF template By default, the OVF template will have a bunch of vApp options. While these work just fine, they only offer a limited amount of functionality. They don’t allow you to add metadata, which is needed if you want to define a static IP for example or if you would like to download some package upon installation. I’ve yet to find a way to do this with Terraform when deploying the template, I’ve also not looked very hard yet, because it’s a still a one time action. From the UI, navigate to your template VM, click Configure and then click Edit Configure VM

In the next screen, uncheck Enable vApp options and click OK

vApp options

That should be it for manual work!

Writing the code

  1. The userdata template

The exact contents of either template will obviously depend on your usecase. For know, I’ve only added an extra user here. The passwd should be a hash of the user’s password (eg. echo ChangeMeNow | mkpasswd --method sha512 --rounds 4096). Ideally, you should use SSH keys though, and set lock_passwd to true.

I’ve used the following contents for userdata.yml.tpl

#cloud-config

# sets the hostname to 'terraformed'
hostname: terraformed

users:
        - name: ${User}
          passwd: $6$rounds=4096$P0uvlB9.8nsiY67$uuOxYSk6n/74Ds3JtV1mT6xYjOguwTWgNmOeHvcHiQa9zan57l8dvfHE/zlu19fDmJGySNzLmh6K0R2I1AU9o0
          lock_passwd: false 
          sudo: ALL=(ALL) ALL
          groups: [adm, audio, cdrom, dialout, floppy, video, plugdev, dip, netdev]
  1. The metadata template

Once again, the exact contains here will differ based on your usecase. For now, I only needed a static IP on a NIC.

I’ve used the following contents for metadata.yml.tpl

instance-id: ${Hostname}
local-hostname: ${Hostname}
network:
  version: 2
  ethernets:
    ens192:
      dhcp4: false
      addresses:
        - ${UplinkAddress}/${UplinkPrefix}
      gateway4: ${UplinkGateway}

Both the userdata and metadata files make use of variables, which are wrapped with ${}

  1. The Terraform resource I’ve omitted the data, variables and provider blocks here as those are bog standard code that can be copied directly from the documentation and it’s examples.

Most of this is a pretty standard virtual machine block. The relevant config is at the bottom here. In the extra_config block, you can see that I’m rendering the templates that were created before. Terraform’s templatefile() function takes in a file, and a map object that maps the template’s variables to actual data. Once this has been completed, the entire template gets encoded to base64 with the base64encode function.

resource "vsphere_virtual_machine" "ubuntu" {
  name             = var.hostname
  guest_id         = "ubuntu64Guest"
  resource_pool_id = data.vsphere_compute_cluster.cluster.resource_pool_id
  folder           = "/${var.VMFolder}"
  datastore_id     = data.vsphere_datastore.datastore.id

  memory               = 1024
  num_cpus             = 1
  num_cores_per_socket = 1
  scsi_type            = "pvscsi"

  disk {
    label = "disk0"
    size  = 10
  }

  network_interface {
    network_id   = data.vsphere_network.pg_uplink.id
    adapter_type = "vmxnet3"
  }

  cdrom {
    client_device = true
  }

  clone {
    template_uuid = data.vsphere_virtual_machine.template.id
  }

  extra_config = {
    "guestinfo.metadata.encoding" = "base64"
    "guestinfo.metadata" = base64encode(templatefile("${path.module}/metadata.yml.tpl", {
      Hostname      = var.hostname
      UplinkAddress = var.uplinkaddress,
      UplinkPrefix  = var.uplinkprefix,
      UplinkGateway = var.uplinkgateway
    }))
    "guestinfo.userdata.encoding" = "base64"
    "guestinfo.userdata"          = base64encode(templatefile("${path.module}/userdata.yml.tpl", { user = "example" }))
  }
}
  1. Run it! Finally, run your brand new code, and validate that your customizations were actually applied :)

Sources

I would not have gotten here without a bunch of existing documentation and blog posts made by several lovely people. You can read more here:

sh0rez’s repo: https://github.com/sh0rez/vSphere-terraform_ubuntu-cloud-ova/blob/master/post/README.md

Ryan Johnson’s repo: https://github.com/tenthirtyam/terrafom-examples-vmware/tree/main/vsphere/vsphere-virtual-machine/clone-template-linux-cloud-init

William Lam’s blog: https://williamlam.com/2022/06/using-the-new-vsphere-guest-os-customization-with-cloud-init-in-vsphere-7-0-update-3.html

This one particular Reddit post I came across as I was about the give up: https://old.reddit.com/r/devops/comments/pweoaj/ubuntu_datasourcevmware_via_terraforms_extra/