En automatisation, dès qu’on dépasse le stade de scripts basiques, le modèle de données devient l’élément le plus critique de la chaîne.
Il doit faire face à de nombreux défis :

  • Exhaustivité
  • Non redondance
  • Modularité pour faciliter l’ajout de nouveaux champs
  • Hiérarchie pour permettre l’héritage des données d’un niveau supérieur

Bref, le modèle de données idéal n’existe pas encore et il nous faut composer avec des modèles imparfaits. Avec comme conséquence que la partie de templating Jinja2 doit accepter des structures de données bancales.
Aussi, je vous propose quelques astuces pour avoir des templates acceptables, c’est-à-dire compacts et sans duplication de code, malgré des données formatées de manière sub-optimale.

Bien sûr, nous n’allons pas tomber dans la facilité en utilisant de nouvelles variables (instruction {% set %}), mais au contraire insérer les conversions de structures à l’intérieur des instructions déjà existantes dans le template (boucles for).
Nous allons parler de formats de données complexes, composées à partir des structures Python de type listes et dictionnaires (tableaux et tableaux associatifs pour la plupart des autres langages).

Regrouper les variables identiques dans des listes ou des dictionnaires

Si vos données proviennent d’un import Excel, il est probable que certaines données se présentent sous la forme suivante :


lan1:   eth 0/1
ip1:    10.0.0.1
mask1:  /24
lan2:   eth 0/2
ip2:    20.0.0.1
mask2:  /24
lan3:   eth 0/3
ip3:    30.0.0.1
mask3:  /16
lan4:
ip4:
mask4

Il est bien sûr possible d’utiliser ces données directement avec un template semblable à celui-ci, avec à chaque utilisation de variable le risque de se tromper d’indice :


interface {{ lan1 }}
  ip address {{ ip1 }}{{ mask1}}
interface {{ lan2 }}
  ip address {{ ip2 }}{{ mask2}}
… 

En outre, vérifier l’existence des données rend le template beaucoup moins attractif :


{% if lan1 %}
interface {{ lan1 }}
  ip address {{ ip1 }}{{ mask1 }}
   {% if lan2 %}
..
      {% if lan4 %}
interface {{ lan4 }}
  ip address {{ ip4 }}{{ mask4 }}
      {% endif %}
   {% endif %}
{% endif %}

Utiliser une boucle for pour injecter ces données, peut simuler le format de données structurées qu’on aurait souhaité, à savoir :

interface :  [ name, ip, mask ]

Il suffit pour cela d’écrire le template sous la forme:


{% for name, ip, mask in     [    [ lan1, ip1, mask1 ],
                                  [ lan2, ip2, mask2 ],
                                  [ lan3, ip3, mask3 ],  
                                  [ lan4, ip4, mask4 ]  ] 
                  if name -%}
interface {{ name  }}
  ip address {{ ip }}{{ mask }}
{% endfor %}

Le template est plus compact, non redondant et le risque d’erreur sur un indice est minimisé.
Une syntaxe alternative utilisant des dictionnaires donne :


{% for itf in     [  { 'name':  lan1,  'ip': ip1, 'mask':  mask1 },
                     { 'name':  lan2,  'ip': ip2, 'mask':  mask2 },
                     { 'name':  lan3,  'ip': ip3, 'mask':  mask3 },  ]
                  if itf.name -%}
interface {{ itf.name  }}
  ip address {{ itf.ip }}{{ itf.mask }}
{% endfor %}

Ou encore (cf utilisation de zip ci-dessous) :


{% for itf in [ 
      dict ( ['name', 'ip', 'mask'] | zip([ lan1, ip1, mask1]) ), 
      dict ( ['name', 'ip', 'mask'] | zip([ lan2, ip2, mask2]) ), 
      dict ( ['name', 'ip', 'mask'] | zip([ lan3, ip3, mask3]) ),       
      dict ( ['name', 'ip', 'mask'] | zip([ lan4, ip4, mask4]) ), 
               ]   if itf.name -%}
interface {{ itf.name  }}
  ip address {{ itf.ip }}{{ itf.mask }}
{% endfor %}

Itérer sur plusieurs listes en parallèle

Une amélioration du modèle de données précédent est de créer des listes parallèles :


lans:  [ eth0/1, eth0/2, eth0/3 ]
ips:   [ 10.0.0.1, 20.0.0.1, 30.0.0.1]
masks: [ /24, /24, /16]

Le template correspondant fait fonctionner les listes en parallèle, par exemple en bouclant sur la variable cachée loop.index0 :


{% for lan in lans %}
interface {{ lan }}
  ip address {{ ips[loop.index0] }}{{ masks[loop.index0] }}
{% endfor %}

Toutefois, l’utilisation du filtre zip permet de fusionner les listes élément par élément en amont. Le template ci-dessous est alors meilleur, parce qu’il ne particularise pas une liste par rapport aux autres et n’introduit pas de variable inutile :


{% for lan, ip, mask in lans|zip(ips, masks) %}
interface {{ lan }}
  ip address {{ ip }}{{ mask }}
{% endfor %}

Utiliser les filtres de préférence aux tests

Dans notre exemple, nous avons regroupé des listes de routes ipv4 et ipv6 pour les traiter simultanément. L’utilisation d’un filtre nous permettra si nécessaire de modifier le CLI produit par le template.
Le filtre ipaddr, intégré à Ansible ou facilement intégrable à votre code Python via la librairie netaddr, permet d’effectuer la plupart des calculs et tests sur les adresses et les subnets IP.

Les données :


routes_ipv4:
   - subnet:   10.0.0.0/8      
     nexthop:  192.0.0.1
   - subnet:   172.16.0.0/12
     nexthop:  192.0.0.1
routes_ipv6:
   - subnet:    2001::/16
     nexthop:   2001:db8::1

Le modèle de données existant :


routes_ipv4:  [ subnet, nexthop ]
routes_ipv6:  [ subnet, nexthop ]      

va devenir, après fusion des listes :

routes: [ subnet, nexthop ]

Le code Jinja initial :


router bgp 65000
  address-family ipv4
{% for route in routes_ipv4 %}
  network {{ route.subnet }}
{% endfor %}
  exit
  address-family ipv6
{% for route in routes_ipv6 %}
  network {{ route.subnet }}
{% endfor %}
  exit        

devient en fusionnant les listes dans la boucle for:


router bgp 65000
{% for route in     routes_ipv4 +   routes_ipv6 %}
   address-family ipv{{ route.subnet | ipaddr('version') }}
   network {{ route.subnet }}
   exit
{% endfor %}

Ici, une simple concaténation de listes, suivi d’un filtre pour obtenir la bonne version de protocole nous a permis de réduire de moitié la taille du template.
Le CLI produit est


router bgp 65000
   address-family ipv4
   network 10.0.0.0/8
   exit
   address-family ipv4
   network 172.16.0.0/12
   exit
   address-family ipv6
   network 2001::/16
   exit

Réduire le nombre et l’imbrication des structures de contrôle

Les tests diminuent la lisibilité des templates en plaçant des lignes de code au milieu du texte de sortie (notre CLI plus ou moins cisco-like pour nous).
Il en bien sûr de même pour les boucles for, mais ces dernières sont inévitables pour traiter des listes, alors que les tests peuvent souvent être intégrés dans les instructions for.
Ici, nous voulons configurer des routes avec une distance selon le champ backup :


routes:
   - subnet:   10.0.0.0/8      
     nexthop:  192.0.0.1
     backup:   true
   - subnet:   172.16.0.0/12
     nexthop:  192.0.0.1
     backup:   false

On aurait souhaité disposer directement de la distance, alors que les données nous indiquent simplement l’état nominal ou backup.

Le template le plus immédiat est du type :


{% for route in routes %}
   {% if route.backup %}
ip route {{ route.subnet }} {{ route.nexthop }} 250
   {% else %}
ip route {{ route.subnet }} {{ route.nexthop }} 10
   {% endif %}
{% endfor %}

Toutefois, la sélection entre les routes nominales et les routes de backup peut être remontée dans la boucle for avec le template suivant :


{% for route in routes|selectattr('backup') %}
ip route {{ route.subnet }} {{ route.nexthop }} 250
{% endfor %}
{% for route in routes|rejectattr('backup') %}
ip route {{ route.subnet }} {{ route.nexthop }} 10
{% endfor %}

Nous avons doublé la boucle for, mais réduit l’empilement des structures de contrôle, ce qui augmente la lisibilité et la maintenabilité du template.

Notons que ce template peut aussi s’écrite avec un if postfixé à l’instruction for.


{% for route in routes  if route.backup %}
ip route {{ route.subnet }} {{ route.nexthop }} 250
{% endfor %}
{% for route in routes   if not route.backup %}
ip route {{ route.subnet }} {{ route.nexthop }} 10
{% endfor %}

Factoriser le code en injectant de nouvelles données

Le premier template peut s’améliorer en utilisant l’opérateur ternaire de Python :


{% for route in routes %}
ip route {{ route.subnet }} {{ route.nexthop }} {{ '250' if route.backup else '10' }}
{% endfor %}

Il est aussi possible de créer une nouvelle variable à l’intérieur de la boucle for en utilisant la logique suivante :


{% for route in 
    routes|selectattr('backup')|map('combine', { 'distance': 250 }) +
    routes|rejectattr('backup')|map('combine', { 'distance': 10 })              %}
ip route {{ route.subnet }} {{ route.nexthop }} {{ route.distance }}
{% endfor %}

Ici, la lourdeur de cette syntaxe ne s’impose pas dans notre court exemple, mais elle peut être pertinente pour éviter des tests répétés. Aussi cherchons à comprendre la syntaxe.

  • routes est une liste de dictionnaires,
  • routes|selectattr(‘backup’) restreint cette liste aux dictionnaires dont l’attribut backup est True.
  • combine est un filtre défini par Ansible qui permet de fusionner plusieurs dictionnaires
    Par exemple { 'a': 1 } | combine ( {'b': 2}, { 'c': 3 } ) retourne {'a': 1, 'b': 2, 'c': 3}
  • map est un filtre qui exécute une commande pour chaque entrée d’une liste
  • routes|map('combine', { 'distance':' 10 } ) ajoute donc le dictionnaire { distance: 10 } à chaque élément de la liste routes
    Par exemple {{ [ { 'a': 1 }, { 'aa': 11 } ] | map ('combine', {'b': 2}, { 'c': 3 } ) }} retourne [{'a': 1, 'b': 2, 'c': 3}, {'aa': 11, 'b': 2, 'c': 3}]
  • Au final, le tableau routes a été enrichi de l’entrée { distance: 250 } pour les routes de backup et de { distance: 10 }pour les autres.

Notons que selectattr est limité à des tests prédéfinis (true/false, equal, length, in, match, gt …). Pour les furieux plus téméraires, il est possible d’utiliser le test in pour utiliser des filtres Jinja2. Par exemple:


{{ 
     neighs |  selectattr( 'ip', 'in',  (neighs | map(attribute='ip') |  ipv6) )
            |  map('combine', { 'type': 'ipv6'} ) 
   + neighs |  selectattr( 'ip', 'in',  (neighs | map(attribute='ip') |  ipv4) )
            |  map('combine', { 'type': 'ipv4'} ) 
 }}

est certes (très) peu lisible, mais transforme bien

[{'ip': '10.0.0.1', 'as': 65000}, {'ip': '2001::1', 'as': 65001}]

en

[{'ip': '2001::1', 'as': 65001, 'type': 'ipv6'}, 
   {'ip': '10.0.0.1', 'as': 65000, 'type': 'ipv4'}]

Inverser une structure de données avec groupby

Enfin, comment travailler avec un modèle dont la hiérarchie est faussée ?
Par exemple, le modèle de donnée suivant ne conviendra pas pour configurer des VRFs :


interfaces:
    - name:   eth 0/1
      subnet: 10.0.0.0/16
      vrf:    BLUE
    - name:   eth 0/2
      subnet: 20.0.0.0/16
      vrf:    BLUE

En effet, si on utilise un template du style :


{% for itf in interfaces %}
vrf definition {{ itf.vrf }}
   rd 1:9{{ "%04d" | format(loop.index) }}
interface {{ itf.name }}
   vrf forwarding {{ itf.vrf }}
   ip address {{ itf.addr }}
{% endfor %}

On obtient le résultat :


vrf definition BLUE
   rd 1:90001
interface eth 0/1
   vrf forwarding BLUE
   ip address 10.0.0.1/16

vrf definition BLUE
   rd 1:90002
interface eth 0/2
   vrf forwarding BLUE
   ip address 20.0.0.1/16

Si la partie interface est correctement configurée, le template nous a amené à définir 2 identifiants pour la même VRF.
Le modèle de données est correct pour la partie interface, mais incorrect pour la partie définition des VRF.
Le modèle de données existant et celui souhaité :


interfaces:
     vrf        

vrfs : [ interfaces ]

Le filtre groupby nous permet de trier les éléments de la liste interfaces par un attribut donné. Ici nous regroupons les éléments par VRF, ce que nous prouve le template suivant :


{% for vrf, itfs in interfaces | groupby('vrf') %}
{{ vrf }}  {{ itfs }}
{% endfor %}

Notre template devient très simplement :


{% for vrf, _ in interfaces|groupby('vrf') %}
vrf definition {{ vrf }}
   rd 1:9{{ "%04d" | format(loop.index) }}
{% endfor %}
{% for itf in interfaces %}
interface {{ itf.name }}
   vrf forwarding {{ itf.vrf }}
   ip address {{ itf.addr }}
{% endfor %}

Et le résultat retourné est conforme:


vrf definition BLUE
   rd 1:90001

interface eth 0/1
   vrf forwarding BLUE
   ip address 10.0.0.1/16

interface eth 0/2
   vrf forwarding BLUE
   ip address 20.0.0.1/16

Si l’on doit accéder aux données internes, une seconde itération est alors nécessaire, mais c’est bien ce que nous voulons, puisqu’on a séparé une liste unique en plusieurs sous-listes. Notre modèle de données souhaité montre bien que chaque VRF possède plusieurs interfaces.
Par exemple, si l’on devait configurer l’annonce des routes en BGP, on pourrait utiliser le template suivant:


router bgp 65500
{% for vrf, itfs in interfaces | groupby('vrf') %}
   address-family ipv4 vrf {{ vrf }}
   {% for itf in itfs %}
   network {{ itf.subnet }}
   {% endfor %}
   exit
{% endfor %}

Ce qui produit le résultat attendu:


router bgp 65500
   address-family ipv4 vrf BLUE
      network 10.0.0.0/16
      network 20.0.0.0/16
   exit

Conclusion

Nous avons vu différents moyens de s’affranchir de données peu ou mal structurées tout en conservant des templates lisibles, correctement structurés et facilement maintenables.

Author

Philippe est architecte réseau chez un opérateur depuis 20 ans. Il a le double rôle de concevoir des réseaux pour les clients, puis de les faire fonctionner. Bien que passionné par l'innovation, il reste un fervent supporter de la RFC 1925 et garde tout son sens critique par rapport au hype et aux promesses féeriques des constructeurs. Ancien développeur, il tente de garder la main en programmant des Arduino en C ou des utilitaires opensource en Ruby. On peut également le croiser en randonnée dans les collines ou dans un club de bridge.

Write A Comment