Terraform and NREC: Part II - Additional resources

Last changed: 2024-04-17

This document describes how to create and manage several instances (virtual machines) using Terraform. This document builds on Terraform and NREC: Part I - Basics. While part 1 relied on preexisting resources such as SSH key pairs and security groups, in this example we create everything from scratch.

The example file can be downloaded here: advanced.tf.

Image Name

In Part 1 we used image_name to specify our preferred image. By itself this is usually not a good idea, unless for testing purposes. The “GOLD” images provided in NREC are renewed (e.g. replaced) each month, and Terraform uses the image ID in its state. If using Terraform as a oneshot utility to spin up instances, this isn’t a problem.

The consequence of using image_name to specify the image is that Terraform’s own state becomes outdated when the NREC image is renamed. When using Terraform at a later time to make changes in the virtual infrastructure, it will destroy all running instances and create new ones, in order to comply with the configuration. This is probably not what you want. Running terraform plan in this scenario would output:

image_name:     "Outdated (CentOS 8)" => "GOLD CentOS 8" (forces new resource)

In order to combat this, we add the following code snippet to our openstack_compute_instance_v2 resource:

advanced.tf
1  lifecycle {
2    ignore_changes = [image_name,image_id]
3  }

This lifecycle meta-argument makes Terraform ignore changes to the image name. Another approach would be to specify image_id instead of image_name. We find the correct image_id by using the Openstack CLI:

$ openstack image list --status active

Multiple instances

Building on the basic.tf file discussed in Part 1:

basic.tf
 1# Define required providers
 2terraform {
 3  required_version = ">= 1.0"
 4  required_providers {
 5    openstack = {
 6      source  = "terraform-provider-openstack/openstack"
 7    }
 8  }
 9}
10
11# Configure the OpenStack Provider
12# Empty means using environment variables "OS_*". More info:
13# https://registry.terraform.io/providers/terraform-provider-openstack/openstack/latest/docs
14provider "openstack" {}
15
16# Create a server
17resource "openstack_compute_instance_v2" "test-server" {
18  name = "test-server"
19  image_name = "GOLD CentOS 8"
20  flavor_name = "m1.small"
21
22  key_pair = "mykey"
23  security_groups = [ "default", "SSH and ICMP from login.uio.no" ]
24
25  network {
26    name = "IPv6"
27  }
28}

This file provisions a single instance. We can add a count directive to specify how many we want to provision. When doing so, we should also make sure that the instances have unique names, and we accomplish that by using the count when specifying the instance name:

 1# Define required providers
 2terraform {
 3  required_version = ">= 1.0"
 4  required_providers {
 5    openstack = {
 6      source  = "terraform-provider-openstack/openstack"
 7    }
 8  }
 9}
10
11# Configure the OpenStack Provider
12# Empty means using environment variables "OS_*". More info:
13# https://registry.terraform.io/providers/terraform-provider-openstack/openstack/latest/docs
14provider "openstack" {}
15
16# Instances
17resource "openstack_compute_instance_v2" "instance" {
18  count = 5
19  name = "test-${count.index}"
20  image_name = "GOLD CentOS 8"
21  flavor_name = "m1.small"
22
23  key_pair = "my-terraform-key"
24  security_groups = [ "default", "uio-ssh-icmp" ]
25
26  network {
27    name = "IPv6"
28  }
29
30  lifecycle {
31    ignore_changes = [image_name,image_id]
32  }
33}

When running this file with terraform apply, a total of 5 instances are created, as expected:

$ openstack server list
+--------------------------------------+--------+--------+----------------------------------------+---------------+----------+
| ID                                   | Name   | Status | Networks                               | Image         | Flavor   |
+--------------------------------------+--------+--------+----------------------------------------+---------------+----------+
| 1f5381bd-2fd3-4152-85c2-1c18d628831c | test-3 | ACTIVE | IPv6=2001:700:2:8201::1055, 10.2.0.89  | GOLD CentOS 8 | m1.small |
| 3b0ea0d4-4af5-4963-8e95-4fce12cdaa04 | test-1 | ACTIVE | IPv6=2001:700:2:8201::1421, 10.2.0.97  | GOLD CentOS 8 | m1.small |
| 83740a1d-b5db-412e-bf73-815a00a5fd8e | test-0 | ACTIVE | IPv6=2001:700:2:8201::1400, 10.2.1.156 | GOLD CentOS 8 | m1.small |
| 87d92714-c375-489f-abe8-887491dfc0af | test-4 | ACTIVE | IPv6=2001:700:2:8201::149b, 10.2.1.172 | GOLD CentOS 8 | m1.small |
| 3b80d3a2-8883-41ea-9d36-7dc9db874f26 | test-2 | ACTIVE | IPv6=2001:700:2:8201::13af, 10.2.3.110 | GOLD CentOS 8 | m1.small |
+--------------------------------------+--------+--------+----------------------------------------+---------------+----------+

Key pairs

In the previous examples, we relied on the key pair already existing in NREC. If we don’t already have a key pair in NREC, or we wish to use another one, we can use Terraform to manage this part.

We can have Terraform automatically create a key pair for us, instead of relying on a preexisting key pair. This is accomplished by creating a resource block for a key pair:

 1# SSH key
 2resource "openstack_compute_keypair_v2" "my-terraform-key" {
 3  name = "my-terraform-key"
 4  public_key = file("~/.ssh/id_rsa.pub")
 5}
 6
 7# Instances
 8resource "openstack_compute_instance_v2" "instance" {
 9  count = 5
10  name = "test-${count.index}"
11  image_name = "GOLD CentOS 8"
12  flavor_name = "m1.small"
13
14  key_pair = "my-terraform-key"
15  security_groups = [ "default", "uio-ssh-icmp" ]
16
17  network {
18    name = "IPv6"
19  }
20
21  lifecycle {
22    ignore_changes = [image_name,image_id]
23  }
24}

The public key file must exist on disk with the given path, Terraform will not create it for us. After running Terraform, we can verify that the key has been created:

$ openstack keypair list
+------------------+-------------------------------------------------+
| Name             | Fingerprint                                     |
+------------------+-------------------------------------------------+
| my-terraform-key | e2:2e:26:7f:5d:98:9e:8f:5e:fd:c7:d5:d0:6b:44:e7 |
| mykey            | e2:2e:26:7f:5d:98:9e:8f:5e:fd:c7:d5:d0:6b:44:e7 |
+------------------+-------------------------------------------------+

Security groups

In all the previous examples, we use existing security groups when provisioning instances. We can use Terraform to create security groups on the fly for us to use:

 1# Security group
 2resource "openstack_networking_secgroup_v2" "instance_access" {
 3  name = "uio-ssh-icmp"
 4  description = "Allow SSH and ICMP access from UiO"
 5}
 6
 7# Allow ssh from IPv4 net
 8resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv4" {
 9  direction = "ingress"
10  ethertype = "IPv4"
11  protocol  = "tcp"
12  port_range_min = 22
13  port_range_max = 22
14  remote_ip_prefix = "129.240.0.0/16"
15  security_group_id = openstack_networking_secgroup_v2.instance_access.id
16}
17
18# Allow ssh from IPv6 net
19resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv6" {
20  direction = "ingress"
21  ethertype = "IPv6"
22  protocol  = "tcp"
23  port_range_min = 22
24  port_range_max = 22
25  remote_ip_prefix = "2001:700:100::/41"
26  security_group_id = openstack_networking_secgroup_v2.instance_access.id
27}
28
29# Allow icmp from IPv4 net
30resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv4" {
31  direction = "ingress"
32  ethertype = "IPv4"
33  protocol  = "icmp"
34  remote_ip_prefix = "129.240.0.0/16"
35  security_group_id = openstack_networking_secgroup_v2.instance_access.id
36}
37
38# Allow icmp from IPv6 net
39resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv6" {
40  direction = "ingress"
41  ethertype = "IPv6"
42  protocol = "ipv6-icmp"
43  remote_ip_prefix = "2001:700:100::/41"
44  security_group_id = openstack_networking_secgroup_v2.instance_access.id
45}
46
47# Instances
48resource "openstack_compute_instance_v2" "instance" {
49  count = 5
50  name = "test-${count.index}"
51  image_name = "GOLD CentOS 8"
52  flavor_name = "m1.small"
53
54  key_pair = "my-terraform-key"
55  security_groups = [ "default", "uio-ssh-icmp" ]
56
57  network {
58    name = "IPv6"
59  }
60
61  lifecycle {
62    ignore_changes = [image_name,image_id]
63  }
64}

There is a lot of new stuff here:

  1. Line 2-5 contains a resource for a security group. This is pretty straightforward and only contains a name and description

  2. Line 8-45 contains 4 security group rules. They are all ingress rules (e.g. incoming traffic) and allows for SSH and ICMP from the UiO IPv4 and IPv6 networks.

  3. The security_group_id is a required field which specifies the security group where the rule shall be applied, and we use the Terraform object notation to specify the security group we created earlier.

As before, 5 instances are created. In addition a new security group is created, with the name and description as specified in the Terraform file:

$ openstack security group list -c Name -c Description
+--------------------------------+--------------------------------------+
| Name                           | Description                          |
+--------------------------------+--------------------------------------+
| uio-ssh-icmp                   | Allow SSH and ICMP access from UiO   |
| SSH and ICMP from login.uio.no | Allow ssh and ping from login.uio.no |
| default                        | Default security group               |
+--------------------------------+--------------------------------------+

We can also inspect the security group uio-ssh-icmp that we created, to verify that the specified rules are present:

$ openstack security group rule list --long uio-ssh-icmp
+--------------------------------------+-------------+-------------------+------------+-----------+-----------+-----------------------+
| ID                                   | IP Protocol | IP Range          | Port Range | Direction | Ethertype | Remote Security Group |
+--------------------------------------+-------------+-------------------+------------+-----------+-----------+-----------------------+
| 391b4869-e900-44d8-9b7e-77318b9484ba | ipv6-icmp   | 2001:700:100::/41 |            | ingress   | IPv6      | None                  |
| 6f06e10a-99d8-4e3b-9dd3-6b20ff43aa28 | tcp         | 2001:700:100::/41 | 22:22      | ingress   | IPv6      | None                  |
| 88e6d5cf-1479-45a7-aa3f-8921ef84b939 | None        | None              |            | egress    | IPv4      | None                  |
| 98827cd8-7461-42c1-af8d-209813a15507 | icmp        | 129.240.0.0/16    |            | ingress   | IPv4      | None                  |
| 9c37c84c-e06c-4a0f-8f8e-24799210ec99 | tcp         | 129.240.0.0/16    | 22:22      | ingress   | IPv4      | None                  |
| bf54f0b2-844e-41a7-8f90-a11fb2c808c0 | None        | None              |            | egress    | IPv6      | None                  |
+--------------------------------------+-------------+-------------------+------------+-----------+-----------+-----------------------+

Volumes

Creating volumes is often required, and Terraform can do that as well. In order to create a volume you will define the resource:

1# Volume
2resource "openstack_blockstorage_volume_v2" "volume" {
3  name = "my-volume"
4  size = "10"
5}

Here, we create a volume named “my-volume” with a size of 10 GB. We also want to attach the volume to one of our instances:

1# Attach volume
2resource "openstack_compute_volume_attach_v2" "volumes" {
3  instance_id = openstack_compute_instance_v2.instance.0.id
4  volume_id   = openstack_blockstorage_volume_v2.volume.id
5}

In this example, we choose to attach the volume to instance number 0, which is the instance named “test-0”. We can inspect using Openstack CLI:

$ openstack volume list
+--------------------------------------+--------------+-----------+------+---------------------------------+
| ID                                   | Name         | Status    | Size | Attached to                     |
+--------------------------------------+--------------+-----------+------+---------------------------------+
| b5240613-404d-4b85-a28b-8ad32f8b0652 | my-volume    | in-use    |   10 | Attached to test-0 on /dev/sdb  |
+--------------------------------------+--------------+-----------+------+---------------------------------+

Complete example

A complete listing of the example file advanced.tf used in this document is provided below.

advanced.tf
 1# Define required providers
 2terraform {
 3  required_version = ">= 1.0"
 4  required_providers {
 5    openstack = {
 6      source  = "terraform-provider-openstack/openstack"
 7    }
 8  }
 9}
10
11# Configure the OpenStack Provider
12# Empty means using environment variables "OS_*". More info:
13# https://registry.terraform.io/providers/terraform-provider-openstack/openstack/latest/docs
14provider "openstack" {}
15
16# SSH key
17resource "openstack_compute_keypair_v2" "my-terraform-key" {
18  name = "my-terraform-key"
19  public_key = file("~/.ssh/id_rsa.pub")
20}
21
22# Security group
23resource "openstack_networking_secgroup_v2" "instance_access" {
24  name = "uio-ssh-icmp"
25  description = "Allow SSH and ICMP access from UiO"
26}
27
28# Allow ssh from IPv4 net
29resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv4" {
30  direction = "ingress"
31  ethertype = "IPv4"
32  protocol  = "tcp"
33  port_range_min = 22
34  port_range_max = 22
35  remote_ip_prefix = "129.240.0.0/16"
36  security_group_id = openstack_networking_secgroup_v2.instance_access.id
37}
38
39# Allow ssh from IPv6 net
40resource "openstack_networking_secgroup_rule_v2" "rule_ssh_access_ipv6" {
41  direction = "ingress"
42  ethertype = "IPv6"
43  protocol  = "tcp"
44  port_range_min = 22
45  port_range_max = 22
46  remote_ip_prefix = "2001:700:100::/41"
47  security_group_id = openstack_networking_secgroup_v2.instance_access.id
48}
49
50# Allow icmp from IPv4 net
51resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv4" {
52  direction = "ingress"
53  ethertype = "IPv4"
54  protocol  = "icmp"
55  remote_ip_prefix = "129.240.0.0/16"
56  security_group_id = openstack_networking_secgroup_v2.instance_access.id
57}
58
59# Allow icmp from IPv6 net
60resource "openstack_networking_secgroup_rule_v2" "rule_icmp_access_ipv6" {
61  direction = "ingress"
62  ethertype = "IPv6"
63  protocol = "ipv6-icmp"
64  remote_ip_prefix = "2001:700:100::/41"
65  security_group_id = openstack_networking_secgroup_v2.instance_access.id
66}
67
68# Instances
69resource "openstack_compute_instance_v2" "instance" {
70  count = 5
71  name = "test-${count.index}"
72  image_name = "GOLD CentOS 8"
73  flavor_name = "m1.small"
74
75  key_pair = "my-terraform-key"
76  security_groups = [ "default", "uio-ssh-icmp" ]
77
78  network {
79    name = "IPv6"
80  }
81
82  lifecycle {
83    ignore_changes = [image_name,image_id]
84  }
85}
86
87# Volume
88resource "openstack_blockstorage_volume_v2" "volume" {
89  name = "my-volume"
90  size = "10"
91}
92
93# Attach volume
94resource "openstack_compute_volume_attach_v2" "volumes" {
95  instance_id = openstack_compute_instance_v2.instance.0.id
96  volume_id   = openstack_blockstorage_volume_v2.volume.id
97}