IFT585 -- Travail pratique #1

Date de remise: 20 mai 2014, 23:59:59

Exécution de programmes à distance

Le but de ce travail pratique est d’implanter une application client-serveur permettant d’exécuter un programme interactif sur un hôte distant. Le principe est semblable à l’application SSH, composée du serveur sshd et du client ssh. La portée du travail ici est plus limitée, puisque nous ne nous intéresserons pas aux questions de cryptage des transmissions, de l’authentification ni de l’allocation ou de l’émulation d’un terminal complet. Nous serons cependant en mesure d’exécuter un shell dans un mode interactif simple, ainsi qu’une séquence arbitraire de commandes non interactive.

Cycle de vie d’un programme

En une seule phrase, l’application que nous construirons ici démarrera un programme sur un hôte distant, où est exécuté le serveur: ses entrées seront acheminées du client au serveur via le réseau, et ses sorties, du serveur au client. Afin de bien cerner comment implanter cette application, il faut comprendre précisément le cycle de vie d’un programme.

Mentionnons que nous nous concentrons ici sur le modèle spécifique d’un programme UNIX pur: les entrées du programme sont lues par un fichier spécial, son entrée standard (stdin en C, sys.stdin en Python) et le programme écrit ses sorties sur deux autres fichiers spéciaux, la sortie standard (stdout en C, sys.stdout en Python) et la sortie d’erreurs (stderr en C, sys.stderr en Python). Pour simplifier les choses, nous redirigerons systématiquement la sortie d’erreurs sur la sortie standard, de sorte que nous ne considérerons qu’un seul flux de données en sortie.

Sur un système POSIX (toutes les saveurs d’UNIX que vous pouvez mentionner, ainsi que Linux, BeOS, etc.), un programme peut en exécuter un autre, formant un processus à part, noté son enfant. Par défaut, le processus enfant hérite des mêmes entrée et sortie standard que le parent; il est cependant possible pour le parent de choisir des canaux d’entrée et de sortie différents lors du déclenchement de son exécution. Un tel canal typiquement utilisé est le pipe, un flux de données unidirectionnel qui peut être écrit d’un côté et lu de l’autre. On peut donc régler l’entrée standard du programme enfant de manière à ce que la parent puisse y écrire, et l’enfant y lire; et réciproquement pour la sortie standard. Le parent contrôle alors l’entrée du programme enfant et capture sa sortie.

Au cours de l’exécution de cette paire de programme, le programme parent peut en venir à avoir fourni toutes les données nécessaires à l’exécution de l’enfant; il peut alors fermer son côté de l’entrée standard de l’enfant. À son tour, l’enfant lira son entrée standard jusqu’à en atteindre la fin. À l’inverse, l’enfant aura éventuellement écrit toutes ses données en sortie. Il fermera alors sa sortie standard. À mesure que le parent lit cette dernière, il en atteindra la fin. Un programme qui se termine ferme inévitablement sa sortie standard: lorsque le parent atteint la fin de fichier de la sortie standard de l’enfant, il sait que ce dernier est passé à la phase finale de son cycle de vie.

Sous POSIX, tout programme se termine en renvoyant un code de sortie numérique sur 8 bits, via l’appel système exit. Ce code indique généralement si le programme a complété son exécution avec succès (valeur 0); dans le cas contraire, la valeur du code est associée à divers cas d’erreur, selon une convention variant d’un programme à l’autre. Le code de sortie est transmis au processus parent, qui a la responsabilité de le retrouver par l’appel système wait. Si un parent néglige de surveiller l’état de surveillance des ses enfants, ceux-ci demeurent dans les structures du noyau sous la forme de zombies; une fois leur code récupéré par le parent, le noyau récupère les ressources occupées par les processus enfants.

Récapitulons:

  1. Un processus en engendre un autre: le premier est le parent, le second, l’enfant.
  2. Par diverses manipulations, on peut faire en sorte que l’entrée standard de l’enfant soit un flux de données que la parent écrit. Le parent peut fermer ce flux, état que l’enfant détecte en atteignant la fin de son entrée standard.
  3. Similairement, on peut faire en sorte que la sortie standard de l’enfant soit un flux de données que le parent lit. L’enfant peut fermer ce flux, état que le parent détecte en atteignant la fin du flux.
  4. Une fois atteinte la fin du flux de sortie de l’enfant, le parent doit attendre la fin de son exécution pour récupérer son code de sortie.

Contrôle indirect d’un programme en Python

Le programme qui suit indique comment un très simple programme parent peut contrôler un programme enfant interactif (code téléchargeable).

 1	import os
 2	import select
 3	import subprocess
 4	import sys
 5	
 6	cmd = "/bin/bash"
 7	if len(sys.argv) > 1:
 8	    cmd = " ".join(sys.argv[1:])
 9	
10	enfant = subprocess.Popen(
11	        cmd,
12	        shell = True,
13	        stdin = subprocess.PIPE,
14	        stdout = subprocess.PIPE,
15	        stderr = subprocess.STDOUT
16	        )
17	
18	entree_fermee = False
19	sortie_fermee = False
20	while True:
21	    entrees = []
22	    for f, e in [(entree_fermee, sys.stdin), (sortie_fermee, enfant.stdout)]:
23	        if not f:
24	            entrees.append(e)
25	    pret, _, _ = select.select(entrees, [], [])
26	    if sys.stdin in pret:
27	        donnees = os.read(sys.stdin.fileno(), 1024)
28	        if len(donnees) > 1:
29	            enfant.stdin.write(donnees)
30	        else:
31	            enfant.stdin.close()
32	            entree_fermee = True
33	    if enfant.stdout in pret:
34	        donnees = os.read(enfant.stdout.fileno(), 1024)
35	        if len(donnees) > 1:
36	            sys.stdout.write(donnees)
37	        else:
38	            sortie_fermee = True
39	            break
40	
41	code_sortie = enfant.wait()
42	sys.exit(code_sortie)
43	

Éléments remarquables:

  • Lignes 10-16: le démarrage et la prise de contrôle du programme enfant se fait à l’aide des outils du module subprocess.
    • shell: indique que la commande est une unique chaîne devant être interprétée à l’aide du shell par défaut de l’usager, plutôt qu’une liste de mots décrivant la commande.
    • stdin = subprocess.PIPE: redirige l’entrée standard vers un flux dans lequel le programme parent peut écrire.
    • stdout = subprocess.PIPE: redirige la sortie standard vers un flux duquel le programme parent peut lire.
    • stderr = subprocess.STDOUT: fait en sorte que la sortie d’erreur utilise le même flux que la sortie standard.
  • Lignes 21-25: on utilise la fonction select.select() pour multiplexer l’attente de données. Le modèle d’exécution admet qu’à tout moment, le parent peut contribuer des données en entrée et l’enfant, des données en sortie. Le modèle ne suppose pas que le parent et l’enfant doivent alterner dans l’échange de données; ils sont concurrents.
  • Lignes 27-29: les données obtenues de l’entrée standard du parent sont repassées à celle de l’enfant.
  • Lignes 30-32: lorsqu’on détecte la fin de l’entrée standard du parent, on ferme celle de l’enfant.
  • Lignes 34-36: les données obtenues de la sortie standard de l’enfant sont rendues en écho à celle du parent.
  • Lignes 37-39: lorsqu’on détecte la fin de la sortie standard de l’enfant, on n’a plus d’autre donnée à en attendre que son code de sortie.
  • Ligne 41: on capture le code de sortie de l’enfant, permettant au système d’exploitation de libérer les ressources qui y étaient allouées.
  • Ligne 42: le parent termine son exécution en repassant à son propre parent le code de sortie de son enfant.
  • Lignes 27 et 34: sachant que les fichiers ouverts en lecture sur Python ont tous une méthode read(), pourquoi plutôt utiliser os.read()? Sur UNIX, la lecture d’un flux de données exige qu’on sache le nombre d’octets qu’on désire lire. Si moins d’octets sont disponibles sur le flux, l’une de deux conventions s’applique:

    1. Le système bloque l’appel jusqu’à ce que tous les octets désirés soient acquis du flux ou que sa fin soit atteinte.
    2. Le système laisse l’appel retourner avec le nombre d’octets alors disponibles.

    Désagréablement, la convention mise en oeuvre varie selon le type de flux considéré. De plus, certaines couches d’intégration autour de la fonction C read() (comme le module Python subprocess, constitué d’une librairie d’extension implantée en C, qui utilise des pipes et read() pour la redirection des entrée et sorties standard) changent la convention rattachée au flux sous-jacent. L’expérience montre que la fonction Python os.read applique la convention #2 ci-haut à tous les types de flux (fichiers, pipes, sockets…). Cette convention est celle qui convient le mieux à ce cas d’application, où on tient à lire les octets disponibles sur le flux à un certain moment, pas nécessairement un nombre précis.

Exemples d’exécution

Vous pouvez exécuter ce programme sans paramètre, auquel cas il exécute le shell commun /bin/bash en mode interactif simple. Notez qu’il n’y a aucune invite (prompt) pour indiquer le contrôle de l’usager. Les invites sont une fonctionnalité liée à l’émulation de terminal, qui sort du cadre de ce travail.

$ python ctrl.py
id
id
uid=1000(hamelin) gid=1000(hamelin) groups=1000(hamelin),4(adm),20(dialout
),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),10
7(lpadmin),111(sambashare)
uname -a
Linux hawking 3.11-2-amd64 #1 SMP Debian 3.11.8-1 (2013-11-13) x86_64 GNU/
Linux
<CTRL+D>
$ echo $?
0

Remarquez comme j’ai fermé l’entrée standard en tapant CTRL+D; dans le cas du shell, cela indique qu’il n’y a plus de commande à exécuter, aussi le programme se termine normalement. L’invite $ revient lorsque le parent (python) se termine à son tour, rendant comme code de sortie celui retourné par le shell. Je vérifie cette terminaison normale du shell enfant en rapportant le code de sortie via la commande echo $?, et sans surprise, le résultat est 0 (i.e. tout va bien). On peut aussi terminer le shell avec sa commande exit, permettant de passer un code de sortie arbitraire:

$ python ctrl.py
echo 'Hi ho!'
Hi ho!
exit 18
$ echo $?
18

Le programme ctrl.py peut aussi être exécuté avec une autre commande que le shell. Par exemple, voici l’exécution interactive de Python. Notez comment le fait de ne pas exécuter Python interactivement dans une émulation de terminal affecte le positionnement de l’invite.

$ python ctrl.py python -i
Python 2.7.5+ (default, Sep 17 2013, 15:31:50)
[GCC 4.8.1] on linux2
Type "help", "copyright", "credits" or "license" for more information.
print 5+6
>>> 11
import sys
sys.path
>>> >>> ['', '/home/hamelin/Desktop/Blogue', '/home/hamelin/usr/python',
'/usr/lib/python2.7', '/usr/lib/python2.7/plat-x86_64-linux-gnu', '/usr/
lib/python2.7/lib-tk', '/usr/lib/python2.7/lib-old', '/usr/lib/python2.7
/lib-dynload', '/usr/local/lib/python2.7/dist-packages', '/usr/lib/pytho
n2.7/dist-packages', '/usr/lib/python2.7/dist-packages/PIL', '/usr/lib/p
ython2.7/dist-packages/gst-0.10', '/usr/lib/python2.7/dist-packages/gtk-
2.0', '/usr/lib/pymodules/python2.7']
sys.exit(0)
>>> $

Problème #1 – Exécution à distance

Implantez, dans le langage de votre choix, un serveur et un client permettant l’exécution à distance de programmes interactif selon le modèle UNIX pur décrit ci-haut. Le serveur doit pouvoir être invoqué pour s’attacher sur une interface et un port arbitraire:

$ ./reshd <adresse> <port>

Une fois le serveur lancé, on peut exécuter un client en l’invoquant ainsi:

$ ./resh <adresse> <port> [<commande>...]

Tous les paramètres suivant le port constituent la commande à exécuter. C’est le serveur qui exécute la commande et articule l’échange de données entre le client et la commande. Si on ne passe pas de commande explicite au client, ce dernier demande au serveur d’exécuter le shell /bin/bash.

Requis additionnels et notes

  • Un nombre arbitraires de client doivent pouvoir se connecter au serveur simultanément. Ces différentes sessions exécutent toutes des commandes distinctes et n’échangent pas de données avec les autres sessions.
  • Le modèle d’exécution du programme suggère que le client et la commande sont des processus concurrents: à tout moment, le client peut générer des entrées et la commande, des sorties.
  • Le client termine son exécution avec la commande distante qu’il a fait exécuter. Il se termine avec le code de sortie correspondant à celui de la commande.
  • L’attente d’un code de sortie qui tarde à venir ne doit pas bloquer l’exécution des autres clients du serveur. Afin d’éviter de mettre en oeuvre ce requis par l’usage de multiples fils d’exécution (multithreading), on peut utiliser une approche d’attente active (polling). En Python, cela peut être implanté par un usage futé des outils des modules subprocess et select.
  • En cas d’erreur de réseau, le client abandonne la connexion et se termine avec le code de sortie -1 (si vous examinez sur le shell le code de sortie obtenu, vous verrez 255: c’est normal). En cas de coupure abrupte d’une connexion client, le serveur doit tuer la commande associée et s’assurer qu’elle ne reste pas sous forme de zombie en mémoire.
  • Le serveur ne comporte pas de mécanisme explicite de fin d’exécution: il peut être terminé “inélégamment” par un vulgaire CTRL+C.

Problème #2 – Téléchargement de la sortie d’un programme dans un fichier

Dans un shell, la commande tee permet de sauvegarder la sortie standard d’un programme dans un fichier ad hoc, en plus d’en faire écho sur sa propre sortie standard. Par exemple:

$ nl ctrl.py | tee ctrl.num
 1	import os
 2	import select
 3	import subprocess
 4	import sys

 5	cmd = "/bin/bash"
...

Le résultat de la commande est aussi sauvegardé dans ctrl.num. On peut même se servir de cette commande pour copier un fichier (en plus de l’obtenir sur la sortie standard):

$ tee dest < src

L’entrée standard est prise du fichier src et dupliquée sur la sortie standard et dans le fichier dest.

Ce concept est applicable dans le contexte de l’exécution d’un programme à distance pour télécharger des données. Considérons que le client veuille sauvegarder le résultat d’une commande dans un fichier local. Il pourrait alors utiliser la commande ltee (local tee) pour envoyer une copie de la sortie standard dans un fichier de son côté. Exemple d’usage:

# Sauvegarde locale de la sortie standard.
nl ctrl.py | ./ltee local_ctrl.py
 1	import os
 2	import select
 3	import subprocess
 4	import sys

 5	cmd = "/bin/bash"
# ...
# Téléchargement d'un fichier.
./ltee dest_sur_client < src_sur_serveur

Le but de ce problème est d’implanter cette commande ltee.

Requis additionnels et notes

  • La commande est exécutée sur le serveur.
  • La commande ltee utilise la connexion déjà existente entre le serveur et le client pour transférer les données à télécharger.
  • Le protocole entre le client et le serveur doit être adapté pour permettre la fonctionnalité de ltee.

Trucs d’implantation

La commande ltee doit utiliser le canal déjà ouvert entre le serveur et le client pour transférer les données au client. Il est donc possible que ltee contacte le serveur, qui servira d’intermédiaire entre ltee et le client.

Cependant, comment est-il possible à ltee de contacter le serveur si on ne spécifie pas son adresse et son port en ligne de commande? Il suffit d’utiliser l’environnement du programme interactif manipulé par le serveur. L’environnement est un ensemble de variables décrivant divers aspects de configuration et d’information sur le contexte dans lequel le programme s’exécute. Par exemple, l’environnement contient la liste de répertoires où on peut trouver des programmes exécutables (variable PATH), le nombre de colonnes et de lignes du terminal (variables COLUMNS et LINES), le répertoire de base de l’usager (variable HOME), et ainsi de suite.

Par défaut, l’environnement d’un programme enfant est hérité sans modification du parent. Il est cependant simple de passer un environnement modifié. En Python, on obtient l’environnement passé à un programme avec la pseudo-variable os.environ, qui s’interroge comme un dictionnaire. On peut aussi créer un nouveau dictionnaire à partir de os.environ de manière à constituer un environnement modifié qu’on passera à un programme enfant. Exemple:

import os
import subprocess

mon_environnement = dict(os.environ)
mon_environnement["HOHO"] = "heyhey"
p = subprocess.Popen(
        "echo $HOHO",
        shell = True,
        stdin = subprocess.PIPE,
        stdout = subprocess.PIPE,
        stderr = subprocess.STDOUT,
        env = mon_environnement
        )
print os.read(p.stdout, 1024)
p.wait()

On peut donc ajouter des variables d’environnement au processus enfant de manière à y stocker l’information suffisant à se connecter au serveur et à identifier la connexion au client pour lequel cette commande est exécutée. Lorsque, à son tour, la commande exécutera ltee comme son propre enfant, elle lui passera cet environnement modifié. ltee peut donc rendre son bon fonctionnement conditionnel à retrouver les variables d’environnement ajoutées par le serveur.

Problème #3 – Téléversement de fichiers en entrée d’un programme

Dans un shell, la commande cat permet de concaténer sur son entrée standard les fichiers nommés sur sa ligne de commande. Par exemple:

$ cat fich1 fich2 fich3 | wc -l

Cette commande permet de compter le nombre de lignes total dans les fichiers fich1, fich2 et fich3. On peut même se servir de cette commande pour copier un fichier:

$ cat src > dest

Ce concept est applicable dans le contexte de l’exécution d’un programme à distance pour téléverser des données (i.e. envoyer des données du client au serveur). Considérons que le client veuille prendre en entrée d’une commande distante le contenu des fichiers locaux fich1, fich2 et fich3. Il pourrait alors utiliser la commande lcat (local cat) pour téléverser ces fichiers dans la sortie standard sur le serveur. Exemple d’usage:

# Compter et stocker sur le serveur les lignes dans fich1, fich2, fich3.
lcat fich1 fich2 fich3 | wc -l > nb_lignes.txt
# Téléverser un fichier sur le serveur.
./lcat src_sur_client > dest_sur_serveur

Le but de ce problème est d’implanter cette commande lcat.

Requis additionnels et notes

  • La commande est exécutée sur le serveur.
  • La commande lcat utilise la connexion déjà existente entre le serveur et le client pour transférer les données à téléverser.
  • Le protocole entre le client et le serveur doit être adapté pour permettre la fonctionnalité de lcat.

Les mêmes trucs d’implantation que pour ltee facilitent cette de lcat.

Instructions pour la remise

  • Le travail est exécuté par équipes de deux.
  • Le travail doit être remis par courriel à l’une de ces deux adresses: benoit@benoithamelin.com, benoit.hamelin@usherbrooke.ca.
    • Le sujet du courriel: IFT585 TP1
    • Le corps du courriel doit contenir le nom des deux coéquipiers.
    • Une archive nommée tp1.zip ou tp1.tar.gz doit être incluse en pièce jointe.
  • Lorsque décompressée, l’archive remise doit engendrer un répertoire nommé simplement tp1 (en minuscules), qui contient au moins les fichiers suivants:
    • reshd (serveur d’exécution à distance)
    • resh (client d’exécution à distance)
    • ltee (téléchargeur dans un fichier local)
    • lcat (téléverseur de fichiers locaux)
  • Si une étape de compilation est nécessaire avant l’exécution des programmes nommés ci-haut, elle doit pouvoir être complétée par l’exécution d’une seule commande de compilation, exécutée depuis le répertoire obtenu de la décompression de l’archive:

    $ ./compile
    
  • Chacun des programmes nommés ci-haut doit être exécutable depuis un terminal ouvert dans le répertoire tp1 engendré par la décompression de l’archive. Par exemple, je dois pouvoir exécuter la session suivante sans erreur:

    $ unzip tp1.zip
    ... listing ...
    $ cd tp1
    $ ./reshd 127.0.0.1 9887
    
  • Garantie: les programmes seront toujours exécutés depuis le répertoire obtenu par décompression de l’archive.
  • Les programmes doivent être pouvoir être exécutés sur une machine physique ou virtuelle correspondant à la plate-forme de référence du cours:

Évaluation

  • Le travail est évalué sur 20 points.
    • Problème #1: 8 points
    • Problème #2: 5 points
    • Problème #3: 5 points
    • Remise conforme aux instructions: 2 points
  • La qualité et la conformité des programmes aux exigences sera testée à l’aide de programmes externes, vérifiant les résultats de l’exécution du client pour divers ensembles de données. La stabilité des programmes est aussi sous évaluation: les tests de conformité seront exécutés plus d’une fois.
  • Le code ne sera pas examiné. Ses qualités intrinsèques ne sont pas évaluées. On s’attend à ce que la difficulté du problème cause des problèmes de maintenance intractables (ou, à tout le moins, douloureux) pour des programmes conçus avec négligence ou sans égard à la lisibilité.

Questions et réponses

  1. Si on programme le travail en Python, peut-on utiliser n’importe quel module ou utilise-t-on seulement les built-ins?

    En principe, on peut se contenter des modules inclus avec la distribution standard de Python 2.7. Ces modules sont tous listés dans la Standard Library Reference, qui inclut tous les modules employés dans le code exemple et en cours. Au final, il faut vos programmes puissent rouler sur la plate-forme d’exécution du cours. Si vous désirez qu’un module ou un autre soit inclus à cette plate-forme, communiquez avec moi.