Construction d'un inventaire dynamique Ansible depuis un cluster Proxmox
Écrit par Emmanuel BENOÎT - Créé le 2022-08-07
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.
by_network
- un groupe qui contient des sous-groupes correspondant aux différents VLAN.by_environment
- un groupe qui contient des sous-groupes pour les différents environnements que j'ai (ici à la maison c'estdev
etprod
)by_failover_stack
- Les VMs qui font partie de services qui supportent la haute dispo seront ajoutées à des sous-groupes nommésfostack_{X}
(oùX
est un nombre) ; les autres seront ajoutées au sous-groupeno_failover
.svc_{service}
- ces groupes doivent être générés automatiquement en se basant sur les métadonnées de la VM. Ils correspondent à toutes les VMs implémentant un service spécifique. Ils contiennent deux types de sous-groupes.svin_{service}_{instance}
- Toutes les VMs d'une instance du service.svcm_{service}_{component}
- un composant du service. Il sera utilisé si un service nécessite plusieurs VM exécutant différentes parties d'une application (par exemple, une base de données et un serveur Web). Les composants peuvent être imbriqués dans d'autres composants avec une profondeur maximale de 2.
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.