282 lines
7.2 KiB
Markdown
282 lines
7.2 KiB
Markdown
# 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 made possible by target abstraction.
|
|
|
|
## Development status: Design state
|
|
|
|
I got the thing to work with an ugly PoC that I basically brute-forced, I'm
|
|
currently rewriting the codebase with a proper design.
|
|
|
|
The stories are in development in this order:
|
|
|
|
- replacing docker build, that's in the state of polishing
|
|
- replacing docker-compose, not in use but the PoC works so far
|
|
- replacing ansible, also working in working PoC state
|
|
|
|
This project is supposed to unblock me from adding the CI feature to the
|
|
Sentry/GitLab/Portainer implementation I'm doing in pure python on top of
|
|
Django, CRUDLFA+ and Ryzom (isomorphic components in Python to replace
|
|
templates). So, as you can see, I'm really deep in it with a strong
|
|
determination.
|
|
|
|
Shlax builds its container itself, so check the shlaxfile.py of this repository
|
|
to see what it currently looks like, and check the build job of the CI pipeline
|
|
to see the output.
|
|
|
|
# Design
|
|
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
async def one(target):
|
|
target.exec('one')
|
|
|
|
async def two(target):
|
|
target.exec('two')
|
|
```
|
|
|
|
You can do:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
# 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 can execute Shlax actions directly on the command line with the `shlax` CLI
|
|
command.
|
|
|
|
For your own Shlaxfiles, you can build your CLI with your favorite CLI
|
|
framework. If you decide to use `cli2`, then Shlax provides a thin layer on top
|
|
of it: Group and Command objects made for Shlax objects.
|
|
|
|
For example:
|
|
|
|
```python
|
|
yourcontainer = Container(
|
|
build=Buildah(
|
|
User('app', '/app', 1000),
|
|
Packages('python', 'unzip', 'findutils'),
|
|
Copy('setup.py', 'yourdir', '/app'),
|
|
base='archlinux',
|
|
commit='yourimage',
|
|
),
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(Group(doc=__doc__).load(yourcontainer).entry_point())
|
|
```
|
|
|
|
The above will execute a cli2 command with each method of yourcontainer as a
|
|
sub-command.
|