diff --git a/storage/bootsync.py b/storage/bootsync.py new file mode 100644 index 0000000..32056b6 --- /dev/null +++ b/storage/bootsync.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python + +import hashlib +import json +# From http://darwinsys.com/file/, not https://github.com/ahupp/python-magic +import magic +import os +import platform +import psutil +import re +import shutil +import subprocess + +# The device:mountpoint of the mounts for the failover partitions. +mounts = {'/dev/sdb1': '/mnt/boot1', + '/dev/sdd1': '/mnt/boot2'} +# The files we checksum. +files = ['initramfs-linux.img', 'intel-ucode.img', 'memtest86+/memtest.bin', + 'vmlinuz-linux'] +# These paths are used to ensure an up-to-date grub. +grub = {'themes': {'orig': '/usr/share/grub/themes', + 'dest': 'grub/themes', + 'pattern': '.*'}, + 'modules': {'orig': '/usr/lib/grub/x86_64-efi', + 'dest': 'grub/x86_64-efi', + 'pattern': '^.*\.(mod|lst|sh)$'}, + 'isos': {'orig': '/boot/iso', + 'dest': 'iso', + 'pattern': '^.*\.(iso|img)$'}} + +############################################################################### +# NOTE: +# If I need to rebuild, +# efibootmgr -c -d /dev/ -p \ +# -l /EFI/Arch/grubx64.efi -L Arch +# efibootmgr -c -d /dev/ -p \ +# -l /EFI/Arch/grubx64.efi -L 'Arch (Fallback)' +# And don't forget to install grub. +# grub-install --boot-directory=/mnt/boot1 --bootloader-id=Arch \ +# --efi-directory=/mnt/boot1/ --target=x86_64-efi --no-nvram --recheck +# grub-install --boot-directory=/mnt/boot2 --bootloader-id=Arch \ +# --efi-directory=/mnt/boot2/ --target=x86_64-efi --no-nvram --recheck +# You need to have grub's config set to use UUIDs. +############################################################################### + +def get_file_kernel_ver(kpath): + # Gets the version of a kernel file. + kpath = os.path.abspath(os.path.expanduser(kpath)) + _kinfo = {} + with open(kpath, 'rb') as f: + _m = magic.detect_from_content(f.read()) + for i in _m.name.split(','): + l = i.strip().split() + # Note: this only grabs the version number. + # If we want to get e.g. the build user/machine, date, etc., + # then we need to join l[1:]. + # We technically don't even need a dict, either. We can just iterate. + # TODO. + _kinfo[l[0].lower()] = (l[1] if len(l) > 1 else None) + if 'version' not in _kinfo: + raise RuntimeError('Cannot deterimine the version of {0}'.format( + kpath)) + else: + return(_kinfo['version']) + +def get_cur_kernel_ver(): + _vers = [] + # If we change the version string capture in get_file_kernel_ver(), + # this will need to be expanded as well. + # Really we only need to pick one, but #YOLO; why not sanity-check. + _vers.append(os.uname().release) + _vers.append(platform.release()) + _vers.append(platform.uname().release) + _vers = sorted(list(set(_vers))) + if len(_vers) != 1: + raise RuntimeError('Cannot reliably determine current running ' + 'kernel version!') + else: + return(_vers[0]) + +class BootSync(object): + def __init__(self): + self.chk_mounts() + # This is the current live kernel. + self.cur_kern_ver = get_cur_kernel_ver() + # This is the installed kernel from Pacman. + self.installed_kern_ver = get_file_kernel_ver('/boot/vmlinuz-linux') + self.reboot = False # If a reboot is needed (WARN, don't execute!) + self.syncs = {} + self.blkids = {} + self.dummy_uuid = None + self.chk_reboot() + self.get_hashes() + self.get_blkids() + self.sync() + + def chk_mounts(self): + _mounts = {m.device:m.mountpoint for m in \ + psutil.disk_partitions(all = True)} + for m in mounts: + mntpt = os.path.abspath(os.path.expanduser(mounts[m])) + if not os.path.isdir(mntpt): + os.makedirs(mntpt, exist_ok = True) + if m not in _mounts: + with open(os.devnull, 'w') as devnull: + c = subprocess.run(['/usr/bin/mount', mounts[m]], stderr = devnull) + if c.returncode == 1: # Not specified in fstab + subprocess.run(['/usr/bin/mount', m, mntpt], stderr = devnull) + elif c.returncode == 32: # Already mounted + pass + return() + + def chk_reboot(self): + if self.installed_kern_ver != self.cur_kern_ver: + self.reboot = True + print( + 'NOTE: REBOOT REQUIRED. New kernel is {0}. Running kernel is ' + '{1}.'.format(self.installed_kern_ver, self.cur_kern_ver)) + return() + + def get_blkids(self): + c = subprocess.run(['/usr/bin/blkid', + '-o', 'export'], + stdout = subprocess.PIPE) + if c.returncode != 0: + raise RuntimeError('Could not fetch block ID information') + for p in c.stdout.decode('utf-8').split('\n\n'): + line = [i.strip() for i in p.splitlines()] + d = dict(map(lambda i : i.split('='), line)) + if 'PARTUUID' in d: + self.blkids[d['DEVNAME']] = d['PARTUUID'] + else: + self.blkids[d['DEVNAME']] = d['UUID'] + c = subprocess.run(['/usr/bin/findmnt', + '--json', + '-T', '/boot'], + stdout = subprocess.PIPE) + # I write ridiculous one-liners. + self.dummy_uuid = self.blkids[json.loads( + c.stdout.decode( + 'utf-8' + ) + )['filesystems'][0]['source']] + return() + + def get_hashes(self): + def _get_hash(fpath): + fpath = os.path.abspath(os.path.expanduser(fpath)) + _hash = hashlib.sha512() + with open(fpath, 'rb') as fh: + _hash.update(fh.read()) + return(_hash.hexdigest()) + for f in files: + # We do /boot files manually in case it isn't specified as a + # separate mount. + fpath = os.path.join('/boot', f) + canon_hash = _get_hash(fpath) + for m in mounts: + fpath = os.path.join(mounts[m], f) + file_hash = _get_hash(fpath) + if file_hash != canon_hash: + if f not in self.syncs: + self.syncs[f] = [] + self.syncs[f].append(mounts[m]) + return() + + def sync(self): + # NOTE: We *may* be able to get away with instead just doing the above + # grub-install commands to each of the boot disks. + for f in self.syncs: + for m in self.syncs[f]: + orig = os.path.join('/boot', f) + dest = os.path.join(m, f) + shutil.copy2(orig, dest) + _mounts = list(mounts.values()) + ['/boot'] + for g in grub: + _fnames = [] + # We don't use filecmp for this because: + # - dircmp doesn't recurse + # - the reports/lists don't retain relative paths + # - we can't regex out files + for root, dirs, files in os.walk(grub[g]['orig']): + prefix = re.sub('\/?{0}\/?'.format(grub[g]['orig']), '', root) + ptrn = re.compile(grub[g]['pattern']) + for f in files: + if ptrn.search(f): + _fnames.append(os.path.join(prefix, f)) + # If we want to delete files in the destination that don't exist in + # the original, here's where we would do it. + # for root, dirs, files in os.walk(grub[g]['dest']): + # _pre_prefix = re.sub('\/?$', '', grub[g]['dest']) + # prefix = re.sub(_pre_prefix, '', root) + # #ptrn = re.compile(grub[g]['pattern']) + # for f in files: + # _p = os.path.join(prefix, f) + # if _p not in _fnames: + # os.remove(os.path.join(grub[g]['dest'], _p)) + # Now we compare the contents. + for f in _fnames: + origfile = os.path.join(grub[g]['orig'], f) + destfile = os.path.join(grub[g]['dest'], f) + with open(origfile, 'rb') as f: + _orig = hashlib.sha512(f.read()).hexdigest() + for m in _mounts: + real_destfile = os.path.join(m, destfile) + if not os.path.isfile(real_destfile): + os.makedirs(os.path.dirname(real_destfile), + exist_ok = True) + shutil.copy2(origfile, real_destfile) + else: + with open(real_destfile, 'rb') as f: + _dest = hashlib.sha512(f.read()).hexdigest() + if _orig != _dest: + shutil.copy2(origfile, real_destfile) + return() + + + def write_confs(self): + # Get a fresh config in place. + with open(os.devnull, 'wb') as DEVNULL: + c = subprocess.run(['/usr/bin/grub-mkconfig', + '-o', '/boot/grub/grub.cfg'], + stdout = DEVNULL, + stderr = DEVNULL) + if c.returncode != 0: + raise RuntimeError('An error occurred when generating the GRUB ' + 'configuration file.') + with open('/boot/grub/grub.cfg', 'r') as f: + _grubcfg = f.read() + for d in mounts: + with open(os.path.join(mounts[d], 'grub/grub.cfg'), 'w') as f: + for line in _grubcfg.splitlines(): + i = re.sub('(?