Julien De Bona
Free Software, Cooking, and Everything


Mon expérience avec PowerShell

Publié le 2017-08-27.

J'ai passé quelques années à programmer en PowerShell. En voici mon expérience, et comment je l'ai utilisé. C'est un peu hors-sujet, mais ça suscite de l'intérêt. Les suggestions qui suivent correspondent à du code que j'ai écrit, mais qui est la propriété de mon ex employeur, et donc c'est là que mon aide s'arrêtera et vous ne trouverez ici aucun code à télécharger. Pour le reste, ça commence à dater un peu, donc veuillez excuser les imprécisions.

Le contexte

J'ai travaillé quelques années dans le département IT d'un éditeur de logiciels employant environ 4000 personnes sur les 5 continents; en Europe, elle compte une quarantaine de bureaux, regroupés en 5 régions. J'ai été impliqué dans l'automatisation d'une série de processus, tournant régulièrement autour d'Active Directory. J'ai également réécrit des scripts cmd, profitant de l'occasion pour réviser les processus, souvent bridés par la technologie ou les compétences disponibles. Ces processus s'appliquaient généralement à l'échelle mondiale ou européenne. L'environnement était PowerShell 2.0 sous Windows Server 2008R2.

PowerShell, c'est bien?

En bref, c'est mieux que CMD et VBA, et pire que tous les autres langages contemporains. Son seul intérêt réside dans l'administration de systèmes Windows.

Les plus:

  • C'est disponible par défaut sur Windows
  • Le backslash ("\") n'est pas une séquence d'échappement, ce qui permet d'écrire les chemins clairement.
  • Le langage n'est pas sensible à la casse par défaut, pratique pour administrer le monde Windows
  • Le pipeline véhicule des objets, pas du texte, ce qui évite le parsing parfois fastidieux, peu lisible et souvent foireux dans les cas limites.

Les moins:

  • C'est verbeux, les commandes sont très longues et, vu le nombre limité de verbes disponibles, on se retrouve souvent à nommer une fonction "Invoke-Action" au lieu de "act".
  • Il a été publié à la hâte et, dès la version 2.0, on observe des comportements bizarres, juste pour conserver la sacro-sainte compatibilité avec une version 1.0 foireuse. Un exemple est les objets .NET, qui ne lancent des exceptions que dans un bloc try ... catch (c'est-à-dire qu'une fonction se comportera différemment selon qu'elle est appelée ou non depuis un try).
  • On ne peut pas faire la distinction entre une fonction qui ne retourne rien et une valeur Null, et on se retrouve toujours, avant d'itérer sur une variable, à devoir vérifier sa valeur pour ne pas se retrouver à faire une itération sur Null alors qu'on s'attend à ne faire aucune itération.
  • La communauté n'est pas faite de développeurs, et il y a donc très peu de modules réutilisables existants et un tant soit peu décents.

Conseils en vrac

Contrôle de révision

Ce n'est pas propre à Powershell, mais dans tous les cas, un système de contrôle de révision pour gérer les scripts est incontournable. Git est le standard de facto. Gitblit est un serveur très simple pour héberger le dépot de référence, et il permet d'authentifier les utilisateurs via Active Directory.

Je m'en suis aussi servi pour gérer les scripts sur le serveur de production:

  • la mise à jour se fait en une commande
  • elle se fait de manière efficace (transferts minimums et peu de latence)
  • il permet de voir rapidement si des changements ont été faits en production, et de les soumettre dans le dépôt si cela devait arriver.

Chaque dépôt contient une branche dédiée au code validé pour la production; cela me permet de travailler sur un script, et de le mettre en production uniquement quand il est prêt.

Propriétés fondamentales pour les scripts

J'ai vu beaucoup d'horreurs dans les scripts existants; les règles suivantes permettent de travailler de manière saine, et notamment de pouvoir travailler facilement ailleurs que sur le serveur de production:

  • Créer un projet par script; en général, je mets le/les scripts invoquables à la racine, et si je sens le besoin de séparer le code en plusieurs fichiers, ces fichiers vont dans un sous-répertoire.
  • Ajouter un fichier README à la racine du script (il servira de page d'accueil à Gitblit), documentant les éventuels points de configuration et dépendances.
  • Le script ne doit pas dépendre de son emplacement sur le disque
  • Utiliser un fichier de configuration; ce fichier ne doit PAS être sous contrôle de révision, mais le projet peut contenir un fichier d'exemple. Le fichier doit se trouver dans le répertoire racine du projet.
  • Les deux consignes précédentes imposent que le script sache dans quel répertoire il se trouve; c'est très simple pour un module, ça l'était beaucoup moins pour un simple script (mais je pense que ça a été résolu après la version 2).
  • En cas d'erreur, le script doit simplement écrire un message sur la sortie standard et quitter avec un code d'erreur (ex: via une exception). Des trucs plus douteux (ex: englober tout le project dans un bloc try ... catch et envoyer l'erreur par e-mail sont un cauchemar à maintenir, et une telle logique relève de l'appelant; j'ai remplacé cette pratique par un petit lanceur très simple qui repérait le code d'erreur et expédiait les éventuels messages du script par mail.

Modules

Lorsqu' une fonctionnalité, un bout de code se répète de script en script, il y a matière à créer un module pour la réutiliser simplement. Pas un fichier .ps1, mais un module (basé sur un/des fichier(s) .psm1) et un manifeste, afin que les variables des modules et des scripts ne risquent pas d'entrer en conflit. Un module est l'occasion de s'arrêter pour réfléchir à une interface correcte pour les fonctions qu'on s'apprête à écrire. Comme le code sera utilisé en de nombreux endroits, on ne veut pas avoir à réécrire des dizaines de scripts parce qu'on doit faire un changement incompatible dans le module.

Le module ne doit requérir aucune compilation: on clone le dépôt dans $PSModulePath et le module est prêt à fonctionner. Dans les cas extrèmes, je m'autorise à dépendre de librairies tierces facilement installables.

Tests

Encore rien de particulier à PowerShell, mais les tests sont très importants; si pour un script simple on peut encore s'en tirer en le lançant à la main sur des fausses données et en examinant le résultat, les modules sont toujours utilisés indirectement depuis un projet qui n'a rien à voir avec eux; on veut donc avoir une très grande confiance dans ces modules, au point que quand un script ne fonctionne pas, on ne pense pas une seconde que le module puisse être coupable. Cela évite beaucoup de temps et de frustration. Le meilleur outil que j'ai trouvé pour tester mes scripts et modules est Pester.

Les modules tiers

En un mot: oubliez; la communauté fournit très peu de code valable: ce ne sont pas des développeurs. Par contre, la communauté .NET fournit beaucoup de composants réutilisables; PowerShell permet de charger des assemblies .NET; il suffit d'en repérer un qui fait le travail et de le wrapper dans un module pour lui donner une interface PowerShell. Je n'ai jamais écrit de code C# dans un script: je préfère le cacher dans un module, et garder les scripts simples et expressifs.

Se créer une bibliothèque de modules

Tout ce qui est décrit ci-dessous fonctionne et est très utile; en conséquence de l'introduction, l'implémentation est laissée au lecteur. Le but ici est d'évacuer toutes les fonctions annexes, de traiter le problème une seule fois dans un module, et d'utiliser ce module pour écrire des scripts courts et expressifs, et non encombrés par du code qui ne reflète pas son rôle.

E-mails

On a toujours besoin d'envoyer des e-mails, soit pour informer les colllègues du département IT, soit aux utilisateurs dont on attend une action, de préférence en HTML. Dans un environnement Microsoft, ça a l'avantage d'être homogène (Outlook); par contre le support de CSS est complètement pourri.

J'ai organisé le module comme suit:

  1. Une classe "message" implémente un e-mail en cours de rédaction; les messages ont comme propriétés:
    • Les champs classiques: expéditeurs, destinataires, titre
    • Le corps du message (en HTML)
    • Les pièces jointes
    • Des curseurs, qui sont des points d'insertion pour le texte; je veille à en avoir dans la feuille de style, dans le corps du message, à la fin du message (pour une signature) et en tête de message, où je garde une zone pour tout message annexe.
  2. Une fonction d'insertion de texte (déjà échappé); elle lit le texte depuis le pipeline, un curseur en paramètre (où le texte sera inséré), et éventuellement des définitions de nouveaux curseurs à créer dans le texte inséré. J'utilise une convention: le texte __FOO__ crée un curseur nommé foo, mais des paramètres me permettent en cas de besoin de spécifier un mapping comme je le veux.
  3. Des fonctions convertissant des données lues sur le pipeline en code HTML:
    • échappement de texte (avec éventuellement encapsulation dans des tags (p, div, span))
    • créations de listes (à puce ou ordonnées)
    • création de tableaux depuis des objets
  4. Une fonction pour envoyer le message
  5. Une fonction pour attacher des fichiers
  6. Un modèle corporate; ces choses-là changent tellement souvent ...

Les tableaux sont les plus intéressants en termes de formatages; c'est toujours sympa d'avoir des lignes de couleurs alternées sans aucun effort; on peut vouloir réordonner ou renommer les colonnes (vu que les propriétés des objets ne sont pas toujours ordonnées comme on veut. Pour mettre en évidence les données, ma fonction créant les tableaux accepte un ScriptBlock en paramètre; il reçoit un objet du pipeline en paramètre et retourne un nom de classe CSS à attribuer à la ligne; la fonction peut donc très simplement mettre en évidence des données anormales ou importantes en collaboration avec quelques classes CSS définies dans mon template ou le script.

Côté configuration, le module recherche dans l'ordre un fichier de configuration dans le répertoire du script qui l'invoque, puis dans le répertoire du module. L'envoi des e-mails peut être configuré comme suit:

  • Choix du serveur SMTP à utiliser
  • Affichage d'un message sur la console au lieu de l'envoyer
  • Envoi de tous les e-mails à une adresse indiquée; le destinataire original est ajouté en tête du message

Cela me permet, lors du développement, de ne pas envoyer réellement des e-mails; soit j'utilise un serveur SMTP local (exemple: Papercut), soit (quand je veux tester le rendu des e-mails sous Outlook) je redirige tous les e-mails vers mon adresse personnelle. En mettant cette configuration dans le module, je sais que, lorsque le script tourne sur mon PC de développement ou le serveur de production, il va faire la bonne chose sans aucune modification, et donc sans risque d'erreur.

Fichiers Excel

On a toujours besoin de lire ou créer l'un ou l'autre fichier Excel. Un exemple très courant est la création de rapports. En la matière, les compétences avant mon arrivée limitaient les rapports à du texte simple, avec à chaque fois d'interminables discussions sur la manière de présenter l'information de manière optimale. Ces discussions ont pris fin avec la disponibilité d'un module permettant de générer des fichiers Excel comportant des tables pouvant être triées et filtrées. En outre, le format xlsx étant compressé, cela réduisait la taille des e-mails pour les rapports un peu conséquents.

La méthode la plus souvent employée est d'installer Excel et de le contrôler via son interface COM. C'est hautement déconseillé (y compris par Microsoft) pour une raison très simple: si Excel a l'idée de poser une question (genre "Êtes-vous sûr?"), on n'est typiquement pas là pour cliquer sur "OK". Ça commence dès le premier lancement, qui est accompagné d'un assistant de démarrage. Une solution plus propre est d'utiliser une DLL .NET (ex: EPPlus); elle permet de créer des fonctions de lecture et d'écriture; l'écriture permet de lire des objets sur le pipeline, de choisir la feuille de destination, de créer une table autour des données (avec le nom des propriétés comme titre de colonne), et de dimensionner les colonnes en fonction du texte qu'elles contiennent (avec une limite maximale pour éviter que les fichiers deviennent difficiles à manipuler).

Requêtes SQL

Voilà un cas où il vaut mieux oublier tout ce qu'on a pu trouver sur Internet. ADO.NET est la seule interface nécessaire (ODBC et le reste sont inutiles) puisque toutes les bases de données contemporaines viennent avec un pilote ADO.NET. L'autre chose à oublier est de lire les requêtes depuis le pipeline, comme on le voit trop souvent dans les exemples qui se veulent moins élémentaires. Il vaut mieux passer des objets sur le pipeline et passer la requête en paramètre: la fonction exécute ainsi la requête sur chacun des objets. Cela permet de proposer une interface simple pour des requêtes préparées, et de les paramétrer dans les règles de l'art pour éviter les injections SQL. Cela permet aussi de piper des données dans une requête "insert", comme on les passerait à Export-CSV ou Out-Printer.

SQLite

SQLite est un moteur de bases de données simple; sous .NET, il se présente sous la forme d'une DLL et la base de données est un simple fichier. C'est donc une très bonne solution pour du prototypage ou éviter de créer un format de fichier personnalisé. Ça a donc été tout naturel pour moi de vouloir l'utiliser sous PowerShell avec mon module SQL, qui rendrait cette DLL utilisable lors du chargement du module. Sauf que ... ADO.NET ne reconnaîtra que les DLLs chargées d'une certaine manière (concrètement, elle ne peut pas être chargée via son chemin complet; seulement en utilisant son nom). Il y a deux manières d'y arriver:

  1. Installer la DLL dans les side-by-side assemblies
  2. Installer la DLL dans le répertoire du programme

La première option ne me convient pas (je veux que le module soit directement utilisable une fois cloné), et est déconseillées par SQLite.

La seconde est ce que je cherche, sauf que ... le programme en question est powershell.exe, pas le fichier .psm1 de mon module. Que faire alors? Charger l'assembly sans référence à son chemin, intercepter l'échec et rediriger le chargeur d'assemblies vers la DLL qui se trouve dans mon module. La technique est expliquée par exemple ici et requiert un peu de code C#. Au passage, dans ce genre de situation, j'écris tout le code autre que PowerShell dans des fichiers à part, avec l'extension adéquate, pour bénéficier de la coloration syntaxique des éditeurs.

Au vu des limitations du système de types de SQLite, j'ai également doté ce module de quelques fonctions pour simplifier les conversions.

Sharepoint

Je ne suis pas fan de cette usine à gaz mais j'ai dû m'interfacer avec. SharePoint vient avec des modules PowerShell, mais ils sont pour son administration et doivent s'exécuter sur un serveur SharePoint. Ce que je cherche à faire est à manipuler les données qu'il contient, comme un simple utilisateur.

Pour récupérer des fichiers, on peut être tenté d'utiliser les chemins UNC (\\serveur\partage\...) mais ils ne s'agit pas d'un vrai partage Windows; c'est l'environnement de bureau qui est responsable de fournir cette vue familière. Sur un serveur, c'est un composant optionnel, qu'on ne veut pas installer (et qui ne permet pas de configurer les login et mot de passe à utiliser). Pour les listes, il y a un webservice disponible, mais on n'obtient que des données textuelles (XML); la reconstitution de données utilisables (entiers, listes de valeurs ...) doit être faite à la main, et c'est le genre d'exercice dont on sait quand on commence, mais pas quand on finira.

Une meilleure solution est d'utiliser le SDK client de SharePoint. Il y a un fichier à télécharger et installer, mais ça reste une exception acceptable à ma politique et il permet de créer une interface fiable et indépendante des composants desktop.

L'artillerie lourde

Voici quelques autres modules que j'ai créés. On tombe dans de l'algorithmique plus sérieuse, le truc qu'on ne fait typiquement pas en PowerShell, mais ils se sont avérés extrèmement utiles. Si vous avez les compétences, lancez-vous dans ces projets, c'est un bon investissement.

Des workflows

J'y suis arrivé suite au processus suivant: pour supprimer les comptes d'ordinateurs inactifs d'Active Directory; lorsqu'un ordinateur n'est pas connecté au domaine depuis 3 mois, on envoie à son propriétaire un e-mail chaque semaine pendant un mois pour lui donner l'occasion de reconnecter l'ordinateur au domaine. Si l'ordinateur n'a pas été reconnecté après ce délai, le compte est supprimé (un rapport est envoyé à des humains pour contrôler le résultat et éviter les catastrophes potentielles).

Cela était implémenté de manière très simple: chaque week-end, 2 scripts étaient lancés:

  • Le premier envoyait une notification aux propriétaires des ordinateurs non connectés entre 3 et 4 mois
  • Le deuxième génère la liste des comptes à effacer (ordinateurs non connectés depuis 4 mois ou plus).

La technique fonctionne plus ou moins, et on peut vivre avec les inconvénients, mais il y a moyen de faire mieux.

PowerShell propose des workflows, mais destinés à la gestion des ordinateurs. Le code qui s'exécute n'est pas du PowerShell (donc on peut oublier nos précieux modules), et certaines tâches essentielles (test, backup, ...) sont impossibles, ou au moins non triviales.

Je me suis donc tourné vers une solution maison. Dans une base de données SQLite, je définis un workflow par objet à traiter; le workflow contient entre autres:

  • des données (des paires clé-valeur)
  • des opérations
  • une fonction d'interruption

Chaque opération, à son tour, est constituée de:

  • une action (le nom d'une fonction)
  • des dépendances envers d'autres actions (l'opération ne sera pas exécutée tant que toutes les dépendances ne seront pas terminées)
  • de paramètres gérant la répétition de l'action: une fréquence maximale de répétition de l'action, et une durée maximale de l'opération, exprimée soit en nombre d'exécutions de l'action, soit en durée depuis la première exécution,

Les fonctions d'interruption et d'action obéissent à des prototypes bien définis:

La fonction d'action reçoit l'opération courante en paramètre, et retourne vrai si l'opération doit se terminer, faux sinon. En cas d'erreur, elle doit lancer une exception.

La fonction d'interruption reçoit le workflow en paramètre, et retourne vrai si le workflow doit être interrompu, faux sinon. Elle est appelée avant chaque exécution d'une fonction d'action. Typiquement, elle récupère dans les données du workflow l'objet sur lequel le workflow s'applique, et prend une décision.

Le workflow se termine lorsque toutes ses opérations sont terminées.

Armé de ces primitives, un script se résume à ceci:

  1. Une fonction recherchant les objets non-conformes, à l'exception de ceux pour lesquels un workflow existe déjà dans la base de données (ça peut être divisé en deux fonctions connectés par un pipeline).
  2. Une fonction d'interruption, qui consiste à vérifier si un object originellement non conforme est à nouveau conforme.
  3. Une fonction créant un workflow sur base d'un objet; c'est essentiellement une ligne pour créer le workflow (y compris les données à y stocker et la fonction d'interruption), puis une ligne par opération à ajouter.
  4. Une fonction pour chaque opération du workflow.

Le code principal invoque ensuite les fonctions 1 et 3 dans un pipe, puis invoque le gestionnaire de workflow, qui va exécuter toutes les opérations pouvant l'être sur base de leurs dépendances et contraintes de timing. Le script est planifié pour des exécutions relativement fréquentes (typiquement quotidiennement).

Dans le cas de ce problème, la fonction de recherche des objets va rechercher tous les comptes d'ordinateurs non connectés depuis plus de 3 mois; la fonction d'interruption renverra vrai si le compte n'existe plus ou si l'ordinateur est connecté depuis moins de 3 mois. Il y a deux opérations; la première envoie un e-mail au propriétaire et retourne faux; elle est configurée avec une durée maximale d'un mois et un intervalle minimum de 7 jours entre deux invocations de l'action. La deuxième a une dépendance sur la première et retourne simplement faux: elle se contente d'attendre que la fonction d'interruption qui mettra fin au workflow. Le listing des comptes à effacer est générée par un autre script exécuté une fois par semaine; il passe en revue tous les workflows bloqués sur la deuxième opération et génère un rapport synthétique.

Sur ce problème précis, la technique est clairement overkill. Cependant elle a été écrite une fois pour toutes sous forme de module abstrait et réutilisable. Une fois qu'on a constaté que ça marchait bien, on a envisagé la nettoyage de certains comptes d'utilisateurs actifs, avec comme étape intermédiaire la désactivation du compte avant sa suppression. Avec l'ancienne technique, il était impossible de savoir si un compte inactif était un compte à ne pas traiter, ou un compte désactivé pendant le processus et donc à finalement effacer. Avec le module conservant l'état de chaque processus de suppression depuis son début jusqu'à la fin, le problème se règle simplement, naturellement et sans mauvaises surprises.

Une fois qu'on a vu que ça fonctionnait, on a attaqué la suppression des comptes d'utilisateurs obsolètes. Un collègue avait décrit un plan d'action basé sur la veille technique. La quantité d'actions à exécuter était plus que conséquente et, en suivant la présentation, il n'était pas facile de comprendre ce que c'était censé faire, et si ça le faisait correctement. Disposant de mon nouvel outil et après une pénible analyse pour reconstituer l'énoncé du problème, j'ai dessiné un graphe représentant toutes les opérations et l'ordre dans lequel elles s'organisent; c'était un vrai graphe, qui ne dégénérait pas en une séquence d'opérations. Cela fournissait une base sur laquelle on pouvait réfléchir au processus qu'on voulait implémenter, et était facilement convertible en une fonction de création de workflow; cette fonction était également facilement convertible en diagramme si on voulait s'assurer de son implémentation correcte. Il restait ensuite à implémenter chacune des opérations indépendamment des autres.  Et les demandes de modifications en cours de développement s'intègrent très simplement.

Sur les workflows linéaires (chaque opération dépend d'une seule autre opération), il est très simple de générer des rapports sur l'avancement des opérations sous forme de tableau excel avec une ligne par workflow, et des colonnes pour l'objet traité, l'opération en cours, la date de début de l'opération en cours, et une colonne par opération avec son état (en attente, en cours, terminée); avec les options de filtrage et de tri d'Excel, il est facile d'extraire toute l'information qu'on désire.

À l'usage, le module a également montré qu'il fournissait par défaut un traitement sensé des déviations sur le planning (quand une opération échoue ou une intervention humaine prend plus de temps que prévu).

Des définitions pour les objets Active Directory

Probablement ma réalisation la plus disruptive. Une bonne partie de mon travail impliquait d'interroger Active Directory. Avant il fallait accepter que la réalité n'est pas obligée se se plier à nos désirs; après, je pouvais créer ma petite réalité virtuelle où l'insoluble devenait un jeu d'enfant (ou un exercice pour étudiant).

Nous disposions de quelques listes de référence, permettant par exemple, sur base de l'OU dans laquelle une fiche se trouve, de trouver le helpdesk qui en est responsable. Plus élaborée, une autre liste associait des motifs à appliquer avec l'opérateur -like (dans un filtre ou du code) sur un champ des comptes d'ordinateurs afin de les associer à une version de Windows.

Malgré ces listes, chaque filtre dans une requête faisait apparaître la même insuffisance au fur et à mesure que les mêmes bouts de filtres se répétaient de script en script: si jamais le concept que ce fragment représentait venait à changer, ça faisait un paquet de scripts à modifier.  Et parfois, le concept n'était pas représenté par un filtre, mais par du code examinant les attributs d'un objet récupéré d'Active Directory, La solution émergea après de longues cogitations, et malgré la charge de travail impliquée, je me résolus à l'implémenter. Les contraintes étaient:

  1. Ça doit être simple à prendre en main et être plus simple que toutes les autres solutions ad-hoc qui pourraient être envisagées. Sans cela, les collègues préféreront recoder leur propre truc et mon retour sur investissement en pâtira.
  2. Ça doit être générique (pas de "ça marche seulement dans telles et telles conditions précises"), pour contribuer à son acceptation.
  3. Ça doit être fiable: de par sa nature, les erreurs risquent très fort de passer inaperçues; cela passe par une implémentation dans les règles de l'art et une batterie de tests hyper complète. J'ai utilisé Active Directory Lightweight Directory Services (installable sur un PC) pour ces tests.
  4. Ça doit fonctionner dans les deux sens: la requête (via un filtre) et l'examen d'une fiche

La solution a été implémentée à l'aide d'un module implémentant des wrappers autour des cmdlets Get-ADUser, Get-ADComputer ... Ils fournissaient les fonctionnalités supplémentaires suivantes:

Des valeurs multiples pour le paramètre SearchBase

Ça simplifie les scripts vu que, comme expliqué en début d'article, toute boucle est plus pénible que l'acceptable; le wrapper va lancer Get-ADUser une fois pour chaque base fournie. L'explication est un peu simplifiée puisque les bases doivent être traitées différemment en fonction du scope (ex: dans une recherche dans tout le subtree, il faut retirer les OUs qui sont incluses dans une autre pour ne pas retourner de doublons).

Un système de zones

J'ai défini un système de zones pour regrouper une ou plusieurs OUs et des informations qui leurs sont attachées; les zones sont des objets avec:

  1. Un type (ex: continent, pays)
  2. Un nom (ex: Belgique, Europe, Autres)
  3. Une liste d'OUs
  4. Un point de contact (typiquement l'adresse e-mail du helpdesk responsable pour la zone ou le destinataire pour les rapports)
  5. Un casting depuis et vers une chaîne de caractères (permet de taper simplement "Pays/Belgique" au lieu de créer explicitement un objet avec une syntaxe bien lourde.
  6. Une zone parente (ex: Pays/Belgique a pour parent Continent/Europe); cela permet d'interroger les zones définies et d'itérer par exemple sur tous les pays d'un continent via la fonction adéquate.

Dans les wrappers, deux choses se passent:

  1. Un paramètre supplémentaire accepte une ou plusieurs zones; le wrapper va en extraire les OUs et lancer une ou plusieurs instances de Get-ADUser (resp Get-ADComputer ...) selon les besoins pour fournir le résultat attendu
  2. Chaque objet retourné est étendu avec des méthodes permettant d'extraire les zones auxquelles l'object appartient ex: $object.zone("Continent"), et indirectement $object.zone("Continent").Name

Ce n'est pas la fonctionnalité la plus spectaculaire ni la plus utile, mais à elle seule elle a justifié l'utilisation du module pour le moindre accès à Active Directory:

  • En mode interactif "Region/Western Europe" est beaucoup plus simple à taper qu'une OU complète, et encore plus qu'une boucle sur plusieurs OUs
  • Dans les scripts, le gain est encore plus visible, puisqu'on évite des requêtes dans notre liste centrale des régions chaque fois qu'on veut récupérer une OU ou une info de la zone
  • Le point précédent permet de garder simplement les données dans le pipeline, d'autant plus que dès qu'on en sort pour assigner un résultat intermédaire, on se retrouve à devoir traiter ce cas spécial de la liste vide indistinguable de la valeur Null.

Des attributs par défaut

Lors d'une requête sur Active Directory, il faut mentionner les attributs à récupérer. PowerShell fournit un mécanisme pour fournir des valeurs par défaut aux paramètres d'une cmdlet ou fonction, cependant elle ne nous permet pas d'oublier ces détails puisque dès qu'on fournit une valeur au paramètre, il faut fournir toutes les valeurs "par défaut" en plus de celles qu'on désire explicitement. Il y a aussi le joker "*", mais (1) il ne retourne pas tous les attributs et (2) le transfert de toutes ces données est très lent.

Au lieu de celà, j'ai simplement créé une fonction qui va ajouter des noms d'attributs dans une liste interne au module, et mes wrappers vont consulter cette liste (il y a en fait une liste et une fonction par commande (Get-ADUser, Get-ADComputer ...)) et les ajouter automatiquement à la requête. Cela permet aussi à un module d'indiquer qu'il a besoin, lorsqu'il a des fonctions qui travaillent sur un attribut donné, que cet attribut soit récupéré depuis Active Directory.

Des définitions

Encore plus fort: je fais apparaître des attributs virtuels dans Active Directory. L'attribut est défini par une fonction prenant comme paramètres:

  1. Un nom pour l'attribut
  2. Une hashtable avec pour clés une valeur pouvant être prise par l'attribut et pour valeurs un filtre (pour l'option -Filter de Get-ADUser) correspondant aux objets pour lesquels l'attribut a la valeur de la clé correspondante.

Et là, ça devient du codage sérieux, avec du parsing d'expressions et des manipulations d'arbres syntaxiques. Heureusement, la syntaxe des filtres est très simple et c'est donc un bon exercice si c'est la première fois qu'on se frotte à ce genre de choses.

Une fois l'attribut défini, les wrappers l'utilisent comme suit:

  1. Le filtre fourni est converti en arbre syntaxique
  2. Les références aux attributs virtuels sont repérés, et l'arbre est modifié pour remplacer cette référence par l'arbre équivalent faisant appel à la définition de l'attribut
  3. Un filtre est recréé (il ne contient plus que des références à des attributs réels)
  4. Les attributs nécessaires (sur base des définitions, elles aussi converties en arbres) pour recréer chaque attribut virtuel défini sont ajoutés à la liste des attributs à retirer.
  5. Les objets retournés sont enrichis de propriétés (implémentées sous forme de méthodes) pour chaque attribut virtuel; le code de ces méthodes est généré depuis les arbres syntaxiques définissant l'attribut.

Les définitions utilisent les filtres (option -Filter) car ils sont les plus restreints en termes de fonctionnalités (si l'attribut est définissable avec un tel filtre, il sera définissable avec un filtre LDAP ou du code). Cela simplifie aussi la mise au point de ces définitions, puisqu'il suffit de passer le filtre à Get-ADUser pour le tester. Un petit détail qui a toute son importance: dans LDAP, les opérateurs (égalité, inégalité) ont une définition particulière auxquelles la logique qu'on a apprise à l'école (par exemple la négation) ne s'applique pas. Il faut donc modifier les arbres générés depuis ces expressions avant de pouvoir leur appliquer ce genre de traitement.

Des attributs virtuels pour gérer les exceptions

- Pourquoi la liste de tout le monde en république tchèque est "D-CS-Everyone" et pas "D-CZ-Everyone"?
- C'est des raisons historiques
- Oui, mais CZ est le code correct, et c'est celui qui apparaît dans les comptes d'utilisateurs. Ça nous force à coder du code spécifique pour traiter cette exception dans divers scripts. On peut renommer ce groupe?
- Non

C'est plus ou moins comment ça s'est passé, et je n'avais ni envie de polluer mes scripts avec ce genre d'exception, ni de contribuer à compliquer l'éventuelle mise en conformité du nom de ce groupe. Comment arriver à mes fins? Et si je pouvais définir un attribut dont la valeur est égale à celle d'un autre attribut, sauf pour une liste d'exceptions? Au travail! Ça résoud effectivement le problème.

Encore des scripts

Sur base de ce module, j'ai créé une série de modules contenant diverses définitions utilisées un peu partout: la structure du domaine, les définitions de compte d'utilisateur ou de service, ...

J'ai aussi pu créer des définitions pour les versions de Windows avec toute la granularité nécessaire pour identifier les systèmes hors de maintenance; il faut donc pouvoir descendre jusqu'au service pack, et distinguer Windows XP 64-bits de Windows 2003 (ils ont le même numéro de version, mais le support de Windows XP s'est terminé bien avant celui de Windows 2003).  C'est dans ce genre d'expérience qu'on apprend à la dure que "Windows 7" s'écrit avec un espace insécable en français.

Le script qui a le plus profité de ces attributs virtuels était celui qui créait les listes de distribution, du genre "tous les employés par bureau", "tous les employés par pays". C'était fait auparavant avec une série de scripts similaires (un par type de liste). Je vais prendre le cas "tous les employés par bureau" comme exemple.

L'ancien script exécutait pour chaque bureau une requête sur tous les employés qui en font partie, et modifie la liste du bureau en conséquence.

J'ai remplacé cette série de scripts par un seul script, où chaque type de liste devenait une définition comme suit:

  • Une clé pour le type de liste (ex: everyone_by_country)
  • Un type d'objet membres du groupe (utilisateurs ou groupes)
  • Un filtre sélectionnant les objets membres des listes de ce champ (ex: employeeType -eq "Employee" or employeeType -eq "External")
  • Un motif pour le nom de la liste: D-%1-Everyone %2
  • Une zone dans laquelle se trouvent les objets pouvant être ajoutés aux listes de ce type.
  • Une liste d'attributs des objets à substituer dans le nom; "c" contient le code de pays (ex: "BE", physicalDeliveryOffice contient le code du bureau (ex: BRU); un compte ayant donc c=BE et physicalDeliveryOffice=BRU ira donc dans le groupe "D-BE-Everyone BRU"

Le script parcourt la liste des types de listes, et génère un attribut virtuel (ex: lists) sur base des définitions, associant la clé au filtre; il récupère aussi la liste d'attributs et les ajoute aux attributs à récupérer par défaut.

Il exécute ensuite une seule grosse requête sur un scope (une zone et un filtre) configurable et examine les objets un par un; la propriété "lists" créée sur base de la liste de types de listes contient tous les types de listes qui le concernent (ex: everyone_by_country; l'attribut est multivalué); pour chacun de ces types, l'appartenance de l'objet à la zone est vérifiée, et si c'est le cas, les propriétés de l'objet (qui ont été récupérées car ajoutées aux propriétés par défaut), sont insérées dans le modèle de nom de la liste pour déterminer dans laquelle il doit aller.

La configuration du script lui permet de charger des modules, pouvant contenir des définitions d'attributs et de zones utilisables dans la définition des listes ou fournissant des attributs directement utilisables dans les substitution (on peut traiter l'exception pour le code de la république tchèque évoquée plus haut, ou faire apparaître une information de manière plus directe (par exemple, faire apparaître un attribut "pays" pour les groupes, sur base de leur convention de nommage).

Le script était donc ramené à quelque chose de très simple, très scolaire (où le prof accorde plein de simplifications: pas d'exceptions, tous les attributs nécessaires existent, on oublie les contraintes de devoir spécifier quels attributs récupérer), et pourtant il est capable de traiter des cas de la vraie vie, et d'être utilisé dans d'autres contextes.


tags: microsoft

Quelques tags ...