Aller au contenu principal

Maîtriser les workflows Ansible AWX

· 11 minutes de lecture
Stéphane ROBERT
Consultant DevOps

Les workflows disponibles sur Ansible Tower (depuis la version 3.1) permettent aux utilisateurs de créer des séquences à partir de plusieurs ressources Ansible : playbooks, synchronisation de projet, autres workflows, approbations, ...

Préparation d'un workflow Ansible AWX

Il faut avant créer un projet contenant tout ce qu'il faut pour construire notre infrastructure. Je vous fournis un exemple qui permet de provisionner une machine sur un cluster libvirt, de l'enregistrer dans un inventaire (donc dynamique), de la tester, pour finir par la détruire.

Clonons le projet :

https://github.com/stephrobert/test-awx-workflow.git
cd test-awx-workflow

Construction de l'environnement d'exécution

Je vais bien sûr créer mon image d'environnement d'exécution intégrant tous les modules et les modules python nécessaires. L'environnement se trouve dans le répertoire EE/terraform. Il contient un fichier de déclaration d'environnement permettant de spécifier comment sera construite l'image :

  • un fichier execution-environment.yml
---
version: 1

build_arg_defaults:
  EE_BASE_IMAGE: 'quay.io/ansible/ansible-runner@sha256:1fa60288b1686946783e0ac864a29fa97dfcf0d5776ca6fda77c41fe8f880587'

dependencies:
  galaxy: requirements.yml
  python: requirements.txt

additional_build_steps:
  prepend: |
        RUN yum install -y yum-utils genisoimage
        RUN yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
        RUN yum -y install terraform

J'ai ajouté l'installation de terraform et la commande mkisofs. mkisofs permet de créer les isos lors de la construction de la machine dans le cluster libvirt.

  • un fichier requirements.txt
python-terraform==0.10.1

Nécessaire au fonctionnement du module Ansible terraform

  • un fichier requirements.yml
---
collections:
  - name: community.general
    version: 4.4.0
  - name: awx.awx
    version: 19.4.0

Je vais utiliser le module awx pour créer l'inventaire dynamique. J'ai dû recourir à ce fonctionnement, car j'aurais pu utiliser l'inventaire terraform mais un bug du provider libvirt empêche de récupérer l'IP de la VM provisionnée.

Pour construire l'image et la pousser dans votre registry docker (changer le tag ):

ansible-builder build --tag=devbox2.robert.local:31320/ee-terraform:1.0.0

docker push devbox2.robert.local:31320/ee-terraform:1.0.0

Ecriture du Code Terraform

Je créé une machine virtuelle en utilisant un cluster libvirt distant. Dans la partie provider je déclare donc un uri qemu+ssh://root@devbox.robert.local/system qui utilise une connexion ssh.

La VM est construire à partir d'une image Ubuntu téléchargé à la volée.

terraform {
  required_providers {
    libvirt = {
      source = "dmacvicar/libvirt"
    }
  }
}

// instance the provider
provider "libvirt" {
  // uri = "qemu:///system"
  uri = "qemu+ssh://root@devbox.robert.local/system"
}

// variables that can be overriden
variable "hostname" { default = "test" }
variable "domain" { default = "example.com" }
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 = "default"
  source = "https://cloud-images.ubuntu.com/bionic/current/bionic-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 = "default"
          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-ubuntu" {
  # domain name in libvirt, not hostname
  name = "${var.hostname}"
  memory = var.memoryMB
  vcpu = var.cpu

  disk {
       volume_id = libvirt_volume.os_image.id
  }
  network_interface {
       network_name = "bridged-network"
  }

  cloudinit = libvirt_cloudinit_disk.commoninit.id

  # IMPORTANT
  # Ubuntu can hang is a isa-serial is not present at boot time.
  # If you find your CPU 100% and never is available this is why
  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-ubuntu
  #value = libvirt_domain.domain-ubuntu.*.network_interface
  # show IP, run 'terraform refresh' if not populated
  value = libvirt_domain.domain-ubuntu.*.network_interface
}

J'ai créé aussi une ressource cloud-init qui se charge de configurer la machine, mais surtout d'installer les packages python3 et qemu-guest-agent. C'est lui qui va permettre de récupérer l'adresse IP de la machine :

hostname: ${hostname}
fqdn: ${fqdn}
manage_etc_hosts: true
users:
  - name: ubuntu
    sudo: ALL=(ALL) NOPASSWD:ALL
    groups: users, admin
    home: /home/ubuntu
    shell: /bin/bash
    lock_passwd: false
    ssh-authorized-keys:
      - ${public_key}
ssh_pwauth: true
disable_root: false
chpasswd:
  list: |
    ubuntu:linux
  expire: False
packages:
    - qemu-guest-agent
    - python3
bootcmd:
    - [ sh, -c, 'echo $(date) | sudo tee -a /root/bootcmd.log' ]
runcmd:
    - [ sh, -c, 'echo $(date) | sudo tee -a /root/runcmd.log' ]

final_message: "The system is finall up, after $UPTIME seconds"

Pour la partie réseau, j'ai modifié l'interface réseau pour qu'elle utilise un pont. Cela permet à ma VM d'être accessible depuis les autres machines de mon réseau. Du coup l'adresse IP est géré par mon serveur DHCP.

version: 2
ethernets:
  ens3:
    dhcp4: true

Avec ces trois fichiers, on peut déjà créé la VM avec :

terraform init
terraform apply --auto-approve

Construction des playbooks Ansible

Maintenant que ma VM se créé je vais pouvoir écrire les playbooks qui seront utilisés dans le workflow :

Le premier terraform-deploy.yml :

---
- name: Deploy Stack
  hosts: all
  become: false
  gather_facts: false
  vars:
    known_hosts: |
      devbox.robert.local,192.168.1.42 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIpDg+z8B5AWiJF89PRzSppCE3aW8czN+8GE9Cc8vOJ9JK2fut9of62+Zz+7cqtWz2KXnRdQbrJymIkdYoQY8Wc=
    id_ed25519: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      38666539653431373639613935323362303438373664636363393564313663356263356164396265
      6364306534383365313331313233303762613034323234630a343661303631623731333636356531
      31663264643337346636643364393263306235663537343166336330616538626535393733373738
      6363343336336561610a353039363135623839616162656230646233316430373233366130646362
      33623864343237363739666663623231363933383763316166383466326434653136653636323665
      38373936653263396334343932633030633332643838666139636437626663313263663338643836
      30326136353965323639653965633336633465616661613134366532333266363634653266623633
      62346433633138333264636236646465646631343861643164643666633338373766303665323265
      34356663353434373431646162636131623662643536353664366537383162636264363931646433
      36326330616465646563653937363361313436643739306565363533616134316265613462373333
      32666332336261646662636238333733393635343463346163313339633961363061383263636635
      65343133373430616665663736646333326435633337313966366232663233386664633864313066
      65653931303666643332303266383138393063386336316162613633343634333130663330626337
      33393263373263636639663364666363363662633561376134653634303330646364346531643639
      64643133633139666433663464613639333064393531376338333666656236346638326166666338
      65653533356132316365333765333138303531336266306639643333646138363366373931303064
      61373031316338663534356364323734363536343931386665383265643766313964346639656637
      32313737373038663561303366623637373235303836393639643166633635303064333762383230
      36636130303663366563383561353535313632613664333430353136633334363165303339613936
      34366234666635316132363162626437643332613338313433383465386430613131343666623239
      36653730363030646639633164613464653766303338623461633630396330383332336538656230
      31313361663066623934333434303337633831633066623539663034353663343066616363316465
      34316136613066613234616535653938663065353966366533323236346562303162393263356131
      34373837353033623965613361653662346363393833623662326565616538653764303935643531
      3933
  tasks:

    - name: create .ssh directory
      ansible.builtin.file:
        path: $HOME/.ssh
        state: directory
        mode: 0755
      delegate_to: localhost

    - name: Create file id_ed25519
      ansible.builtin.copy:
        dest: $HOME/.ssh/id_ed25519.pub
        content: "{{ id_ed25519 }}"
        mode: 0644
      delegate_to: localhost

    - name: Ensure ssh host key known
      ansible.builtin.copy:
        dest: $HOME/.ssh/known_hosts
        content: "{{ known_hosts }}"
        mode: 0600
      delegate_to: localhost

    - name: Deploy VM
      community.general.terraform:
        project_path: './'
        state: present
        force_init: true
      delegate_to: localhost

    - name: Store tfstate
      ansible.builtin.copy:
        src: terraform.tfstate
        dest: ~/terraform.tfstate
        mode: 0644

Vous remarquez que j'ai inclus des variables dont une que j'ai encrypté avec la commande ansible-vault. Il s'agit de la clé publique SSH permettant de me connecter à mon cluster libvirt.

Comme tout se passe dans l'environnement d'exécution je reconstruis le répertoire .ssh dans le container qui contient la clé publique et le fichier known_hosts (sinon le provider libvirt se plante).

Ensuite je fais appel au module ansible terraform qui lance les deux phases init et apply. Je finis par stocker le state terraform, pour le restaurer lors de la destruction de la VM.

Le second playbook create-inventory.yml :

---
- name: Test
  hosts: all
  become: false
  gather_facts: false
  vars:
    token: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      33653263303262396336316133343531306639333732366634323331356163393136653864613865
      3132613930323166666534383431363063326362353634330a616531366634613939366237306534
      36613963393764366135383664343765313933633364346337383830313762316637663562333932
      3339643033323236330a323065313632653637346561356337633639643336386561333937326433
      31653062393135643132353665383332333836643263363437333338643538653733

  tasks:
    - name: Get info from VM
      ansible.builtin.shell: "virsh -c qemu+ssh://root@devbox.robert.local/system qemu-agent-command test '{\"execute\":\"guest-network-get-interfaces\"}'"
      register: info
      changed_when: info.rc != 0
      retries: 15
      delay: 10
      until: info is success
    - name: register value
      ansible.builtin.set_fact:
        ip_address: "{{ (info.stdout | from_json).return[1]['ip-addresses'][0]['ip-address'] }}"

    - name: Delete Inventory
      awx.awx.inventory:
        controller_host: "http://192.168.1.74:30932"
        controller_oauthtoken: "{{ token }}"
        controller_username: "admin"
        name: "Test"
        state: absent
        organization: "Default"
        validate_certs: false

    - name: Create Inventory
      awx.awx.inventory:
        controller_host: "http://192.168.1.74:30932"
        controller_oauthtoken: "{{ token }}"
        controller_username: "admin"
        name: "Test"
        description: "My first Servers"
        state: present
        organization: "Default"
        validate_certs: false

    - name: Add Host to Inventory
      awx.awx.host:
        controller_host: "http://192.168.1.74:30932"
        controller_oauthtoken: "{{ token }}"
        controller_username: "admin"
        inventory: "Test"
        description: "My first Servers"
        state: present
        name: "{{ ip_address }}"
        enabled: true
        validate_certs: false

    - name: Add inventory to Job template Test
      awx.awx.job_template:
        name: test-VM
        inventory: Test

Ce playbook récupère l'adresse IP de la VM en utilisant virsh en mode commande : virsh qemu-agent-command test '{"execute":"guest-network-get-interfaces"}' Cela retourne un json dont j'extrais l'IP. Si vous lisez attentivement j'ai ajouté la relance de cette tache jusqu'à ce qu'elle s'exécute avec succès (max 15 fois). Et ou le provisionnement n'est pas instantané.

Ensuite je fais appel au module awx.awx pour détruire l'ancien inventaire, le recréer avec le host dont j'ai récupéré l'IP et l'ajouter au modèle de job test-vm (comme il est détruit et recréé il n'a plus le même ID).

  • Le troisième playbook test.yml est un simple ping.
---
- name: Test Stack
  hosts: all
  become: false
  gather_facts: false

  tasks:
    - name: Ping
      ansible.builtin.ping:

Le dernier terraform-destroy :

---
- name: Deploy Stack
  hosts: all
  become: false
  gather_facts: false
  vars:
    known_hosts: |
      devbox.robert.local,192.168.1.42 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIpDg+z8B5AWiJF89PRzSppCE3aW8czN+8GE9Cc8vOJ9JK2fut9of62+Zz+7cqtWz2KXnRdQbrJymIkdYoQY8Wc=
    id_ed25519: !vault |
      $ANSIBLE_VAULT;1.1;AES256
      38666539653431373639613935323362303438373664636363393564313663356263356164396265
      6364306534383365313331313233303762613034323234630a343661303631623731333636356531
      31663264643337346636643364393263306235663537343166336330616538626535393733373738
      6363343336336561610a353039363135623839616162656230646233316430373233366130646362
      33623864343237363739666663623231363933383763316166383466326434653136653636323665
      38373936653263396334343932633030633332643838666139636437626663313263663338643836
      30326136353965323639653965633336633465616661613134366532333266363634653266623633
      62346433633138333264636236646465646631343861643164643666633338373766303665323265
      34356663353434373431646162636131623662643536353664366537383162636264363931646433
      36326330616465646563653937363361313436643739306565363533616134316265613462373333
      32666332336261646662636238333733393635343463346163313339633961363061383263636635
      65343133373430616665663736646333326435633337313966366232663233386664633864313066
      65653931303666643332303266383138393063386336316162613633343634333130663330626337
      33393263373263636639663364666363363662633561376134653634303330646364346531643639
      64643133633139666433663464613639333064393531376338333666656236346638326166666338
      65653533356132316365333765333138303531336266306639643333646138363366373931303064
      61373031316338663534356364323734363536343931386665383265643766313964346639656637
      32313737373038663561303366623637373235303836393639643166633635303064333762383230
      36636130303663366563383561353535313632613664333430353136633334363165303339613936
      34366234666635316132363162626437643332613338313433383465386430613131343666623239
      36653730363030646639633164613464653766303338623461633630396330383332336538656230
      31313361663066623934333434303337633831633066623539663034353663343066616363316465
      34316136613066613234616535653938663065353966366533323236346562303162393263356131
      34373837353033623965613361653662346363393833623662326565616538653764303935643531
      3933

  tasks:
    - name: create .ssh directory
      ansible.builtin.file:
        path: $HOME/.ssh
        state: directory
        mode: 0755
      delegate_to: localhost

    - name: Ensure ssh host key known
      ansible.builtin.copy:
        dest: $HOME/.ssh/known_hosts
        content: "{{ known_hosts }}"
        mode: 0600
      delegate_to: localhost

    - name: Create file id_ed25519
      ansible.builtin.copy:
        dest: $HOME/.ssh/id_ed25519.pub
        content: "{{ id_ed25519 }}"
        mode: 0644
      delegate_to: localhost

    - name: Restore tfstate
      ansible.builtin.fetch:
        src: ~/terraform.tfstate
        dest: ./
        flat: true
        validate_checksum: false

    - name: Destroy VM
      community.general.terraform:
        project_path: './'
        state: absent
        check_destroy: true
        force_init: true
      delegate_to: localhost

Ce playbook est identique à celui du deploy sauf bien sur le status du module terraform qui est à absent. Comme cela tourne dans un container, il faut tout reconstruire et récupérer le state terraform.

Construction des ressources AWX

Nous avons tout le code. Maintenant créons ce qu'il faut dans AWX pour en faire un workflow. Pour cette partie je vous renvoie au billet sur les premiers pas avec AWX, car cela ferait trop de copies d'écran.

Il faut créer les :

  • un environnement d'exécution pointant sur l'image qui a été poussé dans la registry docker.
  • une ressource de type projet qui pointe sur le projet donné en exemple.
  • une ressource de type informations d'identifications :
    • un de type Vault contenant le mot de passe du vault
    • un de type Machine pour se connecter au cluster libvirt
    • un autre de type Machine pour se connecter à la VM provisionné
  • Une ressource de type inventaire pointant sur le cluster libvirt.
  • Des ressources de type Modèle de Job pointant sur les playbooks :
    • deploy-vm avec inventaire cluster libvirt avec la clé SSH et le mdp vault dans les informations d'identifications
    • create-inventory comme le précédent
    • test-vm avec comme inventaire Test
    • destroy-vm configuré comme le deploy.

Construction du Workflow AWX

Maintenant que nous avons tout ce qu'il nous faut créons le Worflow.

Dans [Ressources / Modèles] Ajouter un modèle de flux de Travail

  • nom : le nom que vous voulez
  • inventaire : celui du cluster libvirt

Pour finir Cliquez sur [Enregistrer].

Cliquez désormais sur [Visualiseur]. C'est ici que l'on va dessiner le WorkFlow :

En déplaçant la souris sur [Démarrer] vous devriez voir apparaitre un [+]. Cliquez dessus. Dans [type de Noeud] prenez Sync Projet et sélectionnez votre projet.

De la même façon déplacer la souris au-dessus de la première étape et cliquez sur [+]. Et là vous voyez apparaître des choix sur les conditions d'exécution de ce job. Prenez [En cas de succès] puis [Suivant]. On va prendre un Type de Noeud [Modèle de job] et le premier job est [deploy-vm].

Répétez l'opération jusqu'à obtenir ce workflow. Il est possible d'ajouter des relations en cliquant sur les deux petits anneaux et en pointant sur le noeud destinataire. Vous pourrez alors choisir le type de condition : [En cas d'échec] (rouge) et [Toujours] (bleu).

Pour lancer le workflow il suffit de cliquer sur la petite fusée en haut à droite de la partie visualizer.

Je dois encore corriger la partie ajout dans l'inventaire. Mais cela permet de valider qu'en cas d'échec de ce playbook la vm est détruite. Cool

Conclusion

Les gros avantages que j'ai trouvés :

  • Cela permet de ne pas avoir besoin d'écrire de gros playbooks monolithiques. Au lieu d'enchainer les rôles les uns derrières les autres on découpe en tâches plus unitaires.
  • Prise en charge de l'état en échec à la sortie d'un job
  • Possiblité de mettre des approbations.

Cet exemple n'est pas parfait, mais j'espère que cela vous aura permis de voir tout le potentiel de cet outil qu'est Ansible AWX. J'ai encore pas mal de travail de découvertes à faire dessus.

Je me souviens des premières versions où je n'avais vu aucun bénéfice à son utilisation. Ce n'est plus le cas désormais !!!