run command on any linux distribution, transparently using docker containers
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

217 lines
7.2 KiB

#!/usr/bin/env python3
import sys
import argparse
import docker
import dockerpty
import io
import json
import getpass
import platform
import os
import re
import xdg.BaseDirectory
import configparser
import subprocess
from pprint import pprint
def natural_sortkey(string):
tokenize = re.compile(r'(\d+)|(\D+)').findall
return tuple(int(num) if num else alpha for num, alpha in tokenize(string))
def read_ini(user_confname, osname=''):
ini_list = [ 'runon.conf', '.runon.conf', os.path.join(xdg.BaseDirectory.xdg_config_home, 'runon', 'runon.conf'), '/etc/runon/runon.conf' ]
defaults = {
'osname': osname,
'user': getpass.getuser()
}
if user_confname:
ini_list.insert(0, user_confname)
ini = configparser.ConfigParser(defaults=defaults, interpolation=configparser.ExtendedInterpolation())
ini.read(ini_list)
return ini
def list_osnames(user_confname):
ini = read_ini(user_confname)
return ini.sections()
def load_config(user_confname, osname):
ini = read_ini(user_confname, osname)
if not ini.has_section(osname):
print('ERROR: cannot find configuration for distribution "{}"'.format(osname))
sys.exit(1)
conf = {}
fields = [ 'dockerfile', 'pkginstall', 'packages', 'environment', 'binds', 'user', 'osname' ]
try:
for f in fields:
conf[f] = ini.get(osname, f)
except configparser.NoOptionError as e:
print('ERROR: {}'.format(e))
sys.exit(1)
for f in [ 'dockerfile', 'environment', 'binds' ]:
conf[f] = [ i for i in conf[f].split('\n') if i ]
for f in [ 'packages' ]:
conf[f] = conf[f].split()
return conf
def make_osname_link(binpath, osname):
link = os.path.join(os.path.dirname(binpath), osname)
try:
os.symlink('runon', link)
except FileExistsError:
pass
def make_image_name(osname):
user = getpass.getuser()
name = 'runon-{}-{}'.format(osname, user)
return name
def build_image(client, conf, update, verbose):
packages = conf['packages']
dockerfile = conf['dockerfile']
pkginstall = conf['pkginstall']
for p in packages:
dockerfile.append(pkginstall.format(p))
tag = make_image_name(conf['osname'])
try:
if verbose:
# fallback to external command 'docker build' as there is
# no way to follow the build progress with API.
print('Building image {} ...'.format(tag))
cmd = ['docker', 'build']
if update:
cmd.append('--no-cache')
cmd += ['-t', tag, '-']
ret = subprocess.run(cmd,
input='\n'.join(dockerfile).encode('utf-8'),
stderr=subprocess.STDOUT, check=True)
image = client.images.get(tag)
else:
with io.BytesIO('\n'.join(dockerfile).encode('utf-8')) as fd:
image, logs = client.images.build(tag=tag, fileobj=fd, rm=True, nocache=update)
if verbose:
print('Built image {} / {}'.format(image.tags[0], image.short_id))
for l in logs:
print(l.get('stream', '').strip('\n'))
except (docker.errors.BuildError, KeyboardInterrupt, subprocess.CalledProcessError, docker.errors.ImageNotFound) as e:
print('Build Error: {}'.format(e))
print()
print('with dockerfile:')
for line in dockerfile:
print(' {}'.format(line))
sys.exit(1)
return image
def create_container(client, image, conf, command, verbose):
volumes = {}
environment = {}
for mnt in conf['binds']:
mnt = mnt.split(':')
if mnt[-1] in ['ro','rw']:
mode = mnt[-1]
del mnt[-1]
else:
mode = 'rw'
mnt = mnt[:2]
bind = mnt[-1]
vol = mnt[0]
volumes[vol] = { 'bind': bind, 'mode': mode }
hostname = platform.node()
for v in conf['environment']:
e = v.split('=')
if len(e) == 1:
e.append(os.getenv(e[0]))
environment[e[0]] = e[1]
#environment['debian_chroot']=conf['osname']
user='{}:{}'.format(os.getuid(), os.getgid())
pwd=os.getcwd()
container = client.containers.create(image, command,
detach=False, stdin_open=True, tty=True,
auto_remove=True,
hostname=hostname,
volumes=volumes,
environment=environment,
user=user,
network_mode='host',
working_dir=pwd
)
return container
def run_container(client, container):
try:
dockerpty.start(client.api, container.id)
container.reload() # to update attrs fields
except docker.errors.APIError as e:
print('ERROR: {}'.format(e))
sys.exit(1)
ret = container.attrs['State']['ExitCode']
return ret
def main():
osname = None
run_name = os.path.basename(os.sys.argv[0])
if run_name == 'runos':
pass
elif run_name.startswith('runon'):
m = re.match('runon[-_]?(.*)$', run_name)
if m:
osname = m[1]
else:
osname = run_name
parser = argparse.ArgumentParser()
if osname:
parser.description = 'run commands on "{}" distribution'.format(osname)
else:
parser.description = 'run commands on any distribution'
parser.add_argument('osname',
help = 'distribution name to run on, '
'"list" to dump all available distributions')
parser.epilog = '(c) 2021 Gilles Grandou <gilles@grandou.net>'
parser.add_argument('-v', '--verbose', action='store_true',
help='verbose output')
parser.add_argument('-c', '--config',
help='specify config file')
parser.add_argument('-u', '--update', action='store_true',
help='force image update')
parser.add_argument('-l', '--link', action='store_true',
help='create a symlink to call "osname" as a shortcut to "runon osname"')
parser.add_argument('command', nargs='*', default=None,
help = 'command to execute')
args = parser.parse_args()
if osname:
args.osname = osname
if args.osname == 'list':
osnames = list_osnames(args.config)
print('Available distributions:')
for o in sorted(osnames, key=natural_sortkey):
print(' {}'.format(o))
print()
if args.link:
for o in osnames:
make_osname_link(sys.argv[0], args.osname)
return 0
client = docker.from_env()
conf = load_config(args.config, args.osname)
if args.link:
make_osname_link(sys.argv[0], args.osname)
image = build_image(client, conf, args.update, args.verbose)
container = create_container(client, image, conf, args.command, args.verbose)
ret = run_container(client, container)
return ret
if __name__ == '__main__':
ret = main()
sys.exit(ret)