Journal Rashell, bibliothèque de programmation shell résiliante pour OCaml

Posté par (page perso) . Licence CC by-sa
18
26
sept.
2015

Sommaire

Une des difficultés principales dans la programmation shell est la gestion des erreurs dans l'utilisation des tubes (pipes) qui sont pourtant au cœur de la programmation shell, et la plupart des interfaces fournies dans nos langages de programmation préférés ne font rien pour améliorer la situation: la règle générale est que soit les erreurs fans les sous-processus sont ignorées, soit il faut mettre en place une infrastructure assez lourde pour récupérer ces erreurs.

La solution à ce problème que j'explore avec Rashell est l'emploi judicieux des monades pour avoir les bénéfices et d'une gestion fine des erreurs et une interface aussi légère que possible.

La monade Success

Bien que les monades puissent être utilisées dans presque tous les langages modernes, pour peu qu'il aient des fonctions d'ordre supérieur et des fermetures ou des fonctions locales, le concept est souvent peu familier aux programmeurs qui ne pratiquent pas la programmation fonctionnelle. Rappelons-donc rapidement ce dont il s'agit.

Tout d'abord les monades sont un design pattern – ce ne sont donc pas des objets du programme comme le sont les listes ou les fonctions – ce qui, conjugué à un vocabulaire fleuri qui parle de “valeur monadique” ou “calcule dans une monade” et à la pédanterie de quelques uns, a probablement contribué à leur réputation, largement injustifiée, d'être un sujet très difficile. Le plus simple consiste à regarder un exemple, disons success – une monade que les pessimistes appellent error.

Comme toutes les monades , la monade success étend un type de départ en en type dit monadique. Dans le cas particulier de success cette extension se fait en ajoutant une condition d'erreur, ici représentée par un message d'erreur:

type 'a t = (* 'a dénote le type de base *)
| Success of 'a
| Error of string

L'extension est concrétisée par l'opérateur return définit par let return x = Success(x) et pour compléter la monade on a encore besoin d'un opérateur dit bind définit ainsi

let bind m f = match m with
| Success(x) -> f x
| Error(_) -> m

Le type a t se comprend comme une sorte de tube dans lequel on peut lire une valeur de type 'a (information in band) mais qui peut aussi signaler une erreur si le calcul de la valeur rate et porte donc une information secondaire (information out of band). L'opérateur bind est simplement un opérateur d'application de fonction ajusté à ce contexte: si notre calcul a fonctionné, nous pouvons passer à l'étape suivante, sinon nous devons nous abstenir de calculer et simplement faire remonter l'erreur.

La monade success fait à peu près la même chose que les exceptions, qui peuvent aussi porter une erreur dans la partie out of band de la communication au sein du programme. La différence essentielle – et qui contribue à rendre les monades si intéressantes – est dans la gestion des erreurs: si on n'y prend garde la gestion des erreurs par exceptions ne produit que du code spaghetti – après tout, une exception est une sorte d'exception, tandis que dans la monade success on examine la valeur finale du calcul qui est soit Success(x) pour un calcul réussi soit Error(mesg) pour un calcul raté: la gestion des deux conditions se fait au même endroit, il n'y a pas d'effort spécial à faire pour écrire du code maintenable. J'ai écrit une implémentation de la monade Success dans la bibliothèque Lemonade.

Les monades utilisés par Rashell

Rashell s'appuie sur la monade Lwt (pour lightweight cooperative threads) qui définit deux structures importantes:

  1. Les valeurs monadiques 'a Lwt.t elles-mêmes, qui représentent un fil d'exécution calculant une valeur de type 'a – comme dans la monade success on a aussi une information out of band indiquant le succès du calcul.

  2. Les flux 'a Lwt_stream.t dont l'opérateur de lecture livre une valeur monadique 'a Lwt.t.

Ces structures permettent de définir les opérations suivantes, qui encapsulent l'appel à un sous-processus – dans ce qui suite le type t représente une commande, grosso modo un argv:

  1. val exec_utility : t -> string Lwt.t pour une commande qui renvoie une information unique, comme uname ou bien curl.

  2. exec_test : t -> bool Lwt.t pour les commandes dont le code de retour est interprété comme un prédicat, comme grep par exemple.

  3. exec_query : t -> string Lwt_stream.t pour les commandes dont le résultat est à interpréter ligne à ligne, par exemple la commande find, ou bien sed, etc.

  4. exec_filter : t -> string Lwt_stream.t -> string Lwt_stream.t pour les commandes utilisées pour réécrire un flux, i.e. comme un filtre Unix.

Lorsqu'une commande rate, le fil d'éxécution correspondant retourne une erreur, indiquant la commande qui a échoué et son statut d'erreur.

Un exemple

À titre d'exemple voici l'implémentation de la fonction tags dans les wrappers pour docker. Cette fonction examine le dépôt local et calcule une liste associative dont les clefs sont des images et les valeurs la liste des tags qu'elles portent.

let tags () =
  let triple_of_alist alist =
    let get field = List.assoc field alist in
    try (get "IMAGE ID", (get "REPOSITORY", get "TAG"))
    with Not_found -> failwith(__MODULE__^": images: Protocol mismatch.")
  in
  let pack lst =
    let images =
      Pool.elements(List.fold_right Pool.add (List.map fst lst) Pool.empty)
    in
    List.map
      (fun x -> (x, List.map snd (List.filter (fun (k,_) -> k = x) lst)))
      images
  in
  Lwt_stream.to_list
    (exec_query
       (command ("", [| ac_path_docker; "images"; "--all=true"; "--no-trunc=true"; |])))
  >>= to_alist "images" image_keyword
  >>= Lwt.wrap1 (List.map triple_of_alist)
  >|= List.filter
    (fun (_,(container, tag)) -> container <> "<none>" && tag <> "<none>")
  >|= pack

Par exemple dans mon toplevel, j'obtiens:

# Lwt_main.run (Rashell_Docker.tags());;
- : (string * (string * string) list) list =
[("0ecdc1a8a4c9eb53830ec59072a7f5dd7bf69c6077f60215cf4a99cd351dd5a1",
  [("redis", "3.0.2")]);
 ("272056a49fd13d5a711dbab6629c715ef9178aeec90f5d634134964e3cf38f2a",
  [("michipili/ubuntu-precise", "latest")]);
 ("5c9464760d54612edf1df762d13207117aa4480b2174d9c23962c44afaa4d808",
  [("mongo", "3.0"); ("mongo", "3.0.6"); ("mongo", "latest"); ("mongo", "3")]);
 ("63e3c10217b8ff32018e44ddd9e92dc317dc7fff204e149fac1efb6620490e7a",
  [("ubuntu", "14.04.2"); ("ubuntu", "trusty-20150730")]);
 ("66b43e3cae49068cb0f2bc768f76adca5e3dfd3269b608771a6f139d3f568073",
  [("mongo", "3.0.4")]);
 ("6d4946999d4fb403f40e151ecbd13cb866da125431eb1df0cdfd4dc72674e3c6",
  [("ubuntu", "trusty-20150612")]);
 ("78cef618c77e86749226ad7855e5e884a7bdbd85fa1c9361b8653931b4adaec5",
  [("anvil/test", "latest"); ("ubuntu", "precise");
   ("ubuntu", "precise-20150612"); ("ubuntu", "12.04.5");
   ("ubuntu", "12.04")]);
 ("91e54dfb11794fad694460162bf0cb0a4fa710cfa3f60979c177d920813e267c",
  [("ubuntu", "trusty"); ("ubuntu", "trusty-20150814"); ("ubuntu", "14.04");
   ("ubuntu", "14.04.3"); ("ubuntu", "latest")]);
 ("9216d5a4eec8459d0bdcc4e13ef45b5e6e6cf3affae59bb3a8673525cbc36118",
  [("redis", "latest"); ("redis", "3"); ("redis", "3.0"); ("redis", "3.0.3")]);
 ("ab57dbafeeeabc3c108245c3582391c5f1c50630e9fd253d499f66c68fde9d50",
  [("ubuntu", "utopic"); ("ubuntu", "14.10"); ("ubuntu", "utopic-20150612")]);
 ("bf84c1d84a8fbea92675f0e8ff61d5b7f484462c4c44fd59f0fdda8093620024",
  [("debian", "jessie"); ("debian", "latest"); ("debian", "8");
   ("debian", "8.1")]);
 ("c6f67f622b2aa9753a2a97adac1906320a62e9f529b80c4367ed0b11f505d660",
  [("mongo", "3.0.5")]);
 ("f38e3980050a2f5e2550b94fd2787931bb316671d89f62b29e9612ba80999e29",
  [("anvil/ubuntu-precise", "latest")])]

Idées de projets

Voici quelques idées de projets, du moins ambitieux au plus ambitieux, pour ceux qui aimeraient s'initier à la programmation shell avec OCaml et Rashell! Rashell peut-être très facilement installé avec opam et le pinning, c'est décrit dans le fichier README du projet.

  • Écrire une ramasse-miette docker qui élimine tous les vieux containers stoppés.
  • Écrire plus de tests dans la test-suite de Rashell
  • Traduire quelques-uns de vos scripts maisons en Rashell
  • Écrire un wrapper pour GNU Make et pour BSD Make.
  • Écrire des wrappers GNU Tar pour Rashell
  • Écrire des wrappers pour curl
  • Écrire des wrappers pour GPG
  • Écrire des wrappers pour apt dpkg debuild et git buildpackage (pour les Debianistes)
  • Écrire des wrappers pour pkg pw, fetch, dump, restore et jail (pour les FreeBSD istes)
  • Écrire un wrapper git pour Rashell (j'ai déjà commencé une branche).
  • Écrire des wrappers pour TeX et METAPOST (déjà commencé aussi)
  • Écrire des wrappers pour yum.
  • Écrire des wrappers pour svn
  • Écrire des wrappers pour noweb l'outil de programmation lettrée de Norman Ramsey
  • Écrire des wrappers pour GraphicsMagick
  • Écrire des wrappers pour pdftk
  • Écrire des wrappers pour sox
  • Écrire des wrappers pour pov
  • Écrire des wrappers pour gcc ou clang
  • Écrire des wrappers pour les outils de préparation des fichiers djvu
  • Écrire des wrappers pour VirtualBox, aws, gcloud, ou autre (déjà commencé aussi)

L'écriture de wrappers est en principe assez facile et permet de réfléchir à la préparation d'interface agréables.

  • # Commentaire supprimé

    Posté par . Évalué à 10.

    Ce commentaire a été supprimé par l'équipe de modération.

    • [^] # Re: Exagération

      Posté par (page perso) . Évalué à 3.

      Dans la pratique, quand on écrit de relativement gros (2kloc) programmes en shell, la gestion des erreurs pose beaucoup de problèmes, non seulement à cause de pipes mais aussi des sous-shells, etc. Le shell est à mes yeux essentiellement un langage de prototypage.

      Dans le cas particulier que je cite, je ne peux de toutes façons pas utiliser bash parceque sa gestion du job control est trop bugguée.

      • [^] # Re: Exagération

        Posté par (page perso) . Évalué à 10.

        quand on écrit de relativement gros (2kloc) programmes en shell

        Le problème est peut-être là.

        « Rappelez-vous toujours que si la Gestapo avait les moyens de vous faire parler, les politiciens ont, eux, les moyens de vous faire taire. » Coluche

        • [^] # Re: Exagération

          Posté par (page perso) . Évalué à 2.

          En effet, de ce que j'ai pu voir des pratiques en informatiques de gestion, on a deux cas :

          • Un serveur moderne, sous linux, qui rend inutile d'utiliser des scripts shells, car on a d'autres outils
          • Un serveur antédiluvien, genre AIX, SunOS, etc.. vieux de 10 ans, et dans ce cas, tout outil moderne genre OCaml est totalement inimaginable.

          Note : Je fais du OCaml régulièrement, dès le second paragraphe, ma tête m'a fait "oulàlà, trop réfléchir pour comprendre".

          « Il n’y a pas de choix démocratiques contre les Traités européens » - Jean-Claude Junker

          • [^] # Re: Exagération

            Posté par . Évalué à 7.

            Un serveur antédiluvien, genre AIX, SunOS, etc.. vieux de 10 ans, et dans ce cas, tout outil moderne genre OCaml est totalement inimaginable.

            C'est marrant cette propension qu'ont les gens à croire que tout Unix en dehors de Linux est forcément vieux et obsolète, alors que rien n'est plus faux.

            Tu es probablement tombé sur des contextes particuliers où par manque de compétences en interne, des machines étaient laissées à l'abandon…

            Sous AIX, on fait aussi du python, du ruby, on a aussi des outils modernes…ansible, chef, etc etc…

            Quant à OCaml sous AIX, il y a des gens qui s'y essayent mais personnellement je n'en ai pas l'utilité donc je ne saurai te répondre.

            Ne généralisons pas….

      • [^] # Commentaire supprimé

        Posté par . Évalué à 5.

        Ce commentaire a été supprimé par l'équipe de modération.

        • [^] # Re: Exagération

          Posté par (page perso) . Évalué à 1.

          est 0_o WoT ???!? WTF ?!? C'était de la bonne ?

          Cela veut dire que les programmes écrits pout le shell sont – sauf rare exception – des prototypes qui ont vocation å être transposé dans un autre langage pour être pérennisés.

          • [^] # Re: Exagération

            Posté par (page perso) . Évalué à 6.

            Perso quand je parts vers un script shell, c'est pour un besoin identifié où celui-ci sera bien adapté, typiquement des séquences de commandes de manipulations de fichiers ou de process avec de le logique autour. Et une fois écrit et fonctionnel, on laisse tourner.

            Si j'ai à faire un proto d'un soft plus conséquent, je parts plutôt vers un langage de script… et là c'est pas obligatoirement pour changer de langage si celui utilisé va bien.

            Python 3 - Apprendre à programmer en Python avec PyZo et Jupyter Notebook → https://www.dunod.com/sciences-techniques/python-3

            • [^] # Re: Exagération

              Posté par (page perso) . Évalué à 1.

              Perso quand je parts vers un script shell, c'est pour un besoin identifié où celui-ci sera bien adapté, typiquement des séquences de commandes de manipulations de fichiers ou de process avec de le logique autour. Et une fois écrit et fonctionnel, on laisse tourner.

              Pour beaucoup de tâches d'administration le shell est un choix naturel pour écrire un prototype, car il permet d'obtenir des résultats très rapidement (la fameuse loi du 80/20 qui dit qu'on développe 80% des fonctionnalités en 20% du temps de développement). Ensuite lorsqu'il s'agit de maintenir, améliorer, etc. ce prototype, cela peut-être utile de passer à un langage plus évolué.

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.