Wednesday, March 9, 2011

Fabric and Pushy, together at last

A workmate of mine recently brought Fabric to my attention. If you're not familiar with Fabric, it is a command-line utility for doing sys-admin type things over SSH. There's an obvious overlap with Pushy there, so I immediately thought, "Could Pushy be used to back Fabric? What are the benefits, what are the downsides?"

A couple of fairly significant benefits:
  • Pushy does more than just SSH, so Fabric could conceivably be made to support additional transports by using Pushy (which uses Paramiko), rather than using Paramiko directly.
  • Pushy provides access to the entire Python standard library, which is largely platform independent. So you could do things like determine the operating system name without using "uname", which is a *NIX thing. That's a trivialisation, but you get the idea I'm sure.
One big fat con:
  • Pushy absolutely requires Python on the remote system (as well as SSH, of course, but Fabric requires that anyway.) So requiring Pushy would mean that Fabric would be restricted to working only with remote machines that have Python installed. Probably a safe bet in general, but not ideal.
How about using Pushy if Python is available, and just failing gracefully if it doesn't? This turns out to be really easy to do, since Fabric and Pushy both use Paramiko. So I wrote a Fabric "operation" to import a remote Python module. Under the covers, this piggy-backs a Pushy connection over the existing Paramiko connection created by Fabric. I'll bring this to the attention of the Fabric developers, but I thought I'd just paste it here for now.

First an example of how one might use the "remote_import" operation. Pass it a module name, and you'll get back a reference to the remote module. You can then use the module as you would use the module as if you had done a plain old "import ".

fabfile.py

from fabric_pushy import remote_import

def get_platform():
    platform = remote_import("platform")
    print platform.platform()

You just execute your fabfile as per usual, and the "remote_import" operation will create a Pushy connection to each host, import the remote Python interpreter's standard platform module, and call its platform method to determine its platform name. Easy like Sunday morning...
    $ fab -H localhost get_platform
    [localhost] Executing task 'get_platform'
    Linux-2.6.35-27-generic-i686-with-Ubuntu-10.10-maverick

    Done.
    Disconnecting from localhost... done.

fabric_pushy.py

from fabric.state import env, default_channel
from fabric.network import needs_host

import pushy
import pushy.transport
from pushy.transport.ssh import WrappedChannelFile

class FabricPopen(pushy.transport.BaseTransport):
    """
    Pushy transport for Fabric, piggy-backing the Paramiko SSH connection
    managed by Fabric.
    """

    def __init__(self, command, address):
        pushy.transport.BaseTransport.__init__(self, address)

        # Join arguments into a string
        args = command
        for i in range(len(args)):
            if " " in args[i]:
                args[i] = "'%s'" % args[i]
        command = " ".join(args)

        self.__channel = default_channel()
        self.__channel.exec_command(command)
        self.stdin  = WrappedChannelFile(self.__channel.makefile("wb"), 1)
        self.stdout = WrappedChannelFile(self.__channel.makefile("rb"), 0)
        self.stderr = self.__channel.makefile_stderr("rb")

    def __del__(self):
        self.close()

    def close(self):
        if hasattr(self, "stdin"):
            self.stdin.close()
            self.stdout.close()
            self.stderr.close()
        self.__channel.close()

# Add a "fabric" transport", which piggy-backs the existing SSH connection, but
# otherwise operates the same way as the built-in Paramiko transport.
class pseudo_module:
    Popen = FabricPopen
pushy.transports["fabric"] = pseudo_module

###############################################################################

# Pushy connection cache
connections = {}

@needs_host
def remote_import(name, python="python"):
    """
    A Fabric operation for importing and returning a reference to a remote
    Python package.
    """

    if (env.host_string, python) in connections:
        conn = connections[(env.host_string, python)]
    else:
        conn = pushy.connect("fabric:", python=python)
        connections[(env.host_string, python)] = conn
    m = getattr(conn.modules, name)
   if "." in name:
        for p in name.split(".")[1:]:
            m = getattr(m, p)
    return m

3 comments:

  1. Hi Andrew,

    This pushy/fab thing is pretty sweet; I'm
    experimenting with it now but I get this error
    in traceback... any idea? I'm running python 2.7
    on localhost and python 2.6 remote, perhaps this is causing "versionitis"?

    Thanks for your contribution. :)

    $ ./tabdev.py get_platform
    Traceback (most recent call last):
    File "/usr/lib/pymodules/python2.7/fabric/main.py", line 551, in main
    commands[name](*args, **kwargs)
    File "/home/jkauth/Development/Ricoh/build_engineering/bin/tabdev.py", line 56, in get_platform
    platform = remote_import("platform")
    File "/usr/lib/pymodules/python2.7/fabric/network.py", line 303, in host_prompting_wrapper
    return func(*args, **kwargs)
    File "/home/jkauth/Development/Ricoh/build_engineering/bin/fabric_pushy.py", line 64, in remote_import
    conn = pushy.connect("fabric:", python=python)
    File "/usr/local/lib/python2.7/dist-packages/pushy-0.5.1-py2.7.egg/pushy/client.py", line 583, in connect
    return PushyClient(target, **kwargs)
    File "/usr/local/lib/python2.7/dist-packages/pushy-0.5.1-py2.7.egg/pushy/client.py", line 352, in __init__
    self.server = transport.Popen(command, **kwargs)
    File "/home/jkauth/Development/Ricoh/build_engineering/bin/fabric_pushy.py", line 28, in __init__
    self.__channel.exec_command(command)
    File "/usr/lib/python2.7/dist-packages/paramiko/channel.py", line 213, in exec_command
    self._wait_for_event()
    File "/usr/lib/python2.7/dist-packages/paramiko/channel.py", line 1084, in _wait_for_event
    raise e
    EOFError
    Disconnecting from root@10.159.151.55... done.

    ReplyDelete
  2. Hi there,

    Thanks for the comments. Sorry for the delay in responding, I didn't have comment notification set up properly.

    I can't tell a great deal from the traceback unfortunately. I've just verified that I can connect from a Python 2.7.2 to a Python 2.6.7 interpreter, using Fabric 1.3.1 and Pushy 0.5.1.

    It appears that you're using an older version of Fabric, as I can see it's using Paramiko, as opposed to the newer 'ssh' replacement package. The only thing I can really suggest for now is to try upgrading Fabric.

    Please contact me at axwalk@gmail.com if you'd like to discuss this further.

    ReplyDelete
  3. thanks Andrew, I'll try that :)

    ReplyDelete