WizardKit/scripts/wk/kit/ufd.py

670 lines
17 KiB
Python

"""WizardKit: UFD Functions"""
# vim: sts=2 sw=2 ts=2
# TODO: Drop OrderedDict use
import logging
import math
import os
import pathlib
import shutil
from subprocess import CalledProcessError
from collections import OrderedDict
from docopt import docopt
from wk import io, log
from wk.cfg.main import KIT_NAME_FULL, KIT_NAME_SHORT
from wk.cfg.ufd import (
BOOT_ENTRIES,
BOOT_FILES,
IMAGE_BOOT_ENTRIES,
ITEMS,
ITEMS_HIDDEN,
SOURCES,
)
from wk.exe import get_json_from_command, run_program
from wk.os import linux
from wk.ui import cli as ui
# STATIC VARIABLES
DOCSTRING = '''WizardKit: Build UFD
Usage:
build-ufd [options] --ufd-device PATH
[--linux PATH]
[--main-kit PATH]
[--winpe PATH]
[--extra-dir PATH]
[EXTRA_IMAGES...]
build-ufd (-h | --help)
Options:
-e PATH, --extra-dir PATH
-k PATH, --main-kit PATH
-l PATH, --linux PATH
-u PATH, --ufd-device PATH
-w PATH, --winpe PATH
-d --debug Enable debug mode
-h --help Show this page
-M --use-mbr Use real MBR instead of GPT w/ Protective MBR
-F --force Bypass all confirmation messages. USE WITH EXTREME CAUTION!
-U --update Don't format device, just update
'''
LOG = logging.getLogger(__name__)
EXTRA_IMAGES_LIST = '/mnt/UFD/arch/extra_images.list'
MIB = 1024 ** 2
ISO_LABEL = f'{KIT_NAME_SHORT}_LINUX'
UFD_LABEL = f'{KIT_NAME_SHORT}_UFD'
# Functions
def apply_image(part_path, image_path, hide_macos_boot=True) -> None:
"""Apply raw image to dev_path using dd."""
cmd = [
'sudo',
'dd',
'bs=4M',
f'if={image_path}',
f'of={part_path}',
]
run_program(cmd)
# Bail?
if not ('macwk' in image_path.name.lower() and hide_macos_boot):
return
# Hide macOS boot files
boot_efi_path = '/mnt/TMP/System/Library/CoreServices/boot.efi'
linux.mount(source=part_path, mount_point='/mnt/TMP', read_write=True)
if os.path.exists(boot_efi_path):
try:
os.rename(
boot_efi_path,
boot_efi_path.replace('boot.efi', 'secretboot.efi'),
)
except OSError:
# Ignore for now?
pass
linux.unmount(source_or_mountpoint='/mnt/TMP')
def build_ufd() -> None:
"""Build UFD using selected sources."""
args = docopt(DOCSTRING)
if args['--debug']:
log.enable_debug_mode()
if args['--update'] and args['EXTRA_IMAGES']:
ui.print_warning('Extra images are ignored when updating')
args['EXTRA_IMAGES'] = []
log.update_log_path(dest_name='build-ufd', timestamp=True)
try_print = ui.TryAndPrint()
try_print.add_error('FileNotFoundError')
try_print.catch_all = False
try_print.indent = 2
try_print.verbose = True
try_print.width = 64
# Show header
ui.print_success(KIT_NAME_FULL)
ui.print_warning('UFD Build Tool')
ui.print_warning(' ')
# Verify selections
ufd_dev = verify_ufd(args['--ufd-device'])
sources = verify_sources(args, SOURCES)
extra_images = [io.case_insensitive_path(i) for i in args['EXTRA_IMAGES']]
show_selections(args, sources, ufd_dev, SOURCES, extra_images)
if not args['--force']:
confirm_selections(update=args['--update'])
# Prep UFD
if not args['--update']:
ui.print_info('Prep UFD')
try_print.run(
message='Zeroing first 64MiB...',
function=zero_device,
dev_path=ufd_dev,
)
try_print.run(
message='Creating partition table...',
function=create_table,
dev_path=ufd_dev,
use_mbr=args['--use-mbr'],
images=extra_images,
)
try_print.run(
message='Setting boot flag...',
function=set_boot_flag,
dev_path=ufd_dev,
use_mbr=args['--use-mbr'],
)
try_print.run(
message='Formatting partition...',
function=format_partition,
dev_path=ufd_dev,
label=UFD_LABEL,
)
ufd_dev_first_partition = find_first_partition(ufd_dev)
# Mount UFD
try_print.run(
message='Mounting UFD...',
function=linux.mount,
source=ufd_dev_first_partition,
mount_point='/mnt/UFD',
read_write=True,
)
# Load extra images if updating
if args['--update'] and os.path.exists(EXTRA_IMAGES_LIST):
with open(EXTRA_IMAGES_LIST, 'r', encoding='utf-8') as _f:
extra_images = [
io.get_path_obj(image.strip(), resolve=False)
for image in _f.readlines()
]
# Remove Arch folder
if args['--update']:
try_print.run(
message='Removing Linux...',
function=remove_arch,
)
# Copy sources
ui.print_standard(' ')
ui.print_info('Copy Sources')
try_print.run(
'Copying Memtest86...', io.recursive_copy,
'/usr/share/memtest86-efi/', '/mnt/UFD/EFI/Memtest86/', overwrite=True,
)
for s_label, s_path in sources.items():
try_print.run(
message=f'Copying {s_label}...',
function=copy_source,
source=s_path,
items=ITEMS[s_label],
overwrite=True,
)
# Apply extra images
if not args['--update']:
ui.print_standard(' ')
ui.print_info('Apply Extra Images')
for part_num, image_path in enumerate(extra_images):
try_print.run(
message=f'Applying {image_path.name}...',
function=apply_image,
part_path=f'{str(ufd_dev_first_partition)[:-1]}{part_num+2}',
image_path=image_path,
)
# Save extra image list
if extra_images:
with open(EXTRA_IMAGES_LIST, 'w', encoding='utf-8') as _f:
_f.write('\n'.join([image.name for image in extra_images]))
# Update boot entries
ui.print_standard(' ')
ui.print_info('Boot Setup')
try_print.run(
message='Updating boot entries...',
function=update_boot_entries,
ufd_dev=ufd_dev,
images=extra_images,
)
# Install syslinux (to partition)
try_print.run(
message='Syslinux (partition)...',
function=install_syslinux_to_partition,
partition=ufd_dev_first_partition,
)
# Unmount UFD
try_print.run(
message='Unmounting UFD...',
function=linux.unmount,
source_or_mountpoint='/mnt/UFD',
)
# Install syslinux (to device)
try_print.run(
message='Syslinux (device)...',
function=install_syslinux_to_dev,
ufd_dev=ufd_dev,
use_mbr=args['--use-mbr'],
)
# Hide items
ui.print_standard(' ')
ui.print_info('Final Touches')
try_print.run(
message='Hiding items...',
function=hide_items,
ufd_dev_first_partition=ufd_dev_first_partition,
items=ITEMS_HIDDEN,
)
# Done
ui.print_standard('\nDone.')
if not args['--force']:
ui.pause('Press Enter to exit...')
def confirm_selections(update=False) -> None:
"""Ask tech to confirm selections, twice if necessary."""
if not ui.ask('Is the above information correct?'):
ui.abort()
# Safety check
if not update:
ui.print_standard(' ')
ui.print_warning('SAFETY CHECK')
ui.print_standard(
'All data will be DELETED from the disk and partition(s) listed above.')
ui.print_colored(
['This is irreversible and will lead to', 'DATA LOSS'],
[None, 'RED'],
)
if not ui.ask('Asking again to confirm, is this correct?'):
ui.abort()
ui.print_standard(' ')
def copy_source(source, items, overwrite=False) -> None:
"""Copy source items to /mnt/UFD."""
is_image = source.is_file()
items_not_found = False
# Mount source if necessary
if is_image:
linux.mount(source, '/mnt/Source')
# Copy items
for i_source, i_dest in items:
i_source = f'{"/mnt/Source" if is_image else source}{i_source}'
i_dest = f'/mnt/UFD{i_dest}'
try:
io.recursive_copy(i_source, i_dest, overwrite=overwrite)
except FileNotFoundError:
items_not_found = True
# Unmount source if necessary
if is_image:
linux.unmount('/mnt/Source')
# Raise exception if item(s) were not found
if items_not_found:
raise FileNotFoundError('One or more items not found')
def create_table(dev_path, use_mbr=False, images=None) -> None:
"""Create GPT or DOS partition table."""
cmd = [
'sudo',
'parted', dev_path,
'--script', '--',
'mklabel', 'msdos' if use_mbr else 'gpt',
]
if images:
images = [os.stat(i_path).st_size for i_path in images]
else:
images = []
start = MIB
end = -1
# Calculate partition sizes
## Align all partitions using 1MiB boundaries for 4K alignment
## NOTE: Partitions are aligned to 1 MiB boundaries to match parted's usage
## NOTE 2: Crashing if dev_size can't be set is fine since it's necessary
dev_size = get_block_device_size(dev_path)
part_sizes = [math.ceil(i/MIB) * MIB for i in images]
main_size = dev_size - start*2 - sum(part_sizes)
main_size = math.floor(main_size/MIB) * MIB
images.insert(0, main_size)
part_sizes.insert(0, main_size)
# Build cmd
for part, real in zip(part_sizes, images):
end = start + real
cmd.append(
f'mkpart primary {"fat32" if start==MIB else "hfs+"} {start}B {end-1}B',
)
start += part
# Run cmd
run_program(cmd)
def find_first_partition(dev_path) -> str:
"""Find path to first partition of dev, returns str."""
cmd = [
'lsblk',
'--list',
'--noheadings',
'--output', 'name',
'--paths',
dev_path,
]
# Run cmd
proc = run_program(cmd)
part_path = proc.stdout.splitlines()[1].strip()
# Done
return part_path
def format_partition(dev_path, label) -> None:
"""Format first partition on device FAT32."""
cmd = [
'sudo',
'mkfs.vfat',
'-F', '32',
'-n', label,
find_first_partition(dev_path),
]
run_program(cmd)
def get_block_device_size(dev_path) -> int:
"""Get block device size via lsblk, returns int."""
cmd = [
'lsblk',
'--bytes',
'--nodeps',
'--noheadings',
'--output',
'size',
dev_path,
]
# Run cmd
proc = run_program(cmd)
# Done
return int(proc.stdout.strip())
def get_uuid(path) -> str:
"""Get filesystem UUID via findmnt, returns str."""
cmd = [
'findmnt',
'--noheadings',
'--target', path,
'--output', 'uuid'
]
# Run findmnt
proc = run_program(cmd, check=False)
# Done
return proc.stdout.strip()
def hide_items(ufd_dev_first_partition, items) -> None:
"""Set FAT32 hidden flag for items."""
with open('/root/.mtoolsrc', 'w', encoding='utf-8') as _f:
_f.write(f'drive U: file="{ufd_dev_first_partition}"\n')
_f.write('mtools_skip_check=1\n')
# Hide items
for item in items:
cmd = [f'yes | sudo mattrib +h "U:/{item}"']
run_program(cmd, shell=True, check=False)
def install_syslinux_to_dev(ufd_dev, use_mbr) -> None:
"""Install Syslinux to UFD (dev)."""
cmd = [
'sudo',
'dd',
'bs=440',
'count=1',
f'if=/usr/lib/syslinux/bios/{"mbr" if use_mbr else "gptmbr"}.bin',
f'of={ufd_dev}',
]
run_program(cmd)
def install_syslinux_to_partition(partition) -> None:
"""Install Syslinux to UFD (partition)."""
cmd = [
'sudo',
'syslinux',
'--install',
'--directory',
'/syslinux/',
partition,
]
run_program(cmd)
def is_valid_path(path_obj, path_type) -> bool:
"""Verify path_obj is valid by type, returns bool."""
valid_path = False
if path_type == 'DIR':
valid_path = path_obj.is_dir()
elif path_type == 'KIT':
valid_path = path_obj.is_dir() and path_obj.joinpath('.bin').exists()
elif path_type == 'IMG':
valid_path = path_obj.is_file() and path_obj.suffix.lower() == '.img'
elif path_type == 'ISO':
valid_path = path_obj.is_file() and path_obj.suffix.lower() == '.iso'
elif path_type == 'UFD':
valid_path = path_obj.is_block_device()
return valid_path
def set_boot_flag(dev_path, use_mbr=False) -> None:
"""Set modern or legacy boot flag."""
cmd = [
'sudo',
'parted', dev_path,
'set', '1',
'boot' if use_mbr else 'legacy_boot',
'on',
]
run_program(cmd)
def remove_arch() -> None:
"""Remove arch dir from UFD.
This ensures a clean installation to the UFD and resets the boot files
"""
shutil.rmtree(io.case_insensitive_path('/mnt/UFD/arch'))
def show_selections(args, sources, ufd_dev, ufd_sources, extra_images) -> None:
"""Show selections including non-specified options."""
# Sources
ui.print_info('Sources')
for label in ufd_sources.keys():
if label in sources:
ui.print_standard(f' {label+":":<18} {sources[label]}')
else:
ui.print_colored(
[f' {label+":":<18}', 'Not Specified'],
[None, 'YELLOW'],
)
# Extra images
if extra_images:
print(f' {"Extra Images:":<18} {extra_images[0]}')
for image in extra_images[1:]:
print(f' {" ":<18} {image}')
# Destination
ui.print_standard(' ')
ui.print_info('Destination')
cmd = [
'lsblk', '--nodeps', '--noheadings', '--paths',
'--output', 'NAME,FSTYPE,TRAN,SIZE,VENDOR,MODEL,SERIAL',
ufd_dev,
]
proc = run_program(cmd, check=False)
ui.print_standard(proc.stdout.strip())
cmd = [
'lsblk', '--noheadings', '--paths',
'--output', 'NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT',
ufd_dev,
]
proc = run_program(cmd, check=False)
for line in proc.stdout.splitlines()[1:]:
ui.print_standard(line)
# Notes
if args['--update']:
ui.print_warning('Updating kit in-place')
elif args['--use-mbr']:
ui.print_warning('Formatting using legacy MBR')
ui.print_standard(' ')
def update_boot_entries(ufd_dev, images=None) -> None:
"""Update boot files for UFD usage"""
configs = []
uuids = [get_uuid('/mnt/UFD')]
# Find config files
for c_path, c_ext in BOOT_FILES.items():
try:
c_path = io.case_insensitive_path(f'/mnt/UFD{c_path}')
except FileNotFoundError:
# Ignore and continue to next file
continue
for item in os.scandir(c_path):
if item.name.lower().endswith(c_ext.lower()):
configs.append(item.path)
# Use UUID instead of label
cmd = [
'sudo',
'sed',
'--in-place',
'--regexp-extended',
f's#archisolabel={ISO_LABEL}#archisodevice=/dev/disk/by-uuid/{uuids[0]}#',
*configs,
]
run_program(cmd)
# Uncomment extra entries if present
for b_path, b_comment in BOOT_ENTRIES.items():
try:
io.case_insensitive_path(f'/mnt/UFD{b_path}')
except (FileNotFoundError, NotADirectoryError):
# Entry not found, continue to next entry
continue
# Entry found, update config files
cmd = [
'sudo',
'sed',
'--in-place',
f's/#{b_comment}#//',
*configs,
]
run_program(cmd, check=False)
# Check if we're working with extra images
if not images and os.path.exists(EXTRA_IMAGES_LIST):
with open(EXTRA_IMAGES_LIST, 'r', encoding='utf-8') as _f:
images = [image.strip() for image in _f.readlines()]
if not images:
# No extra images detected
return
# Get PARTUUID values
cmd = ['lsblk', '--json', '--output', 'partuuid', ufd_dev]
try:
uuids = get_json_from_command(cmd)
except (CalledProcessError, ValueError):
# Bail
return
uuids = [v['partuuid'] for v in uuids['blockdevices']]
# Uncomment extra image entries if present
for _i, image in enumerate(images):
for name, comment in IMAGE_BOOT_ENTRIES.items():
if name.lower() in image.name.lower():
cmd = [
'sudo',
'sed',
'--in-place',
'--regexp-extended',
fr's/(#{comment}#.*)"PARTUUID/\1"{uuids[_i+2]}/',
*configs,
]
run_program(cmd, check=False)
cmd = [
'sudo',
'sed',
'--in-place',
f's/#{comment}#//',
*configs,
]
run_program(cmd, check=False)
IMAGE_BOOT_ENTRIES.pop(name)
break
def verify_sources(args, ufd_sources) -> dict[str, pathlib.Path]:
"""Check all sources and abort if necessary, returns dict."""
sources = OrderedDict()
for label, data in ufd_sources.items():
s_path = args[data['Arg']]
if s_path:
try:
s_path_obj = io.case_insensitive_path(s_path)
except FileNotFoundError:
ui.print_error(f'ERROR: {label} not found: {s_path}')
ui.abort()
if not is_valid_path(s_path_obj, data['Type']):
ui.print_error(f'ERROR: Invalid {label} source: {s_path}')
ui.abort()
sources[label] = s_path_obj
return sources
def verify_ufd(dev_path) -> pathlib.Path:
"""Check that dev_path is a valid UFD, returns pathlib.Path obj."""
ufd_dev = None
try:
ufd_dev = io.case_insensitive_path(dev_path)
except FileNotFoundError:
ui.print_error(f'ERROR: UFD device not found: {dev_path}')
ui.abort()
if not is_valid_path(ufd_dev, 'UFD'):
ui.print_error(f'ERROR: Invalid UFD device: {ufd_dev}')
ui.abort()
return ufd_dev # type: ignore[reportGeneralTypeIssues]
def zero_device(dev_path) -> None:
"""Zero-out first 64MB of device."""
cmd = [
'sudo',
'dd',
'bs=4M',
'count=16',
'if=/dev/zero',
f'of={dev_path}',
]
run_program(cmd)
if __name__ == '__main__':
print("This file is not meant to be called directly.")