#!/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 ' 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)