ebenoit.info

Construction d'un inventaire dynamique Ansible depuis un cluster Proxmox

Je suis en train de faire migrer lentement les machines virtuelles de mes serveurs domestiques depuis mes anciens scripts de configuration (faits main, en Perl) vers un ensemble de scripts Ansible. Cependant, comme je suis assez paresseux, je ne veux pas avoir à mettre à jour manuellement les inventaires du playbook si je peux l'éviter. Idéalement, il me suffirait d'écrire la configuration réelle d'un service, puis d'exécuter le playbook. Les VM appropriées devraient être sélectionnées automatiquement dans le cluster Proxmox et utilisées comme cibles.

Version courte : essayer d'en faire le plus possible directement dans Ansible a pour résultat un fonctionnement beaucoup trop complexe.

Structure cible de l'inventaire

En ce qui concerne les groupes de l'inventaire, je souhaite obtenir une hiérarchie quelque peu imbriquée. Tout d'abord, toutes les machines virtuelles qui sont gérées par Ansible se trouveront dans le groupe managed, qui est organisé avec les sous-groupes suivants.

Par exemple, une paire de clusters mettant en œuvre des services LDAP (un à des fins de test, l'autre pour une utilisation réelle) pourrait être organisée comme indiqué ci-dessous :

managed
 |- by_network
 |   |- net_dev -> [vm0, vm1, vm2, vm3, vm4]
 |   |- net_infra -> [vm5, vm6, vm7, vm8, vm9]
 |- by_environment
 |   |- env_dev -> [vm0, vm1, vm2, vm3, vm4]
 |   |- env_prod -> [vm5, vm6, vm7, vm8, vm9]
 |- by_failover_stack
 |   |- fostack_1 -> [vm0, vm2, vm5, vm7]
 |   |- fostack_2 -> [vm1, vm3, vm6, vm8]
 |   |- no_failover -> [vm4, vm9]
 |- svc_ldap
     |- svin_ldap_dev -> [vm0, vm1, vm2, vm3, vm4]
     |- svin_ldap_prod -> [vm5, vm6, vm7, vm8, vm9]
     |- svcm_ldap_front -> [vm0, vm1, vm5, vm6]
     |- svcm_ldap_ldap
        |- svcm_ldap_roldap -> [vm2, vm3, vm7, vm8]
        |- svcm_ldap_rwldap -> [vm5, vm9]

Problèmes

Il y a deux problèmes principaux qui doivent être résolus.

Tout d'abord, Proxmox a un support très limité des métadonnées sur les VMs. Il prend en charge les "tags", qui sont une liste de mots avec des contraintes assez fortes sur les caractères autorisés, et pour autant que je sache, ils ne peuvent être définis ou lus que par l'API ou la ligne de commande, mais ils seraient insuffisants pour ce que j'ai besoin de faire. De plus, bien que je n'aie aucune intention de gérer mes métadonnées manuellement, le fait de ne pas les avoir du tout dans l'interface graphique est un peu pénible.

Du côté d'Ansible, bien qu'il y ait un plugin communautaire qui peut lire l'inventaire de Proxmox, et un plugin fourni avec le cœur d'Ansible qui peut construire des groupes à partir de diverses variables, ce dernier ne peut pas construire de groupes vides (par exemple le groupe svc_ldap dans l'exemple ci-dessus), et il y a des limitations dans la façon dont les facts peuvent être générés par les deux plugins - principalement, aucun des facts générés ne peut se référer à un autre fact généré au même stade.

Mise en œuvre

Dans les sections suivantes, je vais implémenter un inventaire qui génère la structure attendue à partir de l'inventaire Proxmox. J'ai créé un dépôt sur GitHub qui contient l'exemple, avec chaque commit dans le dépôt correspondant aux étapes ci-dessous.

Structure statique

Le premier fichier de l'inventaire doit générer les parties statiques de la structure. Ceci est fait en utilisant un simple fichier d'inventaire ; ce fichier doit être lu avant le reste, nous le nommerons 00-static-structure.yml.

all:
  children:
    managed:
      children:
        by_environment:
        by_failover_stack:
          children:
            no_failover:
        by_network:

À ce stade, tester en utilisant ansible-inventory --playbook-dir . --graph montre les groupes ci-dessus plus le groupe supplémentaire ungrouped.

Récupérer l'inventaire de Proxmox

Maintenant, nous devons récupérer la liste des VM et leurs métadonnées associées à partir du cluster Proxmox en utilisant le plugin community.general.proxmox. Nous avons besoin qu'Ansible l'exécute juste après le chargement des groupes statiques, donc son nom commencera par 01-. En outre, le plugin exige que le nom du fichier se termine par .proxmox.yml.

Nous allons configurer le plugin pour qu'il récupère tous les faits et les écrive dans des variables avec le préfixe proxmox__. Les groupes générés par le plugin utiliseront le même préfixe.

plugin: community.general.proxmox

url: https://proxmox.example.org:8006
validate_certs: false
user: test@pve
password: ...
want_facts: true
facts_prefix: proxmox__
group_prefix: proxmox__
want_proxmox_nodes_ansible_host: false

Si la configuration Ansible est utilisée pour restreindre la liste des plugins qui peuvent lire l'inventaire, elle doit également être modifiée :

[inventory]
enable_plugins = community.general.proxmox, yaml

Et il peut être nécessaire d'installer le module Python requests (pip install requests dans le même venv qu'Ansible devrait fonctionner).

Une fois ceci fait, et en supposant que url, user et password sont configurés de manière appropriée, ansible-inventory devrait afficher à la fois la structure statique de la section ci-dessus et les VMs et groupes qui ont été récupérés sur le cluster Proxmox :

@all:
  |--@managed
  |  |--@by_environment:
  |  |--@by_failover_stack:
  |  |  |--@no_failover:
  |  |--@by_network:
  |--@proxmox__all_lxc:
  |--@proxmox__all_qemu:
  |  |--vm1
  |  |--vm2
  |  |--vm3
  | ...
  |--@ungrouped:

En outre, l'utilisation de ansible-inventory --host pour afficher les données d'une VM devrait montrer un ensemble d'entrées correspondant aux paramètres de la VM :

{
    "proxmox__agent": "1",
    "proxmox__boot": {
        "order": "ide2;scsi0"
    },
    "proxmox__cores": 4,
    "proxmox__cpu": "kvm64",
    "proxmox__description": "something",
    // ...
    "proxmox__net0": {
        "bridge": "vmbr0",
        "firewall": "1",
        "tag": "16",
        "virtio": "12:23:34:45:56:67"
    },
    // ...
}

Stockage des métadonnées sur le cluster Proxmox

Comme je l'ai mentionné dans l'introduction, les tags de VM ne sont pas suffisants pour ce que nous devons faire. Cependant, chaque VM peut être associée à un texte Markdown arbitraire. Ce texte peut être vu dans la partie "Notes" de l'interface graphique de Proxmox.

Une solution au problème du stockage de métadonnées arbitraires serait de les encoder en JSON directement dans ces notes. Elles peuvent ensuite être lues à partir de la variable proxmox__description.

Cependant, cette approche est insuffisante à deux égards. Tout d'abord, le JSON lui-même est assez illisible sur l'interface graphique, ce qui réduit l'utilité de l'avoir visible à cet endroit. Deuxièmement, cela rend impossible l'ajout de notes destinées à l'administrateur.

Au lieu de cela, j'ai choisi d'utiliser la structure suivante :

(Markdown arbitraire ici)
```ansible
{
  "service": "ldap",
  "instance": "dev",
  "component": "ldap",
  "subcomponent": "roldap",
  "fostack": 1
}
```
(plus de Markdown ici parce que pourquoi pas)

Grâce au marqueur ansible, il est possible de scinder la description au début du bloc, puis d'utiliser le marqueur non modifié pour supprimer la fin de la description. La chaîne résultante peut alors être lue comme du JSON.

Ceci peut être réalisé en ajoutant une section compose à la configuration du plugin Proxmox.

compose:
  inv__data: >-
    ( ( proxmox__description | split( '```ansible' ) )[1]
      | split( '```' ) )[0]
    | from_json

Lorsque les notes contiennent un bloc qui suit le bon format, le plugin créera une variable inv__data qui contiendra les données extraites. Si le format est incorrect, ou s'il n'y a pas de description, ou si le bloc contient du JSON invalide, la variable ne sera tout simplement pas définie (ceci est dû au fait que l'option strict du plugin d'inventaire Proxmox est à false par défaut).

Il est possible d'utiliser la commande ansible-inventory pour vérifier la variable après avoir ajouté un test sur l'une des VMs :

{
    "inv__data": {
        "component": "ldap",
        "fostack": 1,
        "instance": "dev",
        "service": "ldap",
        "subcomponent": "roldap"
    },
    "proxmox__agent": "1",
    // ...
}

Calcul des facts

Nous devons maintenant déduire quelques éléments des différentes données que nous avons recueillies.

Copie des métadonnées dans les variables

Comme la variable inv__data peut être indéfinie, nous allons copier une partie de son contenu dans des variables séparées pour éviter d'avoir à écrire (inv__data|default({})) à chaque accès. Ceci sera fait dans le fichier d'inventaire 02-copy-metadata.yml, en utilisant le plugin constructed. Comme il fonctionne en mode non strict, les différentes variables ne seront pas générées si inv__data n'existe pas.

plugin: constructed
strict: false

compose:
  inv__component: inv__data.component
  inv__fostack: inv__data.fostack
  inv__instance: inv__data.instance
  inv__service: inv__data.service
  inv__subcomponent: inv__data.subcomponent

Il sera nécessaire d'activer le plugin constructed dans la configuration Ansible pour que cela fonctionne :

[inventory]
enable_plugins = constructed, community.general.proxmox, yaml

Cette VM doit-elle être gérée ?

Le fichier suivant, 03-check-managed.yml, crée une variable _inv__managed si les métadonnées incluent un nom de service et un nom d'instance, et si la première interface réseau est connectée. Lorsqu'elle est définie, cette variable contiendra toujours une chaîne vide. Cela nous permet de l'utiliser dans d'autres définitions de variables ou dans des définitions de noms de groupes. Si elle existe, l'ajout de son contenu à une variable quelconque n'aura aucun effet. Si elle n'existe pas en revanche, l'évaluation par Jinja échouera, ce qui empêchera la création de groupes ou de variables.

Pour ce faire, nous devons utiliser des conditionnels Jinja en plus des expressions. Le compose du plugin constructed ne le permet normalement pas, mais il est possible de le faire quand même. En fait, Ansible préfixe simplement l'expression avec {{ et la suffixe avec }}, il est donc possible de terminer ces expressions et d'ajouter des conditionnels.

Voici le fichier 03-check-managed.yml qui implémente cela.

plugin: constructed
strict: false

compose:
  _inv__managed: >-
    ( inv__instance and inv__service ) | ternary( '' , '' )
    }}{% if proxmox__net0.link_down | default("0") == "1"
    %}{{   this_variable_does_not_exist_and_so_inv_managed_will_not_be_created
    }}{% endif
    %}{{ ''

La première ligne de la définition s'appuie sur le fait qu'essayer d'utiliser inv__instance ou inv__service dans une expression empêchera la définition de la variable si l'une d'entre elles est manquantue.

La deuxième ligne termine l'expression, ce qui permet d'utiliser une instruction conditionnelle. Il est cependant nécessaire de ré-entrer dans une expression et de fournir quelque chose de valide, ce qui est fait par la dernière ligne.

Enfin, le très long nom de variable dans l'expression fait référence à une variable non définie, et ne sera exécuté que si la condition est vraie, empêchant dans ce cas la définition de _inv__managed.

Groupes de base

A ce stade, nous sommes à peu près prêts à commencer à ajouter nos VMs aux groupes correspondant aux réseaux, environnements et de piles de haute disponibilité. Nous allons créer un nouveau fichier d'inventaire appelé 04-env-fo-net-groups.yml pour effectuer cela avec le plugin constructed.

Tout d'abord, nous allons utiliser une table pour déterminer le réseau sur lequel se trouve la VM en fonction du tag VLAN de son interface net0 :

plugin: constructed
strict: false

compose:
  inv__network: >
    {
      "30": "infra",
      "31": "dmz",
      "32": "pubapps",
      "33": "intapps",
      "34": "users",
      "60": "dev",
    }[ proxmox__net0.tag | default("") ]
    | default( "unknown" )
    ~ _inv__managed

La dernière ligne utilise la variable _inv_managed pour empêcher la variable d'être définie si la VM ne doit pas être gérée. Comme la variable contient normalement une chaîne vide, son utilisation n'a aucun autre effet.

À ce stade, nous pouvons créer le groupe basé sur le réseau :

keyed_groups:
  - prefix: net
    key: inv__network
    parent_group: by_network

L'environnement peut être calculé en vérifiant la présence d'un champ environment dans les métadonnées d'origine. Dans le cas contraire, la VM sera assignée à l'environnement prod si son nom d'instance est prod, ou à 'environnement dev s'il ne l'est pas. Nous devons également faire référence à _inv__managed pour empêcher les VM non gérées d'être ajoutées au groupe.

compose:
  # ...
  inv__environment: >-
    inv__data.environment
      | default(
          ( inv__instance == "prod" ) | ternary( "prod", "dev" )
        )
    ~ _inv__managed

keyed_groups:
  # ...
  - prefix: env
    key: inv__environment
    parent_group: by_environment

Le dernier groupe de base à générer est basé sur la pile HA dont la VM fait partie, le cas échéant. Notez le default("") utilisé dans le ternary pour l'empêcher de faire référence à une variable non définie.

compose:
  # ...
  _inv__fostack_group: >-
    ( inv__fostack is defined )
      | ternary(
          "fostack_" ~ inv__fostack | default("") ,
          "no_failover"
        )
    ~ _inv__managed

keyed_groups:
  # ...
  - prefix: ''
    key: _inv__fostack_group
    parent_group: by_failover_stack

Génération de groupes intermédiaires vides

A ce stade, nous devons commencer à travailler sur la création des groupes intermédiaires correspondant au service et aux composants de celui-ci.

Le principal problème est que ces groupes doivent être créés vides - nous ne voulons pas que nos VM soient ajoutées directement à ces groupes, car cela poserait des problèmes de priorité de variables lorsque nous tenterions de les utiliser pour la configuration réelle.

Malheureusement, ni le plugin constructed, que nous avons utilisé ci-dessus, ni le plugin generator (documenté ici) ne peuvent être utilisés pour générer les groupes vides dont nous avons besoin, car tous deux ajoutent toujours un hôte aux groupes qui sont créés. De plus, generator ne traite pas les noms de layers avec Jinja. Nous devons écrire un plugin personnalisé pour générer les groupes dont nous avons besoin.

Générateur de groupes vides

Ce dont nous avons besoin est un plugin d'inventaire relativement simple qui génère des groupes avec des noms et des parents variables. Il pourrait être configuré en utilisant une liste de groupes, chacun décrit par un dictionnaire avec une entrée name contenant un template Jinja, et une entrée parents contenant une liste de templates Jinja (un pour chaque groupe parent). Chaque template individuel pourrait renvoyer une chaîne vide ; dans la partie name, cela ferait disparaître le groupe, et dans la liste des parents elle serait simplement ignorée.

Nous ne couvrirons pas l'écriture du plugin ici, mais nous commenterons certaines parties du code. Il se trouve dans le fichier inventory_plugins/group_creator.py du dépôt.

Il commence par la "documentation", qu'Ansible utilise pour valider les données de configuration du plugin et définir les valeurs par défaut des différentes options.

Ensuite, nous définissons la classe du plugin. Sa méthode principale, parse(), est présentée ci-dessous :

    def parse(self, inventory, loader, path, cache=False):
        super(InventoryModule, self).parse(inventory, loader, path, cache=cache)
        self._read_config_data(path)
        strict = self.get_option("strict")
        for host in inventory.hosts:
            host_vars = self.inventory.get_host(host).get_vars()
            for group in self.get_option("groups"):
                name = self._get_group_name(host, group['name'], host_vars, strict)
                if not name:
                    continue
                self.inventory.add_group(name)
                for ptmpl in group.get("parents"):
                    parent = self._get_group_name(host, ptmpl, host_vars, strict)
                    if parent:
                        self.inventory.add_group(parent)
                        self.inventory.add_child(parent, name)

Il passe en revue tous les hôtes d'inventaire connus et essaie de générer des groupes basés sur les facts de chacun de ces hôtes. Il calcule ensuite les noms des groupes parents, s'assure que les groupes parents existent, et leur ajoute le nouveau groupe comme enfant. La méthode _get_group_name() appliquera simplement les templates, en retournant une chaîne vide ou en provoquant une exception si un problème survient, selon la valeur de l'option strict.

Le plugin doit également être ajouté aux plugins activés dans la configuration Ansible.

[inventory]
enable_plugins = constructed, community.general.proxmox, group_creator, yaml

Note : à ce stade, le test avec ansible-inventory nécessite l'option --playbook-dir . car l'outil ne trouvera pas le plugin si elle n'est pas présente.

Création des groupes

Nous pouvons créer un nouveau fichier dans l'inventaire pour la création des groupes intermédiaires. Pour toutes les VM gérées par Ansible, nous devons nous assurer que le groupe de service existe. Nous devons également créer des groupes de composants si des composants sont définis, et des groupes de sous-composants si des composants et des sous-composants sont définis.

La création du groupe de services est assez simple :

plugin: group_creator
strict: true

groups:

  - name: >-
      {{ 'svc_' ~ inv__service ~ _inv__managed }}
    parents:
    - managed

La création des groupes de composants est à peu près aussi simple. Si aucun composant n'est défini pour le service actuel, les groupes ne seront pas créés car la variable inv__component ne pourra pas être trouvée.

  - name: >-
      {{
        'svcm_' ~ inv__service
        ~ '_' ~ inv__component
        ~ _inv__managed
      }}
    parents:
    - 'svc_{{ inv__service }}'

Enfin, si des sous-composants sont utilisés, leurs groupes doivent également être créés. En le faisant à ce stade, nous n'aurons pas à spécifier les groupes parents à l'étape suivante. Les groupes de sous-composants doivent être créés si la VM est gérée et définit à la fois un composant et un sous-composant.

  - name: >-
      {{
        'svcm_' ~ inv__service
        ~ '_' ~ inv__subcomponent
        ~ _inv__managed
        ~ ( inv__component | ternary('','') )
      }}
    parents:
    - 'svcm_{{ inv__service }}_{{ inv__component }}'

Les tests effectués à ce stade devraient montrer que les différents groupes ont bien été créés. Ils ne devraient pas contenir d'hôtes.

@all:
  |--@managed:
  |  |--@svc_ldap:
  |  |  |--@svcm_ldap_front:
  |  |  |--@svcm_ldap_ldap:
  |  |  |  |--@svcm_ldap_roldap:
  |  |  |  |--@svcm_ldap_rwldap:

Affectation des VM aux groupes de services

Nous pouvons procéder à l'affectation des VMs aux groupes de services en utilisant le plugin constructed. Ceci est fait dans le fichier 06-hosts-in-service-groups.yml.

Tout d'abord, nous allons ajouter les hôtes aux groupes d'instances sous les groupes de service principaux. Comme d'habitude, l'utillisation de _inv__managed garantit que nous ne créons des groupes qu'à partir des VM dont nous avons réellement besoin de gérer et pour lesquelles c'est possible.

compose:

  _inv__instance_group: >-
    inv__service ~ '_' ~ inv__instance ~ _inv__managed

keyed_groups:

  - prefix: svin
    key: _inv__instance_group
    parent_group: "svc_{{ inv__service }}"

Ensuite, nous devons ajouter la VM au groupe qui correspond au composant ou au sous-composant du service. Ceci ne doit être fait que s'il y a un composant. Il n'est pas nécessaire de spécifier un parent_group dans la définition du groupe, car la hiérarchie a déjà été définie lors de la création du groupe.

compose:

  _inv__component_group: >-
    inv__service ~ '_' ~ inv__subcomponent | default( inv__component )
    ~ _inv__managed

keyed_groups:

  - prefix: svcm
    key: _inv__component_group

Conclusion

Cette configuration crée la structure dont nous avions besoin. Cependant, la réalisation de cette structure est assez alambiquée (7 fichiers YAML et un plugin Python), et doit s'appuyer sur un certain nombre d'astuces et d'effets secondaires - l'"injection Jinja" utilisée pour les choses dont Ansible attend qu'elles contiennent une seule expression Jinja étant particulièrement sale. Compte tenu de la complexité impliquée, cela vaudrait probablement la peine de remplacer toutes les étapes suivant la récupération de l'inventaire Proxmox par un seul plugin Python qui gère l'ensemble du processus.