import sys import os import argparse import getpass import pathlib import xdg.BaseDirectory import yaml import datetime import pytz import subprocess from pprint import pprint def find_config_file(user_conf): conf_list = [ 'runon.yaml', '.runon.yaml', os.path.join(xdg.BaseDirectory.xdg_config_home, 'runon', 'runon.yaml'), os.path.join(xdg.BaseDirectory.xdg_config_home, 'runon', 'runon.default.yaml') ] if user_conf: conf_list = [ user_conf ] for conf in conf_list: if os.path.exists(conf): return conf return None def read_yaml(conf_file): try: with open(conf_file, 'r') as file: conf = yaml.safe_load(file) conf['stamp'] = datetime.datetime.fromtimestamp(os.path.getmtime(conf_file), tz=pytz.UTC) return conf except yaml.YAMLError as e: print(f'ERROR: bad configuration file:') print(e) sys.exit(1) return conf def list_osnames(conf_file): conf = read_yaml(conf_file) osnames = [] for key in conf: if (type(conf[key]) is dict) and conf[key].get('image'): osnames.append(key) return osnames def load_config(conf_file, osname): user_vars = { 'osname': osname, 'user': getpass.getuser(), 'uid': os.getuid(), 'home': pathlib.Path.home(), } conf = read_yaml(conf_file) osconf = conf.get(osname) if not osconf: print(f"ERROR: cannot find configuration for distribution {osname}") sys.exit(1) osconf['stamp'] = conf.get('stamp') osconf['osname'] = osname for k in [ 'dockerfile', 'packages', 'environment', 'binds' ]: if osconf.get(k): osconf[k] = [ s.format(**user_vars) for s in osconf[k]] return osconf def make_osname_link(binpath, osname): link = os.path.join(os.path.dirname(binpath), osname) try: os.symlink('runon', link) except FileExistsError: pass def build_image(conf, update, verbose): osname = conf.get('osname') image_name = 'runon-{}'.format(osname) cache_dir = os.path.join(xdg.BaseDirectory.xdg_cache_home, 'runon') cache_file = os.path.join(cache_dir, image_name) if not update and os.path.exists(cache_file): ts_image = datetime.datetime.fromtimestamp(os.path.getmtime(cache_file), tz=pytz.UTC) ts_conf = conf.get('stamp') if verbose: print('config: {}'.format(ts_conf)) print('image: {}'.format(ts_image)) if ts_image and ts_image > ts_conf: if verbose: print('image: {} up-to-date'.format(image_name)) return image_name image = conf.get('image') dockerfile = conf.get('dockerfile') pkginstall = conf.get('pkginstall') packages = conf.get('packages') if not dockerfile: dockerfile = [] if image: dockerfile.insert(0, 'FROM {}'.format(image)) if packages: for p in packages: dockerfile.append(pkginstall.format(p)) if verbose: print('Dockerfile:') for l in dockerfile: print(' * {}'.format(l)) print('Building image {} ...'.format(image_name)) cmd = ['podman', 'build'] if update: cmd.append('--no-cache') cmd += ['-t', image_name, '-'] ret = subprocess.run(cmd, input='\n'.join(dockerfile).encode('utf-8'), stderr=subprocess.STDOUT, check=True) if not os.path.exists(cache_dir): os.mkdir(cache_dir) with open(cache_file, 'w') as file: if verbose: print('cache: {}'.format(cache_file)) file.write('') return image_name def run_image(name, conf, command, verbose): volumes = { } environment = {} if conf.get('binds'): 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] if os.path.exists(vol): volumes[vol] = { 'bind': bind, 'mode': mode } if conf.get('environment'): for v in conf['environment']: e = v.split('=') if len(e) == 1: e.append(os.getenv(e[0])) environment[e[0]] = e[1] cmd = ['podman', 'run', '--rm', '--interactive', '--tty', '--userns=keep-id', '--net=host' ] for e in environment: cmd += [ '-e', '{}={}'.format(e, environment[e]) ] for v in volumes: cmd += [ '-v', ':'.join([v, volumes[v]['bind'], volumes[v]['mode']]) ] cmd += [ '--workdir', os.getcwd() ] cmd += [ name ] if command: cmd += command if verbose: print('executing: {}\n'.format(' '.join(cmd))) ret = subprocess.run(cmd) return ret.returncode def main(): osname = None run_name = os.path.basename(os.sys.argv[0]) if run_name == "runon": pass elif run_name.startswith("runon_"): osname = run_name[len("runon_"):] 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, ' '"edit" to open the current config file in a text editor.') 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 conf_file = find_config_file(args.config) if not conf_file: print('ERROR: config file not found') sys.exit(1) if args.osname == 'list': osnames = list_osnames(conf_file) print('Available distributions:') for o in sorted(osnames): print(' {}'.format(o)) print() return 0 elif args.osname == 'edit': cmd = [ 'xdg-open', conf_file ] ret = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); return 0 if args.link: make_osname_link(sys.argv[0], args.osname) conf = load_config(conf_file, args.osname) image_name = build_image(conf, args.update, args.verbose) ret = run_image(image_name, conf, args.command, args.verbose) return ret