6.2 KiB
Shlax: Pythonic automation tool
Shlax is a Python framework for system automation, initially with the purpose of replacing docker, docker-compose and ansible with a single tool, with the purpose of code-reuse. It may be viewed as "async fabric rewrite by a megalomanic Django fanboy".
The pattern resolves around two moving parts: Actions and Targets.
Action
An action is a function that takes a target argument, it may execute nested actions by passing over the target argument which collects the results.
Example:
async def hello_world(target):
"""Bunch of silly commands to demonstrate action programming."""
await target.mkdir('foo')
python = await target.which('python3', 'python')
await target.exec(f'{python} --version > foo/test')
version = target.exec('cat foo/test').output
print('version')
Recursion
An action may call other actions recursively. There are two ways:
async def something(target):
# just run the other action code
hello_world(target)
# or delegate the call to target
target(hello_world)
In the first case, the resulting count of ran actions will remain 1: "something" action.
In the second case, the resulting count of ran actions will be 2: "something" and "hello_world".
Callable classes
Actually in practice, Actions are basic callable Python classes, here's a basic example to run a command:
class Run:
def __init__(self, cmd):
self.cmd = cmd
async def __call__(self, target):
return await target.exec(self.cmd)
This allows to create callable objects which may be called just like functions and as such be appropriate actions, instead of:
async def one(target):
target.exec('one')
async def two(target):
target.exec('two')
You can do:
one = Run('one')
two = Run('two')
Parallel execution
Actions may be executed in parallel with an action named ... Parallel. This defines an action that will execute three actions in parallel:
action = Parallel(
hello_world,
something,
Run('echo hi'),
)
In this case, all actions must succeed for the parallel action to be considered a success.
Methods
An action may also be a method, as long as it just takes a target argument, for example:
class Thing:
def start(self, target):
"""Starts thing"""
def stop(self, target):
"""Stops thing"""
action = Thing().start
Cleaning
If an action defines a clean method, it will always be called wether or not
the action succeeded. Example:
class Thing:
def __call__(self, target):
"""Do some thing"""
def clean(self, target):
"""Clean-up target after __call__"""
Colorful actions
If an action defines a colorize method, it will be called with the colorset
as argument for every output, this allows to code custom output rendering.
Target
A Target is mainly an object providing an abstraction layer over the system we want to automate with actions. It defines functions to execute a command, mount a directory, copy a file, manage environment variables and so on.
Pre-configuration
A Target can be pre-configured with a list of Actions in which case calling the target without argument will execute its Actions until one fails by raising an Exception:
say_hello = Localhost(
hello_world,
Run('echo hi'),
)
await say_hello()
Results
Every time a target execute an action, it will set the "status" attribute on it to "success" or "failure", and add it to the "results" attribute:
say_hello = Localhost(Run('echo hi'))
await say_hello()
say_hello.results # contains the action with status="success"
Targets as Actions: the nesting story
We've seen that any callable taking a target argument is good to be considered an action, and that targets are callables.
To make a Target runnable like any action, all we had to do is add the target
keyword argument to Target.__call__.
But target() fills self.results, so nested action results would not
propagate to the parent target.
That's why if Target receives a non-None target argument, it will has to set
self.parent with it.
This allows nested targets to traverse parents and get to the root Target
with target.caller, where it can then attach results to.
This opens the nice side effect that a target implementation may call the parent target if any, you could write a Docker target as such:
class Docker(Target):
def __init__(self, *actions, name):
self.name = name
super().__init__(*actions)
async def exec(self, *args):
return await self.parent.exec(*['docker', 'exec', self.name] + args)
This also means that you always need a parent with an exec implementation, there are two:
- Localhost, executes on localhost
- Stub, for testing
The result of that design is that the following use cases are available:
# This action installs my favorite package on any distro
action = Packages('python3')
# Run it right here: apt install python3
Localhost()(action)
# Or remotely: ssh yourhost apt install python3
Ssh(host='yourhost')(action)
# Let's make a container build receipe with that action
build = Buildah(package)
# Run it locally: buildah exec apt install python3
Localhost()(build)
# Or on a server: ssh yourhost build exec apt install python3
Ssh(host='yourhost')(build)
# Or on a server behingh a bastion:
# ssh yourbastion ssh yourhost build exec apt install python3
Localhost()(Ssh(host='bastion')(Ssh(host='yourhost')(build))
# That's going to do the same
Localhost(Ssh(
Ssh(
build,
host='yourhost'
),
host='bastion'
))()
CLI
You should build your CLI with your favorite CLI framework. Nonetheless, shlax provides a ConsoleScript built on cli2 (a personnal experiment, still pre-alpha stage) that will expose any callable you define in a script, for example:
#!/usr/bin/env shlax
from shlax.shortcuts import *
webpack = Container(
build=Buildah(
Packages('npm')
)
)
django = Container(
build=Buildah(
Packages('python')
)
)
pod = Pod(
django=django,
webpack=webpack,
)
Running this file will output: