optools/storage/bootsync.py

250 lines
10 KiB
Python

#!/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/<PRIMARY DEVICE> -p <PARTITION NUMBER> \
# -l /EFI/Arch/grubx64.efi -L Arch
# efibootmgr -c -d /dev/<FALLBACK DEVICE> -p <PARTITION NUMBER> \
# -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('(?<!\=UUID\=){0}'.format(self.dummy_uuid),
self.blkids[d],
line)
i = re.sub('\s--hint=\'mduuid\/[a-f0-9]{32}\'', '', i)
f.write('{0}\n'.format(i))
return()
def main():
if os.geteuid() != 0:
exit('You must be root to run this!')
bs = BootSync()
bs.sync()
bs.write_confs()
return()
if __name__ == '__main__':
main()