Aller au contenu principal

Terraform et le provider Libvirt

· 6 minutes de lecture
Stéphane ROBERT
Consultant DevOps

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.