Aller au contenu

Terraform et le provider Libvirt

logo terraform

Dans le premier billet consacré au couple Terraform / Libvirt, nous avions vu comment créer des ressources, mais pas comment enchaîner automatiquement le provisioning. Par provisioning j’entends l’utilisation des fonctions remote-exec et local-exec qui permettent par exemple de lancer des playbooks ansible ou des scripts.

Je bloquais sur :

  • Comment attendre que la machine provisionnée ait une adresse IP allouée par le serveur DHCP.
  • Comment attendre la fin de la partie cloud-init

Depuis j’ai pas mal bossé sur mon projet de Home Lab Devops ou je cherche à tout automatiser et j’ai fini par trouver les solutions à ces problèmes.

Attendre que la machine ait une adresse IP

Si je reprends la création de la ressource libvirt_domain qui est la machine en elle-même, telle que je la définissais auparavant.

Ce qu’il manquait, c’était trois choses pour que terraform comprenne que l’adresse IP était définie :

  • le paramètre qemu_agent = true qui indique qu’il faut utiliser l’agent qemu pour récupérer l’adresse IP
  • le paramètre wait_for_lease = true dans la partie network_interface
  • et surtout dans le cloud-init le démarrage du qemu-guest-agent

Ce qui donne :

terraform {
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
}
}
}
// instance the provider
provider "libvirt" {
// uri = "qemu:///system"
uri = "qemu+ssh://admuser@devbox3/system"
}
// variables that can be overriden
variable "hostname" { default = "test" }
variable "domain" { default = "robert.local" }
variable "ip_type" { default = "dhcp" } # dhcp is other valid type
variable "memoryMB" { default = 1024 * 1 }
variable "cpu" { default = 1 }
// fetch the latest ubuntu release image from their mirrors
resource "libvirt_volume" "os_image" {
name = "${var.hostname}-os_image"
pool = "devbox"
source = "impish-server-cloudimg-amd64.img"
format = "qcow2"
}
// Use CloudInit ISO to add ssh-key to the instance
resource "libvirt_cloudinit_disk" "commoninit" {
name = "${var.hostname}-commoninit.iso"
pool = "devbox"
user_data = data.template_cloudinit_config.config.rendered
network_config = data.template_file.network_config.rendered
}
data "template_file" "user_data" {
template = file("${path.module}/cloud_init.cfg")
vars = {
hostname = var.hostname
fqdn = "${var.hostname}.${var.domain}"
public_key = file("~/.ssh/id_ed25519.pub")
}
}
data "template_cloudinit_config" "config" {
gzip = false
base64_encode = false
part {
filename = "init.cfg"
content_type = "text/cloud-config"
content = data.template_file.user_data.rendered
}
}
data "template_file" "network_config" {
template = file("${path.module}/network_config_${var.ip_type}.cfg")
}
// Create the machine
resource "libvirt_domain" "domain-alma" {
# domain name in libvirt, not hostname
name = var.hostname
memory = var.memoryMB
vcpu = var.cpu
autostart = true
qemu_agent = true
disk {
volume_id = libvirt_volume.os_image.id
}
network_interface {
network_name = "bridged-network"
mac = "52:54:00:36:14:e5"
wait_for_lease = true
}
cloudinit = libvirt_cloudinit_disk.commoninit.id
console {
type = "pty"
target_port = "0"
target_type = "serial"
}
graphics {
type = "spice"
listen_type = "address"
autoport = "true"
}
terraform {
required_version = ">= 0.12"
}
output "ips" {
#value = libvirt_domain.domain-alma
#value = libvirt_domain.domain-alma.*.network_interface
# show IP, run 'terraform refresh' if not populated
value = libvirt_domain.domain-alma.*.network_interface
}

Et dans le cloud-init complet :

#cloud-config
# https://cloudinit.readthedocs.io/en/latest/topics/modules.html
timezone: Europe/Paris
fqdn: test.robert.local
manage_etc_hosts: true
resize_rootfs: true
users:
- name: admuser
sudo: ALL=(ALL) NOPASSWD:ALL
groups: users, wheel
home: /home/admuser
shell: /bin/bash
lock_passwd: false
ssh-authorized-keys:
- ${public_key}
# only cert auth via ssh (console access can still login)
## debug - ssh_pwauth: true
disable_root: false
ssh_pwauth: true
chpasswd:
list: |
root:passwd
admuser:123456
expire: false
growpart:
mode: auto
devices: ['/']
packages:
- qemu-guest-agent
write_files:
- path: /etc/sysctl.d/10-disable-ipv6.conf
permissions: 0644
owner: root
content: |
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
# every boot
bootcmd:
- [ sh, -c, 'echo $(date) | sudo tee -a /root/bootcmd.log' ]
# run once for setup
runcmd:
- sed -i 's/#UseDNS yes/UseDNS no/' /etc/ssh/sshd_config
- systemctl start qemu-guest-agent
- systemctl restart sshd
- sysctl --load /etc/sysctl.d/10-disable-ipv6.conf
- localectl set-keymap fr
- localectl set-locale LANG=fr_FR.UTF8
- domainname robert.local

Attendre que le cloud-init soit terminé.

Maintenant que l’adresse IP est récupérée nous pouvons lancer un remote-exec avec la commande cloud-init status --wait qui va se charger d’attendre la fin de la configuration de la machine.

Donc dans le bloc de ressource de la machine nous pouvons ajouter ceci :

// Create the machine
resource "libvirt_domain" "domain-alma" {
# domain name in libvirt, not hostname
name = var.hostname
memory = var.memoryMB
vcpu = var.cpu
autostart = true
qemu_agent = true
disk {
volume_id = libvirt_volume.os_image.id
}
network_interface {
network_name = "bridged-network"
mac = "52:54:00:36:14:e5"
wait_for_lease = true
}
cloudinit = libvirt_cloudinit_disk.commoninit.id
console {
type = "pty"
target_port = "0"
target_type = "serial"
}
graphics {
type = "spice"
listen_type = "address"
autoport = "true"
}
provisioner "remote-exec" {
inline = [
"cloud-init status --wait",
]
connection {
host = "${self.network_interface.0.addresses.0}"
type = "ssh"
user = "admuser"
private_key = "${file("~/.ssh/id_ed25519")}"
}
}
}

Vous remarquerez que dans la définition de la connection j’utilise :

  • l’addresse IP de la machine
  • la clé privé pour me connecter à la machine.

Lancer un playbook ansible

Pour lancer le playbook Ansible, il faut utiliser un local-exec. Il suffit de l’ajouter à la suite du précédent remote-exec :

provisioner "local-exec" {
command = "ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -u admuser -i ${self.network_interface.0.addresses.0}, --private-key ${var.private_key_path} provision.yml"
}
}

On réutilise l’adresse IP et cette on charge la clé depuis une variable définie dans le fichier variables.tf (pour montrer les deux façons de la récupérer.)

Le fichier variables.tf :

variable "private_key_path" {
description = "Path to the private SSH key, used to access the instance."
default = "~/.ssh/id_ed25519"
}

Pour cete exemple j’utilise un simple playbook dont le contenu est le suivant :

---
- hosts: all
gather_facts: true
become: true
vars:
timezone: Europe/Paris
tasks:
- name: install packages
ansible.builtin.package:
state: present
name:
- tar
- unzip
- python3-pip
- git
- htop
- net-tools
- name: set as default locale
ansible.builtin.command: localectl set-locale LANG=en_US.UTF-8
- name: Set timezone
community.general.timezone:
name: "{{ timezone }}"

Mais on pourrait faire tout le provisonning d’une application comme je le faire sur les application de mon Home Lab: rundeck, powerdns et nexus.

Plus loin

Je vous propose de créer dans le projet home lab un template avec le contenu précédent. Comme ça vous pouvez l’utiliser comme base pour vos projets. Le lien

Je dois encore trouver comment automatiser la récupération de l’image Ubuntu et le changement de sa taille directement par terraform. En effet par défaut elle ne fait que 2Go. On ne peut pas utiliser l’url avec le paramètre size.