Flexible provisioning of resources with Safespring's new Terraform modules

From basic to more advanced/powerful usage of Safespring's Terraform modules

Jarle Bjørgeengen

Jarle Bjørgeengen

Chief Product Officer

This is part two in the series about Safespring's Terraform modules. This blog post will look at the new and more general Safespring modules for compute instances and security groups.

We will also look at how we can use it to provision sets of instances in different configurations allowing only the necessary connections using security groups. The next post will be about using Ansible and from terraform/OpenStack to configure services on the provisioned instances.

Read more

If you found this post useful, be sure to check out the rest of the series on using Terraform and Ansible for resource provisioning and compliance. In particular, you might also enjoy:

  1. Dead easy provisioning using the Safespring Terraform modules
  2. Flexible provisioning of resources with Safespring’s new Terraform modules
  3. Integrating Terraform and ansible for efficient resource management
  4. From zero to continuous compliance with Terraform, ansible and Rudder

Prerequisites

This blog post assumes that you use the open source Terraform CLI. Terraform CLI is just a binary program that you download from the releases page, for your architecture/platform. Here you also find checksums for the files to verify their integrity.

Unless otherwise explained, all the examples presuppose that you put the code in a .tf in a separate directory and run plan, init, apply and destroy from within that directory. main.tf is mostly used as a convention for the file name, but you can name it whatever you like as long as it ends in .tf

There is also the official Terraform documentation

Terraform introduction

Terraform takes plain text files with «HCL - Hashicorp Configuration Language» as input and provides servers and storage as output. HCL is a declarative language, i.e., it does not specify any actions to be taken but rather a desired state - or outcome.

The idea that configuration languages should be declarative, and that the agent should drive/converge real state into the declared desired state, has become widely accepted over the last three decades and is based on ideas and research by Mark Burgess during the early nineties and later.

Terraform providers

The superpower of Terraform comes from all of its providers. The Terraform providers are binary extensions of Terraform that, as the name indicates, «provide» resources of different kinds using the APIs of the cloud provider reflected by the extension’s name.

These extensions do all the heavy lifting for the cloud provider APIs and ensure that the actual state (the cloud resources) is converged to what is specified as the desired state.

Terraform can be viewed as a desired state configuration agent for infrastructure. Every time it is run, it will turn the desired state into the actual state for cloud resources.

Reducing the level of «lock-in»

Terraform has tons of battle-tested providers available to use, thus easing the burden of provisioning cloud resources from all kinds of cloud APIs within the same (or different) configurations.

Let’s say you need resources in other clouds (or on-premise) for the same multi-cloud or hybrid environments. Then you can do that using one Terraform config, and you can even scale up and down the number of resources by changing some variables in your Terraform code.

Terraform is cloud-agnostic and thus is excellent insurance that your resources are as portable as possible, thus reducing the level of “lock-in” to a minimum.

Disclaimer

Terraform is a powerful tool, and powerful tools can make powerful failures if misused, so be sure to read up on documentation and best practices to understand the nature of the tool before using it for the important stuff.

The new «v2-compute-instance» module

In the previous blog post we showcased basic usage of the initial version of the Safespring Terraform modules. These modules are now deprecated and are replaced by a single module that does more than the deprecated ones. The reason for this is that the new module automatically switches usage of «boot from volume» on and off based on whether the flavor name starts with an «l» or not. The new module also defaults to use our new compute flavors, while the deprecated ones default to the old deprecated flavors. Last but not least, the new module can receive a map variable describing a set of additional data disks to be attached to the instance.

Note

The module library is constantly evolving, so this blog post explains the features currently available and how to use them. Please also look at the code, comments, and variable definitions to get the whole picture. Especially at a later point in time.

Examples

We’ll use the code examples in the Terraform module git repo as a reference and explain each of them underneath the code.

Ex1: One instance with default parameters

module my_sf_instance {
   source          = "github.com/safespring-community/terraform-modules/v2-compute-instance"
   # name          = "hello-safespring"
   key_pair_name   = "an-existing-keypair"
   # config_drive  = false
   # disk_size     = 5                 # When using b2-flavors
   # network       = "default"         # One of default, private, public
   # wg_ip         = ""                # Ends up as metadata. Can be used to assign wireguard address for us in Ansible.
   # role          = "general"         # Ends up as metadata. Can be for example be used as ansible host group with Ansible Terraform Inventory (ATI)
   # image         = "ubuntu-20.04"
   # flavor        = "l2.c2r4.100"     # Use openstack flavor list. Pick flavors starting with b2 or l2
   # security_groups = ["default"]
   # data_disks = {
   #   "db" = {
   #     size    = 5
   #     type    = "fast"
   #   }
   #   "archive" = {
   #      size = 10
   #      type = "large"
   #   }
   # }
}

This is the simplest possible example using only the module source on GitHub and a pre-existing keypair. All other values are default. The commented lines document the contents of the default values. To override a default just uncomment and change the value.

When applied, this code will create a compute instance with the name hello-safespring, operating system ubuntu 20.04, from a flavor with the local disk, 2 VCPUS, and 4 GB of RAM. It will be attached to the default network, which gives the instance a public IPv6 address and a private IPv4 address. The instance will have no data disks and will be a member of the default security group, which will contain rules that allow traffic from the instance to the world on IPv4 and IPv6 (egress). Since the flavor is of type local disk, the disk_size parameter will be ignored, and the local NVMe disk defined in the flavor (100GB) will be used for the Ubuntu operating system.

The config_drive parameter is rarely used. If you don’t know what it is used for, you can safely leave the default (false). For the role and wg_ip parameters, we’ll leave the explanation until later.

Ex2: A set of 3 instances using count

module my_sf_instances {
   count           = 3
   source          = "github.com/safespring-community/terraform-modules/v2-compute-instance"
   name            = "hello-safespring-${count.index + 1}.example.com"
   key_pair_name   = "an-existing-keypair"
}

Here we added a count of 3, and we use the count index to differentiate the names of the 3 instances created (you can’t create more than one instance with the same name). Applying this will yield 3 instances named hello-safespring-{1,2,3}.example.com. Commented default parameters were explained in the first example, so they are left out here. As in the first example, default values will be used where none is given, so all 3 instances will have the same properties, and these properties are the same default values as in the first example.

Ex3: Security group(s) and keypair as part of the code

# This is needed when creating resources directly. When using modules
# the modules will have this included.
terraform {
  required_version = ">= 0.14.0"
  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
    }
  }
}

# Create a keypair from a public key.
# An openstack keypair contains only the public key. Thus a misleading name for it.
resource "openstack_compute_keypair_v2" "skp" {
  name       = "hello-pubkey"
  public_key = "${chomp(file("~/.ssh/id_rsa.pub"))}"
}

# Create a security group using a safespring module
module puff {
   source = "github.com/safespring-community/terraform-modules/v2-compute-security-group"
   name = "bowl-of-petunias"
   description = "Oh no! Not again"
   rules = {
     one = {
       ip_protocol = "tcp"
       to_port = "22"
       from_port = "22"
       ethertype = "IPv4"
       cidr = "0.0.0.0/0"
     }
     two = {
       ip_protocol = "tcp"
       to_port = "443"
       from_port = "443"
       ethertype = "IPv4"
       cidr = "0.0.0.0/0"
     }
  }
}

module my_sf_instances {
   source          = "github.com/safespring-community/terraform-modules/v2-compute-instance"
   name            = "hello-safespring-${count.index + 1}.example.com"
   count           = 3
   security_groups = [ module.puff.name ]
   key_pair_name   = openstack_compute_keypair_v2.skp.name
}

Now we’ve added code to create the keypair hello-pubkey and the security group puff. Those names are used to name the objects within OpenStack. There are also the Terraform internal names which are used only for referencing back and forth inside the Terraform code/state. The latter is used to reference the names of the keypair and security group in the definition of the instances.

The result of this config will be the same 3 instances as in the previous example except they won’t be a member of the default security group, but rather the puff security group that we created with ingress rules for ssh and https.

Also, we have created our own keypair (public key) that our instances will get in their cloud users’ authorized_keys-file. This code takes the local (where Terraform is run) ~/.ssh/id_rsa.pub file and creates an OpenStack keypair for it. For details about ssh-keys in OpenStack, please head over to another blog post regarding that

In this config, we have mixed the creation of resources directly in the config and via external modules. This is fine, sometimes the resources are so simple that it doesn’t make sense to create an abstraction (module) for it. The OpenStack keypairs are an excellent example of such a resource.

The specification of security group rules is done with map variables directly inside the security group module instantiation, a map of maps «one» and «two». These can be replaced with «locals» or even variable definitions that can be used as parameters if using this code as a module.

It is totally up to you if you want to make use of our module library, create your own modules or just create the resources directly in your config. At least the module library, with its default values, can serve as documentation or a thin wrapper around the resources and names in our platform as seen from a Terraform perspective.

Ex4: Maps define instances and security group rules

module ingress {
   source = "github.com/safespring-community/terraform-modules/v2-compute-security-group"
   name = "ingress"
   delete_default_rules = true
   description = "For exposing web servers on port 443 (https) to the world"
   rules = {
     ingress = {
       direction   = "ingress"
       ip_protocol = "tcp"
       to_port     = "443"
       from_port   = "443"
       ethertype   = "IPv4"
       cidr        = "0.0.0.0/0"
     }
  }
}

module interconnect {
   source = "github.com/safespring-community/terraform-modules/v2-compute-security-group"
   name = "interconnect"
   delete_default_rules = true
   description = "For interconnecting servers with full network access between members"
   rules = {
     ingress = {
       direction             = "ingress"
       remote_group_id = "self"
     }
     egress = {
       direction             = "egress"
       remote_group_id = "self"
     }
  }
}

locals {
  instances = {
    "web1" = {
      name    = "websrv1.example.com"
      flavor  = "l2.c2r4.100"
      os      = "centos-7"
      network = "public"
      sgs     = [ module.interconnect.name, module.ingress.name ]
    }
    "web2" = {
      name    = "websrv2.example.com"
      flavor  = "l2.c2r4.100"
      os      = "centos-7"
      network = "public"
      sgs     = [ module.interconnect.name, module.ingress.name ]
    }
    "db" = {
      name    = "db.example.com"
      flavor  = "l2.c4r8.100"
      network = "default"
      os      = "ubuntu-20.04"
      sgs     = [ module.interconnect.name ]
    }
  }
}

module my_sf_instances {
   for_each        = local.instances
   source          = "github.com/safespring-community/terraform-modules/v2-compute-instance"
   name            = each.value.name
   image           = each.value.os
   network         = each.value.network
   security_groups = each.value.sgs
   key_pair_name   = an-existing-keypair-or-id-of-one-in-terraform-config
}

Here we iterate over a local map of maps that define all aspects of the instances to be created (see the line for_each = local.instances). Then we override the defaults of the v2-compute-instance-module using the individual fields of each map (in the instances-map) thus creating 3 instances with different properties.

The instances websrv{1,2}.example.com is created from a centos-7-image, attached to the public network (hence they get public IP addresses). They are also attached to both the ingress and the interconnect security groups which means that the sum/union of all rules in those security groups apply to them.

The interconnect security group has rules that open up full connectivity between all members of the group, but nothing else. The ingress security group opens up port tcp/443 from the world to all of its members.

Since the db server is the only member of the interconnect security group, the websrv{1,2} servers can connect to it (and vice versa) but the db server can not be reached from anywhere else, both because it is attached to the default network, which is a private (RFC1918) network, and because of the rules in the ingress security group (which only allows members of the same group to connect). If you are puzzled about why the webservers on the public network can connect to the db server on the default network with only one interface on each of them, please read this blog post about Safespring’s network stack.

It is worth noting that the parameter delete_default_rules = true will remove the default egress rules that allow access to the world on IPv4 and IPv6, hence giving you full control over what traffic will be allowed. This will effectively firewall all attempts from servers to initiate outbound connections and can be used as efficient prevention of [stage 2 downloads of executable code during an attack and hence prevent attackers establishment of command and control (COC)). Then you can punch only the necessary holes for legitimate outbound connections to software repositories etc. This is relevant also for servers on the default-network both via IPv6 and NATed IPv4.

Note

If you create an instance that has no security groups attached to it, it will still be attached to the `default` security group that includes egress rules that allow the instance to connect to the world. To prevent this, create your own security groups that you attach instances to and use the «delete_default_rules = true» parameter to the «v2-compute-security-group» module.

Ex5: Combining count and map for instances and map for disks

It would be nice if you could combine iteration with for_each (map) and count, right? That way you could say: «Give me 10 web servers with no datadisk on the public network with flavor X, and 2 backend servers on the default network with a 100GB datadisk». Well, if you try to combine them in the same call to v2-compute-instance you will get an error saying:

The "count" and "for_each" meta-arguments are mutually-exclusive, only one
should be used to be explicit about the number of resources to be created.  

However it can be done by wrapping one of them into its own module. let’s say we create the following local module in a directory named ./a-set-of-instances:

main.tf

module my_sf_instances {
   source          = "github.com/safespring-community/terraform-modules/v2-compute-instance"
   name            = "${var.prefix}-${count.index + 1}.example.com"
   count           = var.i_count
   key_pair_name   = var.key_pair_name
   data_disks      = var.data_disks
   image           = var.image
   network         = var.network
   flavor          = var.flavor
}

variables.tf

variable "i_count" {
  description = "Count"
  type        = number
}

variable "flavor" {
  type        = string
}

variable "prefix" {
  type        = string
}

variable "key_pair_name" {
  type = string
}

variable "image" {
  type = string
}

variable "network" {
  type = string
}

variable "data_disks" {
  type        = map(
    object({
      type      = string
      size      = number
    })
  )
}

providers.tf

terraform {
  required_version = ">= 0.14.0"
    required_providers {
      openstack = {
      source  = "terraform-provider-openstack/openstack"
    }
  }
}

And then this code in our main.tf:

locals {
  instances = {
    "web" = {
      prefix  = "web"
      flavor  = "l2.c2r4.100"
      os      = "centos-7"
      network = "public"
      i_count   = 2
    }
    "db" = {
      prefix  = "db"
      flavor  = "l2.c4r8.100"
      network = "default"
      os      = "ubuntu-20.04"
      data_disks = {
        "db" = {
          size    = 5
          type    = "fast"
        }
      }
    }
  }
}

module my_sf_instances {
   for_each        = local.instances
   source          = "./a-set-of-instances"
   prefix          = each.value.prefix
   i_count         = try(each.value.i_count,1)
   image           = each.value.os
   flavor          = each.value.flavor
   network         = each.value.network
   key_pair_name   = "jb-jump"
   data_disks      = try(each.value.data_disks,{})
}

So first we created a module that used our v2-compute-instance as the source with the necessary variable definitions for the values, we intend to override the defaults for and the i_count parameter which defines the count value for each.

Then we call our local module, that now supports an i_count parameter, and iterate over a map that has all the necessary default overrides for each set and the count for each set. So now, instead of copying two identical map entries and only varying the name, we can generate the name from a prefix and the count index in the local module; hence, we with one map entry we can create a set of as many instances we want with the same properties. If we need different properties, we create another set with its own parameters and i_count. The naming of the i_count parameter is chosen so it will not collide with the internal, reserved count parameter.

So here we have combined methods of examples 2 and 4 to make the same thing as example 4 but in a more generic way that can scale up sets without duplicating lots of map entries. To scale up the number of web servers now you only increase i_count field in the map entry for web servers instead of creating as many new map entries as new servers needed.

In addition, we have defined another map inside the map entry of the db instance that will create and attach a volume of type fast and size 5GB.

The try is used to give the local module the mandatory fallback parameters when different map entries need to override different sets of parameters in the v2_compute_instance. The local module must have variables for the sum/union of all parameters to be specified.