mercredi 17 avril 2013

Perl - Installation de module CPAN sans Internet

Cpan, c'est bien pratique pour installer des modules Perl. Mais sans accès à Internet, l'installation des modules devient vite fastidieuse. Il est même parfois inconcevable d'installer en automatique des packages qui tombent du net sur un serveur de prod...

Pour avoir plusieurs fois rencontré la situation et avoir téléchargé les dépendances une à une sur CPAN et les copier sur le serveur au fur et à mesure que l'installation d'un module plante, je me suis dit qu'il doit y avoir plus intelligent...
Il me semblait donc utile de pouvoir récupérer un package de toutes les dépendances de mon appli, avec toutes les dépendances des dépendances et encore les dépendances de ces dernières. CPAN est là pour ça me diriez vous... Effectivement, mais la tâche n'est pas si simple, car sur CPAN les dépendances n'apparaissent pas. Enfin, n'apparaissaient pas car depuis peu CPAN donne accès à un listing des mais qui reste fastidieux car il faut encore aller chercher chaque module à la main.

Il existe plusieurs modules qui pourraient faire l'affaire sur CPAN (CPAN::Dependency ou Module::ScanDeps pour ne citer qu'eux) mais c'est l'occasion d'aller mettre les mains dans le cambouis pour comprendre les coulisses de ce superbe repository, et de créer une archive de tous les packages d'un coup dans la foulée.

L'objectif de ce post est donc de détailler les différentes étapes pour :
  1. Récupérer récursivement toutes les dépendances d'un module
  2. Stocker quelque part toutes les archives des packages à installer
  3. Générer un listing ordonné des packages pour dérouler l'installation
Une fois ces 3 étapes terminées, il restera à déplacer le package sur le serveur cible et :
  1. comparer le listing avec les packages déjà installé
  2. installer tous les packages manquants

La solution CPAN::Mini

Une petite parenthèse sur la solution CPAN::Mini : elle ne permet pas de faire exactement ce que propose ce post, mais est beaucoup plus simple à mettre en oeuvre dans certains cas.
Le CPAN::Mini, ou minicpan, permet d'installer en local une version miniature (uniquement les dernières versions et seulement les fichiers utiles à la commande cpan) du repository CPAN. Le minicpan se comporte alors via à vis de lui même ou d'une autre machine comme un référentiel CPAN et peut ainsi permettre d'installer simplement des modules sur une machine qui n'a pas accès à Internet.

Schématiquement :
Schéma minicpan
Cette solution a l'avantage de permettre au serveur cible d'installer des modules simplement, mais n'est pas toujours réalisable : il faut pouvoir disposer d'un serveur connecté à Internet qui soit accessible du serveur cible.

Pour faire le parallèle avec le monde RedHat & dérivés, elle doit pouvoir être comparable à un référentiel de rpm interne interrogé directement par yum, et bien utilisé elle doit permettre aussi bien la mise à dispo des paquets que la gestion des versions et l'inventaire des paquets installés sur une machine.

A creuser, je ne m'attarde pas plus là dessus dans ce post, l'objectif étant également de comprendre les mécanismes basiques sous-jacent de CPAN.

Petit détour sur la structure de CPAN

Avant de commencer à naviguer programmatiquement dans CPAN, attardons nous un peu sur la structure des infos qu'on peut y trouver. N'ayant jamais utilisé CPAN au delà de cpan install mon_module, j'ai passé pas mal de temps à comprendre cette structure, somme toute assez simple.

Modules & distributions

La recherche sur www.cpan.org se fait via module : par exemple XML::Twig, CPAN::Distribution ou DBD::Oracle.
Les modules sont mis à disposition dans des distributions qui contiennent un ou plusieurs modules : DBD-Oracle-1.44 par exemple pour le module DBD::Oracle. Cette distribution contient d'autres modules, notamment DBD::Oracle::GetInfo ou DBD::Oracle::Object. La distribution est identifié par le fichier archive qui la contient, P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz pour notre exemple.

Ce sont donc les distributions qui vont nous intéresser puisque installer une distribution revient à installer tous les modules qu'il contient. Nous allons donc chercher à établir la liste de toutes les distributions, dans le bon ordre, à installer pour satisfaire toutes les dépendances du module qui nous intéresse.

Le shell CPAN permet d'interroger les modules et les distributions avec les commandes m (comme module) et d (comme distribution) :
cpan> m DBD::Oracle
Module id = DBD::Oracle
    DESCRIPTION  Oracle Driver for DBI
    CPAN_USERID  DBIML (DBI Mailing Lists <dbi-users@perl.org>)
    CPAN_VERSION 1.44
    CPAN_FILE    P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz
    DSLI_STATUS  MmcO (mature,mailing-list,C,object-oriented)
    MANPAGE      DBD::Oracle - Oracle database driver for the DBI module
    INST_FILE    /usr/lib/perl5/site_perl/5.8.5/i386-linux-thread-multi/DBD/Oracle.pm
    INST_VERSION 1.21

cpan> d P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz
Distribution id = P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz
    CPAN_USERID  DBIML (DBI Mailing Lists <dbi-users@perl.org>)
    CONTAINSMODS DBD::Oracle::db DBD::Oracle::GetInfo DBD::Oracle::st DBD::Oracle::Object DBD::Oracle DBD::Oracle::dr DBD::Oracle::Troubleshooting
On voit ici qu'installer DBD::Oracle revient à installer la distribution P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz qui contient les modules listés sur la ligne CONTAINSMODS.

Installation

L'installation d'une distribution (et non pas d'un module puisque c'est la distribution qui est téléchargée, compilée et installée) récupérée sur CPAN suit toujours le même schéma :
# perl Makefile.PL
Puis compilation et test du module :
# make && make test
Si le test est OK, il ne reste plus qu'à l'installer :
# make install  
Si des dépendances sont manquantes, soit la compilation échoue, soit le test échoue.

Dépendances

Où sont les dépendances ? C'est bien ce qui nous intéresse mais les commandes m ou d ne les affichent pas. Malheureusement, elle ne semblent pas accessible directement via CPAN et il faut chercher un peu.
Dans la plupart des cas, la distribution contient un fichier META.yml qui décrit le package et contient des 3 sections de dépendance :
  • build_requires
  • configure_requires
  • requires
Si ce fichier n'est pas présent, il faut chercher un peu plus loin dans les fichiers makfile. D'après la doc CPAN, la fonction CPAN::Distribution:: permet de récupérer les dépendances après la compilation avec make. Je n'ai pas été si loin pour l'instant : se baser sur le META.yml sort déjà des résultats assez interessants.

Intéragir avec CPAN en perl

C'est parti pour les choses intéressantes. Le but de la manœuvre est d'avoir un script qui va :
  1. Se connecter à CPAN
  2. Récupérer la distribution du module qui nous intéresse
  3. Extraire les dépendances
  4. Recommencer sur chacune des dépendances
  5. Enregistrer toutes les archives tar.gz récupérée de CPAN dans un coin
  6. Générer une liste des distributions dans l'ordre des dépendances pour pouvoir les installer
A part l'algorithme de parcours des dépendances, la seule difficulté vient de l'interrogation de CPAN pour récupérer les distributions.
Le module CPAN, outre le shell bien pratique, propose des fonctions qui nous permettent d'interagir directement en perl. Les modules et distributions sont représentés par les objets CPAN::Distribution et CPAN::Module.
La documentation de l'inteface Perl du shell cpan n'est pas très fournie, l'essentiel se trouve sur la page du module CPAN : http://search.cpan.org/~andk/CPAN-1.9800/lib/CPAN.pm#Methods_in_the_other_Classes

Un petit exemple sera plus clair qu'un long discours. Reprenons l'exemple ci dessous : récupérer le nom de la distribution contenant DBD::Oracle et récupérer l'objet CPAN::Distribution qui correspond :
# Récupération de l'objet CPAN::Module pour DBD::Oracle
my $mod = CPAN::Shell->expand("Module","DBD::Oracle");
# Récupération du nom de la distribution ($dist_file contient P/PY/PYTHIAN/DBD-Oracle-1.44.tar.gz)
my $dist_file = $mod->cpan_file();
# Récupération de l'objet CPAN::Distribution
my $dist = CPAN::Shell->expand("Distribution",$dist_file);
Il ne nous reste plus qu'à télécharger et extraire la distribution à l'aide de la méthode CPAN::Distribution::get(), et analyser le fichier META.yml qui se trouvera dans le dossier CPAN::Distribution::dir() (là où la distribution a été extraite) pour télécharger l'archive, l'extraire, analyser le fichier META.yml et recommencer !
# Téléchargement et extraction de l'archive
$dist->get();

# Répertoire où est extraite l'archive
my $directory = $dist->dir();
my $meta_file = "$directory/META.yml"
Il n'y a pas de méthode qui donne directement le chemin où l'archive a été téléchargée : dir() ne donne que le dossier où elle a été extraite. En regardant un peu le code de l'objet CPAN::Distribution, je n'ai rien trouvé de mieux que la propriété localfile, mais qui semble n'être initialisée que dans certains cas. Sur les différents tests que j'ai effectué, elle a toujours été définie.
Exemple pour copier l'archive .tar.gz dans un dossier $dossier_sortie :
copy($dist->{localfile}, $dossier_sortie);

Putting it all together : lister les dépendances

L'algorithme est somme toute assez simple : on récupère la distribution, on récupère la liste des dépendances et on exécute le même algo récursivement sur chaque dépendance, en construisant l'arbre de dépendance au fur et à mesure. Pour  éviter de boucler indéfiniment, on enregistre simplement les modules déjà récupérés dans une table.

Arbre de dépendence : structure

L'arbre de dépendence va servir à ordonner la liste des dépendence dans l'ordre d'installation. La structure sera un simple pointeur vers une table, chaque élément étant lui même un pointeur vers une table de hashage des sous-dépendences. A chaque appel récursif, la variable $dependency_tree va pointer sur la table de dépendence du père (donc celui qui dépend du module analyser dans l'appel en cours). L'enregistrement du fils se fait simplement par :
# Ajout de la distribution a l'arbre de dependence
$dependency_tree->{$dist_file} = {};
La table de dépendence de la distribution en cours d'installation est donc $dependency_tree->{$dist_file}. C'est cette valeur qui sera donc passé en paramètre de l'appel récursif de toutes les dépendences trouvées dans le META.ymlde cette distribution.

Pour éviter de boucler indéfiniement, les distributions déjà rencontrées sont enregistrées dans la table %downloaded_dists, en y stockant au passage le nom du module demandé (il nous servira à faire le ménage sur le serveur cible pour n'installer que les distributions qui ne sont pas déjà présentes).

On ne stocke dans l'arbre que les dépendences qui n'ont pas déjà été rencontrée par les autres distributions. Ne pas les stocker permettera de sortir la liste dans l'ordre d'installation en un parcours en profondeur du graph : si une distribution dépend d'un module qui a déjà été rencontré par une autre distribution, ce module aura sera rencontré avant dans le parcours en profondeur.

Une petite exception à celà : le module ExtUtils::MakeMaker est une dépendence de tous les modules, puisqu'il est utilisé par cpan pour l'installation, y compris les modules dont il dépend comme Data::Dumper... On va l'éliminer de l'arbre de dépendence car il crée des dépendences récursives.

Voici par exemple l'arbre de dépendence du module DBD::Oracle :
Graph de dépendence de DBD::Oracle
 Les noeud gris sont les dépendences qui sont analysées, les noeuds orange ExtUtils qui est éliminié de l'analyse (on voit la dépendence récursive en rouge sur la première branche) et ceux en jaune les dépendences déjà rencontrées précédement.
Seuls les noeuds gris sont enregistrés dans l'arbre de dépendence. On voit sur le cas de Test-Simple que le parcours en profondeur permettera de lister le noeud gris avant le noeud jaune.

Récupération des dépendences et appel récursif

Nous avons déjà vu comment récupérer la distribution associée à un module, l'enregistrer dans un coin pour constituer notre package global et la décompresser pour pouvoir accéder au META.yml.
Parser le META.yml est très simple gràce au module YAML :

my $file = "$directory/META.yml";
        if( -f $file) {
            my $meta = YAML::LoadFile($file);
            my $requires = $meta->{'build_requires'};
            my %reqs = %$requires;
            foreach my $required_mod (keys(%reqs)) {
                get_dist_and_dependencies($required_mod,$dependency_tree->{$dist_file},$level+1);
            }
        }

La function get_dist_and_dependencies correspond à l'appel récursif.

Code global

Le code global est publié sur GitHub à l'adresse suivante : https://github.com/...
Il peut bien sur être amélioré pour mieux gérer les erreurs et la sortie. Il a dans sa forme l'avantage d'être simple et facilement comprehensible avec les explications de ce post.

Seul un passage est légèrement obscur :
open STDOUT_OLD,">&STDOUT";
open STDOUT,">/dev/null";
$dist->get();
close STDOUT;
open STDOUT, ">&STDOUT_OLD";
close STDOUT_OLD;
Ce drôle de code sert à éliminer la sortie standard avant l'appel à get() pour éviter d'avoir toute la sortie du log cpan s'afficher et mieux voir l'arbre de dépendence se construire au fur et à mesure des appels récursifs.

Installer le tout sur le serveur cible

 Nous avons maintenant toutes nos distribution sous la main, et l'ordre dans lequel les installer.
Certaines sont peut être déjà installée sur le serveur, autant aller jusqu'au bout de la démarche et n'installer que ce qui manque.
Rappelez vous le petit détail des paragraphes précédents que nous n'avons pas encore exploité : nous avons associé à chaque distribution le nom du module requit qui a ammené à l'inclure.
Cela parce qu'il est beaucoup plus simple de lister les modules installées que les distributions.
ExtUtils::Installed est là pour ça et propose tout simplement une méthode modules() qui liste les modules installés. Et pour faire encore plus simple, le script instmodsh fournit une interface à ExtUtils::Installed, et à modules() avec la commande l. Même pas de Perl à écrire ;)

Donc c'est parti : on extrait la liste des modules installées avec instmodsh, et avant d'installer chaque module on vérifie si il n'est pas présent dans cette liste.

En shell, si le fichier install.txt est la sortie de notre script (au format "distribution;module") et installed le fichier qui contient la liste des modules installés, ça donne :
for i in `cat install.txt `; do
    dist=`echo $i | cut -d";" -f1`;
    module=`echo $i | cut -d";" -f2`;
    if grep -q "^$module$" installed; then
        echo "$dist: SKIP";
    else
        echo "$dist: INSTALL";
        # Decommentez cette ligne pour lancer l'installation dans la foulé
        #(cd `echo $dist | sed -e 's/.tar.gz//'`; perl Makefile.PL && make && make test && make install) || break ;
    fi;
done
On pourrait faire plus propre avec un petit script qui appelle ExtUtils::Installed et qui déroule lui même l'installation (cpan le fait bien !), mais ce sera pour une autre fois ;)