27 Commits
Author SHA1 Message Date
jpic 0eef6241a7 Breaking it 2020-05-31 13:37:09 +02:00
jpic 879bb6f79e Silence pip output 2020-05-31 13:24:31 +02:00
jpic e733694f2d A bit of work on the command line 2020-05-31 13:22:36 +02:00
jpic 0ec287c977 Tag/build/push refactor 2020-05-31 12:53:57 +02:00
jpic 833cef7527 Typo in ci 2020-05-31 12:28:49 +02:00
jpic 28f555bcf3 Try more build 2020-05-31 12:23:48 +02: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
26 changed files with 162 additions and 614 deletions
+6 -12
View File
@@ -1,27 +1,21 @@
build: build:
cache: cache:
key: cache key: cache
paths: [.cache, /var/lib/containers/] paths: [.cache]
image: yourlabs/buildah image: yourlabs/buildah
script: script:
- pip3 install -U --user .[cli] - pip3 install -U --user -e .[cli]
- CACHE_DIR=$(pwd)/.cache python3 ./shlaxfile.py build push=docker://docker.io/yourlabs/shlax:$CI_COMMIT_SHORT_SHA - CACHE_DIR=$(pwd)/.cache python3 ./shlaxfile.py build
stage: build stage: build
build-itself: build-itself:
cache: cache:
key: cache key: cache
paths: [.cache, /var/lib/containers/] paths: [.cache]
image: yourlabs/shlax:$CI_COMMIT_SHORT_SHA image: shlax:$CI_COMMIT_SHORT_SHA
script: python3 ./shlaxfile.py build push=docker://docker.io/yourlabs/shlax:$CI_COMMIT_REF script: python3 ./shlaxfile.py build
stage: test stage: test
test-exitcode:
image: yourlabs/shlax:$CI_COMMIT_SHORT_SHA
script:
- tests/shlaxfail.py build || [ $? -eq 1 ]
- tests/shlaxsuccess.py build
test: test:
image: yourlabs/python image: yourlabs/python
stage: build stage: build
-24
View File
@@ -4,30 +4,6 @@ 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 of replacing docker, docker-compose and ansible with a single tool with the
purpose of code-reuse made possible by target abstraction. 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, the shlax command line
demonstrates
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. The pattern resolves around two moving parts: Actions and Targets.
## Action ## Action
+1 -1
View File
@@ -7,7 +7,7 @@ setup(
setup_requires='setupmeta', setup_requires='setupmeta',
extras_require=dict( extras_require=dict(
cli=[ cli=[
'cli2>=2.3.0', 'cli2>=2.2.2',
], ],
test=[ test=[
'pytest', 'pytest',
View File
View File
+1 -8
View File
@@ -1,19 +1,12 @@
import asyncio import asyncio
import binascii import binascii
import glob
import os import os
class Copy: class Copy:
def __init__(self, *args): def __init__(self, *args):
self.src = args[:-1]
self.dst = args[-1] self.dst = args[-1]
self.src = []
for src in args[:-1]:
if '*' in src:
self.src += glob.glob(src)
else:
self.src.append(src)
def listfiles(self): def listfiles(self):
if getattr(self, '_listfiles', None): if getattr(self, '_listfiles', None):
+31 -42
View File
@@ -37,13 +37,11 @@ class Packages:
update='pacman -Sy', update='pacman -Sy',
upgrade='pacman -Su --noconfirm', upgrade='pacman -Su --noconfirm',
install='pacman -S --noconfirm', install='pacman -S --noconfirm',
lastupdate='stat -c %Y /var/lib/pacman/sync/core.db',
), ),
dnf=dict( dnf=dict(
update='dnf makecache --assumeyes', update='dnf makecache --assumeyes',
upgrade='dnf upgrade --best --assumeyes --skip-broken', # noqa upgrade='dnf upgrade --best --assumeyes --skip-broken', # noqa
install='dnf install --setopt=install_weak_deps=False --best --assumeyes', # noqa install='dnf install --setopt=install_weak_deps=False --best --assumeyes', # noqa
lastupdate='stat -c %Y /var/cache/dnf/* | head -n1',
), ),
yum=dict( yum=dict(
update='yum update', update='yum update',
@@ -54,52 +52,54 @@ class Packages:
installed = [] installed = []
def __init__(self, *packages, upgrade=False): def __init__(self, *packages, upgrade=True):
self.packages = [] self.packages = []
self.upgrade = upgrade 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(' ')
async def cache_setup(self, target): @property
def cache_root(self):
if 'CACHE_DIR' in os.environ: if 'CACHE_DIR' in os.environ:
self.cache_root = os.path.join(os.getenv('CACHE_DIR')) return os.path.join(os.getenv('CACHE_DIR'))
else: else:
self.cache_root = os.path.join(await target.parent.getenv('HOME'), '.cache') return os.path.join(os.getenv('HOME'), '.cache')
# run pkgmgr_setup functions ie. apk_setup
await getattr(self, self.mgr + '_setup')(target)
async def update(self, target): async def update(self, target):
# lastupdate = await target.exec(self.cmds['lastupdate'], raises=False) # run pkgmgr_setup functions ie. apk_setup
# lastupdate = int(lastupdate.out) if lastupdate.rc == 0 else None cachedir = await getattr(self, self.mgr + '_setup')(target)
lastupdate = None lastupdate = None
if os.path.exists(cachedir + '/lastupdate'):
with open(cachedir + '/lastupdate', 'r') as f:
try:
lastupdate = int(f.read().strip())
except:
pass
if not os.path.exists(cachedir):
os.makedirs(cachedir)
now = int(datetime.now().strftime('%s')) now = int(datetime.now().strftime('%s'))
if not lastupdate or now - lastupdate > 604800: # cache for a week
await target.rexec(self.cmds['update'])
return
# disabling with the above return call until needed again
# might have to rewrite this to not have our own lockfile
# or find a better place on the filesystem
# also make sure the lockfile is actually needed when running on
# targets that don't have isguest=True
if not lastupdate or now - lastupdate > 604800: if not lastupdate or now - lastupdate > 604800:
# crude lockfile implementation, should work against *most* # crude lockfile implementation, should work against *most*
# race-conditions ... # race-conditions ...
lockfile = cachedir + '/update.lock' lockfile = cachedir + '/update.lock'
if not await target.parent.exists(lockfile): if not os.path.exists(lockfile):
await target.parent.write(lockfile, str(os.getpid())) with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
try: try:
await target.rexec(self.cmds['update']) await target.rexec(self.cmds['update'])
finally: finally:
await target.parent.rm(lockfile) os.unlink(lockfile)
await target.parent.write(cachedir + '/lastupdate', str(now)) with open(cachedir + '/lastupdate', 'w+') as f:
f.write(str(now))
else: else:
while await target.parent.exists(lockfile): while os.path.exists(lockfile):
print(f'{self.target} | Waiting for {lockfile} ...') print(f'{self.target} | Waiting for {lockfile} ...')
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -116,13 +116,7 @@ class Packages:
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 target.isguest:
# we're going to mount
await self.cache_setup(target)
await self.update(target) await self.update(target)
if self.upgrade: if self.upgrade:
await target.rexec(self.cmds['upgrade']) await target.rexec(self.cmds['upgrade'])
@@ -153,24 +147,19 @@ class Packages:
return cachedir return cachedir
async def apt_setup(self, target): async def apt_setup(self, target):
codename = (await target.rexec( codename = (await self.rexec(
f'source /etc/os-release; echo $VERSION_CODENAME' f'source {self.mnt}/etc/os-release; echo $VERSION_CODENAME'
)).out )).out
cachedir = os.path.join(self.cache_root, self.mgr, codename) cachedir = os.path.join(self.cache_root, self.mgr, codename)
await self.rexec('rm /etc/apt/apt.conf.d/docker-clean') await self.rexec('rm /etc/apt/apt.conf.d/docker-clean')
cache_archives = os.path.join(cachedir, 'archives') cache_archives = os.path.join(cachedir, 'archives')
await target.mount(cache_archives, f'/var/cache/apt/archives') await self.mount(cache_archives, f'/var/cache/apt/archives')
cache_lists = os.path.join(cachedir, 'lists') cache_lists = os.path.join(cachedir, 'lists')
await target.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, target): async def pacman_setup(self, target):
cachedir = os.path.join(self.cache_root, self.mgr) return self.cache_root + '/pacman'
await target.mkdir(cachedir + '/cache', cachedir + '/sync')
await target.mount(cachedir + '/sync', '/var/lib/pacman/sync')
await target.mount(cachedir + '/cache', '/var/cache/pacman')
if await target.host.exists('/etc/pacman.d/mirrorlist'):
await target.copy('/etc/pacman.d/mirrorlist', '/etc/pacman.d/mirrorlist')
def __str__(self): def __str__(self):
return f'Packages({self.packages}, upgrade={self.upgrade})' return f'Packages({self.packages}, upgrade={self.upgrade})'
-3
View File
@@ -9,6 +9,3 @@ class Parallel:
return await asyncio.gather(*[ return await asyncio.gather(*[
target(action) for action in self.actions target(action) for action in self.actions
]) ])
def __str__(self):
return 'Parallel executor'
+1 -5
View File
@@ -1,14 +1,10 @@
class Run: class Run:
def __init__(self, cmd, root=False): def __init__(self, cmd):
self.cmd = cmd self.cmd = cmd
self.root = root
async def __call__(self, target): async def __call__(self, target):
if self.root:
self.proc = await target.rexec(self.cmd)
else:
self.proc = await target.exec(self.cmd) self.proc = await target.exec(self.cmd)
def __str__(self): def __str__(self):
+1 -3
View File
@@ -24,7 +24,7 @@ class User:
return f'User({self.username}, {self.home}, {self.uid})' return f'User({self.username}, {self.home}, {self.uid})'
async def __call__(self, target): async def __call__(self, target):
result = await target.rexec('id', self.uid, raises=False) result = await target.rexec('id', self.uid)
if result.rc == 0: if result.rc == 0:
old = re.match('.*\(([^)]*)\).*', result.out).group(1) old = re.match('.*\(([^)]*)\).*', result.out).group(1)
await target.rexec( await target.rexec(
@@ -40,5 +40,3 @@ class User:
'-u', self.uid, '-u', self.uid,
self.username self.username
) )
await target.mkdir(self.home)
await target.rexec('chown', self.uid, self.home)
+10 -39
View File
@@ -13,8 +13,6 @@ import importlib
import os import os
import sys import sys
from .proc import ProcFailure
class Group(cli2.Group): class Group(cli2.Group):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -23,63 +21,36 @@ class Group(cli2.Group):
class TargetArgument(cli2.Argument): class TargetArgument(cli2.Argument):
""" """DSN of the target to execute on, localhost by default, TBI"""
Target to execute on: localhost by default, target=@ssh_host for ssh.
"""
def __init__(self, cmd, param, doc=None, color=None, default=None): def __init__(self, cmd, param, doc=None, color=None, default=None):
from shlax.targets.base import Target from shlax.targets.base import Target
super().__init__(cmd, param, doc=self.__doc__, default=Target()) super().__init__(cmd, param, doc=self.__doc__, default=Target())
self.alias = ['target', 't'] self.alias = ['target', 't']
def cast(self, value):
from shlax.targets.ssh import Ssh
user, host = value.split('@')
return Ssh(host=host, user=user)
def match(self, arg):
return arg if isinstance(arg, str) and '@' in arg else None
class Command(cli2.Command): class Command(cli2.Command):
def setargs(self): def setargs(self):
cli2.arg('target', cls=TargetArgument)(self.target)
super().setargs() super().setargs()
if 'target' in self.sig.parameters: # that works though, so I went for that
self['target'] = TargetArgument( # self['target'] = TargetArgument(
self, # self,
self.sig.parameters['target'], # self.sig.parameters['target'],
) # )
if 'actions' in self: if 'actions' in self:
del self['actions'] del self['actions']
def __call__(self, *argv): def __call__(self, *argv):
result = None super().__call__(*argv)
try:
result = super().__call__(*argv)
except ProcFailure:
# just output the failure without TB, as command was already
# printed anyway
pass
if self['target'].value.results:
if self['target'].value.results[-1].status == 'failure':
self.exit_code = 1
self['target'].value.output.results(self['target'].value) self['target'].value.output.results(self['target'].value)
return result
class ActionCommand(cli2.Command): class ActionCommand(cli2.Command):
def setargs(self):
super().setargs()
self['target'] = TargetArgument(
self,
inspect.Parameter('target', inspect.Parameter.KEYWORD_ONLY),
)
def call(self, *args, **kwargs): def call(self, *args, **kwargs):
self.target = self.target(*args, **kwargs) self.target = self.target(*args, **kwargs)
return super().call(self['target'].value) from shlax.targets.base import Target
return super().call(Target())
class ConsoleScript(Group): class ConsoleScript(Group):
+9 -106
View File
@@ -1,19 +1,12 @@
import copy
import os import os
from .podman import Podman
from .image import Image from .image import Image
class Container: class Container:
def __init__(self, build=None, image=None, env=None, volumes=None): def __init__(self, build=None, image=None):
self.build = build self.build = build
self.image = image or self.build.image self.image = self.build.image
if isinstance(self.image, str):
self.image = Image(self.image)
self.volumes = volumes or {}
self.env = env or {}
prefix = os.getcwd().split('/')[-1] prefix = os.getcwd().split('/')[-1]
repo = self.image.repository.replace('/', '-') repo = self.image.repository.replace('/', '-')
if prefix == repo: if prefix == repo:
@@ -21,109 +14,19 @@ class Container:
else: else:
self.name = '-'.join([prefix, repo]) self.name = '-'.join([prefix, repo])
self.pod = None async def start(self, target):
"""Start the container"""
@property await target.rexec(
def full_name(self):
if self.pod:
return '-'.join([self.pod.name, self.name])
return self.name
async def up(self, target, *args):
"""Start the container foreground"""
podman = Podman(target)
if self.pod:
pod = None
for _ in await podman.pod.ps():
if _['Name'] == self.pod.name:
pod = _
break
if not pod:
await podman.pod.create('--name', self.pod.name)
args = list(args) + ['--pod', self.pod.name]
# skip if already up
for result in await podman.ps('-a'):
for name in result['Names']:
if name == self.full_name:
if result['State'] == 'running':
target.output.info(f'{self.full_name} already running')
return
elif result['State'] in ('exited', 'configured'):
target.output.info(f'{self.full_name} starting')
startargs = ['podman', 'start']
if '-d' not in args:
startargs.append('--attach')
startargs.append(self.full_name)
await target.exec(*startargs)
return
cmd = [
'podman', 'podman',
'run', 'run',
] + list(args)
for src, dest in self.volumes.items():
cmd += ['--volume', ':'.join([src, dest])]
for src, dest in self.env.items():
cmd += ['--env', '='.join([src, str(dest)])]
cmd += [
'--name', '--name',
self.full_name, self.name,
str(self.image), str(self.image),
] )
await target.exec(*cmd)
async def start(self, target):
"""Start the container background"""
await self.up(target, '-d')
async def stop(self, target): async def stop(self, target):
"""Start the container""" """Start the container"""
await target.exec('podman', 'stop', self.full_name) await target.rexec('podman', 'stop', self.name)
async def inspect(self, target):
"""Inspect container"""
await target.exec('podman', 'inspect', self.full_name)
async def logs(self, target):
"""Show container logs"""
await target.exec('podman', 'logs', self.full_name)
async def exec(self, target, cmd=None):
"""Execute a command in the container"""
cmd = cmd or 'bash'
if cmd.endswith('sh'):
import os
os.execvp(
'/usr/bin/podman',
[
'podman',
'exec',
'-it',
self.full_name,
cmd,
]
)
result = await target.exec(
'podman',
'exec',
self.full_name,
cmd,
)
async def down(self, target):
"""Start the container"""
await target.exec('podman', 'rm', '-f', self.full_name, raises=False)
async def apply(self, target):
"""Start the container"""
if self.build:
await target(self.build)
await target(self.down)
await target(self.start)
def __str__(self): def __str__(self):
return f'Container(name={self.name}, image={self.image}, volumes={self.volumes})' return f'Container(name={self.name}, image={self.image})'
View File
+16 -50
View File
@@ -1,52 +1,21 @@
import json import copy
import os import os
import re import re
class Layers(set):
def __init__(self, image):
self.image = image
async def ls(self, target):
"""Fetch layers from localhost"""
ret = set()
results = await target.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):
self.add(name)
return self
async def rm(self, target, tags=None):
"""Drop layers for this image"""
if tags is None:
tags = [layer for layer in await self.ls(target)]
await target.exec('podman', 'rmi', *tags, raises=False)
class Image: 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
) )
def __init__(self, arg=None, format=None, backend=None, registry=None, def __init__(self, arg=None, format=None, backend=None, registry=None, repository=None, tags=None):
repository=None, tags=None):
self.arg = arg self.arg = arg
self.format = format self.format = format
self.backend = backend self.backend = backend
self.registry = registry self.registry = registry
self.repository = repository self.repository = repository
self.tags = tags or [] self.tags = tags or []
self.layers = Layers(self)
match = re.match(self.PATTERN, arg) match = re.match(self.PATTERN, arg)
if match: if match:
@@ -60,11 +29,9 @@ class Image:
setattr(self, k, v) setattr(self, k, v)
# docker.io currently has issues with oci format # docker.io currently has issues with oci format
self.format = format or 'oci'
if self.registry == 'docker.io': if self.registry == 'docker.io':
self.backend = 'docker' self.format = 'docker'
if not self.format:
self.format = 'docker' if self.backend == 'docker' else 'oci'
# 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]
@@ -76,19 +43,18 @@ class Image:
def __str__(self): def __str__(self):
return f'{self.repository}:{self.tags[-1]}' return f'{self.repository}:{self.tags[-1]}'
async def push(self, target, name=None): async def push(self, *args, **kwargs):
user = os.getenv('IMAGES_USER', os.getenv('DOCKER_USER')) user = os.getenv('DOCKER_USER')
passwd = os.getenv('IMAGES_PASS', os.getenv('DOCKER_PASS')) passwd = os.getenv('DOCKER_PASS')
action = kwargs.get('action', self)
if user and passwd: if user and passwd:
target.output.cmd('buildah login -u ... -p ...' + self.registry) action.output.cmd('buildah login -u ... -p ...' + self.registry)
await target.parent.exec( await action.exec('buildah', 'login', '-u', user, '-p', passwd, self.registry or 'docker.io', debug=False)
'buildah', 'login', '-u', user, '-p', passwd,
self.registry or 'docker.io', quiet=True)
for tag in self.tags: for tag in self.tags:
await target.parent.exec( await action.exec('buildah', 'push', f'{self.repository}:{tag}')
'buildah',
'push', def layer(self, key):
self.repository + ':final', layer = copy.deepcopy(self)
name if isinstance(name, str) else f'{self.registry}/{self.repository}:{tag}' layer.tags = ['layer-' + key]
) return layer
-6
View File
@@ -101,11 +101,7 @@ class Output:
) )
def highlight(self, line, highlight=True): def highlight(self, line, highlight=True):
try:
line = line.decode('utf8') if isinstance(line, bytes) else line line = line.decode('utf8') if isinstance(line, bytes) else line
except UnicodeDecodeError:
highlight = False
if not highlight or ( if not highlight or (
'\x1b[' in line '\x1b[' in line
or '\033[' in line or '\033[' in line
@@ -189,8 +185,6 @@ class Output:
])) ]))
def results(self, action): def results(self, action):
if len(action.results) < 2:
return
success = 0 success = 0
fail = 0 fail = 0
for result in action.results: for result in action.results:
-52
View File
@@ -1,23 +1,13 @@
import cli2 import cli2
import json
import os
import sys
from shlax.targets.base import Target from shlax.targets.base import Target
from shlax.actions.parallel import Parallel from shlax.actions.parallel import Parallel
from shlax.proc import Proc
from .podman import Podman
class Pod: class Pod:
"""Help text""" """Help text"""
def __init__(self, **containers): def __init__(self, **containers):
self.containers = containers self.containers = containers
for name, container in self.containers.items():
container.pod = self
container.name = name
self.name = os.getcwd().split('/')[-1]
async def _call(self, target, method, *names): async def _call(self, target, method, *names):
methods = [ methods = [
@@ -29,50 +19,8 @@ class Pod:
async def build(self, target, *names): async def build(self, target, *names):
"""Build container images""" """Build container images"""
if not (Proc.test or os.getuid() == 0):
os.execvp('buildah', ['buildah', 'unshare'] + sys.argv)
else:
await self._call(target, 'build', *names) await self._call(target, 'build', *names)
async def down(self, target, *names):
"""Delete container images"""
await self._call(target, 'down', *names)
async def start(self, target, *names): async def start(self, target, *names):
"""Start container images""" """Start container images"""
await self._call(target, 'start', *names) await self._call(target, 'start', *names)
async def logs(self, target, *names):
"""Start container images"""
await self._call(target, 'logs', *names)
async def ps(self, target):
"""Show containers and volumes"""
containers = []
names = []
for container in await Podman(target).ps('-a'):
for name in container['Names']:
if name.startswith(self.name + '-'):
container['Name'] = name
containers.append(container)
names.append(name)
for name, container in self.containers.items():
full_name = '-'.join([self.name, container.name])
if full_name in names:
continue
containers.append(dict(
Name=full_name,
State='not created',
))
cli2.Table(
['Name', 'State'],
*[
(container['Name'], container['State'])
for container in containers
]
).print()
def __str__(self):
return f'Pod({self.name})'
-20
View File
@@ -1,20 +0,0 @@
import json
class Podman(list):
def __init__(self, target, *args):
self.target = target
super().__init__(args or ['podman'])
def __getattr__(self, command):
if command.startswith('_'):
return super().__getattr__(command)
return Podman(self.target, *self + [command])
async def __call__(self, *args, **kwargs):
cmd = self + list(args) + [
f'--{k}={v}' for k, v in kwargs.items()
]
if 'ps' in cmd:
cmd += ['--format=json']
return (await self.target.exec(*cmd, quiet=True)).json
View File
View File
+10 -62
View File
@@ -6,19 +6,17 @@ import re
import sys import sys
from ..output import Output from ..output import Output
from ..proc import Proc, ProcFailure from ..proc import Proc
from ..result import Result, Results from ..result import Result, Results
class Target: class Target:
isguest = False
def __init__(self, *actions, root=None): def __init__(self, *actions, root=None):
self.actions = actions self.actions = actions
self.results = [] self.results = []
self.output = Output() self.output = Output()
self.parent = None self.parent = None
self.root = root or '' self.root = root or os.getcwd()
def __str__(self): def __str__(self):
return 'localhost' return 'localhost'
@@ -47,18 +45,10 @@ class Target:
# the calling target # the calling target
self.parent = target self.parent = target
result = Result(self, self)
result.status = 'success'
for action in actions or self.actions: for action in actions or self.actions:
if await self.action(action, reraise=bool(actions)): if await self.action(action, reraise=bool(actions)):
result.status = 'failure'
break break
if getattr(self, 'clean', None):
self.output.clean(self)
await self.clean(self, result)
async def action(self, action, reraise=False): async def action(self, action, reraise=False):
result = Result(self, action) result = Result(self, action)
self.output.start(action) self.output.start(action)
@@ -68,22 +58,13 @@ class Target:
self.output.fail(action, e) self.output.fail(action, e)
result.status = 'failure' result.status = 'failure'
result.exception = e result.exception = e
if not isinstance(e, ProcFailure):
# no need to reraise in case of command error
# because the command has been printed
if reraise: if reraise:
# nested call, re-raise # nested call, re-raise
raise raise
else: else:
import traceback import traceback
traceback.print_exception(type(e), e, sys.exc_info()[2]) traceback.print_exception(type(e), e, sys.exc_info()[2])
return True
return True # because it failed
else:
if getattr(action, 'skipped', False):
self.output.skip(action)
else: else:
self.output.success(action) self.output.success(action)
result.status = 'success' result.status = 'success'
@@ -149,53 +130,20 @@ class Target:
@root.setter @root.setter
def root(self, value): def root(self, value):
self._root = Path(value) if value else '' self._root = Path(value or os.getcwd())
@property
def host(self):
current = self
while current.isguest:
current = self.parent
return current
def path(self, path): def path(self, path):
if not self.root:
return path
if str(path).startswith('/'): if str(path).startswith('/'):
path = str(path)[1:] path = str(path)[1:]
return str(self.root / path) return self.root / path
async def mkdir(self, *paths): async def mkdir(self, path):
if '_mkdir' not in self.__dict__: if '_mkdir' not in self.__dict__:
self._mkdir = [] self._mkdir = []
path = str(path)
make = [str(path) for path in paths if str(path) not in self._mkdir] if path not in self._mkdir:
if make: await self.exec('mkdir', '-p', path)
await self.exec('mkdir', '-p', *make) self._mkdir.append(path)
self._mkdir += make
async def copy(self, *args): async def copy(self, *args):
return await self.exec('cp', '-a', *args) return await self.exec('cp', '-a', *args)
async def exists(self, path):
return (await self.exec('ls ' + self.path(path), raises=False)).rc == 0
async def read(self, path):
return (await self.exec('cat', self.path(path))).out
async def write(self, path, content, **kwargs):
return await self.exec(
f'cat > {self.path(path)} <<EOF\n'
+ content
+ '\nEOF',
**kwargs
)
async def rm(self, path):
return await self.exec('rm', self.path(path))
async def getenv(self, key):
return (await self.exec('echo $' + key)).out
async def getcwd(self):
return (await self.exec('pwd')).out
+59 -83
View File
@@ -14,9 +14,11 @@ from ..proc import Proc
class Buildah(Target): class Buildah(Target):
"""Build container image with buildah""" """Build container image with buildah"""
isguest = True
def __init__(self, *actions, base=None, commit=None): def __init__(self,
*actions,
base=None, commit=None,
cmd=None):
self.base = base or 'alpine' self.base = base or 'alpine'
self.image = Image(commit) if commit else None self.image = Image(commit) if commit else None
@@ -24,6 +26,10 @@ class Buildah(Target):
self.root = None self.root = None
self.mounts = dict() self.mounts = dict()
self.config = dict(
cmd=cmd or 'sh',
)
# Always consider localhost as parent for now # Always consider localhost as parent for now
self.parent = Target() self.parent = Target()
@@ -37,34 +43,54 @@ class Buildah(Target):
return 'Replacing with: buildah unshare ' + ' '.join(sys.argv) return 'Replacing with: buildah unshare ' + ' '.join(sys.argv)
return f'Buildah({self.image})' return f'Buildah({self.image})'
async def __call__(self, *actions, target=None, push: str=False): async def __call__(self, *actions, target=None):
if target: if target:
self.parent = target self.parent = target
self.push = push
if not self.is_runnable(): if not self.is_runnable():
os.execvp('buildah', ['buildah', 'unshare'] + sys.argv) os.execvp('buildah', ['buildah', 'unshare'] + sys.argv)
return # process has been replaced # program has been replaced
layers = await self.image.layers.ls(self) layers = await self.layers()
keep = await self.cache_setup(self.image.layers, *actions) keep = await self.cache_setup(layers, *actions)
keepnames = [*map(lambda x: 'localhost/' + str(x), keep)] keepnames = [*map(lambda x: 'localhost/' + str(x), keep)]
self.invalidate = [name for name in self.image.layers if name not in keepnames] self.invalidate = [name for name in layers if name not in keepnames]
if self.invalidate: if self.invalidate:
self.output.info('Invalidating old layers') self.output.info('Invalidating old layers')
await self.image.layers.rm(self.parent, self.invalidate) await self.parent.exec(
'buildah', 'rmi', *self.invalidate, raises=False)
if actions: if actions:
actions = actions[len(keep):] actions = actions[len(keep):]
if not actions:
return self.output.success('Image up to date')
else: else:
self.actions = self.actions[len(keep):] self.actions = self.actions[len(keep):]
if not self.actions:
return self.output.success('Image up to date')
self.ctr = (await self.parent.exec('buildah', 'from', self.base)).out self.ctr = (await self.parent.exec('buildah', 'from', self.base)).out
self.root = Path((await self.parent.exec('buildah', 'mount', self.ctr)).out) self.root = Path((await self.parent.exec('buildah', 'mount', self.ctr)).out)
return await super().__call__(*actions) return await super().__call__(*actions)
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): async def cache_setup(self, layers, *actions):
keep = [] keep = []
self.image_previous = Image(self.base) self.image_previous = Image(self.base)
@@ -95,9 +121,7 @@ class Buildah(Target):
action_key = str(action) action_key = str(action)
key = prefix + action_key key = prefix + action_key
sha1 = hashlib.sha1(key.encode('ascii')) sha1 = hashlib.sha1(key.encode('ascii'))
action_image = copy.deepcopy(self.image) return self.image.layer(sha1.hexdigest())
action_image.tags = ['layer-' + sha1.hexdigest()]
return action_image
async def action(self, action, reraise=False): async def action(self, action, reraise=False):
stop = await super().action(action, reraise) stop = await super().action(action, reraise)
@@ -107,6 +131,7 @@ class Buildah(Target):
await self.parent.exec( await self.parent.exec(
'buildah', 'buildah',
'commit', 'commit',
'--format=' + action_image.format,
self.ctr, self.ctr,
action_image, action_image,
) )
@@ -118,15 +143,13 @@ class Buildah(Target):
for src, dst in self.mounts.items(): for src, dst in self.mounts.items():
await self.parent.exec('umount', self.root / str(dst)[1:]) await self.parent.exec('umount', self.root / str(dst)[1:])
await self.parent.exec('buildah', 'umount', self.ctr) await self.parent.exec('buildah', 'umount', self.ctr)
if result.status == 'success' and self.ctr:
await self.commit()
if self.push:
await self.image.push(target, self.push)
if self.ctr is not None:
await self.parent.exec('buildah', 'rm', self.ctr) await self.parent.exec('buildah', 'rm', self.ctr)
if result.status == 'success':
await self.commit()
if os.getenv('BUILDAH_PUSH'):
await self.image.push(target)
async def mount(self, src, dst): async def mount(self, src, dst):
"""Mount a host directory into the container.""" """Mount a host directory into the container."""
target = self.root / str(dst)[1:] target = self.root / str(dst)[1:]
@@ -142,21 +165,15 @@ class Buildah(Target):
_args += [' '.join([str(a) for a in args])] _args += [' '.join([str(a) for a in args])]
return await self.parent.exec(*_args, **kwargs) return await self.parent.exec(*_args, **kwargs)
async def commit(self): async def commit(self, image=None):
await self.parent.exec( image = image or self.image
'buildah', if not image:
'commit', return
f'--format={self.image.format}',
self.ctr, if not image:
f'{self.image.repository}:final', # don't go through that if layer commit
) for key, value in self.config.items():
if self.image.backend == 'docker': await self.parent.exec(f'buildah config --{key} "{value}" {self.ctr}')
await self.parent.exec(
'buildah',
'push',
f'{self.image.repository}:final',
f'docker-daemon:{self.image.repository}:latest'
)
ENV_TAGS = ( ENV_TAGS = (
# gitlab # gitlab
@@ -176,56 +193,15 @@ class Buildah(Target):
if value: if value:
self.image.tags.append(value) self.image.tags.append(value)
if self.image.tags: if image.tags:
tags = [f'{self.image.repository}:{tag}' for tag in self.image.tags] tags = [f'{image.repository}:{tag}' for tag in image.tags]
else: else:
tags = [self.image.repository] tags = [image.repository]
await self.parent.exec('buildah', 'tag', self.image.repository + ':final', *tags) await self.parent.exec('buildah', 'tag', self.image_previous, *tags)
async def mkdir(self, *paths): async def mkdir(self, path):
return await self.parent.mkdir(*[self.path(path) for path in paths]) return await self.parent.mkdir(self.path(path))
async def copy(self, *args): async def copy(self, *args):
return await self.parent.exec('buildah', 'copy', self.ctr, *args) return await self.parent.copy(*args[:-1], self.path(args[-1]))
async def write(self, path, content):
return await self.write(path, content)
async def write(self, path, content, **kwargs):
return await self.exec(
f'cat > {path} <<EOF\n'
+ content
+ '\nEOF',
**kwargs
)
class Config:
def __init__(self, **config):
self.config = config
async def __call__(self, target):
for key, value in self.config.items():
await target.parent.exec(
f'buildah config --{key} "{value}" {target.ctr}'
)
def __str__(self):
return f'Buildah.Config({self.config})'
class Env:
def __init__(self, **env):
self.env = env
async def __call__(self, target):
for key, value in self.env.items():
await target.parent.exec(
'buildah',
'config',
'--env',
f'{key}={value}',
target.ctr,
)
def __str__(self):
return f'Buildah.Env({self.env})'
-15
View File
@@ -1,15 +0,0 @@
from .base import Target
class Ssh(Target):
def __init__(self, *actions, host, user=None):
self.host = host
self.user = user
super().__init__(*actions)
async def exec(self, *args, user=None, **kwargs):
_args = ['ssh', self.host]
if user == 'root':
_args += ['sudo']
_args += [' '.join([str(a) for a in args])]
return await self.parent.exec(*_args, **kwargs)
+3 -3
View File
@@ -9,9 +9,9 @@ shlax = Container(
build=Buildah( build=Buildah(
Packages('python38', 'buildah', 'unzip', 'findutils', upgrade=False), Packages('python38', 'buildah', 'unzip', 'findutils', upgrade=False),
Copy('setup.py', 'shlax', '/app'), Copy('setup.py', 'shlax', '/app'),
Pip('/app[cli]'), Pip('/app'),
base='quay.io/buildah/stable', base='quay.io/podman/stable',
commit='docker://docker.io/yourlabs/shlax', commit='shlax',
), ),
) )
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env python
"""
Shlaxfile for shlax itself.
"""
from shlax.shortcuts import *
shlax = Container(
build=Buildah(
Packages('prout', upgrade=False),
base='alpine',
commit='shlaxfail',
),
)
if __name__ == '__main__':
print(Group(doc=__doc__).load(shlax).entry_point())
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python
"""
Shlaxfile for shlax itself.
"""
from shlax.shortcuts import *
shlax = Container(
build=Buildah(
base='alpine',
commit='shlaxsuccess',
),
)
if __name__ == '__main__':
print(Group(doc=__doc__).load(shlax).entry_point())
-31
View File
@@ -72,37 +72,6 @@ async def test_function():
await Stub()(hello) await Stub()(hello)
@pytest.mark.asyncio
async def test_action_clean():
class Example:
def __init__(self):
self.was_called = False
async def clean(self, target, result):
self.was_called = True
async def __call__(self, target):
raise Exception('lol')
action = Example()
target = Stub()
with pytest.raises(Exception):
await target(action)
assert action.was_called
@pytest.mark.asyncio
async def test_target_clean():
class Example(Stub):
def __init__(self, action):
self.was_called = False
super().__init__(action)
async def clean(self, target, result):
self.was_called = True
target = Example(Error())
await target()
assert target.was_called
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_method(): async def test_method():
class Example: class Example: