Provision libvirt Multiple VM with Terraform/Opentofu

Background

If you are like me that tried to run proxmox on debian or ubuntu while also installing a desktop environment. Cause you are broke and don’t have a seperate hardware to run proxmox, then you’ve come to the right place. Here we will setup libvirt/kvm and opentofu/terraform to automate the provisioning.

Table of Contents

Install Dependencies

1apt install qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils -y
2
3# enable and start libvirtd service
4sudo systemctl enable --now libvirtd

Verify if the host is can now run guest machine.

virt-host-validate

Add Permission to user

Add user to libvirt group to manage VM without using sudo.

sudo adduser $USER libvirt

Network

If you don’t need network access to your vm then you can skip this. To connect you VM to you physical network, we need to create a bridge that act likes a nic on you vm (bridge network).

Using nmcli

nmcli connection down “Wired connection 1” nmcli connection add type bridge ifname br0 con-name bridge-br0 nmcli connection add type ethernet ifname enp0s31f6 master br0 con-name bridge-slave-enp0s31f6 nmcli connection modify bridge-br0 ipv4.method manual ipv4.addresses 192.168.254.169/24 ipv4.gateway 192.168.254.254 ipv4.dns “8.8.8.8 1.1.1.1” ipv4.ignore-auto-dns yes nmcli connection up bridge-br0

Using netplan

/etc/netplan/01-netcfg.yaml

 1network:
 2  version: 2
 3  renderer: networkd
 4  ethernets:
 5    enp4s0:
 6      dhcp4: no
 7  bridges:
 8    br0:
 9      interfaces: [enp4s0]
10      dhcp4: no
11      addresses: [192.168.254.169/24]
12      routes:
13        - to: default
14          via: 192.168.254.254
15      nameservers:
16        addresses: [8.8.8.8, 1.1.1.1]

netplan apply

Create libvirt network. br0.xml

1<network>
2  <name>br0</name>
3  <forward mode='bridge'/>
4  <bridge name='br0'/>
5</network>
1sudo virsh net-define br0.xml
2sudo virsh net-start br0
3sudo virsh net-autostart br0

Verify.

1$ virsh net-list --all
2 Name      State    Autostart   Persistent
3--------------------------------------------
4 br0       active   yes         yes
5 default   active   yes         yes

Cockpit

Enable cockpit, this provide web UI to manage your server and VM.

1apt install cockpit cockpit-machines -y
2systemctl enable --now cockpit.socket

Open browser and navigate to https://localhost:9090.

Create VM

Login to cockpit and turn on administrative access. Then click Virttual Machines. For this example i downloaded a cloud-init image to /mnt/ssd/develop/iso. imagen

Click on Automation and edit login config. imagen

Then Create and Edit. Here you can configure ram, cpu and edit. For now just click install and run the vm. imagen

Permission Error

Look at this thread to find possible solution.

For my case I used this solution. Edit /etc/libvirt/qemu.conf, look for security_driver.

security_driver = "none"

Restart service.

systemctl restart libvirtd

SSH to VM.

 1$ ssh 192.168.254.26
 2Expanded Security Maintenance for Applications is not enabled.
 3
 40 updates can be applied immediately.
 5
 6Enable ESM Apps to receive additional future security updates.
 7See https://ubuntu.com/esm or run: sudo pro status
 8
 9
10The list of available updates is more than a week old.
11To check for new updates run: sudo apt update
12
13
14The programs included with the Ubuntu system are free software;
15the exact distribution terms for each program are described in the
16individual files in /usr/share/doc/*/copyright.
17
18Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
19applicable law.
20
21$ 

Optional: Using Virt-Manager

You can also explore virt-manager. This has a more classic GUI, unlike cockpit which is designed for multi-server and container management, this focus mainly on VM administration.

1apt install virt-manager

Terraform/Opentofu

init

Define the provider, we’ll be using provider by dmacvicar/libvirt.

Create the directory and files.

touch main.tf providers.tf terraform.tfvars variables.tf

Define the provider.

main.tf

1terraform {
2 required_version = ">= 0.13"
3  required_providers {
4    libvirt = {
5      source  = "dmacvicar/libvirt"
6      version = "0.8.3"
7    }
8  }
9}

If you’re running terraform on the host use;

uri = "qemu:///system"

If you’re running terraform remotely. Change username and IP. uri = "qemu+ssh://root@192.168.254.48/system"

providers.tf

1provider "libvirt" {
2  #uri = "qemu:///system"
3  uri = "qemu+ssh://root@192.168.254.48/system"
4}

Save the files and initialize Opentofu. If all goes well, the provider will be installed and Opentofu has been initialized.

 1$ tofu init
 2
 3Initializing the backend...
 4
 5Initializing provider plugins...
 6- Reusing previous version of dmacvicar/libvirt from the dependency lock file
 7- Reusing previous version of hashicorp/template from the dependency lock file
 8- Using previously-installed dmacvicar/libvirt v0.8.3
 9- Using previously-installed hashicorp/template v2.2.0
10
1112│ Warning: Additional provider information from registry
1314│ The remote registry returned warnings for registry.opentofu.org/hashicorp/template:
15│ - This provider is deprecated. Please use the built-in template functions instead of the provider.
1617
18OpenTofu has been successfully initialized!
19
20You may now begin working with OpenTofu. Try running "tofu plan" to see
21any changes that are required for your infrastructure. All OpenTofu commands
22should now work.
23
24If you ever set or change modules or backend configuration for OpenTofu,
25rerun this command to reinitialize your working directory. If you forget, other
26commands will detect it and remind you to do so if necessary.

Variable and Config

Before we execute terraform plan, let us first define some variables and config. Under img_url_path; it is where the cloud-init image downloaded earlier.

For vm_names, it is defined in array-meaning it will create VM depending on how many are defined in the array. In this example it will create master and worker VM.

variables.tf

 1variable "img_url_path" {
 2  default = "/home/User/Downloads/noble-server-cloudimg-amd64.img"
 3}
 4
 5variable "vm_names" {
 6  description = "vm names"
 7  type = list(string)
 8  default = ["master", "worker"]
 9}
10
11# This is optional, if you want to create volume pool
12variable "libvirt_disk_path" {
13  description = "path for libvirt pool"
14  default     = "/mnt/nvme0n1/kvm-pool"
15}

For minimal setup, let set the user and password to root and password123.

cloud_init.cfg

1ssh_pwauth: True
2chpasswd:
3  list: |
4     root:password123
5  expire: False

Also for network, it will just be using the default network and will get IP from dhcp. For other configuration check this link1, link2.

network_config.cfg

1version: 2
2ethernets:
3  ens3:
4    dhcp4: true

plan

Let’s now create the VM.

main.tf

 1terraform {
 2 required_version = ">= 0.13"
 3  required_providers {
 4    libvirt = {
 5      source  = "dmacvicar/libvirt"
 6      version = "0.8.3"
 7    }
 8  }
 9}
10
11
12resource "libvirt_volume" "k8s-cloudinit" {
13  count = length(var.vm_names)
14  name   = "${var.vm_names[count.index]}"
15  pool   = "kvm-pool"
16  source = var.img_url_path
17  format = "qcow2"
18}
19
20data "template_file" "user_data" {
21  template = file("${path.module}/cloud_init.cfg")
22}
23
24data "template_file" "network_config" {
25  template = file("${path.module}/network_config.cfg")
26}
27
28# for more info about paramater check this out
29# https://github.com/dmacvicar/terraform-provider-libvirt/blob/master/website/docs/r/cloudinit.html.markdown
30# Use CloudInit to add our ssh-key to the instance
31# you can add also meta_data field
32resource "libvirt_cloudinit_disk" "commoninit" {
33  name           = "commoninit.iso"
34  user_data      = data.template_file.user_data.rendered
35  network_config = data.template_file.network_config.rendered 
36}
37
38
39# Create the machine
40resource "libvirt_domain" "domain-k8s" {
41  count = length(var.vm_names)
42  name = var.vm_names[count.index]
43  memory = "2048"
44  vcpu   = 2
45
46  cloudinit = libvirt_cloudinit_disk.commoninit.id
47  
48  network_interface {
49    network_name = "default"
50  }
51
52  # IMPORTANT: this is a known bug on cloud images, since they expect a console
53  # we need to pass it
54  # https://bugs.launchpad.net/cloud-images/+bug/1573095
55  console {
56    type        = "pty"
57    target_port = "0"
58    target_type = "serial"
59  }
60
61  console {
62    type        = "pty"
63    target_type = "virtio"
64    target_port = "1"
65  }
66
67  disk {
68    volume_id = libvirt_volume.k8s-cloudinit[count.index].id
69  }
70
71  graphics {
72    type        = "spice"
73    listen_type = "address"
74    autoport    = true
75  }
76  
77}

Save the file and we can run Opentofu plan command.

  1$ tofu plan
  2data.template_file.network_config: Reading...
  3data.template_file.network_config: Read complete after 0s [id=b36a1372ce4ea68b514354202c26c0365df9a17f25cd5acdeeaea525cd913edc]
  4data.template_file.user_data: Reading...
  5data.template_file.user_data: Read complete after 0s [id=69a2f32bd20850703577ebc428d302999bc1b2e11021b1221e7297fef83b2479]
  6
  7OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  8  + create
  9
 10OpenTofu will perform the following actions:
 11
 12  # libvirt_cloudinit_disk.commoninit will be created
 13  + resource "libvirt_cloudinit_disk" "commoninit" {
 14      + id             = (known after apply)
 15      + name           = "commoninit.iso"
 16      + network_config = <<-EOT
 17            version: 2
 18            ethernets:
 19              ens3:
 20                dhcp4: true
 21        EOT
 22      + pool           = "default"
 23      + user_data      = <<-EOT
 24            #cloud-config
 25            # vim: syntax=yaml
 26            #
 27            # ***********************
 28            #   ---- for more examples look at: ------
 29            # ---> https://cloudinit.readthedocs.io/en/latest/topics/examples.html
 30            # ******************************
 31            #
 32            # This is the configuration syntax that the write_files module
 33            # will know how to understand. encoding can be given b64 or gzip or (gz+b64).
 34            # The content will be decoded accordingly and then written to the path that is
 35            # provided.
 36            #
 37            # Note: Content strings here are truncated for example purposes.
 38            ssh_pwauth: True
 39            chpasswd:
 40              list: |
 41                 root:password123
 42              expire: False
 43        EOT
 44    }
 45
 46  # libvirt_domain.domain-k8s[0] will be created
 47  + resource "libvirt_domain" "domain-k8s" {
 48      + arch        = (known after apply)
 49      + autostart   = (known after apply)
 50      + cloudinit   = (known after apply)
 51      + emulator    = (known after apply)
 52      + fw_cfg_name = "opt/com.coreos/config"
 53      + id          = (known after apply)
 54      + machine     = (known after apply)
 55      + memory      = 2048
 56      + name        = "master"
 57      + qemu_agent  = false
 58      + running     = true
 59      + type        = "kvm"
 60      + vcpu        = 2
 61
 62      + console {
 63          + source_host    = "127.0.0.1"
 64          + source_service = "0"
 65          + target_port    = "0"
 66          + target_type    = "serial"
 67          + type           = "pty"
 68        }
 69      + console {
 70          + source_host    = "127.0.0.1"
 71          + source_service = "0"
 72          + target_port    = "1"
 73          + target_type    = "virtio"
 74          + type           = "pty"
 75        }
 76
 77      + cpu (known after apply)
 78
 79      + disk {
 80          + scsi      = false
 81          + volume_id = (known after apply)
 82          + wwn       = (known after apply)
 83        }
 84
 85      + graphics {
 86          + autoport       = true
 87          + listen_address = "127.0.0.1"
 88          + listen_type    = "address"
 89          + type           = "spice"
 90        }
 91
 92      + network_interface {
 93          + addresses    = (known after apply)
 94          + hostname     = (known after apply)
 95          + mac          = (known after apply)
 96          + network_id   = (known after apply)
 97          + network_name = "default"
 98        }
 99
100      + nvram (known after apply)
101    }
102
103  # libvirt_domain.domain-k8s[1] will be created
104  + resource "libvirt_domain" "domain-k8s" {
105      + arch        = (known after apply)
106      + autostart   = (known after apply)
107      + cloudinit   = (known after apply)
108      + emulator    = (known after apply)
109      + fw_cfg_name = "opt/com.coreos/config"
110      + id          = (known after apply)
111      + machine     = (known after apply)
112      + memory      = 2048
113      + name        = "worker"
114      + qemu_agent  = false
115      + running     = true
116      + type        = "kvm"
117      + vcpu        = 2
118
119      + console {
120          + source_host    = "127.0.0.1"
121          + source_service = "0"
122          + target_port    = "0"
123          + target_type    = "serial"
124          + type           = "pty"
125        }
126      + console {
127          + source_host    = "127.0.0.1"
128          + source_service = "0"
129          + target_port    = "1"
130          + target_type    = "virtio"
131          + type           = "pty"
132        }
133
134      + cpu (known after apply)
135
136      + disk {
137          + scsi      = false
138          + volume_id = (known after apply)
139          + wwn       = (known after apply)
140        }
141
142      + graphics {
143          + autoport       = true
144          + listen_address = "127.0.0.1"
145          + listen_type    = "address"
146          + type           = "spice"
147        }
148
149      + network_interface {
150          + addresses    = (known after apply)
151          + hostname     = (known after apply)
152          + mac          = (known after apply)
153          + network_id   = (known after apply)
154          + network_name = "default"
155        }
156
157      + nvram (known after apply)
158    }
159
160  # libvirt_volume.k8s-cloudinit[0] will be created
161  + resource "libvirt_volume" "k8s-cloudinit" {
162      + format = "qcow2"
163      + id     = (known after apply)
164      + name   = "master"
165      + pool   = "kvm-pool"
166      + size   = (known after apply)
167      + source = "/home/mcbtaguiad/Downloads/noble-server-cloudimg-amd64.img"
168    }
169
170  # libvirt_volume.k8s-cloudinit[1] will be created
171  + resource "libvirt_volume" "k8s-cloudinit" {
172      + format = "qcow2"
173      + id     = (known after apply)
174      + name   = "worker"
175      + pool   = "kvm-pool"
176      + size   = (known after apply)
177      + source = "/home/mcbtaguiad/Downloads/noble-server-cloudimg-amd64.img"
178    }
179
180Plan: 5 to add, 0 to change, 0 to destroy.
181
182─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
183
184Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now.

apply

After plan-review the output summary of terraform plan, we can now create the VM.

  1$ tofu apply
  2data.template_file.network_config: Reading...
  3data.template_file.network_config: Read complete after 0s [id=b36a1372ce4ea68b514354202c26c0365df9a17f25cd5acdeeaea525cd913edc]
  4data.template_file.user_data: Reading...
  5data.template_file.user_data: Read complete after 0s [id=69a2f32bd20850703577ebc428d302999bc1b2e11021b1221e7297fef83b2479]
  6
  7OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  8  + create
  9
 10OpenTofu will perform the following actions:
 11
 12  # libvirt_cloudinit_disk.commoninit will be created
 13  + resource "libvirt_cloudinit_disk" "commoninit" {
 14      + id             = (known after apply)
 15      + name           = "commoninit.iso"
 16      + network_config = <<-EOT
 17            version: 2
 18            ethernets:
 19              ens3:
 20                dhcp4: true
 21        EOT
 22      + pool           = "default"
 23      + user_data      = <<-EOT
 24            #cloud-config
 25            # vim: syntax=yaml
 26            #
 27            # ***********************
 28            #   ---- for more examples look at: ------
 29            # ---> https://cloudinit.readthedocs.io/en/latest/topics/examples.html
 30            # ******************************
 31            #
 32            # This is the configuration syntax that the write_files module
 33            # will know how to understand. encoding can be given b64 or gzip or (gz+b64).
 34            # The content will be decoded accordingly and then written to the path that is
 35            # provided.
 36            #
 37            # Note: Content strings here are truncated for example purposes.
 38            ssh_pwauth: True
 39            chpasswd:
 40              list: |
 41                 root:password123
 42              expire: False
 43        EOT
 44    }
 45
 46  # libvirt_domain.domain-k8s[0] will be created
 47  + resource "libvirt_domain" "domain-k8s" {
 48      + arch        = (known after apply)
 49      + autostart   = (known after apply)
 50      + cloudinit   = (known after apply)
 51      + emulator    = (known after apply)
 52      + fw_cfg_name = "opt/com.coreos/config"
 53      + id          = (known after apply)
 54      + machine     = (known after apply)
 55      + memory      = 2048
 56      + name        = "master"
 57      + qemu_agent  = false
 58      + running     = true
 59      + type        = "kvm"
 60      + vcpu        = 2
 61
 62      + console {
 63          + source_host    = "127.0.0.1"
 64          + source_service = "0"
 65          + target_port    = "0"
 66          + target_type    = "serial"
 67          + type           = "pty"
 68        }
 69      + console {
 70          + source_host    = "127.0.0.1"
 71          + source_service = "0"
 72          + target_port    = "1"
 73          + target_type    = "virtio"
 74          + type           = "pty"
 75        }
 76
 77      + cpu (known after apply)
 78
 79      + disk {
 80          + scsi      = false
 81          + volume_id = (known after apply)
 82          + wwn       = (known after apply)
 83        }
 84
 85      + graphics {
 86          + autoport       = true
 87          + listen_address = "127.0.0.1"
 88          + listen_type    = "address"
 89          + type           = "spice"
 90        }
 91
 92      + network_interface {
 93          + addresses    = (known after apply)
 94          + hostname     = (known after apply)
 95          + mac          = (known after apply)
 96          + network_id   = (known after apply)
 97          + network_name = "default"
 98        }
 99
100      + nvram (known after apply)
101    }
102
103  # libvirt_domain.domain-k8s[1] will be created
104  + resource "libvirt_domain" "domain-k8s" {
105      + arch        = (known after apply)
106      + autostart   = (known after apply)
107      + cloudinit   = (known after apply)
108      + emulator    = (known after apply)
109      + fw_cfg_name = "opt/com.coreos/config"
110      + id          = (known after apply)
111      + machine     = (known after apply)
112      + memory      = 2048
113      + name        = "worker"
114      + qemu_agent  = false
115      + running     = true
116      + type        = "kvm"
117      + vcpu        = 2
118
119      + console {
120          + source_host    = "127.0.0.1"
121          + source_service = "0"
122          + target_port    = "0"
123          + target_type    = "serial"
124          + type           = "pty"
125        }
126      + console {
127          + source_host    = "127.0.0.1"
128          + source_service = "0"
129          + target_port    = "1"
130          + target_type    = "virtio"
131          + type           = "pty"
132        }
133
134      + cpu (known after apply)
135
136      + disk {
137          + scsi      = false
138          + volume_id = (known after apply)
139          + wwn       = (known after apply)
140        }
141
142      + graphics {
143          + autoport       = true
144          + listen_address = "127.0.0.1"
145          + listen_type    = "address"
146          + type           = "spice"
147        }
148
149      + network_interface {
150          + addresses    = (known after apply)
151          + hostname     = (known after apply)
152          + mac          = (known after apply)
153          + network_id   = (known after apply)
154          + network_name = "default"
155        }
156
157      + nvram (known after apply)
158    }
159
160  # libvirt_volume.k8s-cloudinit[0] will be created
161  + resource "libvirt_volume" "k8s-cloudinit" {
162      + format = "qcow2"
163      + id     = (known after apply)
164      + name   = "master"
165      + pool   = "kvm-pool"
166      + size   = (known after apply)
167      + source = "/home/mcbtaguiad/Downloads/noble-server-cloudimg-amd64.img"
168    }
169
170  # libvirt_volume.k8s-cloudinit[1] will be created
171  + resource "libvirt_volume" "k8s-cloudinit" {
172      + format = "qcow2"
173      + id     = (known after apply)
174      + name   = "worker"
175      + pool   = "kvm-pool"
176      + size   = (known after apply)
177      + source = "/home/mcbtaguiad/Downloads/noble-server-cloudimg-amd64.img"
178    }
179
180Plan: 5 to add, 0 to change, 0 to destroy.
181
182Do you want to perform these actions?
183  OpenTofu will perform the actions described above.
184  Only 'yes' will be accepted to approve.
185
186  Enter a value: yes
187
188libvirt_volume.k8s-cloudinit[1]: Creating...
189libvirt_cloudinit_disk.commoninit: Creating...
190libvirt_volume.k8s-cloudinit[0]: Creating...
191libvirt_cloudinit_disk.commoninit: Creation complete after 4s [id=/var/lib/libvirt/images/commoninit.iso;ecbb0a27-be52-435c-a5db-c0e87b58fd3a]
192libvirt_volume.k8s-cloudinit[1]: Still creating... [10s elapsed]
193libvirt_volume.k8s-cloudinit[0]: Still creating... [10s elapsed]
194libvirt_volume.k8s-cloudinit[1]: Still creating... [20s elapsed]
195libvirt_volume.k8s-cloudinit[0]: Still creating... [20s elapsed]
196libvirt_volume.k8s-cloudinit[1]: Still creating... [30s elapsed]
197libvirt_volume.k8s-cloudinit[0]: Still creating... [30s elapsed]
198libvirt_volume.k8s-cloudinit[1]: Still creating... [40s elapsed]
199libvirt_volume.k8s-cloudinit[0]: Still creating... [40s elapsed]
200libvirt_volume.k8s-cloudinit[1]: Still creating... [50s elapsed]
201libvirt_volume.k8s-cloudinit[0]: Still creating... [51s elapsed]
202libvirt_volume.k8s-cloudinit[1]: Still creating... [1m0s elapsed]
203libvirt_volume.k8s-cloudinit[0]: Still creating... [1m1s elapsed]
204libvirt_volume.k8s-cloudinit[1]: Still creating... [1m10s elapsed]
205libvirt_volume.k8s-cloudinit[0]: Still creating... [1m11s elapsed]
206libvirt_volume.k8s-cloudinit[1]: Still creating... [1m20s elapsed]
207libvirt_volume.k8s-cloudinit[0]: Still creating... [1m21s elapsed]
208libvirt_volume.k8s-cloudinit[1]: Still creating... [1m30s elapsed]
209libvirt_volume.k8s-cloudinit[0]: Still creating... [1m31s elapsed]
210libvirt_volume.k8s-cloudinit[1]: Still creating... [1m40s elapsed]
211libvirt_volume.k8s-cloudinit[0]: Still creating... [1m41s elapsed]
212libvirt_volume.k8s-cloudinit[1]: Creation complete after 1m43s [id=/mnt/nvme0n1/kvm-pool/worker]
213libvirt_volume.k8s-cloudinit[0]: Still creating... [1m51s elapsed]
214libvirt_volume.k8s-cloudinit[0]: Still creating... [2m1s elapsed]
215libvirt_volume.k8s-cloudinit[0]: Still creating... [2m11s elapsed]
216libvirt_volume.k8s-cloudinit[0]: Still creating... [2m21s elapsed]
217libvirt_volume.k8s-cloudinit[0]: Still creating... [2m31s elapsed]
218libvirt_volume.k8s-cloudinit[0]: Still creating... [2m41s elapsed]
219libvirt_volume.k8s-cloudinit[0]: Still creating... [2m51s elapsed]
220libvirt_volume.k8s-cloudinit[0]: Still creating... [3m1s elapsed]
221libvirt_volume.k8s-cloudinit[0]: Still creating... [3m11s elapsed]
222libvirt_volume.k8s-cloudinit[0]: Still creating... [3m21s elapsed]
223libvirt_volume.k8s-cloudinit[0]: Creation complete after 3m30s [id=/mnt/nvme0n1/kvm-pool/master]
224libvirt_domain.domain-k8s[1]: Creating...
225libvirt_domain.domain-k8s[0]: Creating...
226libvirt_domain.domain-k8s[0]: Creation complete after 3s [id=ecdfd825-9912-4876-86d4-50cfc883101e]
227libvirt_domain.domain-k8s[1]: Creation complete after 3s [id=9d27f366-6165-4e32-bebe-785a4b1cc75e]
228
229Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

Verify VM

1$ virsh list --all
2 Id   Name          State
3------------------------------
4 1    master        running
5 2    worker        running