29 Commits
Author SHA1 Message Date
DrClaw ad069a1aa0 dnf stuff 2020-06-02 09:54:34 +00:00
jpic 363bdb1493 Fix CI command to build 2020-05-31 05:15:15 +02:00
jpic 1373196eb5 Buildah __str__ 2020-05-31 03:51:13 +02:00
jpic da7b7191c9 Missing await in test function 2020-05-31 03:44:57 +02:00
jpic 1660acbcbb Pass status directly to clean 2020-05-31 03:44:51 +02:00
jpic 9c3790e438 Improve Buildah clean method 2020-05-31 03:40:00 +02:00
jpic f5c7e0b1a1 Set the action result prior to calling clean 2020-05-31 03:39:42 +02:00
jpic 3039f75179 Pip action implementation 2020-05-31 03:39:26 +02:00
jpic 637d49e1ab Bugfix: legacy code would prevent containers from shuting down after build 2020-05-31 02:45:24 +02:00
jpic ea4be19a86 Proper cache invalidation 2020-05-31 02:44:57 +02:00
jpic befd01cb03 Proper traceback prints 2020-05-31 02:44:13 +02:00
jpic fdd0ff6532 Copy action: refactor, caching, filtering 2020-05-31 02:43:37 +02:00
jpic 074546bdda Work on the CLI story 2020-05-31 00:10:57 +02:00
jpic 6a6e474a1e Adding Copy/User/Pip actions again 2020-05-31 00:00:25 +02:00
jpic 3eb0f22ef9 Add CLI to execute Actions on the fly 2020-05-31 00:00:25 +02:00
jpic d68fdf8d5d Proper render method actions 2020-05-31 00:00:25 +02:00
jpic 2dc00dc2cd fixup! Add layer caching 2020-05-31 00:00:25 +02:00
jpic 02e9ac6683 Replace Localhost with plain Target, ensure parent presence 2020-05-31 00:00:25 +02:00
jpic 700d13876a Add layer caching 2020-05-31 00:00:25 +02:00
jpic a6f2c9fb07 Add Proc.quiet 2020-05-31 00:00:25 +02:00
jpic 407240e2a2 Add Package.upgrade option 2020-05-31 00:00:25 +02:00
852f8551af Core rewrite
See merge request oss/shlax!2
2020-04-22 03:41:16 +02:00
jpic 8ab2fbcccd Pip for python 3.8 2020-04-22 03:25:06 +02:00
jpic 00bc4a74ae Fix can't execute shlax: no such file or dir 2020-04-22 03:25:06 +02:00
jpic 29505d1004 No need for a shlax image to build the shlax image
It will always build on python with shlax[cli] installed because that's
part of the requirement: no extra dependency to use just the framework
besides current Python stable release
2020-04-22 03:25:06 +02:00
jpic 111f086bdd Fix dependencies 2020-04-22 03:25:06 +02:00
jpic 4601ead8f8 Enable gitlab-ci 2020-04-22 03:25:06 +02:00
jpic b279760374 Complete core rewrite, with documentation
Still missing documentation about Output core component

And actual Action/Targets etc ... in the process of migrating to the new
engine
2020-04-22 03:25:06 +02:00
87ac000e87 Merge branch 'jpic' into 'master'
yourlabs/shlax auto build

See merge request oss/shlax!1
2020-02-16 20:09:15 +01:00
43 changed files with 1198 additions and 1268 deletions
+13 -5
View File
@@ -2,14 +2,22 @@ build:
cache: cache:
key: cache key: cache
paths: [.cache] paths: [.cache]
image: yourlabs/shlax image: quay.io/buildah/stable
script: pip install -U --user -e . && CACHE_DIR=$(pwd)/.cache ./shlaxfile.py -d script:
shlax build push - dnf install -y python3-pip
- pip3 install -U --user -e .[cli]
- CACHE_DIR=$(pwd)/.cache python3 ./shlaxfile.py build
stage: build stage: build
test:
image: yourlabs/python
stage: build
script:
- pip install -U --user -e .[test]
- py.test -sv tests
pypi: pypi:
image: yourlabs/python image: yourlabs/python
only: [tags] only: [tags]
script: pypi-release script: pypi-release
stage: deploy stage: deploy
test: {image: yourlabs/python, script: 'pip install -U --user -e .[test] && py.test
-svv tests', stage: build}
+258
View File
@@ -0,0 +1,258 @@
# 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.
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.
+3 -4
View File
@@ -5,10 +5,9 @@ setup(
name='shlax', name='shlax',
versioning='dev', versioning='dev',
setup_requires='setupmeta', setup_requires='setupmeta',
install_requires=['cli2'],
extras_require=dict( extras_require=dict(
full=[ cli=[
'pyyaml', 'cli2>=2.2.2',
], ],
test=[ test=[
'pytest', 'pytest',
@@ -25,7 +24,7 @@ setup(
python_requires='>=3', python_requires='>=3',
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'shlax = shlax.cli:cli', 'shlax = shlax.cli:cli.entry_point',
], ],
}, },
) )
View File
+1 -234
View File
@@ -1,235 +1,2 @@
from copy import deepcopy
import functools
import inspect
import importlib
import sys
from ..output import Output
from ..exceptions import WrongResult
from ..result import Result
class class_or_instance_method:
def __init__(self, f):
self.f = f
def __get__(self, instance, owner):
def newfunc(*args, **kwargs):
return self.f(
instance if instance is not None else owner,
*args,
**kwargs
)
return newfunc
class Action: class Action:
display_variables = [] pass
hide_variables = ['output']
default_steps = ['apply']
parent = None
contextualize = []
regexps = {
r'([\w]+):': '{cyan}\\1{gray}:{reset}',
r'(^|\n)( *)\- ': '\\1\\2{red}-{reset} ',
}
options = dict(
debug=dict(
alias='d',
default='visit',
help='''
Display debug output. Supports values (combinable): cmd,out,visit
'''.strip(),
immediate=True,
),
)
def __init__(self, *args, **kwargs):
self.args = args
for key, value in kwargs.items():
setattr(self, key, value)
if isinstance(value, Action):
getattr(self, key).shlaxstep = True
def actions_filter(self, results, f=None, **filters):
if f:
def ff(a):
try:
return f(a)
except:
return False
results = [*filter(ff, results)]
for k, v in filters.items():
if k == 'type':
results = [*filter(
lambda s: type(s).__name__.lower() == str(v).lower(),
results
)]
else:
results = [*filter(
lambda s: getattr(s, k, None) == v,
results
)]
return results
def sibblings(self, f=None, **filters):
if not self.parent:
return []
return self.actions_filter(
[a for a in self.parent.actions if a is not self],
f, **filters
)
def parents(self, f=None, **filters):
if self.parent:
return self.actions_filter(
[self.parent] + self.parent.parents(),
f, **filters
)
return []
def children(self, f=None, **filters):
children = []
def add(parent):
if parent != self:
children.append(parent)
if 'actions' not in parent.__dict__:
return
for action in parent.actions:
add(action)
add(self)
return self.actions_filter(children, f, **filters)
async def __call__(self, *targets, **options):
if not targets:
from ..targets.localhost import Localhost
targets = [Localhost()]
output = Output(regexp=self.regexps, debug=True)
results = []
for target in targets:
target.output = output
if len(targets) > 1:
output.prefix = target
from copy import deepcopy
action = deepcopy(self)
action.target = target
result = Result(action, target)
results.append(result)
action.result = result
action.output = output
for step in options.get('steps', None) or self.default_steps:
if step not in action.steps():
print(f'Failed to find {type(action).__name__}.{step}')
continue
action.step = step
output.start(action)
try:
await getattr(action, step)()
except Exception as e:
output.fail(action, e)
action.result.status = 'fail'
proc = getattr(e, 'proc', None)
if proc:
result = proc.rc
else:
raise
else:
output.success(action)
result.status = 'success'
finally:
clean = getattr(action, 'clean', None)
if clean:
output.clean(action)
await clean(target)
return results
def __repr__(self):
return ' '.join([type(self).__name__] + [
f'{k}={v}'
for k, v in self.__dict__.items()
if (k in self.display_variables or not self.display_variables)
and (k not in self.hide_variables)
])
def colorized(self, colors):
return ' '.join([
colors['pink1']
+ type(self).__name__
+ '.'
+ self.step
+ colors['yellow']
] + [
f'{colors["blue"]}{k}{colors["gray"]}={colors["green2"]}{v}'
for k, v in self.__dict__.items()
if (k in self.display_variables or not self.display_variables)
and (k not in self.hide_variables)
] + [colors['reset']])
def callable(self):
from ..targets import Localhost
async def cb(*a, **k):
from shlax.cli import cli
script = Localhost(self, quiet=True)
result = await script(*a, **k)
success = functools.reduce(
lambda a, b: a + b,
[1 for c in script.children() if c.status == 'success'] or [0])
if success:
script.output.success(f'{success} PASS')
failures = functools.reduce(
lambda a, b: a + b,
[1 for c in script.children() if c.status == 'fail'] or [0])
if failures:
script.output.fail(f'{failures} FAIL')
cli.exit_code = failures
return result
return cb
def kwargs_output(self):
return self.kwargs
def action(self, action, *args, **kwargs):
if isinstance(action, str):
# import dotted module path string to action
import cli2
a = cli2.Callable.factory(action).target
if not a:
a = cli2.Callable.factory(
'.'.join(['shlax', action])
).target
if a:
action = a
p = action(*args, **kwargs)
p.parent = self
for parent in self.parents():
if hasattr(parent, 'actions'):
p.parent = parent
break
if 'actions' not in self.__dict__:
# "mutate" to Strategy
from ..strategies.script import Actions
self.actions = Actions(self, [p])
return p
@class_or_instance_method
def steps(self):
return {
key: getattr(self, key)
for key in dir(self)
if key != 'steps' # avoid recursion
and (
key in self.default_steps
or getattr(getattr(self, key), 'shlaxstep', False)
)
}
+54 -5
View File
@@ -1,7 +1,56 @@
from .base import Action import asyncio
import binascii
import os
class Copy(Action): class Copy:
"""Copy files or directories to target.""" def __init__(self, *args):
async def call(self, *args, **kwargs): self.src = args[:-1]
await self.copy(*self.args) self.dst = args[-1]
def listfiles(self):
if getattr(self, '_listfiles', None):
return self._listfiles
result = []
for src in self.src:
if os.path.isfile(src):
result.append(src)
continue
for root, dirs, files in os.walk(src):
if '__pycache__' in root:
continue
result += [
os.path.join(root, f)
for f in files
if not f.endswith('.pyc')
]
self._listfiles = result
return result
async def __call__(self, target):
await target.mkdir(self.dst)
for path in self.listfiles():
if os.path.isdir(path):
await target.mkdir(os.path.join(self.dst, path))
elif '/' in path:
dirname = os.path.join(
self.dst,
'/'.join(path.split('/')[:-1])
)
await target.mkdir(dirname)
await target.copy(path, dirname)
else:
await target.copy(path, self.dst)
def __str__(self):
return f'Copy({", ".join(self.src)}, {self.dst})'
async def cachekey(self):
async def chksum(path):
with open(path, 'rb') as f:
return (path, str(binascii.crc32(f.read())))
results = await asyncio.gather(*[chksum(f) for f in self.listfiles()])
return {path: chks for path, chks in results}
-37
View File
@@ -1,37 +0,0 @@
import hashlib
import secrets
import string
from .base import Action
class Htpasswd(Action):
"""Ensure a user is present in an htpasswd file."""
display_variables = ('user', 'path')
regexps = {
r'(.*)': '{red}\\1{gray}:${blue}\\2${blue}',
r'([^:]*):\\$([^$]*)\\$(.*)$': '{red}\\1{gray}:${blue}\\2${blue}\\3',
}
def __init__(self, user, path, **kwargs):
self.user = user
self.path = path
super().__init__(**kwargs)
async def apply(self):
found = False
htpasswd = await self.target.exec(
'cat', self.path, raises=False)
if htpasswd.rc == 0:
for line in htpasswd.out.split('\n'):
if line.startswith(self.user + ':'):
found = True
break
if not found:
self.password = ''.join(secrets.choice(
string.ascii_letters + string.digits
) for i in range(20))
hashed = hashlib.sha1(self.password.encode('utf8'))
line = f'{self.user}:\\$sha1\\${hashed.hexdigest()}'
await self.target.exec(f'echo {line} >> {self.path}')
+24 -28
View File
@@ -7,10 +7,8 @@ import os
import subprocess import subprocess
from textwrap import dedent from textwrap import dedent
from .base import Action
class Packages:
class Packages(Action):
""" """
Package manager abstract layer with caching. Package manager abstract layer with caching.
@@ -54,12 +52,12 @@ class Packages(Action):
installed = [] installed = []
def __init__(self, *packages, **kwargs): def __init__(self, *packages, upgrade=True):
self.packages = [] self.packages = []
self.upgrade = upgrade
for package in packages: for package in packages:
line = dedent(package).strip().replace('\n', ' ') line = dedent(package).strip().replace('\n', ' ')
self.packages += line.split(' ') self.packages += line.split(' ')
super().__init__(*packages, **kwargs)
@property @property
def cache_root(self): def cache_root(self):
@@ -68,9 +66,9 @@ class Packages(Action):
else: else:
return os.path.join(os.getenv('HOME'), '.cache') return os.path.join(os.getenv('HOME'), '.cache')
async def update(self): async def update(self, target):
# run pkgmgr_setup functions ie. apk_setup # run pkgmgr_setup functions ie. apk_setup
cachedir = await getattr(self, self.mgr + '_setup')() cachedir = await getattr(self, self.mgr + '_setup')(target)
lastupdate = None lastupdate = None
if os.path.exists(cachedir + '/lastupdate'): if os.path.exists(cachedir + '/lastupdate'):
@@ -94,7 +92,7 @@ class Packages(Action):
f.write(str(os.getpid())) f.write(str(os.getpid()))
try: try:
await self.rexec(self.cmds['update']) await target.rexec(self.cmds['update'])
finally: finally:
os.unlink(lockfile) os.unlink(lockfile)
@@ -102,15 +100,15 @@ class Packages(Action):
f.write(str(now)) f.write(str(now))
else: else:
while os.path.exists(lockfile): while os.path.exists(lockfile):
print(f'{self.container.name} | Waiting for update ...') print(f'{self.target} | Waiting for {lockfile} ...')
await asyncio.sleep(1) await asyncio.sleep(1)
async def apply(self): async def __call__(self, target):
cached = getattr(self.target, 'pkgmgr', None) cached = getattr(target, 'pkgmgr', None)
if cached: if cached:
self.mgr = cached self.mgr = cached
else: else:
mgr = await self.target.which(*self.mgrs.keys()) mgr = await target.which(*self.mgrs.keys())
if mgr: if mgr:
self.mgr = mgr[0].split('/')[-1] self.mgr = mgr[0].split('/')[-1]
@@ -118,11 +116,9 @@ class Packages(Action):
raise Exception('Packages does not yet support this distro') raise Exception('Packages does not yet support this distro')
self.cmds = self.mgrs[self.mgr] self.cmds = self.mgrs[self.mgr]
if not getattr(self, '_packages_upgraded', None): await self.update(target)
await self.update() if self.upgrade:
if self.kwargs.get('upgrade', True): await target.rexec(self.cmds['upgrade'])
await self.target.exec(self.cmds['upgrade'], user='root')
self._packages_upgraded = True
packages = [] packages = []
for package in self.packages: for package in self.packages:
@@ -135,22 +131,22 @@ class Packages(Action):
else: else:
packages.append(package) packages.append(package)
await self.target.exec(*self.cmds['install'].split(' ') + packages, user='root') await target.rexec(*self.cmds['install'].split(' ') + packages)
async def apk_setup(self): async def apk_setup(self, target):
cachedir = os.path.join(self.cache_root, self.mgr) cachedir = os.path.join(self.cache_root, self.mgr)
await self.mount(cachedir, '/var/cache/apk') await target.mount(cachedir, '/var/cache/apk')
# special step to enable apk cache # special step to enable apk cache
await self.rexec('ln -sf /var/cache/apk /etc/apk/cache') await target.rexec('ln -sf /var/cache/apk /etc/apk/cache')
return cachedir return cachedir
async def dnf_setup(self): async def dnf_setup(self, target):
cachedir = os.path.join(self.cache_root, self.mgr) cachedir = os.path.join(self.cache_root, self.mgr)
await self.mount(cachedir, f'/var/cache/{self.mgr}') await target.mount(cachedir, f'/var/cache/{self.mgr}')
await self.rexec('echo keepcache=True >> /etc/dnf/dnf.conf') await target.rexec('echo keepcache=True >> /etc/dnf/dnf.conf')
return cachedir return cachedir
async def apt_setup(self): async def apt_setup(self, target):
codename = (await self.rexec( codename = (await self.rexec(
f'source {self.mnt}/etc/os-release; echo $VERSION_CODENAME' f'source {self.mnt}/etc/os-release; echo $VERSION_CODENAME'
)).out )).out
@@ -162,8 +158,8 @@ class Packages(Action):
await self.mount(cache_lists, f'/var/lib/apt/lists') await self.mount(cache_lists, f'/var/lib/apt/lists')
return cachedir return cachedir
async def pacman_setup(self): async def pacman_setup(self, target):
return self.cache_root + '/pacman' return self.cache_root + '/pacman'
def __repr__(self): def __str__(self):
return f'Packages({self.packages})' return f'Packages({self.packages}, upgrade={self.upgrade})'
+11
View File
@@ -0,0 +1,11 @@
import asyncio
class Parallel:
def __init__(self, *actions):
self.actions = actions
async def __call__(self, target):
return await asyncio.gather(*[
target(action) for action in self.actions
])
+46 -36
View File
@@ -1,59 +1,69 @@
from glob import glob from glob import glob
import os import os
from urllib import request
from .base import Action from .base import Action
class Pip(Action): class Pip(Action):
"""Pip abstraction layer.""" """Pip abstraction layer."""
def __init__(self, *pip_packages):
self.pip_packages = pip_packages
def __init__(self, *pip_packages, pip=None, requirements=None): async def __call__(self, target):
self.requirements = requirements # ensure python presence
super().__init__(*pip_packages, pip=pip, requirements=requirements) results = await target.which('python3', 'python')
if results:
async def call(self, *args, **kwargs): python = results[0]
pip = self.kwargs.get('pip', None)
if not pip:
pip = await self.which('pip3', 'pip', 'pip2')
if pip:
pip = pip[0]
else: else:
from .packages import Packages raise Exception('Could not find pip nor python')
action = self.action(
Packages, # ensure pip module presence
'python3,apk', 'python3-pip,apt', result = await target.exec(python, '-m', 'pip', raises=False)
args=args, kwargs=kwargs if result.rc != 0:
if not os.path.exists('get-pip.py'):
req = request.urlopen(
'https://bootstrap.pypa.io/get-pip.py'
) )
await action(*args, **kwargs) content = req.read()
pip = await self.which('pip3', 'pip', 'pip2') with open('get-pip.py', 'wb+') as f:
if not pip: f.write(content)
raise Exception('Could not install a pip command')
else:
pip = pip[0]
await target.copy('get-pip.py', '.')
await target.exec(python, 'get-pip.py')
# choose a cache directory
if 'CACHE_DIR' in os.environ: if 'CACHE_DIR' in os.environ:
cache = os.path.join(os.getenv('CACHE_DIR'), 'pip') cache = os.path.join(os.getenv('CACHE_DIR'), 'pip')
else: else:
cache = os.path.join(os.getenv('HOME'), '.cache', 'pip') cache = os.path.join(os.getenv('HOME'), '.cache', 'pip')
if getattr(self, 'mount', None): # and mount it
if getattr(target, 'mount', None):
# we are in a target which shares a mount command # we are in a target which shares a mount command
await self.mount(cache, '/root/.cache/pip') await target.mount(cache, '/root/.cache/pip')
await self.exec(f'{pip} install --upgrade pip')
# https://github.com/pypa/pip/issues/5599 source = []
if 'pip' not in self.kwargs: nonsource = []
pip = 'python3 -m pip' for package in self.pip_packages:
if os.path.exists(package):
source.append(package)
else:
nonsource.append(package)
source = [p for p in self.args if p.startswith('/') or p.startswith('.')] if nonsource:
if source: await target.exec(
await self.exec( python, '-m', 'pip',
f'{pip} install --upgrade --editable {" ".join(source)}' 'install', '--upgrade',
*nonsource
) )
nonsource = [p for p in self.args if not p.startswith('/')] if source:
if nonsource: await target.exec(
await self.exec(f'{pip} install --upgrade {" ".join(nonsource)}') python, '-m', 'pip',
'install', '--upgrade', '--editable',
*source
)
if self.requirements: def __str__(self):
await self.exec(f'{pip} install --upgrade -r {self.requirements}') return f'Pip({", ".join(self.pip_packages)})'
+8 -15
View File
@@ -1,18 +1,11 @@
from .base import Action
class Run(Action): class Run:
"""Run a script or command on a target.""" def __init__(self, cmd):
async def call(self, *args, **kwargs): self.cmd = cmd
image = self.kwargs.get('image', None)
if not image:
return await self.exec(*self.args, **self.kwargs)
from ..targets.buildah import Buildah
from ..targets.docker import Docker
if isinstance(image, Buildah):
breakpoint()
result = await self.action(image, *args, **kwargs)
return await Docker( async def __call__(self, target):
image=image, self.proc = await target.exec(self.cmd)
).exec(*args, **kwargs)
def __str__(self):
return f'Run({self.cmd})'
-19
View File
@@ -1,19 +0,0 @@
import asyncio
from .base import Action
class Service(Action):
"""
Manage a systemd service.
"""
def __init__(self, *names, state=None):
self.state = state or 'started'
self.names = names
super().__init__()
async def call(self, *args, **kwargs):
return asyncio.gather(*[
self.exec('systemctl', 'start', name, user='root')
for name in self.names
])
+42
View File
@@ -0,0 +1,42 @@
import os
import re
from .packages import Packages
class User:
"""
Create a user.
Example:
User('app', '/app', getenv('_CONTAINERS_ROOTLESS_UID', 1000)),
_CONTAINERS_ROOTLESS_UID allows to get your UID during build, which happens
in buildah unshare.
"""
def __init__(self, username, home, uid):
self.username = username
self.home = home
self.uid = uid
def __str__(self):
return f'User({self.username}, {self.home}, {self.uid})'
async def __call__(self, target):
result = await target.rexec('id', self.uid)
if result.rc == 0:
old = re.match('.*\(([^)]*)\).*', result.out).group(1)
await target.rexec(
'usermod',
'-d', self.home,
'-l', self.username,
old
)
else:
await target.rexec(
'useradd',
'-d', self.home,
'-u', self.uid,
self.username
)
+56 -146
View File
@@ -1,170 +1,80 @@
""" """
Shlax automation tool manual
Shlax is built mostly around 3 moving pieces:
- Target: a target host and protocol
- Action: execute a shlax action
- Strategy: defines how to apply actions on targets (scripted only)
Shlax executes mostly in 3 ways: Shlax executes mostly in 3 ways:
- Execute actions on targets with the command line - Execute actions on targets with the command line
- With your shlaxfile as first argument: offer defined Actions - With your shlaxfile as first argument: offer defined Actions
- With the name of a module in shlax.repo: a community maintained shlaxfile - With the name of a module in shlax.repo: a community maintained shlaxfile
""" """
import ast
import copy import asyncio
import cli2 import cli2
import glob
import inspect import inspect
import importlib import importlib
import glob
import os import os
import sys import sys
from .actions.base import Action
from .exceptions import ShlaxException, WrongResult
from .strategies import Script
class Group(cli2.Group):
class ConsoleScript(cli2.ConsoleScript):
class Parser(cli2.Parser):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.targets = dict()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.cmdclass = Command
def append(self, arg):
if '=' not in arg and '@' in arg:
if '://' in arg:
kind, spec = arg.split('://')
else:
kind = 'ssh'
spec = arg
mod = importlib.import_module('shlax.targets.' + kind) class Command(cli2.Command):
target = getattr(mod, kind.capitalize())(spec) def call(self, *args, **kwargs):
self.targets[str(target)] = target return self.shlax_target(self.target)
else:
super().append(arg)
def __call__(self): def __call__(self, *argv):
if len(sys.argv) > 1 and os.path.exists(sys.argv[1]): from shlax.targets.base import Target
pass self.shlax_target = Target()
else: result = super().__call__(*argv)
scripts = glob.glob(os.path.join( self.shlax_target.output.results(self.shlax_target)
os.path.dirname(__file__), 'actions', '*.py')) return result
for script in scripts:
modname = script.split('/')[-1].replace('.py', '')
mod = importlib.import_module('shlax.actions.' + modname) class ActionCommand(Command):
for key, value in mod.__dict__.items(): def call(self, *args, **kwargs):
if key.lower() != modname: self.target = self.target(*args, **kwargs)
return super().call(*args, **kwargs)
class ConsoleScript(Group):
def __call__(self, *argv):
self.load_actions()
#self.load_shlaxfiles() # wip
return super().__call__(*argv)
def load_shlaxfiles(self):
filesdir = os.path.dirname(__file__) + '/shlaxfiles/'
for filename in os.listdir(filesdir):
filepath = filesdir + filename
if not os.path.isfile(filepath):
continue continue
break
self[modname] = cli2.Callable(
modname, self.action_class(value))
scripts = glob.glob(os.path.join( with open(filepath, 'r') as f:
os.path.dirname(__file__), 'repo', '*.py')) tree = ast.parse(f.read())
for script in scripts: group = self.group(filename[:-3])
modname = script.split('/')[-1].replace('.py', '')
mod = importlib.import_module('shlax.repo.' + modname) main = Group(doc=__doc__).load(shlax)
self[modname] = cli2.Group(key, doc=inspect.getdoc(mod))
for key, value in mod.__dict__.items(): def load_actions(self):
if not isinstance(value, Action): actionsdir = os.path.dirname(__file__) + '/actions/'
for filename in os.listdir(actionsdir):
filepath = actionsdir + filename
if not os.path.isfile(filepath):
continue continue
doc = (inspect.getdoc(mod) or '').split("\n")[0] with open(filepath, 'r') as f:
if key == 'main': tree = ast.parse(f.read())
if len(value.steps()) == 1: cls = [
self[modname] = cli2.Callable( node
modname, self.action(value), doc=doc) for node in tree.body
else: if isinstance(node, ast.ClassDef)
for name, method in value.steps().items(): ]
self[modname][name] = cli2.Callable( if not cls:
modname, self.action(value),
doc=inspect.getdoc(method)
)
else:
if len(value.steps()) == 1:
self[modname][key] = cli2.Callable(
modname, self.action(value), doc=doc)
else:
self[modname][key] = cli2.Group('steps')
for step in value.steps():
self[modname][key][step] = cli2.Callable(
modname, self.action(value), doc='lol')
return super().__call__()
def action(self, action):
async def cb(*args, **kwargs):
options = dict(steps=args)
options.update(self.parser.options)
# UnboundLocalError: local variable 'action' referenced before assignment
# ??? gotta be missing something, commenting meanwhile
# action = copy.deepcopy(action)
return await action(*self.parser.targets, **options)
return cb
def action_class(self, action_class):
async def cb(*args, **kwargs):
argspec = inspect.getfullargspec(action_class)
required = argspec.args[1:]
missing = []
for i, name in enumerate(required):
if len(args) - 1 <= i:
continue continue
if name in kwargs: mod = importlib.import_module('shlax.actions.' + filename[:-3])
continue cls = getattr(mod, cls[0].name)
missing.append(name) self.add(cls, name=filename[:-3], cmdclass=ActionCommand)
if missing:
if not args:
print('No args provided after action name ' + action_class.__name__.lower())
print('Required arguments: ' + ', '.join(argspec.args[1:]))
if args:
print('Provided: ' + ', '.join(args))
print('Missing arguments: ' + ', '.join(missing))
print('Try to just add args on the command line separated with a space')
print(inspect.getdoc(action_class))
example = 'Example: shlax action '
example += action_class.__name__.lower()
if args:
example += ' ' + ' '.join(args)
example += ' ' + ' '.join(missing)
print(example)
return
_args = []
steps = []
for arg in args:
if arg in action_class.steps():
steps.append(arg)
else:
_args.append(arg)
options = dict(steps=steps) cli = ConsoleScript(doc=__doc__)
'''
varargs = argspec.varargs
if varargs:
extra = args[len(argspec.args) - 1:]
args = args[:len(argspec.args) - 1]
options = dict(steps=extra)
else:
extra = args[len(argspec.args) - 1:]
args = args[:len(argspec.args) - 1]
options = dict(steps=extra)
'''
options.update(self.parser.options)
return await action_class(*_args, **kwargs)(*self.parser.targets, **options)
cb.__doc__ = (inspect.getdoc(action_class) or '').split("\n")[0]
return cb
def call(self, command):
try:
return super().call(command)
except WrongResult as e:
print(e)
self.exit_code = e.proc.rc
except ShlaxException as e:
print(e)
self.exit_code = 1
cli = ConsoleScript(__doc__).add_module('shlax.cli')
+32
View File
@@ -0,0 +1,32 @@
import os
from .image import Image
class Container:
def __init__(self, build=None, image=None):
self.build = build
self.image = self.build.image
prefix = os.getcwd().split('/')[-1]
repo = self.image.repository.replace('/', '-')
if prefix == repo:
self.name = repo
else:
self.name = '-'.join([prefix, repo])
async def start(self, target):
"""Start the container"""
await target.rexec(
'podman',
'run',
'--name',
self.name,
str(self.image),
)
async def stop(self, target):
"""Start the container"""
await target.rexec('podman', 'stop', self.name)
def __str__(self):
return f'Container(name={self.name}, image={self.image})'
-22
View File
@@ -1,22 +0,0 @@
class ShlaxException(Exception):
pass
class Mistake(ShlaxException):
pass
class WrongResult(ShlaxException):
def __init__(self, proc):
self.proc = proc
msg = f'FAIL exit with {proc.rc} ' + proc.args[0]
if not proc.debug or 'cmd' not in str(proc.debug):
msg += '\n' + proc.cmd
if not proc.debug or 'out' not in str(proc.debug):
msg += '\n' + proc.out
msg += '\n' + proc.err
super().__init__(msg)
+7 -22
View File
@@ -1,19 +1,9 @@
import copy
import os import os
import re import re
class Image:
ENV_TAGS = (
# gitlab
'CI_COMMIT_SHORT_SHA',
'CI_COMMIT_REF_NAME',
'CI_COMMIT_TAG',
# CircleCI
'CIRCLE_SHA1',
'CIRCLE_TAG',
'CIRCLE_BRANCH',
# contributions welcome here
)
class Image:
PATTERN = re.compile( PATTERN = re.compile(
'^((?P<backend>[a-z]*)://)?((?P<registry>[^/]*[.][^/]*)/)?((?P<repository>[^:]+))?(:(?P<tags>.*))?$' # noqa '^((?P<backend>[a-z]*)://)?((?P<registry>[^/]*[.][^/]*)/)?((?P<repository>[^:]+))?(:(?P<tags>.*))?$' # noqa
, re.I , re.I
@@ -43,12 +33,6 @@ class Image:
if self.registry == 'docker.io': if self.registry == 'docker.io':
self.format = 'docker' self.format = 'docker'
# figure tags from CI vars
for name in self.ENV_TAGS:
value = os.getenv(name)
if value:
self.tags.append(value)
# filter out tags which resolved to None # filter out tags which resolved to None
self.tags = [t for t in self.tags if t] self.tags = [t for t in self.tags if t]
@@ -56,10 +40,6 @@ class Image:
if not self.tags: if not self.tags:
self.tags = ['latest'] self.tags = ['latest']
async def __call__(self, action, *args, **kwargs):
args = list(args)
return await action.exec(*args, **self.kwargs)
def __str__(self): def __str__(self):
return f'{self.repository}:{self.tags[-1]}' return f'{self.repository}:{self.tags[-1]}'
@@ -73,3 +53,8 @@ class Image:
for tag in self.tags: for tag in self.tags:
await action.exec('buildah', 'push', f'{self.repository}:{tag}') await action.exec('buildah', 'push', f'{self.repository}:{tag}')
def layer(self, key):
layer = copy.deepcopy(self)
layer.tags = ['layer-' + key]
return layer
+70 -8
View File
@@ -1,5 +1,6 @@
import re import re
import sys import sys
import types
from .colors import colors from .colors import colors
@@ -25,7 +26,23 @@ class Output:
def colorize(self, code, content): def colorize(self, code, content):
return self.color(code) + content + self.color() return self.color(code) + content + self.color()
def __init__(self, prefix=None, regexps=None, debug=False, write=None, flush=None, **kwargs): def colorized(self, action):
if hasattr(action, 'colorized'):
return action.colorized(self.colors)
elif isinstance(action, types.MethodType):
return f'{action.__self__}.{action.__name__}'
else:
return str(action)
def __init__(
self,
prefix=None,
regexps=None,
debug='cmd,visit,out',
write=None,
flush=None,
**kwargs
):
self.prefix = prefix self.prefix = prefix
self.debug = debug self.debug = debug
self.prefix_length = 0 self.prefix_length = 0
@@ -99,22 +116,21 @@ class Output:
return line return line
def test(self, action): def test(self, action):
if self.debug is True:
self(''.join([ self(''.join([
self.colors['purplebold'], self.colors['purplebold'],
'! TEST ', '! TEST ',
self.colors['reset'], self.colors['reset'],
action.colorized(self.colors), self.colorized(action),
'\n', '\n',
])) ]))
def clean(self, action): def clean(self, action):
if self.debug is True: if self.debug:
self(''.join([ self(''.join([
self.colors['bluebold'], self.colors['bluebold'],
'+ CLEAN ', '+ CLEAN ',
self.colors['reset'], self.colors['reset'],
action.colorized(self.colors), self.colorized(action),
'\n', '\n',
])) ]))
@@ -124,7 +140,27 @@ class Output:
self.colors['orangebold'], self.colors['orangebold'],
'⚠ START ', '⚠ START ',
self.colors['reset'], self.colors['reset'],
action.colorized(self.colors), self.colorized(action),
'\n',
]))
def info(self, text):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['cyanbold'],
'➤ INFO ',
self.colors['reset'],
text,
'\n',
]))
def skip(self, action):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['yellowbold'],
'↪️ SKIP ',
self.colors['reset'],
self.colorized(action),
'\n', '\n',
])) ]))
@@ -134,7 +170,7 @@ class Output:
self.colors['greenbold'], self.colors['greenbold'],
'✔ SUCCESS ', '✔ SUCCESS ',
self.colors['reset'], self.colors['reset'],
action.colorized(self.colors) if hasattr(action, 'colorized') else str(action), self.colorized(action),
'\n', '\n',
])) ]))
@@ -144,6 +180,32 @@ class Output:
self.colors['redbold'], self.colors['redbold'],
'✘ FAIL ', '✘ FAIL ',
self.colors['reset'], self.colors['reset'],
action.colorized(self.colors) if hasattr(action, 'colorized') else str(action), self.colorized(action),
'\n',
]))
def results(self, action):
success = 0
fail = 0
for result in action.results:
if result.status == 'success':
success += 1
if result.status == 'failure':
fail += 1
self(''.join([
self.colors['greenbold'],
'✔ SUCCESS REPORT: ',
self.colors['reset'],
str(success),
'\n',
]))
if fail:
self(''.join([
self.colors['redbold'],
'✘ FAIL REPORT: ',
self.colors['reset'],
str(fail),
'\n', '\n',
])) ]))
-9
View File
@@ -1,9 +0,0 @@
from .targets import Localhost
class Play:
def __init__(self, *actions, targets=None, options=None):
self.options = options or {}
self.targets = targets or dict(localhost=Localhost())
self.actions =
+26
View File
@@ -0,0 +1,26 @@
import cli2
from shlax.targets.base import Target
from shlax.actions.parallel import Parallel
class Pod:
"""Help text"""
def __init__(self, **containers):
self.containers = containers
async def _call(self, target, method, *names):
methods = [
getattr(container, method)
for name, container in self.containers.items()
if not names or name in names
]
await target(Parallel(*methods))
async def build(self, target, *names):
"""Build container images"""
await self._call(target, 'build', *names)
async def start(self, target, *names):
"""Start container images"""
await self._call(target, 'start', *names)
+29 -12
View File
@@ -7,31 +7,46 @@ import os
import shlex import shlex
import sys import sys
from .exceptions import WrongResult
from .output import Output from .output import Output
class ProcFailure(Exception):
def __init__(self, proc):
self.proc = proc
msg = f'FAIL exit with {proc.rc} ' + proc.args[0]
if not proc.output.debug or 'cmd' not in str(proc.output.debug):
msg += '\n' + proc.cmd
if not proc.output.debug or 'out' not in str(proc.output.debug):
msg += '\n' + proc.out
msg += '\n' + proc.err
super().__init__(msg)
class PrefixStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol): class PrefixStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol):
""" """
Internal subprocess stream protocol to add a prefix in front of output to Internal subprocess stream protocol to add a prefix in front of output to
make asynchronous output readable. make asynchronous output readable.
""" """
def __init__(self, output, *args, **kwargs): def __init__(self, proc, *args, **kwargs):
self.output = output self.proc = proc
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def pipe_data_received(self, fd, data): def pipe_data_received(self, fd, data):
if self.output.debug is True or 'out' in str(self.output.debug): if self.proc.output.debug is True or 'out' in str(self.proc.output.debug):
if fd in (1, 2): if fd in (1, 2):
self.output(data) self.proc.output(data)
super().pipe_data_received(fd, data) super().pipe_data_received(fd, data)
def protocol_factory(output): def protocol_factory(proc):
def _p(): def _p():
return PrefixStreamProtocol( return PrefixStreamProtocol(
output, proc,
limit=asyncio.streams._DEFAULT_LIMIT, limit=asyncio.streams._DEFAULT_LIMIT,
loop=asyncio.events.get_event_loop() loop=asyncio.events.get_event_loop()
) )
@@ -54,8 +69,10 @@ class Proc:
""" """
test = False test = False
def __init__(self, *args, prefix=None, raises=True, debug=None, output=None): def __init__(self, *args, prefix=None, raises=True, output=None, quiet=False):
self.debug = debug if not self.test else False if quiet:
self.output = Output(debug=False)
else:
self.output = output or Output() self.output = output or Output()
self.cmd = ' '.join(args) self.cmd = ' '.join(args)
self.args = args self.args = args
@@ -87,7 +104,7 @@ class Proc:
if self.called: if self.called:
raise Exception('Already called: ' + self.cmd) raise Exception('Already called: ' + self.cmd)
if self.debug is True or 'cmd' in str(self.debug): if 'cmd' in str(self.output.debug):
self.output.cmd(self.cmd) self.output.cmd(self.cmd)
if self.test: if self.test:
@@ -98,7 +115,7 @@ class Proc:
loop = asyncio.events.get_event_loop() loop = asyncio.events.get_event_loop()
transport, protocol = await loop.subprocess_exec( transport, protocol = await loop.subprocess_exec(
protocol_factory(self.output), *self.args) protocol_factory(self), *self.args)
self.proc = asyncio.subprocess.Process(transport, protocol, loop) self.proc = asyncio.subprocess.Process(transport, protocol, loop)
self.called = True self.called = True
@@ -123,7 +140,7 @@ class Proc:
if not self.communicated: if not self.communicated:
await self.communicate() await self.communicate()
if self.raises and self.proc.returncode: if self.raises and self.proc.returncode:
raise WrongResult(self) raise ProcFailure(self)
return self return self
@property @property
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env shlax
"""
Manage a traefik container maintained by Shlax community.
"""
from shlax.shortcuts import *
main = Docker(
name='traefik',
image='traefik:v2.0.0',
install=Htpasswd(
'./htpasswd', 'root', doc='Install root user in ./htpasswd'
),
networks=['web'],
command=[
'--entrypoints.web.address=:80',
'--providers.docker',
'--api',
],
ports=[
'80:80',
'443:443',
],
volumes=[
'/var/run/docker.sock:/var/run/docker.sock:ro',
'/etc/traefik/acme/:/etc/traefik/acme/',
'/etc/traefik/htpasswd:/htpasswd:ro',
],
labels=[
'traefik.http.routers.traefik.rule=Host(`{{ url.split("/")[2] }}`)',
'traefik.http.routers.traefik.service=api@internal',
'traefik.http.routers.traefik.entrypoints=web',
],
)
+10 -4
View File
@@ -1,7 +1,13 @@
class Result: class Result:
def __init__(self, action, target): def __init__(self, target, action):
self.action = action
self.target = target self.target = target
self.action = action
self.status = 'pending' self.status = 'pending'
self.exception = None
class Results(list):
def new(self, target, action):
result = Result(target, action)
self.append(result)
return result
-28
View File
@@ -1,28 +0,0 @@
import importlib
import os
from .actions.base import Action
class Shlaxfile:
def __init__(self, actions=None, tests=None):
self.actions = actions or {}
self.tests = tests or {}
self.paths = []
def parse(self, path):
spec = importlib.util.spec_from_file_location('shlaxfile', path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
for name, value in mod.__dict__.items():
if isinstance(value, Action):
value.__name__ = name
self.actions[name] = value
elif callable(value) and getattr(value, '__name__', '').startswith('test_'):
self.tests[value.__name__] = value
self.paths.append(path)
@property
def path(self):
return self.paths[0]
+14 -10
View File
@@ -1,14 +1,18 @@
from .actions.copy import Copy from .targets.base import Target
from .actions.packages import Packages # noqa
from .actions.base import Action # noqa
from .actions.htpasswd import Htpasswd
from .actions.run import Run # noqa
from .actions.pip import Pip
from .actions.service import Service
from .targets.buildah import Buildah from .targets.buildah import Buildah
from .targets.docker import Docker
from .targets.localhost import Localhost from .targets.localhost import Localhost
from .targets.ssh import Ssh from .targets.stub import Stub
from .actions.copy import Copy
from .actions.packages import Packages
from .actions.run import Run
from .actions.pip import Pip
from .actions.parallel import Parallel
from .actions.user import User
from .cli import Command, Group
from .container import Container
from .pod import Pod
from os import getenv, environ
-3
View File
@@ -1,3 +0,0 @@
from .asyn import Async
from .script import Script
from .pod import Pod, Container
-11
View File
@@ -1,11 +0,0 @@
import asyncio
from .script import Script
class Async(Script):
async def call(self, *args, **kwargs):
return await asyncio.gather(*[
action(*args, **kwargs)
for action in self.actions
])
-44
View File
@@ -1,44 +0,0 @@
import os
from .script import Script
from ..image import Image
class Container(Script):
"""
Wolcome to crazy container control cli
Such wow
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('start', dict())
super().__init__(*args, **kwargs)
async def call(self, *args, **kwargs):
if step('build'):
await self.kwargs['build'](**kwargs)
self.image = self.kwargs['build'].image
else:
self.image = kwargs.get('image', 'alpine')
if isinstance(self.image, str):
self.image = Image(self.image)
if step('install'):
await self.install(*args, **kwargs)
if step('test'):
self.output.test(self)
await self.action('Docker',
*self.kwargs['test'].actions,
image=self.image,
mount={'.': '/app'},
workdir='/app',
)(**kwargs)
if step('push'):
await self.image.push(action=self)
#name = kwargs.get('name', os.getcwd()).split('/')[-1]
class Pod(Script):
pass
-41
View File
@@ -1,41 +0,0 @@
import copy
import os
from ..exceptions import WrongResult
from ..actions.base import Action
from ..proc import Proc
class Actions(list):
def __init__(self, owner, actions):
self.owner = owner
super().__init__()
for action in actions:
self.append(action)
def append(self, value):
action = copy.deepcopy(value)
action.parent = self.owner
action.status = 'pending'
super().append(action)
class Script(Action):
contextualize = ['shargs', 'exec', 'rexec', 'env', 'which', 'copy']
def __init__(self, *actions, **kwargs):
self.home = kwargs.pop('home', os.getcwd())
super().__init__(**kwargs)
self.actions = Actions(self, actions)
async def call(self, *args, **kwargs):
for action in self.actions:
result = await action(*args, **kwargs)
if action.status != 'success':
break
def pollute(self, gbls):
for name, script in self.kwargs.items():
if not isinstance(script, Script):
continue
gbls[name] = script
-7
View File
@@ -1,7 +0,0 @@
from .script import Script
class Test(Script):
async def call(self, *args, backend=None, **kwargs):
backend = backend or 'Docker'
breakpoint()
return await self.action(backend, self.actions, **kwargs)
View File
+146
View File
@@ -0,0 +1,146 @@
import asyncio
import copy
from pathlib import Path
import os
import re
import sys
from ..output import Output
from ..proc import Proc
from ..result import Result, Results
class Target:
def __init__(self, *actions, root=None):
self.actions = actions
self.results = []
self.output = Output()
self.parent = None
self.root = root or os.getcwd()
@property
def parent(self):
return self._parent or Target()
@parent.setter
def parent(self, value):
self._parent = value
@property
def caller(self):
"""Traverse parents and return the top-levels Target."""
if not self._parent:
return self
caller = self._parent
while caller._parent:
caller = caller._parent
return caller
async def __call__(self, *actions, target=None):
if target:
# that's going to be used by other target methods, to access
# the calling target
self.parent = target
for action in actions or self.actions:
if await self.action(action, reraise=bool(actions)):
break
async def action(self, action, reraise=False):
result = Result(self, action)
self.output.start(action)
try:
await action(target=self)
except Exception as e:
self.output.fail(action, e)
result.status = 'failure'
result.exception = e
if reraise:
# nested call, re-raise
raise
else:
import traceback
traceback.print_exception(type(e), e, sys.exc_info()[2])
return True
else:
self.output.success(action)
result.status = 'success'
finally:
self.caller.results.append(result)
clean = getattr(action, 'clean', None)
if clean:
self.output.clean(action)
await clean(self, result)
async def rexec(self, *args, **kwargs):
kwargs['user'] = 'root'
return await self.exec(*args, **kwargs)
async def which(self, *cmd):
"""
Return the first path to the cmd in the container.
If cmd argument is a list then it will try all commands.
"""
proc = await self.exec('type ' + ' '.join(cmd), raises=False)
result = []
for res in proc.out.split('\n'):
match = re.match('([^ ]+) is ([^ ]+)$', res.strip())
if match:
result.append(match.group(1))
return result
def shargs(self, *args, **kwargs):
user = kwargs.pop('user', None)
args = [str(arg) for arg in args if args is not None]
if args and ' ' in args[0]:
if len(args) == 1:
args = ['sh', '-euc', args[0]]
else:
args = ['sh', '-euc'] + list(args)
if user == 'root':
args = ['sudo'] + args
elif user:
args = ['sudo', '-u', user] + args
return args, kwargs
if self.parent:
return self.parent.shargs(*args, **kwargs)
else:
return args, kwargs
async def exec(self, *args, **kwargs):
kwargs['output'] = self.output
args, kwargs = self.shargs(*args, **kwargs)
proc = await Proc(*args, **kwargs)()
if kwargs.get('wait', True):
await proc.wait()
return proc
@property
def root(self):
return self._root
@root.setter
def root(self, value):
self._root = Path(value or os.getcwd())
def path(self, path):
if str(path).startswith('/'):
path = str(path)[1:]
return self.root / path
async def mkdir(self, path):
if '_mkdir' not in self.__dict__:
self._mkdir = []
path = str(path)
if path not in self._mkdir:
await self.exec('mkdir', '-p', path)
self._mkdir.append(path)
async def copy(self, *args):
return await self.exec('cp', '-a', *args)
+191 -123
View File
@@ -1,156 +1,224 @@
import asyncio import asyncio
import copy
import hashlib
import json
import os import os
import asyncio
from pathlib import Path
import signal
import shlex
import subprocess
import sys import sys
import textwrap from pathlib import Path
from .base import Target
from ..actions.base import Action
from ..exceptions import Mistake
from ..proc import Proc
from ..image import Image from ..image import Image
from .localhost import Localhost from ..proc import Proc
class Buildah(Localhost): class Buildah(Target):
""" """Build container image with buildah"""
The build script iterates over visitors and runs the build functions, it
also provides wrappers around the buildah command.
"""
contextualize = Localhost.contextualize + ['mnt', 'ctr', 'mount', 'image']
def __init__(self, base, *args, commit=None, push=False, cmd=None, **kwargs): def __init__(self,
if isinstance(base, Action): *actions,
args = [base] + list(args) base=None, commit=None,
base = 'alpine' # default selection in case of mistake cmd=None):
super().__init__(*args, **kwargs) self.base = base or 'alpine'
self.base = base
self.mounts = dict()
self.ctr = None
self.mnt = None
self.image = Image(commit) if commit else None self.image = Image(commit) if commit else None
self.ctr = None
self.root = None
self.mounts = dict()
self.config = dict( self.config = dict(
cmd=cmd or 'sh', cmd=cmd or 'sh',
) )
def shargs(self, *args, user=None, buildah=True, **kwargs): # Always consider localhost as parent for now
if not buildah or args[0].startswith('buildah'): self.parent = Target()
return super().shargs(*args, user=user, **kwargs)
super().__init__(*actions)
def is_runnable(self):
return Proc.test or os.getuid() == 0
def __str__(self):
if not self.is_runnable():
return 'Replacing with: buildah unshare ' + ' '.join(sys.argv)
return f'Buildah({self.image})'
async def __call__(self, *actions, target=None):
if target:
self.parent = target
if not self.is_runnable():
os.execvp('buildah', ['buildah', 'unshare'] + sys.argv)
# program has been replaced
layers = await self.layers()
keep = await self.cache_setup(layers, *actions)
keepnames = [*map(lambda x: 'localhost/' + str(x), keep)]
self.invalidate = [name for name in layers if name not in keepnames]
if self.invalidate:
self.output.info('Invalidating old layers')
await self.parent.exec(
'buildah', 'rmi', *self.invalidate, raises=False)
if actions:
actions = actions[len(keep):]
if not actions:
return self.uptodate()
else:
self.actions = self.actions[len(keep):]
if not self.actions:
return self.uptodate()
self.ctr = (await self.parent.exec('buildah', 'from', self.base)).out
self.root = Path((await self.parent.exec('buildah', 'mount', self.ctr)).out)
return await super().__call__(*actions)
def uptodate(self):
self.clean = None
self.output.success('Image up to date')
return
async def layers(self):
ret = set()
results = await self.parent.exec(
'buildah images --json',
quiet=True,
)
results = json.loads(results.out)
prefix = 'localhost/' + self.image.repository + ':layer-'
for result in results:
if not result.get('names', None):
continue
for name in result['names']:
if name.startswith(prefix):
ret.add(name)
return ret
async def cache_setup(self, layers, *actions):
keep = []
self.image_previous = Image(self.base)
for action in actions or self.actions:
action_image = await self.action_image(action)
name = 'localhost/' + str(action_image)
if name in layers:
self.base = self.image_previous = action_image
keep.append(action_image)
self.output.skip(
f'Found layer for {action}: {action_image.tags[0]}'
)
else:
break
return keep
async def action_image(self, action):
prefix = str(self.image_previous)
for tag in self.image_previous.tags:
if tag.startswith('layer-'):
prefix = tag
break
if hasattr(action, 'cachekey'):
action_key = action.cachekey()
if asyncio.iscoroutine(action_key):
action_key = str(await action_key)
else:
action_key = str(action)
key = prefix + action_key
sha1 = hashlib.sha1(key.encode('ascii'))
return self.image.layer(sha1.hexdigest())
async def action(self, action, reraise=False):
stop = await super().action(action, reraise)
if not stop:
action_image = await self.action_image(action)
self.output.info(f'Commiting {action_image} for {action}')
await self.parent.exec(
'buildah',
'commit',
'--format=' + action_image.format,
self.ctr,
action_image,
)
self.image_previous = action_image
return stop
async def clean(self, target, result):
for src, dst in self.mounts.items():
await self.parent.exec('umount', self.root / str(dst)[1:])
if self.root is not None:
await self.parent.exec('buildah', 'umount', self.ctr)
if self.ctr is not None:
if result.status == 'success':
await self.commit()
await self.parent.exec('buildah', 'rm', self.ctr)
if result.status == 'success' and os.getenv('BUILDAH_PUSH'):
await self.image.push(target)
async def mount(self, src, dst):
"""Mount a host directory into the container."""
target = self.root / str(dst)[1:]
await self.parent.exec(f'mkdir -p {src} {target}')
await self.parent.exec(f'mount -o bind {src} {target}')
self.mounts[src] = dst
async def exec(self, *args, user=None, **kwargs):
_args = ['buildah', 'run'] _args = ['buildah', 'run']
if user: if user:
_args += ['--user', user] _args += ['--user', user]
_args += [self.ctr, '--', 'sh', '-euc'] _args += [self.ctr, '--', 'sh', '-euc']
return super().shargs( _args += [' '.join([str(a) for a in args])]
*( return await self.parent.exec(*_args, **kwargs)
_args
+ [' '.join([str(a) for a in args])]
),
**kwargs
)
def __repr__(self): async def commit(self, image=None):
return f'Base({self.base})' image = image or self.image
if not image:
async def config(self, line):
"""Run buildah config."""
return await self.exec(f'buildah config {line} {self.ctr}', buildah=False)
async def copy(self, *args):
"""Run buildah copy to copy a file from host into container."""
src = args[:-1]
dst = args[-1]
await self.mkdir(dst)
procs = []
for s in src:
if Path(s).is_dir():
target = self.mnt / s
if not target.exists():
await self.mkdir(target)
args = ['buildah', 'copy', self.ctr, s, Path(dst) / s]
else:
args = ['buildah', 'copy', self.ctr, s, dst]
procs.append(self.exec(*args, buildah=False))
return await asyncio.gather(*procs)
async def mount(self, src, dst):
"""Mount a host directory into the container."""
target = self.mnt / str(dst)[1:]
await self.exec(f'mkdir -p {src} {target}', buildah=False)
await self.exec(f'mount -o bind {src} {target}', buildah=False)
self.mounts[src] = dst
def is_runnable(self):
return (
Proc.test
or os.getuid() == 0
)
async def call(self, *args, **kwargs):
if self.is_runnable():
self.ctr = (await self.exec('buildah', 'from', self.base, buildah=False)).out
self.mnt = Path((await self.exec('buildah', 'mount', self.ctr, buildah=False)).out)
result = await super().call(*args, **kwargs)
return result
from shlax.cli import cli
debug = kwargs.get('debug', False)
# restart under buildah unshare environment
argv = [
'buildah', 'unshare',
sys.argv[0], # current script location
cli.shlaxfile.path, # current shlaxfile location
]
if debug is True:
argv.append('-d')
elif isinstance(debug, str) and debug:
argv.append('-d=' + debug)
argv += [
cli.parser.command.name, # script name ?
]
await self.exec(*argv)
async def commit(self):
if not self.image:
return return
if not image:
# don't go through that if layer commit
for key, value in self.config.items(): for key, value in self.config.items():
await self.exec(f'buildah config --{key} "{value}" {self.ctr}') await self.parent.exec(f'buildah config --{key} "{value}" {self.ctr}')
self.sha = (await self.exec( self.sha = (await self.parent.exec(
'buildah', 'buildah',
'commit', 'commit',
'--format=' + self.image.format, '--format=' + image.format,
self.ctr, self.ctr,
buildah=False,
)).out )).out
if self.image.tags: ENV_TAGS = (
tags = [f'{self.image.repository}:{tag}' for tag in self.image.tags] # gitlab
'CI_COMMIT_SHORT_SHA',
'CI_COMMIT_REF_NAME',
'CI_COMMIT_TAG',
# CircleCI
'CIRCLE_SHA1',
'CIRCLE_TAG',
'CIRCLE_BRANCH',
# contributions welcome here
)
# figure tags from CI vars
for name in ENV_TAGS:
value = os.getenv(name)
if value:
self.image.tags.append(value)
if image.tags:
tags = [f'{image.repository}:{tag}' for tag in image.tags]
else: else:
tags = [self.image.repository] tags = [image.repository]
for tag in tags: for tag in tags:
await self.exec('buildah', 'tag', self.sha, tag, buildah=False) await self.parent.exec('buildah', 'tag', self.sha, tag)
async def clean(self, *args, **kwargs): async def mkdir(self, path):
if self.is_runnable(): return await self.parent.mkdir(self.path(path))
for src, dst in self.mounts.items():
await self.exec('umount', self.mnt / str(dst)[1:], buildah=False)
if self.status == 'success': async def copy(self, *args):
await self.commit() return await self.parent.copy(*args[:-1], self.path(args[-1]))
if 'push' in args:
await self.image.push(action=self)
if self.mnt is not None:
await self.exec('buildah', 'umount', self.ctr, buildah=False)
if self.ctr is not None:
await self.exec('buildah', 'rm', self.ctr, buildah=False)
-111
View File
@@ -1,111 +0,0 @@
import asyncio
from pathlib import Path
import os
from ..image import Image
from .localhost import Localhost
class Docker(Localhost):
"""Manage a docker container."""
default_steps = ['install', 'up']
def __init__(self, *args, **kwargs):
self.image = kwargs.get('image', 'alpine')
self.name = kwargs.get('name', os.getcwd().split('/')[-1])
if not isinstance(self.image, Image):
self.image = Image(self.image)
super().__init__(*args, **kwargs)
def shargs(self, *args, daemon=False, **kwargs):
if args[0] == 'docker':
return args, kwargs
extra = []
if 'user' in kwargs:
extra += ['--user', kwargs.pop('user')]
args, kwargs = super().shargs(*args, **kwargs)
if self.name:
executor = 'exec'
extra = [self.name]
return [self.kwargs.get('docker', 'docker'), executor, '-t'] + extra + list(args), kwargs
executor = 'run'
cwd = os.getcwd()
if daemon:
extra += ['-d']
extra = extra + ['-v', f'{cwd}:{cwd}', '-w', f'{cwd}']
return [self.kwargs.get('docker', 'docker'), executor, '-t'] + extra + [str(self.image)] + list(args), kwargs
async def call(self, *args, **kwargs):
def step(step):
return not args or step in args
# self.name = (
# await self.exec(
# 'docker', 'ps', '-aq', '--filter',
# 'name=' + self.name,
# raises=False
# )
# ).out.split('\n')[0]
if step('install') and 'install' in self.kwargs:
breakpoint()
await self.action(self.kwargs['install'], *args, **kwargs)
if step('rm') and await self.exists():
await self.exec('docker', 'rm', '-f', self.name)
if step('up'):
if await self.exists():
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.id = (await self.exec(
'docker', 'run', '-d', '--name', self.name, str(self.image))
).out
return await super().call(*args, **kwargs)
async def exists(self):
proc = await self.exec(
'docker', 'ps', '-aq', '--filter',
'name=' + self.name,
raises=False
)
return bool(proc.out.strip())
async def copy(self, *args):
src = args[:-1]
dst = args[-1]
await self.mkdir(dst)
procs = []
for s in src:
'''
if Path(s).is_dir():
await self.mkdir(s)
args = ['docker', 'copy', self.ctr, s, Path(dst) / s]
else:
args = ['docker', 'copy', self.ctr, s, dst]
'''
args = ['docker', 'cp', s, self.name + ':' + dst]
procs.append(self.exec(*args))
return await asyncio.gather(*procs)
async def up(self):
"""Ensure container is up and running."""
if await self.exists():
self.name = (await self.exec('docker', 'start', self.name)).out
else:
self.id = (await self.exec(
'docker', 'run', '-d', '--name', self.name, str(self.image))
).out
up.shlaxstep = True
async def rm(self):
"""Remove container."""
await self.exec('docker', 'rm', '-f', self.name)
rm.shlaxstep = True
+8 -48
View File
@@ -1,19 +1,14 @@
import os import copy
import re import re
from shlax.proc import Proc from ..output import Output
from ..proc import Proc
from ..result import Result, Results
from ..strategies.script import Script from .base import Target
class Localhost(Script): class Localhost(Target):
root = '/'
contextualize = Script.contextualize + ['home']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.home = kwargs.pop('home', os.getcwd())
def shargs(self, *args, **kwargs): def shargs(self, *args, **kwargs):
user = kwargs.pop('user', None) user = kwargs.pop('user', None)
args = [str(arg) for arg in args if args is not None] args = [str(arg) for arg in args if args is not None]
@@ -29,6 +24,8 @@ class Localhost(Script):
elif user: elif user:
args = ['sudo', '-u', user] + args args = ['sudo', '-u', user] + args
return args, kwargs
if self.parent: if self.parent:
return self.parent.shargs(*args, **kwargs) return self.parent.shargs(*args, **kwargs)
else: else:
@@ -41,40 +38,3 @@ class Localhost(Script):
if kwargs.get('wait', True): if kwargs.get('wait', True):
await proc.wait() await proc.wait()
return proc return proc
async def rexec(self, *args, **kwargs):
kwargs['user'] = 'root'
return await self.exec(*args, **kwargs)
async def env(self, name):
return (await self.exec('echo $' + name)).out
async def exists(self, *paths):
proc = await self.exec('type ' + ' '.join(cmd), raises=False)
async def which(self, *cmd):
"""
Return the first path to the cmd in the container.
If cmd argument is a list then it will try all commands.
"""
proc = await self.exec('type ' + ' '.join(cmd), raises=False)
result = []
for res in proc.out.split('\n'):
match = re.match('([^ ]+) is ([^ ]+)$', res.strip())
if match:
result.append(match.group(1))
return result
async def copy(self, *args):
if args[-1].startswith('./'):
args = list(args)
args[-1] = self.home + '/' + args[-1][2:]
args = ['cp', '-rua'] + list(args)
return await self.exec(*args)
async def mount(self, *dirs):
pass
async def mkdir(self, *dirs):
return await self.exec(*['mkdir', '-p'] + list(dirs))
-17
View File
@@ -1,17 +0,0 @@
import os
from shlax.proc import Proc
from .localhost import Localhost
class Ssh(Localhost):
root = '/'
def __init__(self, host, *args, **kwargs):
self.host = host
super().__init__(*args, **kwargs)
def shargs(self, *args, **kwargs):
args, kwargs = super().shargs(*args, **kwargs)
return (['ssh', self.host] + list(args)), kwargs
+23
View File
@@ -0,0 +1,23 @@
from ..proc import Proc
from .base import Target
class ProcStub(Proc):
async def __call__(self, wait=True):
return self
async def communicate(self):
self.communicated = True
return self
async def wait(self):
return self
class Stub(Target):
async def exec(self, *args, **kwargs):
proc = await ProcStub(*args, **kwargs)()
if kwargs.get('wait', True):
await proc.wait()
return proc
+15 -50
View File
@@ -1,55 +1,20 @@
#!/usr/bin/env shlax #!/usr/bin/env python
from shlax.contrib.gitlab import * """
Shlaxfile for shlax itself.
"""
PYTEST = 'py.test -svv tests' from shlax.shortcuts import *
test = Script(
Pip('.[test]'),
Run(PYTEST),
)
build = Buildah(
'quay.io/podman/stable',
Packages('python38', 'buildah', 'unzip', 'findutils', 'python3-yaml', upgrade=False),
Async(
# dancing for pip on centos python3.8
Run('''
curl -o setuptools.zip https://files.pythonhosted.org/packages/42/3e/2464120172859e5d103e5500315fb5555b1e908c0dacc73d80d35a9480ca/setuptools-45.1.0.zip
unzip setuptools.zip
mkdir -p /usr/local/lib/python3.8/site-packages/
sh -c "cd setuptools-* && python3.8 setup.py install"
easy_install-3.8 pip
echo python3.8 -m pip > /usr/bin/pip
chmod +x /usr/bin/pip
'''),
Copy('shlax/', 'setup.py', '/app'),
),
Pip('/app[full]'),
commit='docker.io/yourlabs/shlax',
workdir='/app',
)
shlax = Container( shlax = Container(
build=build, build=Buildah(
test=Script(Run('./shlaxfile.py -d test')), Packages('python38', 'buildah', 'unzip', 'findutils', upgrade=False),
Copy('setup.py', 'shlax', '/app'),
Pip('/app'),
base='quay.io/podman/stable',
commit='shlax',
),
) )
gitlabci = GitLabCI(
test=dict( if __name__ == '__main__':
stage='build', print(Group(doc=__doc__).load(shlax).entry_point())
script='pip install -U --user -e .[test] && ' + PYTEST,
image='yourlabs/python',
),
build=dict(
stage='build',
image='yourlabs/shlax',
script='pip install -U --user -e . && CACHE_DIR=$(pwd)/.cache ./shlaxfile.py -d shlax build push',
cache=dict(paths=['.cache'], key='cache'),
),
pypi=dict(
stage='deploy',
only=['tags'],
image='yourlabs/python',
script='pypi-release',
),
)
-53
View File
@@ -1,53 +0,0 @@
from shlax import *
inner = Run()
other = Run('ls')
middle = Buildah('alpine', inner, other)
outer = Localhost(middle)
middle = outer.actions[0]
other = middle.actions[1]
inner = middle.actions[0]
def test_action_init_args():
assert other.args == ('ls',)
def test_action_parent_autoset():
assert list(outer.actions) == [middle]
assert middle.parent == outer
assert inner.parent == middle
assert other.parent == middle
def test_action_context():
assert outer.context is inner.context
assert middle.context is inner.context
assert middle.context is outer.context
assert other.context is outer.context
def test_action_sibblings():
assert inner.sibblings() == [other]
assert inner.sibblings(lambda s: s.args[0] == 'ls') == [other]
assert inner.sibblings(lambda s: s.args[0] == 'foo') == []
assert inner.sibblings(type='run') == [other]
assert inner.sibblings(args=('ls',)) == [other]
def test_actions_parents():
assert other.parents() == [middle, outer]
assert other.parents(lambda p: p.base == 'alpine') == [middle]
assert inner.parents(type='localhost') == [outer]
assert inner.parents(type='buildah') == [middle]
def test_action_childrens():
assert middle.children() == [inner, other]
assert middle.children(lambda a: a.args[0] == 'ls') == [other]
assert outer.children() == [middle, inner, other]
def test_action_getattr():
assert other.exec == middle.exec
assert other.shargs == middle.shargs
+1 -7
View File
@@ -1,7 +1,7 @@
import pytest import pytest
import os import os
from shlax import Image from shlax.image import Image
tests = { tests = {
@@ -25,9 +25,3 @@ def test_args(arg, expected):
im = Image(arg) im = Image(arg)
for k, v in expected.items(): for k, v in expected.items():
assert getattr(im, k) == v assert getattr(im, k) == v
def test_args_env():
os.environ['IMAGE_TEST_ARGS_ENV'] = 'foo'
Image.ENV_TAGS = ['IMAGE_TEST_ARGS_ENV']
im = Image('re/po:x,y')
assert im.tags == ['x', 'y', 'foo']
+1 -1
View File
@@ -1,5 +1,5 @@
import pytest import pytest
from shlax import Output from shlax.output import Output
class Write: class Write:
-65
View File
@@ -1,65 +0,0 @@
import pytest
from unittest.mock import patch
from shlax import *
from shlax import proc
test_args_params = [
(
Localhost(Run('echo hi')),
[('sh', '-euc', 'echo hi')]
),
(
Localhost(Run('echo hi', user='jimi')),
[('sudo', '-u', 'jimi', 'sh', '-euc', 'echo hi')]
),
(
Localhost(Run('echo hi', user='root')),
[('sudo', 'sh', '-euc', 'echo hi')]
),
(
Ssh('host', Run('echo hi', user='root')),
[('ssh', 'host', 'sudo', 'sh', '-euc', 'echo hi')]
),
(
Buildah('alpine', Run('echo hi')),
[
('buildah', 'from', 'alpine'),
('buildah', 'mount', ''),
('buildah', 'run', '', '--', 'sh', '-euc', 'echo hi'),
('buildah', 'umount', ''),
('buildah', 'rm', ''),
]
),
(
Buildah('alpine', Run('echo hi', user='root')),
[
('buildah', 'from', 'alpine'),
('buildah', 'mount', ''),
('buildah', 'run', '--user', 'root', '', '--', 'sh', '-euc', 'echo hi'),
('buildah', 'umount', ''),
('buildah', 'rm', ''),
]
),
(
Ssh('host', Buildah('alpine', Run('echo hi', user='root'))),
[
('ssh', 'host', 'buildah', 'from', 'alpine'),
('ssh', 'host', 'buildah', 'mount', ''),
('ssh', 'host', 'buildah', 'run', '--user', 'root', '', '--', 'sh', '-euc', 'echo hi'),
('ssh', 'host', 'buildah', 'umount', ''),
('ssh', 'host', 'buildah', 'rm', ''),
]
),
]
@pytest.mark.parametrize(
'script,commands',
test_args_params
)
@pytest.mark.asyncio
async def test_args(script, commands):
with Proc.mock():
await script()
assert commands == Proc.test
+101
View File
@@ -0,0 +1,101 @@
import pytest
from shlax.targets.stub import Stub
from shlax.actions.run import Run
from shlax.actions.parallel import Parallel
from shlax.result import Result
class Error:
async def __call__(self, target):
raise Exception('lol')
@pytest.mark.asyncio
async def test_success():
action = Run('echo hi')
target = Stub(action)
await target()
assert target.results[0].action == action
assert target.results[0].status == 'success'
@pytest.mark.asyncio
async def test_error():
action = Error()
target = Stub(action)
await target()
assert target.results[0].action == action
assert target.results[0].status == 'failure'
@pytest.mark.asyncio
async def test_nested():
nested = Error()
class Nesting:
async def __call__(self, target):
await target(nested)
nesting = Nesting()
target = Stub(nesting)
await target()
assert len(target.results) == 2
assert target.results[0].status == 'failure'
assert target.results[0].action == nested
assert target.results[1].status == 'failure'
assert target.results[1].action == nesting
@pytest.mark.asyncio
async def test_parallel():
winner = Run('echo hi')
looser = Error()
parallel = Parallel(winner, looser)
target = Stub(parallel)
await target()
assert len(target.results) == 3
assert target.results[0].status == 'success'
assert target.results[0].action == winner
assert target.results[1].status == 'failure'
assert target.results[1].action == looser
assert target.results[2].status == 'failure'
assert target.results[2].action == parallel
@pytest.mark.asyncio
async def test_function():
async def hello(target):
await target.exec('hello')
await Stub()(hello)
@pytest.mark.asyncio
async def test_method():
class Example:
def __init__(self):
self.was_called = False
async def test(self, target):
self.was_called = True
example = Example()
action = example.test
target = Stub()
await target(action)
assert example.was_called
@pytest.mark.asyncio
async def test_target_action():
child = Stub(Run('echo hi'))
parent = Stub(child)
grandpa = Stub()
await grandpa(parent)
assert len(grandpa.results) == 3
grandpa = Stub(parent)
await grandpa()
assert len(grandpa.results) == 3