This commit is contained in:
jpic 2020-02-12 03:19:21 +01:00
parent f52cc8971a
commit 6abb061dc8
17 changed files with 489 additions and 244 deletions

View File

@ -11,46 +11,67 @@ from .script import Script
class Build(Script):
def __init__(self, container):
"""
The build script iterates over visitors and runs the build functions, it
also provides wrappers around the buildah command.
"""
def __init__(self):
super().__init__()
self.container = container
self.log = []
self.mounts = dict()
async def config(self, line):
"""Run buildah config."""
return await self.append(f'buildah config {line} {self.ctr}')
async def copy(self, src, dst):
"""Run buildah copy to copy a file from host into container."""
return await self.append(f'buildah copy {self.ctr} {src} {dst}')
async def exec(self, *args, **kwargs):
kwargs.setdefault('prefix', self.container.name)
proc = await Proc(*args, **kwargs)()
if kwargs.get('wait', True):
await proc.wait()
return proc
async def cexec(self, *args, user=None, **kwargs):
_args = ['buildah', 'run', self.ctr]
"""Execute a command in the container."""
_args = ['buildah', 'run']
if user:
_args += ['--user', user]
_args += ['--', 'sh', '-euc']
return await self.exec(*(_args + list(args)))
_args += [self.ctr, '--', 'sh', '-euc']
return await self.exec(*(_args + [' '.join([str(a) for a in args])]))
async def crexec(self, *args, **kwargs):
"""Execute a command in the container as root."""
kwargs['user'] = 'root'
return await self.cexec(*args, **kwargs)
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}')
await self.exec(f'mount -o bind {src} {target}')
self.mounts[src] = dst
async def umounts(self):
"""Unmount all mounted directories from the container."""
for src, dst in self.mounts.items():
await self.exec('umount', self.mnt / str(dst)[1:])
async def umount(self):
"""Unmount the buildah container with buildah unmount."""
await self.exec(f'buildah unmount {self.ctr}')
def which(self, cmd):
for path in self.container.paths:
if os.path.exists(os.path.join(self.mnt, path[1:], cmd)):
return True
async def paths(self):
"""Return the list of $PATH directories"""
return (await self.cexec('echo $PATH')).out.split(':')
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.
"""
if not isinstance(cmd, (list, tuple)):
cmd = [cmd]
for path in await self.paths():
for c in cmd:
p = os.path.join(self.mnt, path[1:], c)
if os.path.exists(p):
return p[len(str(self.mnt)):]

View File

@ -4,91 +4,66 @@ docker & docker-compose frustrated me, podctl unfrustrates me.
import asyncio
import cli2
import importlib
import inspect
import os
import sys
from .container import Container
from .pod import Pod
from .proc import WrongResult
from .exceptions import Mistake, WrongResult
from .service import Service
@cli2.option('debug', help='Print debug output', color=cli2.GREEN, alias='d')
async def build(*services_or_flags, **kwargs):
flags = []
services = []
for arg in services_or_flags:
if arg.startswith('-') or arg.startswith('+'):
flags.append(arg)
else:
services.append(arg)
if services:
services = {
k: v
for k, v in console_script.pod.services.items()
if k in services
}
else:
services = console_script.pod.services
procs = []
asyncio.events.get_event_loop()
for name, service in services.items():
service.container.name = name
service.container.flags = flags
procs.append(service.container.script('build', flags))
try:
result = await asyncio.gather(*procs)
except WrongResult:
sys.exit(1)
class ConsoleScript(cli2.ConsoleScript):
def __setitem__(self, name, cb):
if name != 'help':
cli2.option(
'file',
alias='f',
help='Path to pod definition (default: pod.py)',
color=cli2.YELLOW,
default='pod.py',
)(cb.target)
cli2.option(
'home',
alias='h',
help=f'Pod home (default is cwd: {os.getcwd()})',
color=cli2.YELLOW,
default=os.getcwd(),
)(cb.target)
super().__setitem__(name, cb)
def __call__(self, *args, **kwargs):
import inspect
from podctl.podfile import Podfile
self.podfile = Podfile.factory(os.getenv('PODFILE', 'pod.py'))
for name in self.podfile.pod.script_names():
self[name] = self.podfile.pod.script(name)
'''
ee = self.script(pod, name)
ee.__doc__ = inspect.getdoc(cb)
self[name] = cli2.Callable(name, ee)
'''
def call(self, command):
if command.name != 'help':
self.path = self.parser.options['file']
self.home = self.parser.options['home']
self.containers = dict()
self.pods = dict()
self.pod = None
spec = importlib.util.spec_from_file_location('pod', self.path)
pod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pod)
for name, value in pod.__dict__.items():
if isinstance(value, Container):
self.containers[name] = value
elif isinstance(value, Pod):
self.pods[name] = value
super().__call__(*args, **kwargs)
if 'pod' in self.pods:
self.pod = self.pods['pod']
if not self.pod:
self.pod = Pod(*[
Service(name, value, restart='no')
for name, value in self.containers.items()
])
return super().call(command)
@staticmethod
def script(script_name):
async def script(*services_or_flags, **options):
flags = []
services = []
for arg in services_or_flags:
if arg.startswith('-') or arg.startswith('+'):
flags.append(arg)
else:
services.append(arg)
if services:
services = {
k: v
for k, v in console_script.pod.services.items()
if k in services
}
else:
services = console_script.pod.services
procs = []
asyncio.events.get_event_loop()
for name, service in services.items():
service.container.name = name
service.container.flags = flags
procs.append(service.container.script(script_name, flags))
try:
result = await asyncio.gather(*procs)
except Mistake as e:
print(e)
sys.exit(1)
except WrongResult:
sys.exit(1)
return script
console_script = ConsoleScript(__doc__).add_module('podctl.console_script')

View File

@ -3,28 +3,17 @@ import os
import shlex
from .build import Build
from .run import Run
from .visitable import Visitable
class Container(Visitable):
default_scripts = dict(
build=Build,
build=Build(),
run=Run(),
)
paths = [
'/bin',
'/sbin',
'/usr/bin',
'/usr/sbin',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log = []
async def script(self, name, flags, loop=None):
self.loop = loop or asyncio.events.get_event_loop()
self.packages = []
for visitor in self.visitors:
self.packages += getattr(visitor, 'packages', [])
result = await super().script(name, loop)
return result
def script(self, name):
script = super().script(name)
script.container = self
return script

10
podctl/exceptions.py Normal file
View File

@ -0,0 +1,10 @@
class PodctlException(Exception):
pass
class Mistake(PodctlException):
pass
class WrongResult(PodctlException):
pass

View File

@ -1,6 +1,21 @@
import os
from .script import Script
from .visitable import Visitable
class Pod:
def __init__(self, *services, **scripts):
self.scripts = scripts
self.services = {s.name: s for s in services}
class Pod(Visitable):
def script_names(self):
for name in self.scripts.keys():
yield name
for visitor in self.visitors:
for script in visitor.scripts.keys():
yield script
def script(self, name):
for script_name in self.scripts.keys():
if script_name == name:
break
script.pod = self
return script

33
podctl/podfile.py Normal file
View File

@ -0,0 +1,33 @@
import importlib
from .container import Container
from .pod import Pod
class Podfile:
def __init__(self, pods, containers):
self.pods = pods
self.containers = containers
if not self.pods:
self.pods['pod'] = Pod(*containers.values())
@property
def pod(self):
return self.pods['pod']
@classmethod
def factory(cls, path):
containers = dict()
pods = dict()
spec = importlib.util.spec_from_file_location('pod', path)
pod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pod)
for name, value in pod.__dict__.items():
if isinstance(value, Container):
containers[name] = value
value.name = name
elif isinstance(value, Pod):
pods[name] = value
value.name = name
return cls(pods, containers)

123
podctl/proc.py Normal file
View File

@ -0,0 +1,123 @@
"""
Asynchronous process execution wrapper.
"""
import asyncio
import shlex
import sys
from .exceptions import WrongResult
class PrefixStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol):
"""
Internal subprocess stream protocol to add a prefix in front of output to
make asynchronous output readable.
"""
def __init__(self, prefix, *args, **kwargs):
self.prefix = prefix
super().__init__(*args, **kwargs)
def pipe_data_received(self, fd, data):
if fd in (1, 2):
for line in data.split(b'\n'):
if not line:
continue
sys.stdout.buffer.write(
self.prefix.encode('utf8') + b' | ' + line + b'\n'
)
sys.stdout.flush()
super().pipe_data_received(fd, data)
def protocol_factory(prefix):
def _p():
return PrefixStreamProtocol(
prefix,
limit=asyncio.streams._DEFAULT_LIMIT,
loop=asyncio.events.get_event_loop()
)
return _p
'''
async def proc(args, prefix=None, wait=True, raises=True):
loop = asyncio.events.get_event_loop()
transport, protocol = await loop.subprocess_exec(
protocol_factory(prefix), *args)
proc = asyncio.subprocess.Process(transport, protocol, loop)
if wait:
stdout, stderr = await proc.communicate()
log['result'] = await proc.wait()
if raises and log['result']:
raise WrongResult()
if wait:
return log
return proc
'''
class Proc:
"""
Subprocess wrapper.
Example usage::
proc = Proc('find', '/', prefix='containername')
await proc() # execute
print(proc.out) # stdout
print(proc.err) # stderr
print(proc.rc) # return code
"""
def __init__(self, *args, prefix=None, raises=True):
if len(args) == 1:
if isinstance(args[0], (list, tuple)):
args = args[0]
else:
args = ['sh', '-euc', ' '.join(args)]
self.args = [str(a) for a in args]
self.cmd = shlex.join(self.args)
self.prefix = prefix
self.raises = raises
self.called = False
self.communicated = False
async def __call__(self, wait=True):
if self.called:
raise Exception('Already called: ' + self.cmd)
print(f'{self.prefix} | + {self.cmd}')
loop = asyncio.events.get_event_loop()
transport, protocol = await loop.subprocess_exec(
protocol_factory(self.prefix), *self.args)
self.proc = asyncio.subprocess.Process(transport, protocol, loop)
self.called = True
if wait:
await self.wait()
return self
async def communicate(self):
self.out_raw, self.err_raw = await self.proc.communicate()
self.out = self.out_raw.decode('utf8').strip()
self.err = self.err_raw.decode('utf8').strip()
self.rc = self.proc.returncode
self.communicated = True
return self
async def wait(self):
if not self.called:
await self()
if not self.communicated:
await self.communicate()
if self.raises and self.proc.returncode:
raise WrongResult()
return self

5
podctl/run.py Normal file
View File

@ -0,0 +1,5 @@
from .script import Script
class Run(Script):
"""Run a container"""

View File

@ -1,5 +1,41 @@
import textwrap
from .proc import Proc
class Script:
pass
async def exec(self, *args, **kwargs):
"""Execute a command on the host."""
kwargs.setdefault('prefix', self.container.name)
proc = await Proc(*args, **kwargs)()
if kwargs.get('wait', True):
await proc.wait()
return proc
async def __call__(self, name, loop=None):
script = copy(self.scripts[name])
script.loop = loop or asyncio.events.get_event_loop()
results = []
async def clean():
for visitor in self.visitors:
if hasattr(visitor, 'clean_' + name):
result = getattr(visitor, 'clean_' + name)(script)
if result:
await result
for prefix in ('init_', 'pre_', '', 'post_', 'clean_'):
method = prefix + name
for visitor in self.visitors:
if not hasattr(visitor, method):
continue
rep = {k: v if not isinstance(v, object) else type(v).__name__ for k, v in visitor.__dict__.items()}
print(self.name + ' | ', type(visitor).__name__, method, rep)
result = getattr(visitor, method)(script)
if result:
try:
await result
except Exception as e:
await clean()
raise

View File

@ -1,3 +1,4 @@
import asyncio
from copy import copy
@ -7,36 +8,12 @@ class Visitable:
def __init__(self, *visitors, **scripts):
self.visitors = list(visitors)
self.scripts = scripts or {
k: v(self) for k, v in self.default_scripts.items()
k: v for k, v in self.default_scripts.items()
}
async def script(self, name, loop):
def script(self, name):
script = copy(self.scripts[name])
script.loop = loop
results = []
async def clean():
for visitor in self.visitors:
if hasattr(visitor, 'clean_' + name):
result = getattr(visitor, 'clean_' + name)(self)
if result:
await result
for prefix in ('init_', 'pre_', '', 'post_', 'clean_'):
method = prefix + name
for visitor in self.visitors:
if not hasattr(visitor, method):
continue
rep = {k: v if not isinstance(v, object) else type(v).__name__ for k, v in visitor.__dict__.items()}
print(self.name + ' | ', type(visitor).__name__, method, rep)
result = getattr(visitor, method)(script)
if result:
try:
await result
except Exception as e:
await clean()
raise
return script
def visitor(self, name):
for visitor in self.visitors:

View File

@ -9,5 +9,6 @@ from .mount import Mount # noqa
from .packages import Packages # noqa
from .pip import Pip # noqa
from .run import Run # noqa
from .template import Template # noqa
from .template import Append, Template # noqa
from .user import User # noqa
from .uwsgi import uWSGI # noqa

View File

@ -9,5 +9,7 @@ class Base:
script.ctr = Path((await script.exec('buildah', 'from', self.base)).out)
script.mnt = Path((await script.exec('buildah', 'mount', script.ctr)).out)
async def post_build(self, script):
async def clean_build(self, script):
await script.umounts()
await script.umount()
proc = await script.exec('buildah', 'rm', script.ctr, raises=False)

View File

@ -42,20 +42,24 @@ class Commit:
# filter out tags which resolved to None
self.tags = [t for t in self.tags if t is not None]
# default tag by default ...
if not self.tags:
self.tags = ['latest']
async def post_build(self, script):
await script.exec(
self.sha = (await script.exec(
'buildah',
'commit',
'--format=' + self.format,
script.ctr,
)
)).out
if 'master' in self.tags:
self.tags.append('latest')
if self.tags:
tags = ' '.join([f'{self.repo}:{tag}' for tag in self.tags])
await script.run('buildah', 'tag', self.repo, ' '.join(tags))
await script.exec('buildah', 'tag', self.sha, self.repo, tags)
if self.push:
user = os.getenv('DOCKER_USER')
@ -72,5 +76,12 @@ class Commit:
)
for tag in self.tags:
await script.run('podman', 'push', f'{self.repo}:{tag}')
await script.exec('podman', 'push', f'{self.repo}:{tag}')
await script.umount()
async def run(self, script):
await script.exec(
'podman', 'run', '-d',
'--name', script.container.name,
':'.join((self.repo, self.tags[0])),
)

View File

@ -1,4 +1,5 @@
import asyncio
import copy
from datetime import datetime
from glob import glob
@ -8,6 +9,14 @@ from textwrap import dedent
class Packages:
"""
The Packages visitor wraps around the container's package manager.
It's a central piece of the build process, and does iterate over other
container visitors in order to pick up packages. For example, the Pip
visitor will declare ``self.packages = dict(apt=['python3-pip'])``, and the
Packages visitor will pick it up.
"""
mgrs = dict(
apk=dict(
update='apk update',
@ -31,99 +40,113 @@ class Packages:
),
)
installed = []
def __init__(self, *packages, **kwargs):
self.packages = list([
dedent(l).strip().replace('\n', ' ') for l in packages
])
self.packages = []
for package in packages:
line = dedent(package).strip().replace('\n', ' ')
self.packages += line.split(' ')
self.mgr = kwargs.pop('mgr') if 'mgr' in kwargs else None
@property
def cache(self):
def cache_root(self):
if 'CACHE_DIR' in os.environ:
return os.path.join(os.getenv('CACHE_DIR'), self.mgr)
return os.path.join(os.getenv('CACHE_DIR'))
else:
return os.path.join(os.getenv('HOME'), '.cache', self.mgr)
return os.path.join(os.getenv('HOME'), '.cache')
async def init_build(self, script):
paths = ('bin', 'sbin', 'usr/bin', 'usr/sbin')
for mgr, cmds in self.mgrs.items():
for path in paths:
if (script.mnt / path / mgr).exists():
cached = script.container.variable('mgr')
if cached:
self.mgr = cached
else:
for mgr, cmds in self.mgrs.items():
if await script.which(mgr):
self.mgr = mgr
self.cmds = cmds
break
if not self.mgr:
raise Exception('Packages does not yet support this distro')
self.cmds = self.mgrs[self.mgr]
async def update(self, script):
# run pkgmgr_setup functions ie. apk_setup
cachedir = await getattr(self, self.mgr + '_setup')(script)
lastupdate = None
if os.path.exists(cachedir + '/lastupdate'):
with open(cachedir + '/lastupdate', 'r') as f:
try:
lastupdate = int(f.read().strip())
except:
pass
now = int(datetime.now().strftime('%s'))
# cache for a week
if not lastupdate or now - lastupdate > 604800:
# crude lockfile implementation, should work against *most*
# race-conditions ...
lockfile = cachedir + '/update.lock'
if not os.path.exists(lockfile):
with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
try:
await script.cexec(self.cmds['update'])
finally:
os.unlink(lockfile)
with open(cachedir + '/lastupdate', 'w+') as f:
f.write(str(now))
else:
while os.path.exists(lockfile):
print(f'{script.container.name} | Waiting for update ...')
await asyncio.sleep(1)
async def build(self, script):
if not getattr(script.container, '_packages_upgraded', None):
# run pkgmgr_setup functions ie. apk_setup
await getattr(self, self.mgr + '_setup')(script)
# first run on container means inject visitor packages
self.packages += script.container.packages
await self.update(script)
await script.cexec(self.cmds['upgrade'])
script.container._packages_upgraded = True
await script.cexec(' '.join([self.cmds['install']] + self.packages))
# first run on container means inject visitor packages
packages = []
for visitor in script.container.visitors:
pp = getattr(visitor, 'packages', None)
if pp:
if isinstance(pp, list):
packages += pp
elif self.mgr in pp:
packages += pp[self.mgr]
script.container._packages_upgraded = True
else:
packages = self.packages
await script.crexec(*self.cmds['install'].split(' ') + packages)
async def apk_setup(self, script):
await script.mount(self.cache, f'/var/cache/{self.mgr}')
cachedir = os.path.join(self.cache_root, self.mgr)
await script.mount(cachedir, '/var/cache/apk')
# special step to enable apk cache
await script.cexec('ln -s /var/cache/apk /etc/apk/cache')
# do we have to update ?
update = False
for f in glob(self.cache + '/APKINDEX*'):
mtime = os.stat(f).st_mtime
now = int(datetime.now().strftime('%s'))
# expect hacker to have internet at least once a week
if now - mtime > 604800:
update = True
break
else:
update = True
if update:
await self.apk_update(script)
async def apk_update(self, script):
while os.path.exists(self.cache + '/update'):
print(f'{script.container.name} | Waiting for update ...')
await asyncio.sleep(1)
return # update was done by another job
with open(self.cache + '/update', 'w+') as f:
f.write(str(os.getpid()))
try:
await script.cexec(self.cmds['update'])
except:
raise
finally:
os.unlink(self.cache + '/update')
return cachedir
async def dnf_setup(self, script):
await script.mount(self.cache, f'/var/cache/{self.mgr}')
await script.run('echo keepcache=True >> /etc/dnf/dnf.conf')
async def apt_setup(self, script):
cache = self.cache + '/$(source $mnt/etc/os-release; echo $VERSION_CODENAME)/' # noqa
await script.run('rm /etc/apt/apt.conf.d/docker-clean')
cache_archives = os.path.join(self.cache, 'archives')
codename = (await script.exec(
f'source {script.mnt}/etc/os-release; echo $VERSION_CODENAME'
)).out
cachedir = os.path.join(self.cache_root, self.mgr, codename)
await script.cexec('rm /etc/apt/apt.conf.d/docker-clean')
cache_archives = os.path.join(cachedir, 'archives')
await script.mount(cache_archives, f'/var/cache/apt/archives')
cache_lists = os.path.join(self.cache, 'lists')
cache_lists = os.path.join(cachedir, 'lists')
await script.mount(cache_lists, f'/var/lib/apt/lists')
await script.run(self.cmds['update'])
"""
await script.append(f'''
old="$(find {cache_lists} -name lastup -mtime +3)"
if [ -n "$old" ] || ! ls {cache_lists}/lastup; then
until [ -z $(lsof /var/lib/dpkg/lock) ]; do sleep 1; done
{script._run(self.cmds['update'])}
touch {cache_lists}/lastup
else
echo Cache recent enough, skipping index update.
fi
''')
"""
return cachedir

View File

@ -3,16 +3,19 @@ import os
class Pip:
packages = dict(
apt=['python3-pip'],
)
def __init__(self, *pip_packages, pip=None, requirements=None):
self.pip_packages = pip_packages
self.pip = pip
#self.pip = pip
self.requirements = requirements
async def build(self, script):
for pip in ('pip3', 'pip', 'pip2'):
if script.which(pip):
self.pip = pip
break
self.pip = await script.which(('pip3', 'pip', 'pip2'))
if not self.pip:
raise Exception('Could not find pip command')
if 'CACHE_DIR' in os.environ:
cache = os.path.join(os.getenv('CACHE_DIR'), 'pip')
@ -20,16 +23,27 @@ class Pip:
cache = os.path.join(os.getenv('HOME'), '.cache', 'pip')
await script.mount(cache, '/root/.cache/pip')
await script.run(f'sudo {self.pip} install --upgrade pip')
source = [p for p in self.pip_packages if p.startswith('/')]
await script.crexec(f'{self.pip} install --upgrade pip')
# https://github.com/pypa/pip/issues/5599
self.pip = 'python3 -m pip'
pip_packages = []
for visitor in script.container.visitors:
pp = getattr(visitor, 'pip_packages', None)
if not pp:
continue
pip_packages += pip_packages
source = [p for p in pip_packages if p.startswith('/')]
if source:
await script.run(
f'sudo {self.pip} install --upgrade --editable {" ".join(source)}'
await script.crexec(
f'{self.pip} install --upgrade --editable {" ".join(source)}'
)
nonsource = [p for p in self.pip_packages if not p.startswith('/')]
nonsource = [p for p in pip_packages if not p.startswith('/')]
if nonsource:
await script.run(f'sudo {self.pip} install --upgrade {" ".join(source)}')
await script.crexec(f'{self.pip} install --upgrade {" ".join(nonsource)}')
if self.requirements:
await script.run(f'sudo {self.pip} install --upgrade -r {self.requirements}')
await script.crexec(f'{self.pip} install --upgrade -r {self.requirements}')

View File

@ -1,11 +1,13 @@
from textwrap import dedent
CMD = '''cat <<EOF > {target}
{script}
EOF'''
class Template:
CMD = dedent(
'''cat <<EOF > {target}
{script}
EOF'''
)
def __init__(self, target, *lines, **variables):
self.target = target
self.lines = lines
@ -15,6 +17,14 @@ class Template:
self.script = '\n'.join([
dedent(l).strip() for l in self.lines
]).format(**self.variables)
await script.run(CMD.strip().format(**self.__dict__))
await script.cexec(self.CMD.strip().format(**self.__dict__))
if self.script.startswith('#!'):
await script.run('sudo chmod +x ' + self.target)
await script.cexec('chmod +x ' + self.target, user='root')
class Append(Template):
CMD = dedent(
'''cat <<EOF >> {target}
{script}
EOF'''
)

View File

@ -3,25 +3,25 @@ from .packages import Packages
class User:
"""Secure the image with a user"""
packages = [
'shadow',
]
packages = dict(
apk=['shadow'],
)
def __init__(self, username, uid, home):
def __init__(self, username, uid, home, directories=None):
self.username = username
self.uid = uid
self.home = home
self.user_created = False
self.directories = directories
async def build(self, script):
await script.run(f'''
if {script._run('id ' + str(self.uid))}; then
i=$({script._run('id -gn ' + str(self.uid))})
{script._run('usermod -d ' + self.home + ' -l ' + self.username + ' $i')}
else
{script._run('useradd -d ' + self.home + ' -u ' + str(self.uid) + ' ' + self.username)}
fi
''') # noqa
try:
await script.cexec('id', self.uid)
except:
await script.cexec('useradd', '-d', self.home, '-u', self.uid, ' ',
self.username)
else:
await script.cexec('id', '-gn', self.uid)
self.user_created = True
def post_build(self, script):