kupferbootstrap/image.py

485 lines
16 KiB
Python
Raw Normal View History

2021-10-22 17:07:05 +02:00
import atexit
import json
import os
2021-10-22 17:07:05 +02:00
import re
import subprocess
import click
import logging
from signal import pause
from subprocess import CompletedProcess
2022-02-18 06:32:04 +01:00
from typing import Optional
2022-02-18 06:32:04 +01:00
from chroot.device import DeviceChroot, get_device_chroot
from constants import Arch, BASE_PACKAGES, DEVICES, FLAVOURS
2022-02-18 06:32:04 +01:00
from config import config, Profile
from distro.distro import get_base_distro, get_kupfer_https
from exec.cmd import run_root_cmd, generate_cmd_su
from exec.file import root_write_file, root_makedir, makedir
from packages import build_enable_qemu_binfmt, build_packages_by_paths
from packages.device import get_profile_device
2021-10-15 22:52:13 +02:00
from ssh import copy_ssh_keys
from wrapper import check_programs_wrap, wrap_if_foreign_arch
# image files need to be slightly smaller than partitions to fit
IMG_FILE_ROOT_DEFAULT_SIZE = "1800M"
IMG_FILE_BOOT_DEFAULT_SIZE = "90M"
def dd_image(input: str, output: str, blocksize='1M') -> CompletedProcess:
cmd = [
'dd',
f'if={input}',
f'of={output}',
f'bs={blocksize}',
'oflag=direct',
'status=progress',
'conv=sync,noerror',
]
logging.debug(f'running dd cmd: {cmd}')
2022-08-15 07:06:03 +02:00
return run_root_cmd(cmd)
def partprobe(device: str):
2022-08-15 07:06:03 +02:00
return run_root_cmd(['partprobe', device])
2021-10-22 17:07:05 +02:00
def shrink_fs(loop_device: str, file: str, sector_size: int):
# 8: 512 bytes sectors
# 1: 4096 bytes sectors
sectors_blocks_factor = 4096 // sector_size
partprobe(loop_device)
2021-10-22 17:07:05 +02:00
logging.debug(f"Checking filesystem at {loop_device}p2")
2022-08-15 07:06:03 +02:00
result = run_root_cmd(['e2fsck', '-fy', f'{loop_device}p2'])
2021-10-01 12:32:44 +02:00
if result.returncode > 2:
2021-10-22 17:07:05 +02:00
# https://man7.org/linux/man-pages/man8/e2fsck.8.html#EXIT_CODE
raise Exception(f'Failed to e2fsck {loop_device}p2 with exit code {result.returncode}')
logging.debug(f'Shrinking filesystem at {loop_device}p2')
2022-08-15 07:06:03 +02:00
result = run_root_cmd(['resize2fs', '-M', f'{loop_device}p2'], capture_output=True)
2021-10-22 17:07:05 +02:00
if result.returncode != 0:
print(result.stdout)
print(result.stderr)
raise Exception(f'Failed to resize2fs {loop_device}p2')
logging.debug(f'Finding end block of shrunken filesystem on {loop_device}p2')
2022-02-18 06:32:04 +01:00
blocks = int(re.search('is now [0-9]+', result.stdout.decode('utf-8')).group(0).split(' ')[2]) # type: ignore
2021-10-22 17:07:05 +02:00
sectors = blocks * sectors_blocks_factor #+ 157812 - 25600
logging.debug(f'Shrinking partition at {loop_device}p2 to {sectors} sectors')
child_proccess = subprocess.Popen(
generate_cmd_su(['fdisk', '-b', str(sector_size), loop_device], switch_user='root'), # type: ignore
2021-10-22 17:07:05 +02:00
stdin=subprocess.PIPE,
)
2022-02-18 06:32:04 +01:00
child_proccess.stdin.write('\n'.join([ # type: ignore
2021-10-22 17:07:05 +02:00
'd',
'2',
'n',
'p',
'2',
'',
f'+{sectors}',
'w',
'q',
]).encode('utf-8'))
child_proccess.communicate()
returncode = child_proccess.wait()
if returncode == 1:
# For some reason re-reading the partition table fails, but that is not a problem
2022-08-15 07:06:03 +02:00
partprobe(loop_device)
2021-10-22 17:07:05 +02:00
if returncode > 1:
raise Exception(f'Failed to shrink partition size of {loop_device}p2 with fdisk')
partprobe(loop_device)
2021-10-22 17:07:05 +02:00
logging.debug(f'Finding end sector of partition at {loop_device}p2')
2022-08-15 07:06:03 +02:00
result = run_root_cmd(['fdisk', '-b', str(sector_size), '-l', loop_device], capture_output=True)
2021-10-22 17:07:05 +02:00
if result.returncode != 0:
print(result.stdout)
print(result.stderr)
raise Exception(f'Failed to fdisk -l {loop_device}')
end_sector = 0
for line in result.stdout.decode('utf-8').split('\n'):
if line.startswith(f'{loop_device}p2'):
parts = list(filter(lambda part: part != '', line.split(' ')))
end_sector = int(parts[2])
if end_sector == 0:
raise Exception(f'Failed to find end sector of {loop_device}p2')
end_size = (end_sector + 1) * sector_size
2021-10-22 17:07:05 +02:00
logging.debug(f'({end_sector} + 1) sectors * {sector_size} bytes/sector = {end_size} bytes')
logging.info(f'Truncating {file} to {end_size} bytes')
result = subprocess.run(['truncate', '-s', str(end_size), file])
if result.returncode != 0:
2021-10-22 17:07:05 +02:00
raise Exception(f'Failed to truncate {file}')
partprobe(loop_device)
def losetup_destroy(loop_device):
logging.debug(f'Destroying loop device {loop_device}')
run_root_cmd(
[
'losetup',
'-d',
loop_device,
],
stderr=subprocess.DEVNULL,
)
2022-02-18 06:32:04 +01:00
def get_device_and_flavour(profile_name: Optional[str] = None) -> tuple[str, str]:
config.enforce_config_loaded()
2022-02-18 06:32:04 +01:00
profile = config.get_profile(profile_name)
if not profile['device']:
raise Exception("Please set the device using 'kupferbootstrap config init ...'")
if not profile['flavour']:
raise Exception("Please set the flavour using 'kupferbootstrap config init ...'")
return (profile['device'], profile['flavour'])
def get_image_name(device, flavour, img_type='full') -> str:
return f'{device}-{flavour}-{img_type}.img'
def get_image_path(device, flavour, img_type='full') -> str:
return os.path.join(config.get_path('images'), get_image_name(device, flavour, img_type))
2021-08-08 18:32:42 +02:00
2021-10-22 17:07:05 +02:00
def losetup_rootfs_image(image_path: str, sector_size: int) -> str:
logging.debug(f'Creating loop device for {image_path} with sector size {sector_size}')
2022-08-15 07:06:03 +02:00
result = run_root_cmd([
2021-10-22 17:07:05 +02:00
'losetup',
'-f',
'-b',
str(sector_size),
'-P',
2021-10-22 17:07:05 +02:00
image_path,
])
if result.returncode != 0:
raise Exception(f'Failed to create loop device for {image_path}')
2021-10-22 17:07:05 +02:00
logging.debug(f'Finding loop device for {image_path}')
result = subprocess.run(['losetup', '-J'], capture_output=True)
if result.returncode != 0:
print(result.stdout)
print(result.stderr)
raise Exception('Failed to list loop devices')
2021-10-22 17:07:05 +02:00
data = json.loads(result.stdout.decode('utf-8'))
loop_device = ''
for d in data['loopdevices']:
if d['back-file'] == image_path:
loop_device = d['name']
break
if loop_device == '':
raise Exception(f'Failed to find loop device for {image_path}')
partprobe(loop_device)
2021-10-22 17:07:05 +02:00
atexit.register(losetup_destroy, loop_device)
2021-10-22 17:07:05 +02:00
return loop_device
2022-02-18 06:32:04 +01:00
def mount_chroot(rootfs_source: str, boot_src: str, chroot: DeviceChroot):
logging.debug(f'Mounting {rootfs_source} at {chroot.path}')
2021-10-22 17:07:05 +02:00
chroot.mount_rootfs(rootfs_source)
assert (os.path.ismount(chroot.path))
2021-10-22 17:07:05 +02:00
root_makedir(chroot.get_path('boot'))
2021-10-22 17:07:05 +02:00
logging.debug(f'Mounting {boot_src} at {chroot.path}/boot')
chroot.mount(boot_src, '/boot', options=['defaults'])
2021-10-22 17:07:05 +02:00
2022-02-06 20:36:11 +01:00
def dump_aboot(image_path: str) -> str:
2022-02-06 19:41:31 +01:00
path = '/tmp/aboot.img'
result = subprocess.run([
'debugfs',
image_path,
'-R',
2022-02-06 19:41:31 +01:00
f'dump /aboot.img {path}',
])
if result.returncode != 0:
raise Exception('Failed to dump aboot.img')
return path
def dump_lk2nd(image_path: str) -> str:
"""
This doesn't append the image with the appended DTB which is needed for some devices, so it should get added in the future.
"""
path = '/tmp/lk2nd.img'
result = subprocess.run([
'debugfs',
image_path,
'-R',
2021-10-22 17:07:05 +02:00
f'dump /lk2nd.img {path}',
])
if result.returncode != 0:
raise Exception('Failed to dump lk2nd.img')
return path
def dump_qhypstub(image_path: str) -> str:
path = '/tmp/qhypstub.bin'
result = subprocess.run([
'debugfs',
image_path,
'-R',
2021-10-22 17:07:05 +02:00
f'dump /qhypstub.bin {path}',
])
if result.returncode != 0:
raise Exception('Failed to dump qhypstub.bin')
return path
def create_img_file(image_path: str, size_str: str):
result = subprocess.run([
'truncate',
'-s',
size_str,
image_path,
])
if result.returncode != 0:
raise Exception(f'Failed to allocate {image_path}')
return image_path
def partition_device(device: str):
boot_partition_size = '100MiB'
create_partition_table = ['mklabel', 'msdos']
create_boot_partition = ['mkpart', 'primary', 'ext2', '0%', boot_partition_size]
create_root_partition = ['mkpart', 'primary', boot_partition_size, '100%']
enable_boot = ['set', '1', 'boot', 'on']
2022-08-15 07:06:03 +02:00
result = run_root_cmd([
'parted',
'--script',
device,
] + create_partition_table + create_boot_partition + create_root_partition + enable_boot)
if result.returncode != 0:
raise Exception(f'Failed to create partitions on {device}')
def create_filesystem(device: str, blocksize: int = 4096, label=None, options=[], fstype='ext4'):
# blocksize can be 4k max due to pagesize
blocksize = min(blocksize, 4096)
if fstype.startswith('ext'):
# blocksize for ext-fs must be >=1024
blocksize = max(blocksize, 1024)
labels = ['-L', label] if label else []
cmd = [
f'mkfs.{fstype}',
'-F',
'-b',
str(blocksize),
] + labels + [device]
2022-08-15 07:06:03 +02:00
result = run_root_cmd(cmd)
if result.returncode != 0:
raise Exception(f'Failed to create {fstype} filesystem on {device} with CMD: {cmd}')
def create_root_fs(device: str, blocksize: int):
create_filesystem(device, blocksize=blocksize, label='kupfer_root', options=['-O', '^metadata_csum', '-N', '100000'])
def create_boot_fs(device: str, blocksize: int):
create_filesystem(device, blocksize=blocksize, label='kupfer_boot', fstype='ext2')
def install_rootfs(
rootfs_device: str,
bootfs_device: str,
device: str,
flavour: str,
arch: Arch,
packages: list[str],
use_local_repos: bool,
profile: Profile,
):
user = profile['username'] or 'kupfer'
post_cmds = FLAVOURS[flavour].get('post_cmds', [])
chroot = get_device_chroot(device=device, flavour=flavour, arch=arch, packages=packages, use_local_repos=use_local_repos)
mount_chroot(rootfs_device, bootfs_device, chroot)
chroot.mount_pacman_cache()
chroot.initialize()
chroot.activate()
chroot.create_user(
user=user,
password=profile['password'],
)
2022-08-28 01:48:53 +02:00
chroot.add_sudo_config(config_name='wheel', privilegee='%wheel', password_required=True)
copy_ssh_keys(
chroot.path,
user=user,
)
2022-03-02 15:29:58 +01:00
files = {
'etc/pacman.conf': get_base_distro(arch).get_pacman_conf(
check_space=True,
extra_repos=get_kupfer_https(arch).repos,
in_chroot=True,
),
'etc/hostname': profile['hostname'],
2022-03-02 15:29:58 +01:00
}
for target, content in files.items():
root_write_file(os.path.join(chroot.path, target.lstrip('/')), content)
if post_cmds:
result = chroot.run_cmd(' && '.join(post_cmds))
2022-02-18 06:32:04 +01:00
assert isinstance(result, subprocess.CompletedProcess)
if result.returncode != 0:
raise Exception('Error running post_cmds')
logging.info('Preparing to unmount chroot')
res = chroot.run_cmd('sync && umount /boot', attach_tty=True)
logging.debug(f'rc: {res}')
chroot.deactivate()
logging.debug(f'Unmounting rootfs at "{chroot.path}"')
res = run_root_cmd(['umount', chroot.path])
assert isinstance(res, CompletedProcess)
logging.debug(f'rc: {res.returncode}')
@click.group(name='image')
def cmd_image():
2022-02-13 19:57:04 +01:00
"""Build and manage device images"""
@cmd_image.command(name='build')
@click.argument('profile_name', required=False)
@click.option('--local-repos/--no-local-repos',
'-l/-L',
default=True,
show_default=True,
help='Whether to use local package repos at all or only use HTTPS repos.')
@click.option('--build-pkgs/--no-build-pkgs',
'-p/-P',
default=True,
show_default=True,
help='Whether to build missing/outdated local packages if local repos are enabled.')
@click.option('--no-download-pkgs',
is_flag=True,
default=False,
help='Disable trying to download packages instead of building if building is enabled.')
@click.option('--block-target', type=click.Path(), default=None, help='Override the block device file to write the final image to')
@click.option('--skip-part-images',
is_flag=True,
default=False,
help='Skip creating image files for the partitions and directly work on the target block device.')
def cmd_build(profile_name: str = None,
local_repos: bool = True,
build_pkgs: bool = True,
no_download_pkgs=False,
block_target: str = None,
skip_part_images: bool = False):
"""
Build a device image.
Unless overriden, required packages will be built or preferably downloaded from HTTPS repos.
"""
arch = get_profile_device(profile_name).arch
check_programs_wrap(['makepkg', 'pacman', 'pacstrap'])
2022-02-18 06:32:04 +01:00
profile: Profile = config.get_profile(profile_name)
device, flavour = get_device_and_flavour(profile_name)
2022-02-18 06:32:04 +01:00
size_extra_mb: int = int(profile["size_extra_mb"])
2021-10-22 17:07:05 +02:00
sector_size = 4096
rootfs_size_mb = FLAVOURS[flavour].get('size', 2) * 1000
packages = BASE_PACKAGES + DEVICES[device] + FLAVOURS[flavour]['packages'] + profile['pkgs_include']
if arch != config.runtime.arch:
build_enable_qemu_binfmt(arch)
if local_repos and build_pkgs:
logging.info("Making sure all packages are built")
build_packages_by_paths(packages, arch, try_download=not no_download_pkgs)
image_path = block_target or get_image_path(device, flavour)
makedir(os.path.dirname(image_path))
logging.info(f'Creating new file at {image_path}')
create_img_file(image_path, f"{rootfs_size_mb + size_extra_mb}M")
2021-10-22 17:07:05 +02:00
loop_device = losetup_rootfs_image(image_path, sector_size)
partition_device(loop_device)
partprobe(loop_device)
2022-02-18 06:32:04 +01:00
boot_dev: str
root_dev: str
loop_boot = loop_device + 'p1'
loop_root = loop_device + 'p2'
if skip_part_images:
boot_dev = loop_boot
root_dev = loop_root
else:
logging.info('Creating per-partition image files')
boot_dev = create_img_file(get_image_path(device, flavour, 'boot'), IMG_FILE_BOOT_DEFAULT_SIZE)
root_dev = create_img_file(get_image_path(device, flavour, 'root'), f'{rootfs_size_mb + size_extra_mb - 200}M')
create_boot_fs(boot_dev, sector_size)
create_root_fs(root_dev, sector_size)
install_rootfs(
root_dev,
boot_dev,
device,
flavour,
arch,
packages,
local_repos,
profile,
2021-10-15 22:52:13 +02:00
)
if not skip_part_images:
logging.info('Copying partition image files into full image:')
logging.info(f'Block-copying /boot to {image_path}')
dd_image(input=boot_dev, output=loop_boot)
logging.info(f'Block-copying rootfs to {image_path}')
dd_image(input=root_dev, output=loop_root)
logging.info(f'Done! Image saved to {image_path}')
@cmd_image.command(name='inspect')
2021-10-25 20:45:23 +02:00
@click.option('--shell', '-s', is_flag=True)
@click.argument('profile', required=False)
def cmd_inspect(profile: str = None, shell: bool = False):
2022-02-13 19:57:04 +01:00
"""Open a shell in a device image"""
arch = get_profile_device(profile).arch
wrap_if_foreign_arch(arch)
device, flavour = get_device_and_flavour(profile)
2021-10-22 17:07:05 +02:00
# TODO: PARSE DEVICE SECTOR SIZE
sector_size = 4096
chroot = get_device_chroot(device, flavour, arch)
image_path = get_image_path(device, flavour)
2021-10-22 17:07:05 +02:00
loop_device = losetup_rootfs_image(image_path, sector_size)
partprobe(loop_device)
mount_chroot(loop_device + 'p2', loop_device + 'p1', chroot)
logging.info(f'Inspect the rootfs image at {chroot.path}')
2021-10-25 20:45:23 +02:00
if shell:
chroot.initialized = True
chroot.activate()
if arch != config.runtime.arch:
logging.info('Installing requisites for foreign-arch shell')
build_enable_qemu_binfmt(arch)
logging.info('Starting inspection shell')
2021-10-25 20:45:23 +02:00
chroot.run_cmd('/bin/bash')
else:
pause()