Occasionally I have to do scripting work outside of our config management tool of choice, Juju 1 - eg. for bootstrapping or one-off jobs. I had used Fabric version 1 previously (as well as Plumbum) for those, and was looking at Fabric 2 (respectively it's sidekick Invoke) now.
So whats Fabric and Invoke
Invoke is a high-level lib for running shell commands. It also offers simple command-line parsing functionality. Fabric offers remote command execution via ssh and builds on Invoke.
Their predecessor, Fabric v1, used to do both local and remote task execution. Invoke was split off as the ssh interfacing functionality carries some heavy dependencies (Paramiko and the underlying crypto libs), placing an unnecessary burden on users who only need local task exec.
Invoke Tasks
Installation
Install it into a virtual env via pip: pip install invoke
. Or via your package manager, e.g. on Ubuntu with sudo apt install python3-invoke
Hello world
Invoke tasks look something like the below.
from invoke import task
@task
def hello(ctxt):
print("Hi there!")
Tasks are functions that are decorated with the task
decorator. They take at least one arg, the context
(more on that below). Typically they're put in a file tasks.py
.
If put in a tasks.py they can be run with the invoke
program, for instance:
$ invoke hello
Hi there!
Note you don't have to use the invoke
program - tasks can be called from your regular Python code just as well.
Running shell commands
The context
comes into play when running shell code. For example, calling the hostname
program:
from invoke import task
@task
def host(ctxt):
"hostname") ctxt.run(
The above task will run the "hostname" program via the contexts' run()
method. The context
object serves as an interface to your system and also holds configuration information.
Results object and hiding stdout/stderr
The results of ctxt.run()
are passed back in a results object. It contains stdout, stderr and the exit code of the command passed in.
By default, run()
will also copy stdout and stderr from the subordinate shell to the controlling terminal. For instance, the below task will echo 3 lines.
from invoke import task
@task
def echo3(ctxt):
"for i in {1..3}; do echo line $i ; done") ctxt.run(
If you're going to process the output it's often useful to prevent copying stdout/stderr. This can be done with the hide
flag to run()
, for instance:
from invoke import task
@task
def mountdev(ctxt):
= ctxt.run("mount | grep /dev/", hide='both')
res print("Exit code:", res.exited)
print("Stdout:", res.stdout)
Full docs for the results object are here
Warn instead of abort
If a shell command exits with a non-zero exit code, Invoke by default will bail out with an UnexpectedExit exception. This is a safe default for many jobs, but sometimes a non-zero exit is expected or can safely be ignored. The run()
method takes a warn
kwarg (default False
) to control this
Echo
For debugging it can be useful to have the shell command to be run echoed back to you; do this with run(echo=True)
Updateing the env
The run()
method also takes an env
kwarg. These values are passed in in addition to the inherited environment. Eg., setting python path:
from invoke import task
@task
def mypath(ctxt):
'someprogram', env={'PYTHONPATH': 'my/libs/'}) ctxt.run(
More run params
Some more options for the run()
method are documented in the Runners.run API docs for replacing env, pty control, input and output stream manipulation.
Sudo
There's also some syntactic sugar for running shell commands via sudo via the sudo()
method exposed in the context object. All args of the run()
method are applicable here as well. In addition you can pass in args user
and password
. The latter can (and probably should) be passed in via configuration as well - more on the configuration system below
Note that for entering passwords by hand the sudo()
wrapper should be avoided, and run("sudo somecommand")
be used instead.
More on running invoke
Params
The invoke
program supports passing in args from the command line. Arguments to task functions become commandline args. By specifying keyword args it's also possible to initiate type casting. Type info is also reflected in the help output (more below on help).
First a task that takes an arg 'name', secondly a kwarg 'num' with a default value of 0. The latter is interpreted as a type hint.
from invoke import task
@task
def heya(ctxt, name):
print("Heya,", name)
@task
def integertask(ctxt, num=0):
print("Type is", type(num))
Command line invocation of tasks:
$ invoke heya --name Peter
Heya, Peter
$ invoke integertask --num 1
Type is <class 'int'>
Listing tasks and help
When running invoke
with the --list
flag it will output a list of defined tasks:
$ invoke --list
Available tasks:
hello
Tasks can also be annotated with documentation, which will be used to output help/usage info. For example, given a task definition:
@task(help={'num': "Number to operate on "})
def mult3(ctxt, num=0):
"""Multiply a number by 3
"""
return num * 3
Then, requesting help would display something like the below:
$ invoke --help mult3
Usage: inv[oke] [--core-opts] mult3 [--options] [other tasks here ...]
Docstring:
Multiply a number by 3
Options:
-n INT, --num=INT Number to operate on
Note that 'num' can be given either with -n
or --num
. Also note that since we gave 'num' an integer default value help prints the appropriate type
Configuration system
Invoke has a nifty configuration system which can be used to both steer invokes behaviour, but is also available for task configuration. Sources for configuration values are Python code, environment variables, system-wide config files (ie. /etc/invoke.yaml
), per-user configuration files (ie. ~/.invoke.yaml
), or per-project config files (ie. invoke.yaml
alongside a tasks.py
), or passed in explicitly as invoke -f myconf.yaml
.
The resulting configuration is put in dict-like object (possibly nested).
Basic example
Let's say your ~/.invoke.yaml
has entries:
foo:
bar: 123
quux:
- a
- b
- c
Then your context objects will have a ctxt.foo
dictionary-like object:
@task
def footask(ctxt):
print(ctxt.foo)
$ invoke footask
<DataProxy: {'bar': 123, 'quux': ['a', 'b', 'c']}>
Note, config files can be given in json and yaml format. For details on this and the lookup rules and formats refer to the docs.
Changing task file name
By default, invoke will load tasks from a tasks
module, for instance a file tasks.py
in the current directory. If you'd rather use another file name, pass in the --collection <module>
arg. Eg., given a file footasks.py
.
invoke --collection footasks ...
If this file is not in the current directory, pass in a directory arg with --search-root /some/dir
Modules given with the --collection
arg are first searched for on sys.path
, then the current working dir, and from there upwards in the file system. It's also possible to give the --collection=
arg multiple times - then all of the modules are searched for.
Remote execution
The Fabric library and executable are used for remote execution of tasks, just like the invoke lib are used for local tasks.
Installation
Install into a venv via pip: pip install fabric
, or via your package manager, e.g. on Ubuntu: sudo apt install python3-fabric
Connecting and running
Fabric uses invoke to execute commands remotely via ssh. One of the principal interfaces for this is the Connection
object. For instance, to run a command on a remote host 'myhost':
from fabric import Connection
= Connection('myhost')
con = con.run('uname -a') result
The con.run()
method works much like Invokes context objects, and in fact Connection
inherits from the Context
class.
Authentication is done much like a regular ssh command line invocation, public key auth and ssh agents are supported.
Connections also can be opened with explicit user and port: Connection(user='peter', host='myhost', port=22).run('uname')
Configuration
Fabrics configuration works similarly as Invokes' does, only the default config files are named fabric
instead of invoke
(eg. fabric.yaml
instead of invoke.yaml
).
Sudo
Similarly to the sudo method of invoke described above, fabric offers a sudo()
method to conveniently run privileged commands. For non-passwordless sudo, it's possible to set passwords in configuration files (e.g. fabric.yaml
):
sudo:
password: sikkritpass
Alternatively, you can have fab
prompt you for sudo passwords by giving the --prompt-for-sudo-password
flag upon invocation.
Multiple servers
Server groups
Fabric has a notion of server groups that allows to run the same operation on several remote machines. This comes in two flavors: SerialGroup
objects serialize access to every server, while the ThreadingGroup
executes in parallel but otherwise has the same API.
In the example below a function upload_and_unpack()
is executed on a group of servers. The function will get a connection object for each server. Also note the c.put()
method of transferring files, see below for more on file transfer.
from fabric import SerialGroup as Group
# Use for parallel execution: from fabric import ThreadingGroup as Group
def upload_and_unpack(c):
if c.run('test -f /opt/mydata/myfile', warn=True).failed:
'myfiles.tgz', '/opt/mydata')
c.put('tar -C /opt/mydata -xzvf /opt/mydata/myfiles.tgz')
c.run(
for connection in Group('web1', 'web2', 'web3'):
upload_and_unpack(connection)
Connection configuration
For configuring connection SSH parameters, Connections take a connect_kwargs
argument which is passed to Paramiko. Parameters therefore are Paramikos, e.g. configuring a specific key is done with:
= Connection(
con 'myhost',
={
connect_kwargs"key_filename": os.path.expanduser("~/.ssh/id_rsa_special")
},)
Gateways
Many installations are not directly reachable via SSH but can only be reached via a bastion or gateway host. Fabric supports this quite neatly via the gateway
parameter:
= Connection('myhost.internal', gateway=Connection('bastion.example.com')) con
The above will bounce the connection to myhost.internal
via the bastion.example.com
connection. The connection can be configured just like any other with custom ssh parameters.
File transfer
Often, you'll want to transfer files to and from remote servers for remote execution, be it data files or scripts you want to run remotely. Fabric provides the .put()
and .get()
methods on connections for this. For example, upload a Python script and run it:
from fabric import Connection
= Connection('myhost')
con 'myscript.py', remote='/home/ubuntu')
con.put('python3 /home/ubuntu/myscript.py') con.run(