Terraform is an open-source infrastructure-as-code software tool created by HashiCorp. Users define and provide data center infrastructure using a declarative configuration language
In this lab we will use it to create new servers on a cloud provider (SWITCHEngines).
Install Terraform either follow their install guide
Or on Windows you can use:
winget install Hashicorp.Terraform
To get code highlighting you can install the Official Terraform Extension.
Create a new folder for your project and a new text file named main.tf
inside it.
We first must inform Terraform which Provider we want to use.
For SWITCHengines we are going to use the OpenStack Provider.
Add this section to your file.
terraform {
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "1.50.0"
}
}
}
With the provider install we need to pass in the minimal configuration.
...
provider "openstack" {
auth_url = "https://keystone.cloud.switch.ch:5000/v3"
region = "LS"
application_credential_id = "your_id"
application_credential_secret = "your_secret"
}
To create an api key login to your SWITCHengines account and under Identity > Application Credentials, create a new key (Leave everything blank except for the name).
Copy the information into your main.tf
file.
We now need to define the resources we want Terraform to create. These resources need to be of a type supported/provided by our provider plugin (openstack).
For a Virtual Machine we will need: a compute instance (vm), a volume (block device), a floating ip (to access it from the outside) and finally assign the floating ip to the compute instance.
Each resource block has a type and a name.
...
resource "openstack_compute_instance_v2" "vm_tf_web" {
name = "vm_tf_db"
image_name = "Debian Bookworm 12 (SWITCHengines)"
flavor_name = "m1.small"
key_pair = "REPLACE_WITH_YOUR_KEY_PAIR_NAME"
security_groups = ["default", "SSH", "web"]
block_device {
uuid = "REPLACE_WITH_ID_OF_IMAGE"
source_type = "image"
destination_type = "volume"
volume_size = 20
delete_on_termination = true
}
}
resource "openstack_networking_floatingip_v2" "myip" {
pool = "public"
}
resource "openstack_compute_floatingip_associate_v2" "myip" {
floating_ip = openstack_networking_floatingip_v2.myip.address
instance_id = openstack_compute_instance_v2.vm_tf_web.id
}
Image_name, flavor_name, key_pair must match names existing in our SWITCHengines account.
For block_device inside a compute instance we cannot use a named alias, and must identify the uuid of an image.
We can now ask terraform to create the defined resources.
The first time we need to download all the defined provider plugins:
terraform init
Then we can run our plan. Terraform will list all the changes which will happen and we can accept or cancel.
terraform apply
After some time our resources will be created.
We should not need to use the admin portal to get the floating IP. Terraform keeps state information of all resources it has created.
For a list of resources do:
terraform state list
To get details on the floatingip:
terraform state show openstack_networking_floatingip_v2.myip
You will get the public ip under address
attribute
In terraform we can also define outputs to more easily find the right information without looking in the state files.
Add an output which will print the floating ip.
...
output "remote_ip_addr" {
value = openstack_networking_floatingip_v2.myip.address
}
Run terraform again.
terraform apply
You will see that nothing is recreated and the output is displayed
Now that we have the public ip of the new remote server we could edit our ansible hosts file and start provisioning. However, if we create a lot of servers this is not practical.
Fortunately, Ansible supports generating Inventories (list of hosts) dynamically via a provider's api.
Ansible supports openstack if the python openstacksdk is installed.
We will add this library to our previously created ansible pipx venv.
pipx inject ansible openstacksdk
We now have to provide the SDK with API keys and service endpoints.
This information is stored in the clouds.yaml file we downloaded before!
Copy clouds.yaml to the right location (create the folders if missing)
~/.config/openstack/clouds.yaml
Inside your ansible project inventory
folder create a new file openstack.yaml
.
We set up some configuration of the plugin to work with our limited SWITCHengines setup. Additionally we provide the user ansible should use to login into our machines (pay attention to the quotes!).
plugin: openstack.cloud.openstack
expand_hostvars: false
fail_on_errors: true
all_projects: false
compose:
ansible_user: "'debian'"
List all hosts in the current inventory (static and dynamic)
ansible -i inventory all --list-hosts
Get host ip's for all server starting with name vm_tf_db
ansible -i inventory/openstack.yaml "vm_tf_db*" -m debug -a "var=ansible_host"
Ansible postgresql example
Could do via sh script like before but since ansible has a postgresql plugin use it for better idempotency
Here is a playbook you can try out.
- name: Setup DB server
hosts: vm_tf_db
become: true
tasks:
- name: Ensure postgresql is installed
ansible.builtin.apt:
name:
- postgresql
- python3-psycopg2
- acl
state: present
update_cache: true
- name: Postgres config
become_user: postgres
block:
- name: Should listen on all ports
community.postgresql.postgresql_set:
name: listen_addresses
value: '*'
- name: Set TimeZone parameter (careful, case sensitive)
community.postgresql.postgresql_set:
name: TimeZone
value: 'Europe/Zurich'
- name: Open connection to remote
community.postgresql.postgresql_pg_hba:
dest: /etc/postgresql/13/main/pg_hba.conf
rules:
- contype: host
databases: all
users: all
method: md5
source: ::0/0
- contype: host
databases: all
users: all
method: md5
source: 0.0.0.0/0
- name: Create a new database with name "guestbook"
community.postgresql.postgresql_db:
name: guestbook
- name: Create guestbook user, set password
community.postgresql.postgresql_user:
name: guestbook
password: guestbook
- name: GRANT ALL PRIVILEGES to guestbook on guestbook
community.postgresql.postgresql_privs:
db: guestbook
role: guestbook
obj: ALL_IN_SCHEMA
privs: ALL
- name: Restart postgresql
ansible.builtin.service:
name: postgresql
state: restarted
It can happen that terraform's state does not correspond with reality. For example, if SWITCHengines stopped your server during the night.
You can tell terraform to update its state with:
terraform refresh
Running
terraform apply
Will put your server back into running mode.
You can easily clean up everything by telling terraform to remove all created resources.
terraform destroy
Let's dive deeper into features of terraform to help us setup four servers: 1 web, 2 apps and 1 db.
First we want to remove our hardcoded API secrets and replace them with variables.
Create a variable.tf file and define sensitive variables for the api key.
variable "switchengine_application_credential_id" {
type = string
sensitive = true
}
variable "switchengine_application_credential_secret" {
type = string
sensitive = true
}
variable "duckdns_token" {
type = string
sensitive = true
}
variable "duckdns_domain" {
type = string
default = "gb-heg"
}
In the main file you can access variables with the var
keyword.
provider "openstack" {
auth_url = "https://keystone.cloud.switch.ch:5000/v3"
region = "LS"
application_credential_id = var.switchengine_application_credential_id
application_credential_secret = var.switchengine_application_credential_secret
}
When running a terraform command you will be asked to provide the values for all variables without defaults.
But you can use a special terraform.tfvars
file to provide initial value of these variable (be sure to NOT COMMIT this file)
switchengine_application_credential_id="REPLACE_HERE"
switchengine_application_credential_secret="REPLACE_HERE"
Attention quotes are required
Inside a tf script you can also use local variables defined in a locals block, these variables will be available under a special local
keyword.
It is possible to run small scripts or command lines directly form terraform either on the remote host or on the local machine. This is done in a provisioner section inside a resource block. This cone will execute on resource creation only.
Below is a complete terraform plan which will configure the servers.
The num_app_srv variable is used to define the number of app servers to create. It is used in combination with the count attribute which helps in duplicating the same resource multiple times (here the app server).
The db server is the only one with a persistent volume.
A local variable is used to store a concatenated lookup of all generated servers. This is used to map the required floating IPs.
In the floating IP association we also use a provisioner script to update duckdns.org with the IP of the web server instance.
variable "num_app_srv" {
type = number
}
terraform {
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "1.50.0"
}
}
}
provider "openstack" {
auth_url = "https://keystone.cloud.switch.ch:5000/v3"
region = "LS"
application_credential_id = var.switchengine_application_credential_id
application_credential_secret = var.switchengine_application_credential_secret
}
resource "openstack_networking_floatingip_v2" "floatingips" {
count = var.num_app_srv + 2
pool = "public"
}
resource "openstack_compute_instance_v2" "vm_tf_web" {
name = "vm_tf_web"
image_name = "Debian Bullseye 11 (SWITCHengines)"
flavor_name = "m1.small"
key_pair = "REPLACE_WITH_OUR_KEY"
security_groups = ["default", "SSH", "web"]
}
resource "openstack_compute_instance_v2" "vm_tf_app" {
count = var.num_app_srv
name = "vm_tf_app${count.index + 1}"
image_name = "Debian Bullseye 11 (SWITCHengines)"
flavor_name = "m1.small"
key_pair = "boris"
security_groups = ["default", "SSH"]
}
resource "openstack_compute_instance_v2" "vm_tf_db" {
name = "vm_tf_db"
image_name = "Debian Bullseye 11 (SWITCHengines)"
flavor_name = "m1.small"
key_pair = "boris"
security_groups = ["default", "SSH"]
block_device {
uuid = "06c5a538-8123-49d3-83f4-0a8fc5a07967"
source_type = "image"
destination_type = "volume"
volume_size = 20
delete_on_termination = true
}
}
locals {
instance_map = { for i in concat(openstack_compute_instance_v2.vm_tf_app, [openstack_compute_instance_v2.vm_tf_db, openstack_compute_instance_v2.vm_tf_web]) : i.id => i.name}
}
resource "openstack_compute_floatingip_associate_v2" "assigned_ips" {
count = var.num_app_srv + 2
floating_ip = openstack_networking_floatingip_v2.floatingips[count.index].address
// splat not supported https://github.com/hashicorp/terraform/issues/19931
instance_id = keys(local.instance_map)[count.index]
provisioner "local-exec" {
command = (
local.instance_map[self.instance_id] == "vm_tf_web" ?
"curl -k %DUCKDNS_URL%" : "exit 0"
)
environment = {
DUCKDNS_URL = "\"https://www.duckdns.org/update?domains=${var.duckdns_domain}&token=${var.duckdns_token}&ip=${self.floating_ip}\""
}
}
}
output "remote_ip_addr" {
value = { for a in openstack_compute_floatingip_associate_v2.assigned_ips : local.instance_map[a.instance_id] => a.floating_ip }
}
Take a screenshot from your SWITCHengines vm instances dashboard showing the created virtual machines.