#!/usr/bin/python3
import asyncio
import configparser
import ctypes
import ctypes.util
import datetime
import inspect
from shutil import copytree as copytree
if hasattr(inspect, 'getfullargspec') and 'dirs_exist_ok' in inspect.getfullargspec(copytree).args:
    def copy_tree(src, dst):
        copytree(src, dst, dirs_exist_ok=True)
else:
    from distutils.dir_util import copy_tree
import glob
import json
import argparse
import os
import platform
import pwd
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import time
try:
    import yaml
except ImportError:
    pass
path = os.path.dirname(os.path.realpath(__file__))
path = os.path.realpath(os.path.join(path, '..', 'lib', 'python'))
if path.startswith('/opt'):
    sys.path.append(path)

try:
    import confluent.osimage as osimage
except ImportError:
    osimage = None

libc = ctypes.CDLL(ctypes.util.find_library('c'))
CLONE_NEWNS = 0x00020000
CLONE_NEWCGROUP = 0x02000000
CLONE_NEWUTS = 0x04000000
CLONE_NEWIPC = 0x08000000
CLONE_NEWUSER = 0x10000000
CLONE_NEWPID = 0x20000000
PR_SET_NO_NEW_PRIVS = 38
PR_SET_DUMPABLE = 4
MS_RDONLY = 1
MS_REMOUNT = 32
MS_BIND = 4096
MS_REC = 16384
MS_PRIVATE = 1<<18

fallocate = libc.fallocate
fallocate.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int64, ctypes.c_int64]
fallocate.restype = ctypes.c_int
FALLOC_FL_KEEP_SIZE = 1
FALLOC_FL_PUNCH_HOLE = 2

numregex = re.compile('([0-9]+)')

def get_partition_info():
    with open('/proc/self/mountinfo') as procinfo:
        mountinfo = procinfo.read()
    capmounts = set([])
    for entry in mountinfo.split('\n'):
        if not entry:
            continue
        firstinf, lastinf = entry.split(' - ')
        root, mount = firstinf.split()[3:5]
        filesystem = lastinf.split()[0]
        if root != '/':
            continue
        if filesystem not in ('ext3', 'ext4', 'xfs', 'btrfs', 'vfat'):
            continue
        capmounts.add(mount)
    with open('/proc/mounts') as procmounts:
        mountinfo = procmounts.read()
    for entry in mountinfo.split('\n'):
        if not entry:
            continue
        dev, mount, fs, flags = entry.split()[:4]
        if mount not in capmounts:
            continue
        if '/dev/zram' in dev:
            continue
        fsinfo = os.statvfs(mount)
        partinfo = {
            'mount': mount,
            'filesystem': fs,
            'minsize': (fsinfo.f_blocks - fsinfo.f_bfree) * fsinfo.f_bsize,
            'initsize': fsinfo.f_blocks * fsinfo.f_bsize,
            'flags': flags,
            'device': dev,
        }
        yield partinfo

def sanitize_shadow(shadowfile):
    with open(shadowfile) as shadin:
        shadata = shadin.read()
    newshadow = ''
    for shadent in shadata.split('\n'):
        if not shadent:
            continue
        passent = shadent.split(':')
        if passent[1] not in ('*', '!!'):
            passent[1] = '!!'
        newshadow += ':'.join(passent) + '\n'
    return newshadow

class FileMasker():
    def __init__(self, mdir):
        self.mdir = mdir
        self.tmpfiles = []

    def __enter__(self):
        self.tmpfiles = []
        return self

    def __exit__(self, type, value, traceback):
        for tf in self.tmpfiles:
            os.unlink(tf)

    def mask(self, filename, maskwith=None):
        mdir = self.mdir
        if filename.startswith(mdir):
            filename = filename.replace(mdir, '', 1)
            if filename[0] == '/':
                filename = filename[1:]
            filename = os.path.join(mdir, filename)
            if filename[0] == '/':
                filename = filename[1:]
            filename = os.path.join('/run/imgutil/capin/', filename)
            for tfilename in glob.glob(filename):
                try:
                    secontext = os.getxattr(tfilename, 'security.selinux')
                except OSError:
                    secontext = None
                if maskwith is None:
                    tmaskwith = tempfile.mkstemp()
                    os.close(tmaskwith[0])
                    tmaskwith = tmaskwith[1]
                    self.tmpfiles.append(tmaskwith)
                else:
                    tmaskwith = maskwith
                _mount_file(tmaskwith, tfilename)
                if secontext:
                    secontext = secontext.split(b'\x00', 1)[0].decode('utf8')
                    subprocess.check_call(['chcon', secontext, tmaskwith])

def capture_fs(args):
    fsinfo, fname = args
    _mount(fsinfo['mount'], '/run/imgutil/capin', flags=MS_BIND|MS_RDONLY)
    targdir = None
    mdir = fsinfo['mount']
    with FileMasker(mdir) as masker:
        masker.mask('/etc/shadow', '/run/imgutil/captmp/shadow')
        masker.mask('/etc/gshadow', '/run/imgutil/captmp/gshadow')
        masker.mask('/etc/fstab', '/run/imgutil/captmp/fstab')
        masker.mask('/etc/confluent/confluent.apikey')
        masker.mask('/etc/shadow-')
        masker.mask('/etc/gshadow-')
        masker.mask('/etc/ssh/*key')
        masker.mask('/etc/pki/tls/private/*')
        masker.mask('/root/.ssh/id_*')
        masker.mask('/etc/netplan/*.yaml')
        subprocess.check_call(['mksquashfs', '/run/imgutil/capin', fname + '.sfs', '-comp', 'xz'])

def capture_local_cleanup():
    try:
        shutil.rmtree('/usr/lib/dracut/modules.d/97confluent')
    except Exception:
        pass
    subprocess.check_call(['umount', '/run/imgutil/capout'])

def build_boot_tree(targpath):
    if  glob.glob('/usr/lib/dracut/modules.d/97confluent/install*'):
        return build_el_boot_tree(targpath)
    elif glob.glob('/etc/initramfs-tools/'):
        return build_deb_boot_tree(targpath)

def build_deb_boot_tree(targpath):
    kver = os.uname().release
    mkdirp(os.path.join(targpath, 'boot/initramfs/'))
    subprocess.check_call(['mkinitramfs', '-o', os.path.join(targpath, 'boot/initramfs/distribution')])
    shutil.copy2('/boot/vmlinuz-{}'.format(kver), os.path.join(targpath, 'boot/kernel'))
    gather_bootloader(targpath)

def build_el_boot_tree(targpath):
    for dscript in glob.glob('/usr/lib/dracut/modules.d/97confluent/install*'):
        os.chmod(dscript, 0o755)
    kver = os.uname().release
    mkdirp(os.path.join(targpath, 'boot/initramfs/'))
    subprocess.check_call(['dracut', '-N', '--xz', '-m', 'confluent base terminfo', os.path.join(targpath, 'boot/initramfs/distribution')])
    shutil.copy2('/boot/vmlinuz-{}'.format(kver), os.path.join(targpath, 'boot/kernel'))
    gather_bootloader(targpath)


async def capture_remote(args):
    targ = args.node
    outdir = args.profilename
    os.umask(0o022)
    if '/' in outdir:
        raise Exception('Full path not supported, supply only the profile name')
    privdir = os.path.join('/var/lib/confluent/private/os/', outdir)
    outdir = os.path.join('/var/lib/confluent/public/os/', outdir)
    # need kernel, initramfs, shim, grub
    # maybe break pack_image into three, one that is common to call
    # with here locally,
    # another that is remotely called to gather target profile info
    # and a third that is exclusive to pack_image for diskless mode
    subprocess.check_call(['ssh', targ, 'mkdir', '-p', '/run/imgutil/capenv'])
    subprocess.check_call(['rsync', __file__, '{0}:/run/imgutil/capenv/'.format(targ)])
    finfo = subprocess.check_output(['ssh', targ, 'python3', '/run/imgutil/capenv/imgutil', 'getfingerprint']).decode('utf8')
    finfo = json.loads(finfo)
    if finfo['oscategory'] not in ('el8', 'el9', 'ubuntu20.04', 'ubuntu22.04', 'ubuntu24.04', 'ubuntu26.04'):
        sys.stderr.write('Not yet supported for capture: ' + repr(finfo) + '\n')
        sys.exit(1)
    unmet = finfo.get('unmetprereqs', [])
    if unmet:
        for cmd in unmet:
            sys.stderr.write(cmd + '\n')
        sys.exit(1)
    oscat = finfo['oscategory']
    subprocess.check_call(['ssh', '-o', 'LogLevel=QUIET', '-t', targ, 'python3', '/run/imgutil/capenv/imgutil', 'capturelocal'])
    utillib = __file__.replace('bin/imgutil', 'lib/imgutil')
    if oscat.startswith('ubuntu'):
        utillib = os.path.join(utillib, '{}/initramfs-tools/'.format(oscat))
        if not os.path.exists(utillib):
            raise Exception('Not yet supported for capture: ' + repr(finfo))
        subprocess.check_call(['rsync', '-a', utillib, '{0}:/etc/initramfs-tools'.format(targ)])
        subprocess.check_call(['ssh', '-o', 'LogLevel=QUIET', '-t', targ, 'chmod', '+x', '/etc/initramfs-tools/hooks/confluent'])
    else:
        utillib = os.path.join(utillib, '{}/dracut/'.format(oscat))
        if not os.path.exists(utillib):
            raise Exception('Not yet supported for capture: ' + repr(finfo))
        subprocess.check_call(['rsync', '-a', utillib, '{0}:/usr/lib/dracut/modules.d/97confluent'.format(targ)])
    sys.stdout.write('Generating deployment initramfs...')
    sys.stdout.flush()
    subprocess.check_call(['ssh', '-o', 'LogLevel=QUIET', '-t', targ, 'python3', '/run/imgutil/capenv/imgutil', 'capturelocalboot'])
    mkdirp(outdir)
    print('Done\nTransferring image...')
    sys.stdout.flush()
    subprocess.check_call(['rsync', '-a', '--info=progress2', '{0}:/run/imgutil/capout/'.format(targ), outdir])
    oum = os.umask(0o077)
    for path in ('/var/lib/confluent', '/var/lib/confluent/private', '/var/lib/confluent/private/os'):
        if not os.path.exists(path):
            mkdirp(path)
            subprocess.check_call(['chown', 'confluent', path])
    mkdirp(os.path.join(privdir, 'pending'))
    subprocess.check_call(['rsync', '-a', '{0}:/run/imgutil/private.key'.format(targ), '{}/pending/rootimg.key'.format(privdir)])
    os.umask(oum)
    subprocess.check_call(['chown', '-R', 'confluent', privdir])
    subprocess.check_call(['chmod', 'og-rwx', '-R', privdir])
    subprocess.check_call(['ssh', '-o', 'LogLevel=QUIET', '-t', targ, 'python3', '/run/imgutil/capenv/imgutil', 'capturelocalcleanup'])
    profname = os.path.basename(outdir)
    os.symlink('/var/lib/confluent/public/site/initramfs.cpio',
               os.path.join(outdir, 'boot/initramfs/site.cpio'))
    confdir = '/opt/confluent/lib/osdeploy/{}-diskless'.format(oscat)
    archaddon = '/opt/confluent/lib/osdeploy/{}-diskless/initramfs/{}/addons.cpio'.format(oscat, platform.machine())
    if os.path.exists(archaddon):
        os.symlink(archaddon, os.path.join(outdir, 'boot/initramfs/addons.cpio'))
    else:
        os.symlink('{}/initramfs/addons.cpio'.format(confdir),
                os.path.join(outdir, 'boot/initramfs/addons.cpio'))
    indir = '{}/profiles/default'.format(confdir)
    if os.path.exists(indir):
        copy_tree(indir, outdir)
        hmap = await osimage.get_hashes(outdir)
        with open('{0}/manifest.yaml'.format(outdir), 'w') as yout:
            yout.write('# This manifest enables rebase to know original source of profile data and if any customizations have been done\n')
            manifestdata = {'distdir': indir, 'disthashes': hmap}
            yout.write(yaml.dump(manifestdata, default_flow_style=False))
    label = '{0} {1} ({2})'.format(finfo['name'], finfo['version'], profname)
    with open(os.path.join(outdir, 'profile.yaml'), 'w') as profileout:
        profileout.write('label: {}\n'.format(label))
        profileout.write('kernelargs: quiet installtodisk # remove installtodisk to boot stateless')
    subprocess.check_call(['chmod', 'o+r,go-w,a-t', '-R', outdir])
    subprocess.check_call(['chown', '-R', 'confluent', outdir])
    sys.stdout.write('Updating boot image... ')
    sys.stdout.flush()
    subprocess.check_call(['osdeploy', 'updateboot', profname])


def capture_system():
    mkdirp('/run/imgutil/capout')
    _mount('none', '/run/imgutil/capout', 'tmpfs')
    run_constrained(capture_system_back, None)

def generalize_fstab():
    with open('/etc/fstab') as tabfile:
        fstab = tabfile.read().split('\n')
    newtab = ''
    for tab in fstab:
        tabent = tab.split('#', 1)[0]
        tabent = tabent.split()
        if len(tabent) >= 3 and tabent[2] in ('ext3', 'ext4', 'xfs', 'btrfs', 'vfat', 'swap'):
            newtab += tab.replace(tabent[0], '#ORIGFSTAB#' + tabent[0] + '#', 1) + '\n'
        else:
            newtab += tab + '\n'
    with open('/run/imgutil/captmp/fstab', 'w') as newtabout:
        newtabout.write(newtab)

def capture_system_back(args):
    newshadow = sanitize_shadow('/etc/shadow')
    newgshadow = sanitize_shadow('/etc/gshadow')
    mkdirp('/run/imgutil/capin')
    mkdirp('/run/imgutil/captmp')
    _mount('none', '/run/imgutil/captmp', 'tmpfs')
    generalize_fstab()
    with open('/run/imgutil/captmp/shadow', 'w') as shadowout:
        shadowout.write(newshadow)
    with open('/run/imgutil/captmp/gshadow', 'w') as shadowout:
        shadowout.write(newgshadow)
    with open('/run/imgutil/captmp/empty', 'w') as shadowout:
        pass
    i = 0
    todelete = []
    with open('/run/imgutil/capout/rootimg.sfs.plain', 'wb') as outimg:
        # Signature
        outimg.write(b'\x63\x7b\x9d\x26\xb7\xfd\x48\x30\x89\xf9\x11\xcf\x18\xfd\xff\xa1\x0fCONFLUENT_IMAGE')
        for fs in get_partition_info():
            fname = '{0:03d}'.format(i)  + fs['mount']
            i += 1
            fname = fname.replace('/', '_')
            if fname[-1] == '_':
                fname = fname[:-1]
            fname = os.path.join('/run/imgutil/capout', fname)
            run_constrained(capture_fs, (fs, fname))
            isize = os.stat(fname + '.sfs').st_size
            todelete.append(fname + '.sfs')
            outimg.write(struct.pack('!H', len(fs['mount'].encode('utf8'))))
            outimg.write(fs['mount'].encode('utf8'))
            fs['compressed_size'] = isize
            with open(fname + '.json', 'w') as fsinfout:
                fsinfout.write(json.dumps(fs))
            todelete.append(fname + '.json')
            jsize = os.stat(fname + '.json').st_size
            outimg.write(struct.pack('!I', jsize))
            with open(fname + '.json','rb') as fsinfoin:
                outimg.write(fsinfoin.read())
            outimg.write(struct.pack('!Q', fs['minsize']))
            outimg.write(struct.pack('!Q', fs['initsize']))
            outimg.write(struct.pack('!H', len(fs['filesystem'].encode('utf8'))))
            outimg.write(fs['filesystem'].encode('utf8'))
            outimg.write(struct.pack('!H', len(fs['device'].encode('utf8'))))
            outimg.write(fs['device'].encode('utf8'))
            # want to pad to 4096, the pad size (2 bytes) and image size
            # (8 bytes) will contribute to padding (or drive need for more)
            # padding
            pad = 4096 - ((outimg.tell() + 10) % 4096)
            outimg.write(struct.pack('!H', pad))
            if pad:
                outimg.write(b'\x00' * pad)
            outimg.write(struct.pack('!Q', isize))
            with open(fname + '.sfs', 'rb+') as imgin:
                lastoffset = 0
                currchunk = imgin.read(2097152)
                while currchunk:
                    fallocate(imgin.fileno(), FALLOC_FL_KEEP_SIZE|FALLOC_FL_PUNCH_HOLE, lastoffset, len(currchunk))
                    lastoffset = imgin.tell()
                    outimg.write(currchunk)
                    currchunk = imgin.read(2097152)
            pad = 4096 - (outimg.tell() % 4096)
            if pad < 4096:
                outimg.write(b'\x00' * pad)
    for fname in todelete:
        os.remove(fname)
    plainfile = '/run/imgutil/capout/rootimg.sfs.plain'
    cryptfile = '/run/imgutil/capout/rootimg.sfs'
    encrypt_image(plainfile, cryptfile, '/run/imgutil/private.key')
    os.remove(plainfile)

def encrypt_image(plainfile, cryptfile, keyfile):
    imgsize = os.stat(plainfile).st_size
    with open(cryptfile, 'wb') as outimg:
        outimg.write(b'\xaa\xd5\x0f\x7e\x5d\xfb\x4b\x7c\xa1\x2a\xf4\x0b\x6d\x94\xf7\xfc\x14CONFLUENT_CRYPTIMAGE')
        outimg.seek(imgsize + 4095)
        outimg.write(b'\x00')
    dmname = os.path.basename(tempfile.mktemp())
    key = os.urandom(32).hex()
    neededblocks = imgsize // 512
    if imgsize % 512:
        neededblocks += 1
    loopdev = subprocess.check_output(['losetup', '-f']).decode('utf8').strip()
    subprocess.check_call(['losetup', loopdev, cryptfile])
    subprocess.check_call(['dmsetup', 'create', dmname, '--table', '0 {} crypt aes-xts-plain64 {} 0 {} 8'.format(neededblocks, key, loopdev)])
    subprocess.check_call(['dmsetup', 'mknodes', dmname])
    with open('/dev/mapper/{}'.format(dmname), 'wb') as cryptout:
        with open(plainfile, 'rb+') as plainin:
            lastoffset = 0
            chunk = plainin.read(2097152)
            while chunk:
                fallocate(plainin.fileno(), FALLOC_FL_KEEP_SIZE|FALLOC_FL_PUNCH_HOLE, lastoffset, len(chunk))
                lastoffset = plainin.tell()
                cryptout.write(chunk)
                chunk = plainin.read(2097152)
    mounted = True
    tries = 30
    time.sleep(0.1)
    while mounted:
        tries -= 1
        try:
            subprocess.check_call(['dmsetup', 'remove', dmname])
            mounted = False
        except subprocess.CalledProcessError:
            time.sleep(0.1)
    subprocess.check_call(['losetup', '-d', loopdev])
    oum = os.umask(0o077)
    with open(keyfile, 'w') as keyout:
        keyout.write('aes-xts-plain64\n{}\n'.format(key))
    os.umask(oum)



def create_yumconf(sourcedir, addrepos):
    mkdirp('/run/imgutil/')
    repodir = tempfile.mkdtemp(prefix='genimage-yumrepos.d-', dir='/run/imgutil/')
    yumconf = open(os.path.join(repodir, 'repos.repo'), 'w+')
    if '/' not in sourcedir:
        sourcedir = os.path.join('/var/lib/confluent/distributions', sourcedir)
    if os.path.exists(sourcedir + '/repodata'):
        yumconf.write('[genimage-topdir]\n')
        yumconf.write('name=Local install repository\n')
        yumconf.write('baseurl=file://{0}\n'.format(sourcedir))
        yumconf.write('enabled=1\ngpgcheck=0\n\n')
    else:
        c = configparser.ConfigParser()
        c.read(sourcedir + '/.treeinfo')
        for sec in c.sections():
            if sec.startswith('variant-'):
                try:
                    repopath = c.get(sec, 'repository')
                except Exception:
                    continue
                _, varname = sec.split('-', 1)
                yumconf.write('[genimage-{0}]\n'.format(varname.lower()))
                yumconf.write('name=Local install repository for {0}\n'.format(varname))
                currdir = os.path.join(sourcedir, repopath)
                yumconf.write('baseurl={0}\n'.format(currdir))
                yumconf.write('enabled=1\ngpgcheck=0\n\n')
    addrepoidx = 1
    for repos in addrepos:
        for repo in repos.split(','):
            if not repo:
                continue
            yumconf.write('[addrepo-{0}]\n'.format(addrepoidx))
            yumconf.write('name=Add-on repository {0}\n'.format(addrepoidx))
            yumconf.write('baseurl={0}\n'.format(repo))
            yumconf.write('enabled=1\ngpgcheck=0\n\n')
            addrepoidx += 1
    return repodir

def get_mydir(oscategory):
    mydir = os.path.dirname(__file__)
    mycopy = os.path.join(mydir, oscategory)
    gencopy = os.path.join('/opt/confluent/lib/imgutil', oscategory)
    if os.path.exists(mycopy):
        return mycopy
    return gencopy

class OsHandler(object):
    def __init__(self, name, version, arch, args):
        self.name = name
        self._interactive = True
        self.version = version
        self.arch = arch
        self.sourcepath = None
        self.osname = '{}-{}-{}'.format(name, version, arch)
        self.captureprereqs = []
        try:
            pkglist = args.packagelist
        except AttributeError:
            pkglist = ''
        self.addpkglists = []
        if hasattr(args, 'addpackagelist'):
            for plist in args.addpackagelist:
                if os.path.exists(os.path.abspath(plist)):
                    plist = os.path.abspath(plist)
                self.addpkglists.append(plist)
        if pkglist:
            if os.path.exists(os.path.abspath(pkglist)):
                pkglist = os.path.abspath(pkglist)
            self.pkglist = pkglist
            if '/' not in self.pkglist:
                self.pkglist = os.path.join(get_mydir(self.oscategory), self.pkglist)
        else:
            self.pkglist = os.path.join(get_mydir(self.oscategory), 'pkglist')
        try:
            self.addrepos = args.addrepos
        except AttributeError:
            self.addrepos = []

    def set_interactive(self, shouldbeinteractive):
        self._interactive = shouldbeinteractive

    def get_json(self):
        odata = [self.oscategory, self.version, self.arch, self.name]
        for idx in range(len(odata)):
            if not isinstance(odata[idx], str):
                odata[idx] = odata[idx].decode('utf8')
        info = {'oscategory': odata[0],
                'version': odata[1], 'arch': odata[2], 'name': odata[3], 'unmetprereqs': self.captureprereqs}
        return json.dumps(info)

    def prep_root_premount(self, args):
        pass

    def prep_root(self, args):
        pass

    def list_packages(self, pkglistfile=None):
        addpkgs = []
        if pkglistfile is None:
            pkglistfile = self.pkglist
            addpkgs = self.addpkglists
        pkglistfile = pkglistfile.strip()
        if pkglistfile[-1] == '>':
            pkglistfile = pkglistfile[:-1]
        with open(pkglistfile, 'r') as pkglist:
            pkgs = ''
            ignoremode = False
            for line in pkglist.readlines():
                line = line.split('#', 1)[0].strip()
                if line.startswith(r'%onlyarch '):
                    archs = line[len(r'%onlyarch '):].split()
                    if self.arch not in archs:
                        ignoremode = True
                    else:
                        ignoremode = False
                    continue
                if line.startswith(r'%onlyversions '):
                    vers = line[len(r'%onlyversions '):].split()
                    if self.version not in vers:
                        ignoremode = True
                    else:
                        ignoremode = False
                    continue
                if not ignoremode:
                    pkgs += line + ' '
            pkgs = pkgs.split()
        retpkgs = []
        for pkg in pkgs:
            if not pkg:
                continue
            if pkg[0] == '<':  # Include from specified file
                subfilename = pkg[1:]
                if subfilename[-1] == '>':
                    subfilename = subfilename[:-1]
                if subfilename[0] != '/':
                    subfilename = os.path.join(os.path.dirname(pkglistfile), subfilename)
                retpkgs.extend(self.list_packages(subfilename))
            else:
                retpkgs.append(pkg)
        for addpkglist in addpkgs:
            retpkgs.extend(self.list_packages(addpkglist))
        return retpkgs

class SuseHandler(OsHandler):
    def __init__(self, name, version, arch, args):
        if not isinstance(version, str):
            version = version.decode('utf8')
        if not version.startswith('15.'):
            raise Exception('Unsupported Suse version {}'.format(version))
        self.oscategory = 'suse15'
        super().__init__(name, version, arch, args)
        self.zyppargs = []
        self.sources = []

    def set_target(self, targpath):
        self.targpath = targpath

    def add_pkglists(self):
        self.zyppargs.extend(self.list_packages())

    def set_source(self, sourcepath):
        self.sources.append('file://' + sourcepath)
        enterprise = False
        for moddir in glob.glob(sourcepath + '/Module-*'):
            self.sources.append('file://' + moddir)
            enterprise = True
        if enterprise:
            self.sources.append('file://' + os.path.join(sourcepath, 'Product-HPC'))

    def prep_root(self, args):
        gpgkeys = []
        mkdirp(self.targpath)
        if not self.sources:
            gpgkeys = glob.glob('/usr/lib/rpm/gnupg/keys/*.asc')
            targzypp = os.path.join(self.targpath, 'etc/zypp')
            mkdirp(targzypp)
            shutil.copytree(
                '/etc/zypp/repos.d/', os.path.join(targzypp, 'repos.d'))
        idx = 1
        for source in self.sources:
            if not source:
                continue
            if source.startswith('file://'):
                gpgpath = source.replace('file://', '')
                gpgkeys.extend(glob.glob(os.path.join(gpgpath, '*/gpg-pubkey*.asc')))
            subprocess.check_call(['zypper', '-R', self.targpath, 'ar', source, 'source-{}'.format(idx)])
            idx += 1
        if gpgkeys:
                addkeycmd = ['rpm', '--root', self.targpath, '--import'] + gpgkeys
                subprocess.check_call(addkeycmd)
        for sources in self.addrepos:
            for source in sources.split(','):
                if not source:
                    continue
                if not source.startswith('/') and os.path.exists(os.path.abspath(source)):
                    source = os.path.abspath(source)
                source = 'file://' + source
                subprocess.check_call(['zypper', '-R', self.targpath, 'ar', source, 'source-{}'.format(idx)])
                idx += 1
        mydir = get_mydir(self.oscategory)
        mkdirp(os.path.join(self.targpath, 'usr/lib/dracut/modules.d'))
        mkdirp(os.path.join(self.targpath, 'etc/dracut.conf.d'))
        dracutdir = os.path.join(mydir, 'dracut')
        targdir = os.path.join(self.targpath, 'usr/lib/dracut/modules.d/97diskless')
        shutil.copytree(dracutdir, targdir)
        with open(os.path.join(self.targpath, 'etc/dracut.conf.d/diskless.conf'), 'w') as dracutconf:
            dracutconf.write('compress=xz\nhostonly=no\ndracutmodules+=" diskless base terminfo "\n')
        cmd = ['chmod', 'a+x']
        cmd.extend(glob.glob(os.path.join(targdir, '*')))
        subprocess.check_call(cmd)
        if self._interactive:
            subprocess.check_call(['zypper', '-R', self.targpath, 'install'] + self.zyppargs)
        else:
            subprocess.check_call(['zypper', '-n', '-R', self.targpath, 'install'] + self.zyppargs)
        os.symlink('/usr/lib/systemd/system/sshd.service', os.path.join(self.targpath, 'etc/systemd/system/multi-user.target.wants/sshd.service'))
        with open(os.path.join(self.targpath, 'etc/permissions.local'), 'a') as permout:
            permout.write(
                '/usr/lib/ssh/ssh-keysign root:ssh_keys 2711\n'
                '/etc/ssh/ssh_host_dsa_key root:ssh_keys 640\n'
                '/etc/ssh/ssh_host_ecdsa_key root:ssh_keys 640\n'
                '/etc/ssh/ssh_host_ed25519_key root:ssh_keys 640\n'
                '/etc/ssh/ssh_host_rsa_key root:ssh_keys 640\n'
            )
        args.cmd = ['groupadd', 'ssh_keys']
        run_constrainedx(fancy_chroot, (args,
                                        self.targpath))
        args.cmd = ['chkstat', '--system', '--set'],
        run_constrainedx(fancy_chroot, (args,
                                        self.targpath))


        if os.path.exists(os.path.join(self.targpath, 'sbin/mkinitrd')):
            args.cmd = ['mkinitrd']
        else:
            args.cmd = ['dracut', '-f']
        run_constrainedx(fancy_chroot, (args, self.targpath))


class DebHandler(OsHandler):
    def __init__(self, name, version, arch, args, codename, hostpath):
        self.includepkgs = []
        self.targpath = None
        self.codename = codename
        self.oscategory = name + version
        super().__init__(name, version, arch, args)
        needpkgs = []
        if not os.path.exists(os.path.join(hostpath, 'usr/bin/tpm2_getcap')):
            needpkgs.append('tpm2-tools')
        lfuses = glob.glob(os.path.join(hostpath, '/lib/*/libfuse.so.2'))
        if not lfuses:
            needpkgs.append('libfuse2')
        if needpkgs:
            needapt = 'Missing packages needed in target for capture, to add required packages: apt install ' + ' '.join(needpkgs)
            self.captureprereqs.append(needapt)

    def add_pkglists(self):
        self.includepkgs.extend(self.list_packages())

    def set_target(self, targpath):
        self.targpath = targpath

    def prep_root_premount(self, args):
        mkdirp(os.path.join(self.targpath, 'etc'))
        mydir = get_mydir(self.oscategory)
        srcdir = os.path.join(mydir, 'initramfs-tools')
        targdir = os.path.join(self.targpath, 'etc/initramfs-tools')
        shutil.copytree(srcdir, targdir)
        os.chmod(os.path.join(targdir, 'hooks/confluent'), 0o755)
        #cmd = ['debootstrap', '--include={0}'.format(','.join(self.includepkgs)), self.codename, self.targpath]
        cmd = ['debootstrap', self.codename, self.targpath]
        subprocess.check_call(cmd)

    def _apt(self, args, cmd):
        args.cmd = ['env', 'DEBIAN_FRONTEND=noninteractive'] + cmd
        rc = run_constrainedx(fancy_chroot, (args, self.targpath))
        if rc:
            sys.stderr.write('{0} failed with exit code {1}\n'.format(
                ' '.join(cmd), rc))
            raise SystemExit(rc)

    def prep_root(self, args):
        mkdirp(os.path.join(self.targpath, 'etc/apt'))
        for aptdir in ('sources.list.d', 'trusted.gpg.d', 'keyrings'):
            mkdirp(os.path.join(self.targpath, 'etc/apt', aptdir))
        shutil.copy('/etc/apt/sources.list', os.path.join(self.targpath, 'etc/apt/sources.list'))
        for listfile in glob.glob('/etc/apt/sources.list.d/*'):
            shutil.copy(listfile, os.path.join(self.targpath, listfile[1:]))
        for keydir in ('/etc/apt/trusted.gpg.d', '/etc/apt/keyrings'):
            for keyfile in glob.glob(os.path.join(keydir, '*')):
                if not os.path.isfile(keyfile):
                    continue
                tkeyfile = os.path.join(
                    self.targpath, os.path.relpath(keyfile, '/'))
                mkdirp(os.path.dirname(tkeyfile))
                shutil.copy(keyfile, tkeyfile)
        if os.path.exists('/etc/apt/trusted.gpg'):
            trustedgpg = os.path.join(self.targpath, 'etc/apt/trusted.gpg')
            mkdirp(os.path.dirname(trustedgpg))
            shutil.copy(
                '/etc/apt/trusted.gpg', trustedgpg)
        self._apt(args, ['apt-get', 'update'])
        if self.includepkgs:
            self._apt(args, ['apt-get', '-y', 'install'] + self.includepkgs)
        servicefile = os.path.join(
            self.targpath, 'usr/lib/systemd/system/ssh.service')
        if os.path.exists(servicefile):
            targfile = os.path.join(
                self.targpath,
                'etc/systemd/system/multi-user.target.wants/ssh.service')
            if not os.path.exists(targfile):
                os.symlink('/usr/lib/systemd/system/ssh.service', targfile)
        else:
            targfile = os.path.join(
                self.targpath,
                'etc/systemd/system/multi-user.target.wants/sshd.service')
            if not os.path.exists(targfile):
                os.symlink('/usr/lib/systemd/system/sshd.service', targfile)



class ElHandler(OsHandler):
    def __init__(self, name, version, arch, args, hostpath='/'):
        self.oscategory = 'el{0}'.format(version.split('.')[0])
        self.yumargs = []
        super().__init__(name, version, arch, args)
        needpkgs = []
        if not hostpath:
            return
        if not os.path.exists(os.path.join(hostpath, 'usr/bin/tpm2_getcap')):
            needpkgs.append('tpm2-tools')
        lfuses = glob.glob(os.path.join(hostpath, '/usr/lib64/libfuse.so.2'))
        if not lfuses:
            needpkgs.append('fuse-libs')
        if not os.path.exists(os.path.join(hostpath, '/usr/bin/ipcalc')):
            needpkgs.append('ipcalc')
        if not os.path.exists(os.path.join(hostpath, 'usr/sbin/dhclient')):
            needpkgs.append('dhcp-client')
        if not os.path.exists(os.path.join(hostpath, 'usr/sbin/mount.nfs')):
            needpkgs.append('nfs-utils')
        if needpkgs:
            needapt = 'Missing packages needed in target for capture, to add required packages: dnf install ' + ' '.join(needpkgs)
            self.captureprereqs.append(needapt)

    def add_pkglists(self):
        self.yumargs.extend(self.list_packages())

    def set_source(self, sourcepath):
        yumconfig = create_yumconf(sourcepath, self.addrepos)
        self.yumargs.extend(
            ['--setopt=reposdir={0}'.format(yumconfig), '--disablerepo=*',
             '--enablerepo=genimage-*'])
        if self.addrepos:
            self.yumargs.extend(['--enablerepo=addrepo-*'])
        self.sourcepath = sourcepath

    def set_target(self, targpath):
        self.targpath = targpath
        self.yumargs.extend(
            ['--installroot={0}'.format(targpath),
             '--releasever={0}'.format(self.version), 'install'])

    def prep_root(self, args):
        mkdirp(os.path.join(self.targpath, 'usr/lib/dracut/modules.d'))
        mkdirp(os.path.join(self.targpath, 'etc/dracut.conf.d'))
        open(os.path.join(self.targpath, 'etc/resolv.conf'),'w').close()
        mydir = get_mydir(self.oscategory)
        dracutdir = os.path.join(mydir, 'dracut')
        targdir = os.path.join(self.targpath, 'usr/lib/dracut/modules.d/97diskless')
        shutil.copytree(dracutdir, targdir)
        with open(os.path.join(self.targpath, 'etc/dracut.conf.d/diskless.conf'), 'w') as dracutconf:
            dracutconf.write('compress=xz\nhostonly=no\ndracutmodules+=" diskless base terminfo "\n')
        cmd = ['chmod', 'a+x']
        cmd.extend(glob.glob(os.path.join(targdir, '*')))
        subprocess.check_call(cmd)
        if self._interactive:
            subprocess.check_call(['yum'] + self.yumargs)
        else:
            subprocess.check_call(['yum', '-y'] + self.yumargs)
        try:
            with open(os.path.join(self.targpath, 'etc/selinux/config'), 'r') as seconfigin:
                seconfig = seconfigin.read().replace('SELINUX=enforcing', 'SELINUX=disabled')
            with open(os.path.join(self.targpath, 'etc/selinux/config'), 'w') as seconfigout:
                seconfigout.write(seconfig)
        except Exception:  # this might not exist in all images
            pass
        with open('/proc/mounts') as mountinfo:
            for line in mountinfo.readlines():
                if line.startswith('selinuxfs '):
                    break
            else:
                try:
                    self.relabel_targdir()
                except subprocess.CalledProcessError:
                    # Some filesystem contexts can not accommodate the selinux labels, warn that
                    # this failed, but allow it to proceed in case it can boot anyway
                    sys.stderr.write('Warning: could not relabel target filesystem for SELinux\n')

    def relabel_targdir(self):
        subprocess.check_call(
            ['setfiles', '-r', self.targpath,
             '/etc/selinux/targeted/contexts/files/file_contexts',
             self.targpath])


def versionize_string(key):
    """Analyzes string in a human way to enable natural sort

    :param nodename: The node name to analyze
    :returns: A structure that can be consumed by 'sorted'
    """
    versionlist = []
    patchlist = []
    addto = versionlist
    for part in re.split(numregex, key):
        if part in ('', '.'):
            continue
        if part == '-':
            addto = patchlist
            continue
        if not part.isdigit():
            break
        addto.append(int(part))
    return [versionlist, patchlist]



def version_sort(iterable):
    """Return a sort using natural sort if possible

    :param iterable:
    :return:
    """
    try:
        return sorted(iterable, key=versionize_string)
    except TypeError:
        # The natural sort attempt failed, fallback to ascii sort
        return sorted(iterable)


def get_kern_version(filename):
    with open(filename, 'rb') as kernfile:
        header = kernfile.read(2)
        if header == b'\x1f\x8b':
            # gzipped... we can't process this right now,
            # assume the filename has the version embedded
            return os.path.basename(filename).replace('vmlinuz-', '')
        if header == b'MZ':
            # PE format, no easy way to get version, assume filename has it
            return os.path.basename(filename).replace('vmlinuz-', '')                
        kernfile.seek(0x20e)
        offset = struct.unpack('<H', kernfile.read(2))[0] + 0x200
        kernfile.seek(offset)
        verinfo = kernfile.read(128)
        version, _ = verinfo.split(b' ', 1)
        if not isinstance(version, str):
            version = version.decode('utf8')
        return version


def mkdirp(path):
    try:
        os.makedirs(path)
    except OSError as e:
        if e.errno != 17:
            raise


def _exitcode_from_status(status):
    if os.WIFEXITED(status):
        return os.WEXITSTATUS(status)
    if os.WIFSIGNALED(status):
        return 128 + os.WTERMSIG(status)
    return 1

def run_constrainedx(function, args):
    # first fork to avoid changing namespace of unconstrained environment
    pid = os.fork()
    if pid:
        _, status = os.waitpid(pid, 0)
        return _exitcode_from_status(status)
    libc.unshare(CLONE_NEWNS|CLONE_NEWPID)
    # must fork again due to CLONE_NEWPID, or else lose the ability to make
    # subprocesses
    pid = os.fork()
    if pid:
        _, status = os.waitpid(pid, 0)
        os._exit(_exitcode_from_status(status))
    # we are pid 1 now
    _mount('none', '/', flags=MS_REC|MS_PRIVATE)
    _mount('proc', '/proc', fstype='proc')
    function(*args)
    os._exit(0)

def run_constrained(function, args):
    # first fork to avoid changing namespace of unconstrained environment
    pid = os.fork()
    if pid:
        _, status = os.waitpid(pid, 0)
        return _exitcode_from_status(status)
    libc.unshare(CLONE_NEWNS|CLONE_NEWPID)
    # must fork again due to CLONE_NEWPID, or else lose the ability to make
    # subprocesses
    pid = os.fork()
    if pid:
        _, status = os.waitpid(pid, 0)
        os._exit(_exitcode_from_status(status))
    # we are pid 1 now
    _mount('none', '/', flags=MS_REC|MS_PRIVATE)
    _mount('proc', '/proc', fstype='proc')
    function(args)
    os._exit(0)


def main():
    if sys.argv[1] == 'getfingerprint':
        print(fingerprint_host(None).get_json())
        return
    elif sys.argv[1] == 'capturelocal':
        capture_system()
        return
    elif sys.argv[1] == 'capturelocalboot':
        build_boot_tree('/run/imgutil/capout')
        return
    elif sys.argv[1] == 'capturelocalcleanup':
        capture_local_cleanup()
        return
    parser = argparse.ArgumentParser(description='Work with confluent OS cloning and diskless images')
    sps = parser.add_subparsers(dest='subcommand')
    buildp = sps.add_parser('build', help='Build a new diskless image from scratch')
    buildp.add_argument('-r', '--addrepos', help='Repositories to add in addition to the main source', default=[], action='append')
    buildp.add_argument('-p', '--packagelist', help='Filename of package list to replace default pkglist', default='')
    buildp.add_argument('-a', '--addpackagelist', action='append', default=[],
                        help='A list of additional packages to include, may be specified multiple times')
    buildp.add_argument('-s', '--source', help='Directory to pull installation from, typically a subdirectory of /var/lib/confluent/distributions. By default, the repositories for the build system are used.  For Ubuntu, this is not supported, the build system repositories are always used.')
    buildp.add_argument('-y', '--non-interactive', help='Avoid prompting for confirmation', action='store_true')
    buildp.add_argument('-v', '--volume',
        help='Directory to make available in the build environment.  -v / will '
        'cause it to be mounted in image as /run/external/, -v /:/run/root '
        'will override the target to be /run/root, and something like /var/lib/repository:- will cause it to mount to the identical path inside the image', action='append')
    buildp.add_argument('scratchdir', help='Directory to build new diskless root in')
    execp = sps.add_parser('exec', help='Start specified scratch directory as container')
    execp.add_argument('-v', '--volume',
        help='Directory to make available in the build environment.  -v / will '
        'cause it to be mounted in image as /run/external/, -v /:/run/root '
        'will override the target to be /run/root', action='append')
    execp.add_argument('scratchdir', help='Directory of an unpacked diskless root')
    execp.add_argument('cmd', nargs='*', help='Optional command to run (defaults to a shell)')
    unpackp = sps.add_parser('unpack', help='Unpack a diskless image to a scratch directory')
    unpackp.add_argument('profilename', help='The diskless OS profile to unpack')
    unpackp.add_argument('scratchdir', help='Directory to extract diskless root to')
    packp = sps.add_parser('pack', help='Pack a scratch directory to a diskless profile')
    packp.add_argument('scratchdir', help='Directory containing diskless root')
    packp.add_argument('profilename', help='The desired diskless OS profile name to pack the root into')
    packp.add_argument('-b', '--baseprofile', help='Profile to copy extra info from, for example to make a new version of an existing profile, reference the previous one as baseprofile', default=None)
    packp.add_argument('-u', '--unencrypted', help='Pack an unencrypted image rather than encrypting', action='store_true')
    capturep = sps.add_parser('capture', help='Capture an image for cloning from a running system')
    capturep.add_argument('node', help='Node to capture image from')
    capturep.add_argument('profilename', help='Profile name for captured image')
    args = parser.parse_args()
    if not args or not args.subcommand:
        parser.print_usage()
        sys.exit(1)
    if args.subcommand == 'build':
        build_root(args)
    elif args.subcommand == 'capture':
        asyncio.run(capture_remote(args))
    elif args.subcommand == 'unpack':
        unpack_image(args)
    elif args.subcommand == 'exec':
        exec_root(args)
    elif args.subcommand == 'pack':
        asyncio.run(pack_image(args))
    else:
        parser.print_usage()


def exec_root(args):
    exitcode = run_constrained(exec_root_backend, args)
    if exitcode:
        sys.exit(exitcode)

def _mount_file(source, dst):
    _mount(source, dst, flags=MS_BIND|MS_RDONLY)
    _mount('none', dst, flags=MS_RDONLY|MS_REMOUNT|MS_BIND)

def exec_root_backend(args):
    installroot = args.scratchdir
    _mount_constrained_fs(args, installroot)
    fancy_chroot(args, installroot)

def fancy_chroot(args, installroot):
    imgname = os.path.basename(installroot)
    oshandler = fingerprint_host(args, args.scratchdir)
    if oshandler:
        try:
            with open(os.path.join(installroot, 'etc/confluentimg.buildinfo')) as binfo:
                for line in binfo.readlines():
                    if '=' in line:
                        k, v = line.split('=', 1)
                        if k == 'BUILDSRC':
                            dst = os.path.join(installroot, 'run/confluentdistro')
                            dst = os.path.abspath(dst)
                            os.makedirs(dst)
                            try:
                                _mount(v.strip(), dst, flags=MS_BIND|MS_RDONLY)
                            except Exception:
                                oshandler = None
                            break
                else:
                    oshandler = None
        except FileNotFoundError:
            oshandler = None
    sourceresolv = '/etc/resolv.conf'
    if os.path.islink(sourceresolv):
        sourceresolv = os.readlink(sourceresolv)
    # normalize and resolve relative and absolute paths
    sourceresolv = os.path.normpath(os.path.join('/etc', sourceresolv))
    dstresolv = os.path.join(installroot, 'etc/resolv.conf')
    if os.path.islink(dstresolv):
        dstresolv = os.path.join(installroot, os.readlink(dstresolv)[1:])
    if not os.path.exists(dstresolv):
        mkdirp(os.path.dirname(dstresolv))
        open(dstresolv, 'w').close()
    _mount(sourceresolv, dstresolv, flags=MS_BIND|MS_RDONLY)
    _mount('none', dstresolv, flags=MS_RDONLY|MS_REMOUNT|MS_BIND)
    os.chroot(installroot)
    os.chdir('/')
    os.environ['PS1'] = '[\x1b[1m\x1b[4mIMGUTIL EXEC {0}\x1b[0m \\W]$ '.format(imgname)
    os.environ['CONFLUENT_IMGUTIL_MODE'] = 'exec'
    if oshandler:
        oshandler.set_source('/run/confluentdistro')
    if args.cmd:
        if not args.cmd[0].startswith('/'):
            args.cmd[0] = shutil.which(args.cmd[0])
        os.execv(args.cmd[0], args.cmd)
    else:
        os.execv('/bin/bash', ['/bin/bash', '--login', '--noprofile'])


def _mount(src, dst, fstype=0, flags=0, options=0, mode=None):
    if not isinstance(src, bytes):
        src = src.encode('utf8')
    if fstype and not isinstance(fstype, bytes):
        fstype = fstype.encode('utf8')
    if not isinstance(dst, bytes):
        dst = dst.encode('utf8')
    res = libc.mount(src, dst, fstype, flags, options)
    if res:
        raise Exception('Unable to mount {0} on {1}'.format(src, dst))
    if mode is not None:
        os.chmod(dst, mode)


def build_root_backend(optargs):
    args, oshandler = optargs
    installroot = args.scratchdir
    oshandler.prep_root_premount(args)
    _mount_constrained_fs(args, installroot)
    oshandler.prep_root(args)
    mkdirp(os.path.join(installroot, 'etc/'))
    with open(os.path.join(installroot, 'etc/confluentimg.buildinfo'), 'w') as imginfo:
        imginfo.write('BUILDDATE={}\n'.format(datetime.datetime.now().strftime('%Y-%m-%dT%H:%M')))
        if args.source:
            imginfo.write('BUILDSRC={}\n'.format(args.source))


def _mount_constrained_fs(args, installroot):
    # This is prepping for a chroot.
    # For the target environment to be content with having a root
    # filesystem, installroot must be a 'mount' entry of it's own,
    # so bind mount to itself to satisfy
    _mount(installroot, installroot, flags=MS_BIND)
    _mount('/dev', os.path.join(installroot, 'dev'), flags=MS_BIND|MS_RDONLY)
    _mount('/dev/pts', os.path.join(installroot, 'dev/pts'), flags=MS_BIND|MS_RDONLY)
    _mount('proc', os.path.join(installroot, 'proc'), fstype='proc')
    _mount('sys', os.path.join(installroot, 'sys'), fstype='sysfs')
    _mount('runfs', os.path.join(installroot, 'run'), fstype='tmpfs')
    if args.volume is None:
        args.volume = []
    for v in args.volume:
        if ':' in v:
            src, dst = v.split(':')
            if dst == '-':
                dst = src
            while dst and dst[0] == '/':
                dst = dst[1:]
            dst = os.path.join(installroot, dst)
        else:
            src = v
            dst = os.path.join(installroot, 'run/external')
            while v and v[0] == '/':
                v = v[1:]
            dst = os.path.join(dst, v)
        mkdirp(dst)
        _mount(src, dst, flags=MS_BIND|MS_RDONLY)

def check_root(installroot):
    # Ensure that the target is an adequate filesystem to
    # be root
    mkdirp(installroot)


def fingerprint_source_suse(files, sourcepath, args):
    if os.path.exists(os.path.join(sourcepath, 'distinfo.yaml')):
        with open(os.path.join(sourcepath, 'distinfo.yaml'), 'r') as distinfo:
            di = distinfo.read()
            issuse = False
            osname, ver, arch = (None, None, None)
            for line in di.split('\n'):
                if ': ' not in line:
                    continue
                key, val = line.split(': ')
                if key == 'category' and val == 'suse15':
                    issuse = True
                if key == 'name':
                    osname, ver, arch = val.split('-')
            if issuse:
                return SuseHandler(osname, ver, arch, args)

    for filen in files:
        if '-release-8' in filen:
            parts = filen.split('-')
            osname = '_'.join(parts[:-3])
            if osname == 'centos_linux':
                osname = 'centos'
            ver = parts[-2]
            arch = parts[-1].split('.')[-2]
            if arch == 'noarch':
                prodinfo = open(os.path.join(sourcepath, '.discinfo')).read()
                arch = prodinfo.split('\n')[2]
            return ElHandler(osname, ver, arch, args)
    return None


def fingerprint_source_el(files, sourcepath, args):
    for filen in files:
        if '-release-10' in filen or '-release-8' in filen or '-release-7' in filen or '-release-9' in filen:
            parts = filen.split('-')
            osname = '_'.join(parts[:-3])
            if osname == 'centos_linux':
                osname = 'centos'
            ver = parts[-2]
            arch = parts[-1].split('.')[-2]
            if arch == 'noarch':
                prodinfo = open(os.path.join(sourcepath, '.discinfo')).read()
                arch = prodinfo.split('\n')[2]
            return ElHandler(osname, ver, arch, args, None)
    return None


def fingerprint_source(sourcepath, args):
    oshandler = None
    funs = [fingerprint_source_el, fingerprint_source_suse]
    for _, _, files in os.walk(sourcepath):
        for ffun in funs:
            oshandler = ffun(files, sourcepath, args)
            if oshandler is not None:
                return oshandler
    return oshandler

def fingerprint_host_el(args, hostpath='/'):
    release = ''
    if hostpath[0] != '/':
         hostpath = os.path.join(os.getcwd(), hostpath)
    try:
        import rpm
        ts = rpm.TransactionSet(hostpath)
        rpms = ts.dbMatch('provides', 'system-release')
        for inf in rpms:
            if 'el8' not in inf.release and 'el7' not in inf.release and 'el9' not in inf.release and 'el10' not in inf.release:
                continue
            osname = inf.name
            version = inf.version
            release = inf.release
    except ImportError:
        try:
            rver = subprocess.check_output('rpm --root {0} -q --whatprovides system-release'.format(hostpath).split())
            if not isinstance(rver, str):
                rver = rver.decode('utf8')
            for infline in subprocess.check_output('rpm -qi {0}'.format(rver).split()).decode('utf8').split('\n'):
                if ':' not in infline:
                    continue
                k, v = infline.split(':', 1)
                k = k.strip()
                v = v.strip()
                if k == 'Name':
                    osname = v
                elif k == 'Release':
                    release = v
                elif k == 'Version':
                    version = v
        except (subprocess.SubprocessError, FileNotFoundError):
            return None
    if 'el8' not in release and 'el7' not in release and 'el9' not in release and 'el10' not in release:
        return None
    osname = osname.replace('-release', '').replace('-', '_')
    if osname == 'centos_linux':
        osname = 'centos'
    return ElHandler(osname, version, os.uname().machine, args, hostpath)


def fingerprint_host_deb(args, hostpath='/'):
    osrelfile = os.path.join(hostpath, 'etc/os-release')
    osname = None
    codename = None
    try:
        with open(osrelfile, 'r') as relfile:
            relinfo = relfile.read().split('\n')
            for inf in relinfo:
                if '=' not in inf:
                    continue
                key, val = inf.split('=', 1)
                if key == 'ID':
                    if val.lower().replace('"', '') == 'ubuntu':
                        osname = 'ubuntu'
                elif 'VERSION_CODENAME' == key:
                    codename = val.lower().replace('"', '')
                elif key == 'VERSION_ID':
                    vers = val.replace('"', '')
    except IOError:
        pass
    if osname:
        return DebHandler(osname, vers, os.uname().machine, args, codename, hostpath)


def fingerprint_host_suse(args, hostpath='/'):
    vers = None
    osname = None
    try:
        with open(os.path.join(hostpath, 'etc/os-release')) as relfile:
            relinfo = relfile.read().split('\n')
            for inf in relinfo:
                if '=' in inf:
                    key, val = inf.split('=', 1)
                    if key == 'ID':
                        if val.lower().replace('"', '') == 'opensuse-leap':
                            osname = 'opensuse_leap'
                        elif val.lower().replace(
                                '"', '') in ('sle_hpc', 'sles'):
                            osname = 'sle'
                    elif key == 'VERSION_ID':
                        vers = val.replace('"', '')
    except IOError:
        pass
    if osname:
        return SuseHandler(osname, vers, os.uname().machine, args)


def fingerprint_host(args, hostpath='/'):
    oshandler = None
    for fun in [fingerprint_host_el, fingerprint_host_suse, fingerprint_host_deb]:
        oshandler = fun(args, hostpath)
        if oshandler is not None:
            return oshandler
    return oshandler

def build_root(args):
    if args.scratchdir:
        args.scratchdir = os.path.abspath(args.scratchdir)
    check_root(args.scratchdir)
    yumargs = ['yum', '--installroot={0}'.format(args.scratchdir)]
    if args.source:
        if '/' not in args.source and not os.path.exists(args.source):
            args.source = os.path.join('/var/lib/confluent/distributions/', args.source)
        oshandler = fingerprint_source(args.source, args)
        if oshandler is not None:
            oshandler.set_source(args.source)
    else:
        oshandler = fingerprint_host(args)
    if oshandler is None:
        if args.source:
            sys.stderr.write(
                'Unable to recognize source directory {0}\n'.format(
                    args.source))
        else:
            sys.stderr.write(
                'Unable to recognize build system os\n')
        sys.exit(1)
    if args.non_interactive:
        oshandler.set_interactive(False)
    oshandler.set_target(args.scratchdir)
    oshandler.add_pkglists()
    for dirname in ('proc', 'sys', 'dev', 'run'):
        mkdirp(os.path.join(args.scratchdir, dirname))
    exitcode = run_constrained(build_root_backend, (args, oshandler))
    if exitcode:
        sys.stderr.write('Image build failed\n')
        sys.exit(exitcode)
    #if len(args) > 1:
    #    pack_image(opts, args)

def prep_decrypt(indir):
    indir = os.path.abspath(indir)
    pubdir = os.path.dirname(indir)
    currtabs = subprocess.check_output(['dmsetup', 'table'])
    currtabs = currtabs.decode('utf8').split('\n')
    usednames = set([])
    for tab in currtabs:
        if ':' not in tab:
            continue
        tabname, _ = tab.split(':', 1)
        usednames.add(tabname)
    dmname = os.path.basename(tempfile.mktemp())
    while dmname in usednames:
        dmname = os.path.basename(tempfile.mktemp())
    privdir = pubdir.replace('public/os', 'private/os')
    privdir = os.path.join(privdir, 'pending')
    privdir = os.path.join(privdir, 'rootimg.key')
    with open(privdir, 'r') as keyfile:
        keyinfo = keyfile.read().split('\n', 2)
    cipher, key = keyinfo[:2]
    imglen = os.path.getsize(indir) - 4096
    if imglen % 512 != 0:
        raise Exception('Image is not correctly sized for encryption')
    imglen = imglen // 512
    loopdev = subprocess.check_output(['losetup', '-f'])
    loopdev = loopdev.decode('utf8')
    loopdev = loopdev.strip()
    subprocess.check_call(['losetup', '-r', loopdev, indir])
    tempfile.mktemp()
    subprocess.check_call(['dmsetup', 'create', dmname, '--table', '0 {0} crypt {1} {2} 0 {3} 8'.format(
        imglen, cipher, key, loopdev)])
    subprocess.check_call(['dmsetup', 'mknodes', dmname])
    return '/dev/mapper/{0}'.format(dmname), loopdev


def unpack_image(args):
    scratchdir = args.scratchdir
    indir = args.profilename
    if not os.path.exists(indir) and '/' not in indir:
        indir = os.path.join('/var/lib/confluent/public/os', indir)
    if os.path.isdir(indir):
        indir = os.path.join(indir, 'rootimg.sfs')
    cleandmtable = None
    loopdev = None
    prepped = False
    try:
        while not prepped:
            with open(indir, 'rb') as inpack:
                hdr = inpack.read(16)
                if hdr == b'\xaa\xd5\x0f\x7e\x5d\xfb\x4b\x7c\xa1\x2a\xf4\x0b\x6d\x94\xf7\xfc':
                    indir, loopdev = prep_decrypt(indir)
                    cleandmtable = os.path.basename(indir)
                    continue
                if hdr == b'\x63\x7b\x9d\x26\xb7\xfd\x48\x30\x89\xf9\x11\xcf\x18\xfd\xff\xa1':
                    raise Exception("Multi-partition squash image not supported")
                if hdr[:4] in (b'sqsh', b'hsqs'):
                    break
                raise Exception('Unrecognized image format')
        while scratchdir.endswith('/'):
            scratchdir = scratchdir[:-1]
        scratchdir = os.path.abspath(scratchdir)
        parentdir = os.path.dirname(scratchdir)
        targdir = os.path.basename(scratchdir)
        mkdirp(parentdir)
        os.chdir(parentdir)
        subprocess.check_call(['unsquashfs', '-d', targdir, indir])
    finally:
        if cleandmtable:
            mounted = True
            tries = 30
            time.sleep(0.1)
            while mounted and tries:
                tries -= 1
                try:
                    subprocess.check_call(['dmsetup', 'remove', cleandmtable])
                    mounted = False
                except subprocess.CalledProcessError:
                    time.sleep(0.1)
        if loopdev:
            subprocess.check_call(['losetup', '-d', loopdev])


def recursecp(source, targ):
    if os.path.islink(source):
        if os.path.exists(targ):
            return
        linktarg = os.readlink(source)
        os.symlink(linktarg, targ)
    if os.path.isdir(source):
        if not os.path.exists(targ):
            os.mkdir(targ)
        for entry in os.listdir(source):
            srcentry = os.path.join(source, entry)
            targentry = os.path.join(targ, entry)
            recursecp(srcentry, targentry)
    elif os.path.exists(targ):
        return
    else:
        shutil.copy2(source, targ)


async def pack_image(args):
    outdir = args.profilename
    if '/' in outdir:
        raise Exception('Full path not supported, supply only the profile name\n')
    if args.baseprofile:
        baseprofiledir = args.baseprofile
        if '/' not in args.baseprofile:
            baseprofiledir = os.path.join('/var/lib/confluent/public/os', args.baseprofile)
        if not os.path.exists(baseprofiledir):
            sys.stderr.write('Specified base profile "{0}" does not seem to exist\n'.format(baseprofiledir))
            sys.exit(1)
    privdir = os.path.join('/var/lib/confluent/private/os/', outdir)
    outdir = os.path.join('/var/lib/confluent/public/os/', outdir)
    if os.path.exists(outdir):
        sys.stderr.write('Profile already exists, select a different name or delete existing {0}\n'.format(outdir))
        sys.exit(1)
    imginfofile = os.path.join(args.scratchdir, 'etc/confluentimg.buildinfo')
    distpath = None
    try:
        with open(imginfofile) as imginfoin:
            imginfo = imginfoin.read().split('\n')
            for lineinfo in imginfo:
                if lineinfo.startswith('BUILDSRC='):
                    distpath = lineinfo.replace('BUILDSRC=', '')
    except IOError:
        pass
    kerns = glob.glob(os.path.join(args.scratchdir, 'boot/vmlinuz-*'))
    kvermap = {}
    for kern in kerns:
        if 'rescue' in kern:
            continue
        kvermap[get_kern_version(kern)] = kern
    if not kvermap:
        sys.stderr.write(
            'No kernel found in {0}\n'.format(
                os.path.join(args.scratchdir, 'boot')))
        sys.exit(1)
    mostrecent = list(version_sort(kvermap))[-1]
    initrdname = os.path.join(args.scratchdir, 'boot/initramfs-{0}.img'.format(mostrecent))
    if not os.path.exists(initrdname):
        initrdname = os.path.join(args.scratchdir, 'boot/initrd-{0}'.format(mostrecent))
    if not os.path.exists(initrdname):
        initrdname = os.path.join(args.scratchdir, 'boot/initrd.img-{0}'.format(mostrecent))
    oum = os.umask(0o077)
    for path in ('/var/lib/confluent', '/var/lib/confluent/private', '/var/lib/confluent/private/os'):
        if not os.path.exists(path):
            mkdirp(path)
            subprocess.check_call(['chown', 'confluent', path])
    mkdirp(os.path.join(privdir, 'pending/'))
    os.umask(oum)
    mkdirp(os.path.join(outdir, 'boot/efi/boot'))
    mkdirp(os.path.join(outdir, 'boot/initramfs'))
    profname = os.path.basename(outdir)
    os.symlink(
        '/var/lib/confluent/public/site/initramfs.cpio',
        os.path.join(outdir, 'boot/initramfs/site.cpio'))
    shutil.copyfile(kvermap[mostrecent], os.path.join(outdir, 'boot/kernel'))
    shutil.copyfile(initrdname, os.path.join(outdir, 'boot/initramfs/distribution'))
    gather_bootloader(outdir, args.scratchdir)
    if args.unencrypted:
        tmploc = os.path.join(outdir, 'rootimg.sfs')
    else:
        tmploc = tempfile.mktemp()
    subprocess.check_call(['mksquashfs', args.scratchdir,
                           tmploc, '-comp', 'xz'])
    if not args.unencrypted:
        encrypt_image(tmploc, os.path.join(outdir, 'rootimg.sfs'), '{}/pending/rootimg.key'.format(privdir))
        os.remove(tmploc)
    with open(os.path.join(outdir, 'build-info'), 'w') as buildinfo:
        buildinfo.write('PACKEDFROM={}\nPACKDATE={}\n'.format(args.scratchdir, datetime.datetime.now().strftime('%Y-%m-%dT%H:%M')))
        if args.baseprofile:
            buildinfo.write('BASEPROFILE={}\n'.format(args.baseprofile))
    if args.baseprofile:
        if '/' not in args.baseprofile:
            args.baseprofile = os.path.join('/var/lib/confluent/public/os', args.baseprofile)
        recursecp(args.baseprofile, outdir)
        tryupdate = True
    else:
        if distpath:
            os.symlink(distpath, os.path.join(outdir, 'distribution'))
        oshandler = fingerprint_host(args, args.scratchdir)
        tryupdate = False
        if oshandler:
            prettyname = oshandler.osname
            with open(os.path.join(args.scratchdir, 'etc/os-release')) as osr:
                osrdata = osr.read().split('\n')
                for line in osrdata:
                    if line.startswith('PRETTY_NAME="'):
                        prettyname = line.replace(
                            'PRETTY_NAME=', '').replace('"', '')
            label = '{0} ({1})'.format(prettyname, 'Diskless Boot')
            with open(os.path.join(outdir, 'profile.yaml'), 'w') as profiley:
                profiley.write('label: {0}\nkernelargs: quiet # confluent_imagemethod=untethered|tethered # tethered is default when unspecified to save on memory, untethered will use more ram, but will not have any ongoing runtime root fs dependency on the http servers.\n'.format(label))
            oscat = oshandler.oscategory
            confdir = '/opt/confluent/lib/osdeploy/{}-diskless'.format(oscat)
            archaddon = '/opt/confluent/lib/osdeploy/{}-diskless/initramfs/{}/addons.cpio'.format(oscat, platform.machine())
            if os.path.exists(archaddon):
                os.symlink(archaddon, os.path.join(outdir, 'boot/initramfs/addons.cpio'))
            else:
                os.symlink('{}/initramfs/addons.cpio'.format(confdir),
                    os.path.join(outdir, 'boot/initramfs/addons.cpio'))
            indir = '{}/profiles/default'.format(confdir)
            if os.path.exists(indir):
                copy_tree(indir, outdir)
                hmap = await osimage.get_hashes(outdir)
                with open('{0}/manifest.yaml'.format(outdir), 'w') as yout:
                    yout.write('# This manifest enables rebase to know original source of profile data and if any customizations have been done\n')
                    manifestdata = {'distdir': indir, 'disthashes': hmap}
                    yout.write(yaml.dump(manifestdata, default_flow_style=False))
            tryupdate = True
    try:
        pwd.getpwnam('confluent')
        subprocess.check_call(['chown', '-R', 'confluent', outdir])
        subprocess.check_call(['chown', '-R', 'confluent', privdir])
        if tryupdate:
            subprocess.check_call(['osdeploy', 'updateboot', profname])
    except KeyError:
        pass

def gather_bootloader(outdir, rootpath='/'):
    shimlocation = os.path.join(rootpath, 'boot/efi/EFI/BOOT/BOOTX64.EFI')
    if not os.path.exists(shimlocation):
        shimlocation = os.path.join(rootpath, 'boot/efi/EFI/BOOT/BOOTAA64.EFI')
    if not os.path.exists(shimlocation):
        shimlocation = os.path.join(rootpath, 'usr/lib64/efi/shim.efi')
    if not os.path.exists(shimlocation):
        shimlocation = os.path.join(rootpath, 'usr/lib/shim/shimx64.efi.signed.latest')
    if not os.path.exists(shimlocation):
        shimlocation = os.path.join(rootpath, 'usr/lib/shim/shimx64.efi.signed')
    if not os.path.exists(shimlocation):
        shimlocation = os.path.join(rootpath, 'usr/lib/shim/shimaa64.efi.signed.latest')
    mkdirp(os.path.join(outdir, 'boot/efi/boot'))
    shutil.copyfile(shimlocation, os.path.join(outdir, 'boot/efi/boot/BOOTX64.EFI'))
    for maybemokmanager in glob.glob(os.path.join(rootpath, 'boot/efi/EFI/*/mmx64.efi')):
        shutil.copyfile(maybemokmanager, os.path.join(outdir, 'boot/efi/boot/mmx64.efi'))
        break
    else:
        if os.path.exists(os.path.join(rootpath, 'usr/lib/shim/mmx64.efi')):
            shutil.copyfile(os.path.join(rootpath, 'usr/lib/shim/mmx64.efi'), os.path.join(outdir, 'boot/efi/boot/mmx64.efi'))
    grubbin = None
    for candidate in glob.glob(os.path.join(rootpath, 'boot/efi/EFI/*')):
        if 'BOOT' not in candidate:
            grubbin = os.path.join(candidate, 'grubx64.efi')
            if os.path.exists(grubbin):
                break
            grubbin = os.path.join(candidate, 'grubaa64.efi')
            if os.path.exists(grubbin):
                break
    if not grubbin:
        grubbin = os.path.join(rootpath, 'usr/lib64/efi/grub.efi')
    if not os.path.exists(grubbin):
        grubbin = os.path.join(rootpath, 'usr/lib/grub/x86_64-efi-signed/grubx64.efi.signed')
    if not os.path.exists(grubbin):
        grubbin = os.path.join(rootpath, 'usr/lib/grub/arm64-efi/monolithic/grubaa64.efi')
    if not os.path.exists(grubbin):
        grubs = os.path.join(rootpath, 'boot/efi/EFI/*/grubx64.efi')
        grubs = glob.glob(grubs)
        if len(grubs) == 1:
            grubbin = grubs[0]
    if 'ubuntu' in grubbin:  # we needd to store a hint that this grub has a different hard coded prefix
        mkdirp(os.path.join(outdir, 'boot/EFI/ubuntu/'))
        with open(os.path.join(outdir, 'boot/EFI/ubuntu/grub.cfg'), 'w') as wo:
            wo.write('')
    shutil.copyfile(grubbin, os.path.join(outdir, 'boot/efi/boot/grubx64.efi'))
    shutil.copyfile(grubbin, os.path.join(outdir, 'boot/efi/boot/grub.efi'))


if __name__ == '__main__':
    main()
