Gestion des renvois d'appel inter-sites sur Cisco CUCM : détection de chaînes et de boucles, classification graduée de l'état réel, et écritures compensées sur une API qui n'a pas de transactions.
Réimplémentation générique et publiable d'un outil interne qui a remplacé une demande par ticket au support par une action autonome des sites.
Poser un renvoi d'appel sur un site n'est pas la modification d'un objet unique. C'est trois écritures cohérentes :
- le point d'entrée du site reçoit une destination, une classe de service et un délai de déclenchement ;
- deux motifs de traduction, dans deux partitions distinctes, interceptent normalement l'appel en amont pour l'annonce et la résolution d'annuaire — ils doivent être neutralisés pendant la durée du renvoi.
Neutraliser n'est pas supprimer : le motif est renommé avec un préfixe qui le rend non composable. L'objet, sa partition et toute sa configuration sont préservés, l'opération est réversible et atomique par objet, et une interruption ne laisse aucun orphelin.
AXL n'offre aucune transaction. Trois écritures, aucune garantie d'atomicité : l'échec partiel est un état réel qu'il faut traiter.
| Module | Rôle |
|---|---|
numbers |
Normalisation des numéros saisis, refus explicite de l'inconnu |
chains |
Suivi du graphe de renvois : chaînes, boucles, sorties externes |
state |
Classification graduée de l'état réel d'un site |
operations |
Orchestration des trois écritures et compensation vérifiée |
axl |
Adaptateur SOAP — le seul module qui sait que c'est du Cisco |
filestore |
Backend sur fichier, pour tourner sans CUCM |
cli |
Interface en ligne de commande |
Il n'existe pas de double de test pour AXL. Le dépôt embarque donc un backend sur fichier JSON qui satisfait le même protocole, ce qui rend l'outil exécutable par n'importe qui après un clone :
pip install -e ".[dev]"
python -m cucm_forwarding.cli surveySITE_A Agence A rest idle
SITE_B Agence B forwarded forwarded to 1003 (ring timeout 12s)
└─ forwards to SITE_C, which is itself forwarded to an external number: SITE_B -> SITE_C -> (external)
SITE_C Agence C forwarded forwarded to 00612345678 (ring timeout 12s)
SITE_D Agence D drifted idle, with leftovers that will be normalised: calling search space is 'CSS_STALE', expected 'CSS_REST'
SITE_E Agence E broken translation patterns are neutralised but no forward destination is set: inbound routing is cut with nothing catching the calls
Les écritures sont en simulation par défaut :
python -m cucm_forwarding.cli set SITE_A 1002 # affiche, n'écrit rien python -m cucm_forwarding.cli set SITE_A 1002 --apply # écrit python -m cucm_forwarding.cli clear SITE_B --apply
Un outil qui suppose que chaque site est soit au repos, soit proprement renvoyé se trompe sur quelques pourcents d'un parc réel — et se trompe dans le sens coûteux : il refuse des sites légitimes, ou écrase silencieusement une configuration délibérée.
La classification est donc graduée. Un écart cosmétique (délai non standard, classe de service résiduelle) est normalisé en silence. Un état contradictoire est refusé et remonté, parce qu'aucune manipulation dans le portail ne peut le corriger :
- motifs neutralisés sans destination de renvoi → le routage entrant est coupé et rien ne rattrape les appels ;
- destination posée avec les motifs toujours actifs → ils interceptent l'appel avant que le renvoi s'applique : le renvoi est configuré et inerte.
Les deux sont la trace d'une opération manuelle interrompue.
Un site renvoyé vers un deuxième, lui-même renvoyé ailleurs, fait aboutir les appels à un endroit que personne n'a choisi et que ni l'un ni l'autre des opérateurs ne voit.
La détection a manqué deux cas lors de sa première écriture, tous deux observés en production :
- un renvoi visant un site par son numéro public plutôt que par son numéro interne — le site ressemblait alors à une destination externe et la chaîne devenait invisible ;
- une chaîne quittant le parc après deux sauts, qui n'est pas plus longue qu'un renvoi ordinaire : la longueur seule ne peut donc pas servir de test.
Les deux sont couverts par des tests. Le comportement retenu est d'avertir sans bloquer — une chaîne est parfois voulue — sauf la boucle directe, qui est refusée.
Sans transaction, la seule garantie possible est la compensation au mieux : capture de l'état avant, application séquentielle, et en cas d'échec annulation en ordre inverse.
Le point important est que l'annulation est vérifiée par une relecture, plutôt que déduite du fait que les appels d'annulation n'ont pas levé d'erreur. Un backend qui accepte chaque appel sans rien changer produirait sinon un « tout est rentré dans l'ordre » entièrement faux — c'est un des cas de test.
Si la compensation échoue à son tour, le site reste dans un état intermédiaire. Ce risque est tracé, pas éliminé : il est signalé, journalisé, et distingué par son code de sortie.
| Code | Signification |
|---|---|
0 |
Appliqué, ou rien à faire |
2 |
Refusé avant toute écriture |
3 |
Échec en cours, site rétabli et rétablissement vérifié |
4 |
Échec en cours, état intermédiaire — intervention humaine |
AXL expose un point d'entrée d'exécution SQL. Il est utilisé pour toutes les lectures de masse : une vue d'ensemble du parc coûte quelques requêtes contre plusieurs centaines d'appels unitaires.
Aucune écriture ne passe par là. Une écriture directe contournerait la
validation métier de l'éditeur et, surtout, la propagation de la configuration
vers les nœuds de traitement d'appel : la base changerait pendant que les
téléphones continueraient de se comporter comme avant. La fonction d'accès SQL
refuse d'ailleurs toute requête qui n'est pas un SELECT, en défense en
profondeur.
Le WSDL AXL est distribué par Cisco avec le produit et n'est pas redistribuable : il n'est donc pas dans ce dépôt. Récupérez-le depuis votre propre grappe (interface d'administration → Plugins), puis :
from cucm_forwarding.axl import AxlClient client = AxlClient( host="cucm.example.local", username="svc-axl", password=os.environ["AXL_PASSWORD"], wsdl_path="wsdl/AXLAPI.wsdl", ) print(client.check())
Trois pièges, tous rencontrés :
Le client se construit sans qu'aucun paquet n'atteigne le serveur.
Le WSDL étant local, l'instanciation réussit toujours. Journaliser
« connecté » à ce stade n'a aucune valeur ; check() fait un vrai aller-retour.
Un refus d'autorisation revient en HTML. Le client SOAP ne sait pas l'interpréter et le traduit par une faute sans rapport. Si le message paraît absurde, vérifiez que le compte porte bien le rôle d'accès à l'API AXL — les groupes génériques d'accès API ne l'impliquent pas.
L'adresse de service déclarée dans le WSDL n'est pas la vraie. Le binding doit être instancié explicitement contre l'endpoint de la grappe.
AxlClient.read_state() est volontairement laissé non implémenté : faire
correspondre un identifiant de site aux objets d'un plan de numérotation est
spécifique à chaque parc.
pytest --cov=cucm_forwarding
83 tests, 91 % de couverture. Toute la logique décidable — normalisation, chaînes, classification, compensation — est isolée de la couche réseau et testée sur jeux de données fictifs, y compris les chemins d'échec :
- panne injectée sur chacune des trois écritures, avec vérification que le site revient exactement à son état initial ;
- annulation qui échoue à son tour →
INCONSISTENT; - annulation qui prétend réussir sans rien changer → détectée par la relecture.
C'est la stratégie choisie précisément parce qu'il n'existe pas de double AXL : faute de pouvoir simuler le système, on rend testable tout ce qui n'a pas besoin de lui.
- La concurrence n'est pas traitée. Deux opérateurs agissant simultanément sur le même site produiraient un résultat indéterminé. Un verrou par site est nécessaire.
- Le processus de réconciliation périodique n'est pas inclus ici.
- La compensation n'a été éprouvée que par injection de panne.
- Un verdict de chaîne dépend de l'exactitude de l'inventaire : une entrée dont le numéro n'existe pas dans le plan de numérotation produirait un renvoi silencieusement inopérant.
MIT — voir LICENSE.