From d220fa370f0c7b6b4d3531b45e570219b51be2b8 Mon Sep 17 00:00:00 2001
From: Daniel STAN <daniel.stan@crans.org>
Date: Sun, 4 Aug 2013 13:33:59 +0200
Subject: [PATCH] [keep-alive] feature connexion unique au serveur
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

C'était plus pratique que de faire un merge de la branche devel-keep-alive.
La feature n'est pas activée automatiquement pour les gens qui ont déjà
un fichier de config, mais elle est présente pour ceux qui en crée un.
---
 client.py               |  76 ++++++++++++++++-----
 clientconfig.example.py |   1 +
 server.py               | 145 ++++++++++++++++++++++++++--------------
 3 files changed, 156 insertions(+), 66 deletions(-)

diff --git a/client.py b/client.py
index 17df3d5..ecc025b 100755
--- a/client.py
+++ b/client.py
@@ -239,7 +239,12 @@ class simple_memoize(object):
            mais il faudra s'en préoccuper si un jour on veut changer le comportement."""
         if self.val == None:
             self.val = self.f(*args, **kwargs)
-        return copy.deepcopy(self.val)
+        # On évite de tout deepcopier. Typiquement, un subprocess.Popen
+        # ne devrait pas l'être (comme dans get_keep_alive_connection)
+        if type(self.val) in [dict, list]:
+            return copy.deepcopy(self.val)
+        else:
+            return self.val
 
 
 ######
@@ -258,6 +263,10 @@ def remote_proc(options, command, arg=None):
     full_command.append(command)
     if arg:
         full_command.append(arg)
+
+    if options.verbose and not options.quiet:
+        print("Running command %s ..." % " ".join(full_command))
+
     proc = subprocess.Popen(full_command,
                             stdin = subprocess.PIPE,
                             stdout = subprocess.PIPE,
@@ -265,34 +274,67 @@ def remote_proc(options, command, arg=None):
                             close_fds = True)
     return proc
 
+@simple_memoize
+def get_keep_alive_connection(options):
+    """Fabrique un process parlant avec le serveur suivant la commande
+    'keep-alive'. On utilise une fonction séparée pour cela afin
+    de memoizer le résultat, et ainsi utiliser une seule connexion"""
+    proc = remote_proc(options, 'keep-alive', None)
+    atexit.register(proc.stdin.close)
+    return proc
+
 def remote_command(options, command, arg=None, stdin_contents=None):
     """Exécute la commande distante, et retourne la sortie de cette
     commande"""
     detail = options.verbose and not options.quiet
+    keep_alive = options.serverdata.get('keep-alive', False)
     
-    proc = remote_proc(options, command, arg)
-    if stdin_contents is not None:
-        proc.stdin.write(json.dumps(stdin_contents))
-        proc.stdin.close()
-    ret = proc.wait()
-    raw_out = proc.stdout.read()
-    if ret != 0:
-        if not options.quiet:
-            print((u"Mauvais code retour côté serveur, voir erreur " +
-                   u"ci-dessus").encode('utf-8'),
-                  file=sys.stderr)
-            if options.verbose:
-                print("raw_output: %s" % raw_out)
-        sys.exit(ret)
+    if keep_alive:
+        conn = get_keep_alive_connection(options)
+        args = filter(None, [arg, stdin_contents])
+        msg = {u'action': unicode(command), u'args': args }
+        conn.stdin.write('%s\n' % json.dumps(msg))
+        conn.stdin.flush()
+        raw_out = conn.stdout.readline()
+    else:
+        proc = remote_proc(options, command, arg)
+        if stdin_contents is not None:
+            proc.stdin.write(json.dumps(stdin_contents))
+            proc.stdin.close()
+        ret = proc.wait()
+        raw_out = proc.stdout.read()
+        if ret != 0:
+            if not options.quiet:
+                print((u"Mauvais code retour côté serveur, voir erreur " +
+                       u"ci-dessus").encode('utf-8'),
+                      file=sys.stderr)
+                if detail:
+                    print("raw_output: %s" % raw_out)
+            sys.exit(ret)
     try:
-        return json.loads(raw_out)
+        answer = json.loads(raw_out.strip())
     except ValueError:
         if not options.quiet:
             print(u"Impossible de parser le résultat".encode('utf-8'),
                   file=sys.stderr)
-            if options.verbose:
+            if detail:
                 print("raw_output: %s" % raw_out)
             sys.exit(42)
+    if not keep_alive:
+        return answer
+    else:
+        try:
+            if answer[u'status'] != u'ok':
+                raise KeyError('Bad answer status')
+            return answer[u'content']
+        except KeyError:
+            if not options.quiet:
+                print(u"Réponse erronée du serveur".encode('utf-8'),
+                    file=sys.stderr)
+            if detail:
+                print("answer: %s" % repr(answer))
+            sys.exit(-1)
+
 
 @simple_memoize
 def all_keys(options):
diff --git a/clientconfig.example.py b/clientconfig.example.py
index c61f3eb..0021209 100755
--- a/clientconfig.example.py
+++ b/clientconfig.example.py
@@ -30,6 +30,7 @@ servers = {
     # Utile pour tester
     'localhost': {
         'server_cmd': [ssh_path, 'localhost'] + distant_cmd,
+        'keep-alive': True, # <-- experimental, n'ouvre qu'une connexion
     },
     'ovh': {
         'server_cmd': [ssh_path, 'ovh.crans.org'] + distant_cmd,
diff --git a/server.py b/server.py
index de234d1..e0bd6e0 100755
--- a/server.py
+++ b/server.py
@@ -25,6 +25,8 @@ MYUID = pwd.getpwuid(os.getuid())[0]
 if MYUID == 'root':
     MYUID = os.environ['SUDO_USER']
 
+## Fonctions internes au serveur
+
 def validate(roles, mode='r'):
     """Vérifie que l'appelant appartient bien aux roles précisés
     Si mode mode='w', recherche un rôle en écriture
@@ -47,6 +49,73 @@ def writefile(filename, contents):
     f.write(contents.encode("utf-8"))
     f.close()
 
+class server_command(object):
+    """
+    Une instance est un décorateur pour la fonction servant de commande
+    externe du même nom"""
+
+    #: nom de la commande
+    name = None
+
+    #: fonction wrappée
+    decorated = None
+
+    #: (static) dictionnaire name => fonction
+    by_name = {}
+
+    #: rajoute un argument en fin de fonction à partir de stdin (si standalone)
+    stdin_input = False
+
+    #: Est-ce que ceci a besoin d'écrire ?
+    write = False
+
+    def __init__(self, name, stdin_input = False, write=False):
+        """
+         * ``name`` nom de l'action telle qu'appelée par le client
+         * ``stdin_input`` si True, stdin sera lu en mode non-keepalive, et
+                           remplira le dernier argument de la commande.
+         * ``write`` s'agit-il d'une commande en écriture ?
+        """
+        self.name = name
+        self.stdin_input = stdin_input
+        self.write = write
+        server_command.by_name[name] = self
+
+    def __call__(self, fun):
+        self.decorated = fun
+        return fun
+
+## Fonction exposées par le serveur
+@server_command('keep-alive')
+def keepalive():
+    """ Commande permettant de réaliser un tunnel json (un datagramme par ligne)
+    Un message entre le client et le serveur consiste en l'échange de dico
+
+    Message du client: {'action': "nom_de_l'action",
+                        'args': liste_arguments_passes_a_la_fonction}
+    Réponse du serveur: {'status': 'ok',
+        'content': retour_de_la_fonction,
+    }
+
+    """
+    for line in iter(sys.stdin.readline, ''):
+        data = json.loads(line.rstrip())
+        try:
+            # Une action du protocole = de l'ascii
+            action = data['action'].encode('ascii')
+            content = server_command.by_name[action].decorated(*data['args'])
+            status = u'ok'
+        except Exception as e:
+            status = u'error'
+            content = repr(e)
+        out = {
+            'status': status,
+            'content': content,
+        }
+        print(json.dumps(out, encoding='utf-8'))
+        sys.stdout.flush()
+
+@server_command('listroles')
 def listroles():
     """Liste des roles existant et de leurs membres.
        Renvoie également un rôle particulier ``"whoami"``, contenant l'username de l'utilisateur qui s'est connecté."""
@@ -56,10 +125,12 @@ def listroles():
     d["whoami"] = MYUID
     return d
 
+@server_command('listkeys')
 def listkeys():
     """Liste les usernames et les (mail, fingerprint) correspondants"""
     return serverconfig.KEYS
 
+@server_command('listfiles')
 def listfiles():
     """Liste les fichiers dans l'espace de stockage, et les roles qui peuvent y accéder"""
     os.chdir(serverconfig.STORE)
@@ -70,7 +141,8 @@ def listfiles():
         file_dict = json.loads(open(filename).read())
         files[filename[:-5]] = file_dict["roles"]
     return files
-    
+
+@server_command('getfile')
 def getfile(filename):
     """Récupère le fichier ``filename``"""
     filepath = getpath(filename)
@@ -83,13 +155,12 @@ def getfile(filename):
     except IOError:
         return [False, u"Le fichier %s n'existe pas." % filename]
      
-
-def getfiles():
+@server_command('getfiles', stdin_input=True)
+def getfiles(filenames):
     """Récupère plusieurs fichiers, lit la liste des filenames demandés sur stdin"""
-    stdin = sys.stdin.read()
-    filenames = json.loads(stdin)
     return [getfile(f) for f in filenames]
 
+# TODO ça n'a rien à faire là, à placer plus haut dans le code
 def _putfile(filename, roles, contents):
     """Écrit ``contents`` avec les roles ``roles`` dans le fichier ``filename``"""
     gotit, old = getfile(filename)
@@ -109,10 +180,9 @@ def _putfile(filename, roles, contents):
     writefile(filepath, json.dumps({'roles': roles, 'contents': contents}))
     return [True, u"Modification effectuée."]
 
-def putfile(filename):
+@server_command('putfile', stdin_input=True, write=True)
+def putfile(filename, parsed_stdin):
     """Écrit le fichier ``filename`` avec les données reçues sur stdin."""
-    stdin = sys.stdin.read()
-    parsed_stdin = json.loads(stdin)
     try:
         roles = parsed_stdin['roles']
         contents = parsed_stdin['contents']
@@ -120,10 +190,9 @@ def putfile(filename):
         return [False, u"Entrée invalide"]
     return _putfile(filename, roles, contents)
 
-def putfiles():
+@server_command('putfiles', stdin_input=True, write=True)
+def putfiles(parsed_stdin):
     """Écrit plusieurs fichiers. Lit les filenames sur l'entrée standard avec le reste."""
-    stdin = sys.stdin.read()
-    parsed_stdin = json.loads(stdin)
     results = []
     for fichier in parsed_stdin:
         try:
@@ -136,7 +205,7 @@ def putfiles():
             results.append(_putfile(filename, roles, contents))
     return results
 
-
+@server_command('rmfile', write=True)
 def rmfile(filename):
     """Supprime le fichier filename après avoir vérifié les droits sur le fichier"""
     gotit, old = getfile(filename)
@@ -153,6 +222,7 @@ def rmfile(filename):
     return u"Suppression effectuée"
 
 
+# TODO monter plus haut
 def backup(corps, fname, old):
     """Backupe l'ancienne version du fichier"""
     os.umask(0077)
@@ -162,6 +232,7 @@ def backup(corps, fname, old):
     back.write((u'* %s: %s\n' % (str(datetime.datetime.now()), corps)).encode("utf-8"))
     back.close()
 
+# TODO monter plus haut
 def notification(subject, corps, fname, old):
     """Envoie par mail une notification de changement de fichier"""
     conn = smtplib.SMTP('localhost')
@@ -180,43 +251,19 @@ def notification(subject, corps, fname, old):
     conn.sendmail(frommail, tomail, msg.as_string())
     conn.quit()
 
-WRITE_COMMANDS = ["putfile", "rmfile"]
-
 if __name__ == "__main__":
-    argv = sys.argv[1:]
-    if len(argv) not in [1, 2]:
-        sys.exit(1)
-    command = argv[0]
-    if serverconfig.READONLY and command in WRITE_COMMANDS:
+    argv = sys.argv[0:]
+    command_name = argv[1]
+
+    command = server_command.by_name[command_name]
+    if serverconfig.READONLY and command.write:
         raise IOError("Ce serveur est read-only.")
-    filename = None
-    try:
-        filename = argv[1]
-    except IndexError:
-        pass
-    
-    answer = None
-    if command == "listroles":
-        answer = listroles()
-    elif command == "listkeys":
-        answer = listkeys()
-    elif command == "listfiles":
-        answer = listfiles()
-    elif command == "getfiles":
-        answer = getfiles()
-    elif command == "putfiles":
-        answer = putfiles()
-    else:
-        if not filename:
-            print("filename nécessaire pour cette opération", file=sys.stderr)
-            sys.exit(1)
-        if command == "getfile":
-            answer = getfile(filename)
-        elif command == "putfile":
-            answer = putfile(filename)
-        elif command == "rmfile":
-            answer = rmfile(filename)
-        else:
-            sys.exit(1)
-    if not answer is None:
+
+    args = argv[2:]
+    # On veut des unicode partout
+    args = [ s.decode('utf-8') for s in args ]
+    if command.stdin_input:
+        args.append(json.loads(sys.stdin.read()))
+    answer = command.decorated(*args)
+    if answer is not None:
         print(json.dumps(answer))
-- 
GitLab