diff --git a/stages/org.osbuild.coreos.live-artifacts.mono b/stages/org.osbuild.coreos.live-artifacts.mono index 44dbde5a..0f3b82d7 100755 --- a/stages/org.osbuild.coreos.live-artifacts.mono +++ b/stages/org.osbuild.coreos.live-artifacts.mono @@ -8,6 +8,7 @@ # For historical context and to see git history, refer to the original source: # https://github.com/coreos/coreos-assembler/blob/43a9c80e1f548269d71d6d586f0d5754c60f6144/src/cmd-buildextend-live +import contextlib import glob import hashlib import json @@ -135,11 +136,16 @@ def get_os_name(tree): return treefile['metadata']['name'] -def ensure_glob(pathname, **kwargs): - '''Call glob.glob(), and fail if there are no results.''' +def ensure_glob(pathname, n="", **kwargs): + """ + Call glob.glob() and fail if there are no results or + if the number of results doesn't match provided n + """ ret = glob.glob(pathname, **kwargs) if not ret: raise ValueError(f'No matches for {pathname}') + if n != "" and len(ret) != int(n): + raise ValueError(f'Matches for {pathname} does not match expected ({n})') return ret @@ -182,121 +188,141 @@ def parse_metal_inputs(inputs): return metal_file, metal4k_file -# Here we will generate the 4 Live/PXE CoreOS artifacts. -# There are four files created and exported from this stage. -# -# 1. live-iso -# 2. live-kernel (i.e. images/pxeboot/vmlinuz) -# 3. live-initramfs (i.e. images/pxeboot/initrd.img) -# 4. live-rootfs (i.e. images/pxeboot/rootfs.img) -# -# The live-iso itself contains the other 3 artifacts inside of -# it. A rough approximation of the structure looks like: -# -# live-iso.iso -# -> images/pxeboot/vmlinuz -# -> images/pxeboot/initrd.img -# -> images/pxeboot/rootfs.img -# -> metal.osmet -# -> metal4k.osmet -# -> root.squashfs -# -> full filesystem of metal image -# -# Where the ISO contains the kernel and initrd and the rootfs. The -# rootfs contains the osmet file and the root.squashfs, which itself -# contains all the files from a full CoreOS system. -# pylint: disable=too-many-statements,too-many-branches -def main(workdir, tree, inputs, options, loop_client): - squashfs_compression = 'zstd' - basearch = os.uname().machine - filenames = options['filenames'] - output_iso = os.path.join(tree, filenames['live-iso']) - output_kernel = os.path.join(tree, filenames['live-kernel']) - output_rootfs = os.path.join(tree, filenames['live-rootfs']) - output_initramfs = os.path.join(tree, filenames['live-initramfs']) - img_metal, img_metal4k = parse_metal_inputs(inputs) - # The deployed tree input is a deployment tree just as it would - # appear on a booted OSTree system. We copy some files out of this - # input tree since it is easier and they match what is in the metal - # images anyway. We also use it as a chroot when we run `coreos-installer`. - deployed_tree = inputs['deployed-tree']['path'] +def genisoargs_x86_64(paths): + # Install binaries from syslinux package + isolinuxfiles = [('/usr/share/syslinux/isolinux.bin', 0o755), + ('/usr/share/syslinux/ldlinux.c32', 0o755), + ('/usr/share/syslinux/libcom32.c32', 0o755), + ('/usr/share/syslinux/libutil.c32', 0o755), + ('/usr/share/syslinux/vesamenu.c32', 0o755)] + for src, mode in isolinuxfiles: + dst = os.path.join(paths["iso/isolinux"], os.path.basename(src)) + shutil.copyfile(src, dst) + os.chmod(dst, mode) - # For dev/test purposes it is useful to create test fixture ISO - # images for the coreos-installer CI framework to use [1]. This test - # fixture is much smaller than an actual ISO and can be stored in git. - # Determine if the user wants a test fixture ISO created by checking - # for the coreos-installer-test-fixture file baked in the tree. - # [1] https://github.com/coreos/coreos-installer/tree/main/fixtures/iso - test_fixture = os.path.exists( - os.path.join( - deployed_tree, - 'usr/share/coreos-assembler/coreos-installer-test-fixture')) + # for legacy bios boot AKA eltorito boot + return ['-eltorito-boot', 'isolinux/isolinux.bin', + '-eltorito-catalog', 'isolinux/boot.cat', + '-no-emul-boot', + '-boot-load-size', '4', + '-boot-info-table'] + + +def genisoargs_ppc64le(paths, kargs_json, volid): + os.makedirs(os.path.join(paths["iso"], 'boot/grub')) + # can be EFI/fedora or EFI/redhat + grubpath = ensure_glob(os.path.join(paths["iso"], 'EFI/*/grub.cfg'), n=1)[0] + shutil.move(grubpath, os.path.join(paths["iso"], 'boot/grub/grub.cfg')) + for f in kargs_json['files']: + if re.match('^EFI/.*/grub.cfg$', f['path']): + f['path'] = 'boot/grub/grub.cfg' + + # safely remove things we don't need in the final ISO tree + for d in ['EFI', 'isolinux']: + shutil.rmtree(os.path.join(paths["iso"], d)) + + # grub2-mkrescue is a wrapper around xorriso + return ['grub2-mkrescue', '-volid', volid] + + +def genisoargs_s390x(paths, kernel_img, test_fixture, volid, name_version): + # The initramfs gets referenced a lot in this function so let's + # make a local variable for it for convenience. + iso_initramfs = paths["iso/images/pxeboot/initrd.img"] + # Reserve 32MB for the kernel, starting memory address of the initramfs + # See https://github.com/weldr/lorax/blob/master/share/templates.d/99-generic/s390.tmpl + INITRD_ADDRESS = '0x02000000' + lorax_templates = '/usr/share/lorax/templates.d/99-generic/config_files/s390' + shutil.copy(os.path.join(lorax_templates, 'redhat.exec'), paths["iso/images"]) + with open(os.path.join(lorax_templates, 'generic.ins'), 'r', encoding='utf8') as fp1: + with open(os.path.join(paths["iso"], 'generic.ins'), 'w', encoding='utf8') as fp2: + for line in fp1: + fp2.write(line.replace('@INITRD_LOAD_ADDRESS@', INITRD_ADDRESS)) + for prmfile in [paths["iso/images/cdboot.prm"], + paths["iso/images/generic.prm"], + paths["iso/images/genericdvd.prm"]]: + shutil.copy(paths["iso/zipl.prm"], prmfile) + + # TODO: check if the kernel.img move can be extracted + # s390x's z/VM CMS files are limited to 8 char for filenames and extensions + # Also it is nice to keep naming convetion with Fedora/RHEL for existing users and code + kernel_dest = os.path.join(paths["iso/images/pxeboot"], 'kernel.img') + shutil.move(os.path.join(paths["iso/images/pxeboot"], kernel_img), kernel_dest) + + if test_fixture: + # truncate it to 128k so it includes the offsets to the initrd and kargs + # https://github.com/ibm-s390-linux/s390-tools/blob/032304d5034e/netboot/mk-s390image#L21-L24 + with open(kernel_dest, 'rb+') as f: + f.truncate(128 * 1024) + with open(iso_initramfs, 'rb+') as f: + f.truncate(1024) + + # On s390x, we reserve space for the Ignition config in the initrd + # image directly since the bootloader doesn't support multiple initrds. + # We do this by inflating the initramfs just for the duration of the + # `mk-s390image` call. + initramfs_size = os.stat(iso_initramfs).st_size + # sanity-check it's 4-byte aligned (see align_initrd_for_uncompressed_append) + assert initramfs_size % 4 == 0 + + # combine kernel, initramfs and cmdline using the mk-s390image tool + os.truncate(iso_initramfs, initramfs_size + IGNITION_IMG_SIZE) + subprocess.check_call(['mk-s390image', + kernel_dest, + paths["iso/images/cdboot.img"], + '-r', iso_initramfs, + '-p', paths["iso/images/cdboot.prm"]]) + os.truncate(iso_initramfs, initramfs_size) + + # generate .addrsize file for LPAR + with open(paths["iso/images/initrd.addrsize"], 'wb') as addrsize: + addrsize_data = struct.pack(">iiii", 0, int(INITRD_ADDRESS, 16), 0, initramfs_size) + addrsize.write(addrsize_data) + + # safely remove things we don't need in the final ISO tree + for d in ['EFI', 'isolinux']: + shutil.rmtree(os.path.join(paths["iso"], d)) + + return ['xorrisofs', '-verbose', + '-volid', volid, + '-volset', f"{name_version}", + '-rational-rock', '-J', '-joliet-long', + '-no-emul-boot', '-eltorito-boot', + os.path.join(os.path.relpath(paths["iso/images"], paths["iso"]), 'cdboot.img')] + + +def gen_live_artifacts(paths, tree, filenames, deployed_tree, loop_client, kernel_img, version, blsentry_kargs): + """ + Generate the ISO image. + Based on the Lorax templates [1]. + A lot of good information can be found on the Fedora wiki [2]. + + [1] https://github.com/weldr/lorax/tree/master/share/templates.d/99-generic + [2] https://fedoraproject.org/wiki/User:Pjones/BootableCDsForBIOSAndUEFI + """ - # Determine some basic information about the CoreOS we are operating on. base_name = get_os_name(tree=deployed_tree) - os_release = osrelease.parse_files(os.path.join(deployed_tree, 'etc', 'os-release')) - version = os_release['OSTREE_VERSION'] name_version = f'{base_name}-{version}' # The short volume ID can only be 32 characters (bytes probably). We may in # the future want to shorten this more intelligently, otherwise we truncate the # version which may impede uniqueness. volid = name_version[0:32] - tmpisofile = os.path.join(workdir, 'live.iso') - tmpisoroot = os.path.join(workdir, 'iso') - tmpisocoreos = os.path.join(tmpisoroot, 'coreos') - tmpisoimages = os.path.join(tmpisoroot, 'images') - tmpisoimagespxe = os.path.join(tmpisoimages, 'pxeboot') - tmpisoisolinux = os.path.join(tmpisoroot, 'isolinux') - # contents of initramfs on both PXE and ISO - tmpinitrd_base = os.path.join(workdir, 'initrd') - # contents of rootfs image - tmpinitrd_rootfs = os.path.join(workdir, 'initrd-rootfs') + genisoargs = ['genisoimage', '-verbose', + '-V', volid, + '-volset', f"{name_version}", + # For greater portability, consider using both + # Joliet and Rock Ridge extensions. Umm, OK :) + '-rational-rock', '-J', '-joliet-long'] - for d in (tmpisoroot, tmpisocoreos, tmpisoimages, tmpisoimagespxe, - tmpisoisolinux, tmpinitrd_base, tmpinitrd_rootfs): - os.mkdir(d) + output_iso = os.path.join(tree, filenames['live-iso']) + output_kernel = os.path.join(tree, filenames['live-kernel']) + output_rootfs = os.path.join(tree, filenames['live-rootfs']) + output_initramfs = os.path.join(tree, filenames['live-initramfs']) + test_fixture = check_for_test_fixture(deployed_tree) + basearch = os.uname().machine - # convention for kernel and initramfs names and a few others - kernel_img = 'vmlinuz' - initrd_img = 'initrd.img' - rootfs_img = 'rootfs.img' - kargs_file = 'kargs.json' - igninfo_file = 'igninfo.json' - - # Find the directory under `/usr/lib/modules/` where the - # kernel/initrd live. It will be the only entity in there. - modules_dir = os.path.join(deployed_tree, 'usr/lib/modules') - modules_dirents = os.listdir(modules_dir) - if len(modules_dirents) != 1: - raise ValueError(f"expected unique entry in modules dir, found: {modules_dirents}") - moduledir = modules_dirents[0] - - # copy those files out of the ostree into the iso root dir - initramfs_img = 'initramfs.img' - for file in [kernel_img, initramfs_img]: - src = os.path.join(modules_dir, moduledir, file) - dst = os.path.join(tmpisoimagespxe, file) - if file == initramfs_img: - dst = os.path.join(tmpisoimagespxe, initrd_img) - shutil.copyfile(src, dst) - # initramfs isn't world readable by default so let's open up perms - os.chmod(dst, 0o644) - - # Generate initramfs stamp file indicating that this is a live - # initramfs. Store the build ID in it. - stamppath = os.path.join(tmpinitrd_base, 'etc/coreos-live-initramfs') - os.makedirs(os.path.dirname(stamppath), exist_ok=True) - with open(stamppath, 'w', encoding='utf8') as fh: - fh.write(version + '\n') - - # Generate rootfs stamp file with the build ID, indicating that the - # rootfs has been appended and confirming that initramfs and rootfs are - # from the same build. - stamppath = os.path.join(tmpinitrd_rootfs, 'etc/coreos-live-rootfs') - os.makedirs(os.path.dirname(stamppath), exist_ok=True) - with open(stamppath, 'w', encoding='utf8') as fh: - fh.write(version + '\n') + kargs_json = copy_configs_and_init_kargs_json(deployed_tree, paths, blsentry_kargs, volid) # Add placeholder for Ignition CPIO file. This allows an external tool, # `coreos-installer iso ignition embed`, to modify an existing ISO image @@ -307,102 +333,310 @@ def main(workdir, tree, inputs, options, loop_client): # moved where Ignition will see it. We only handle !s390x here since that's # the simple case (where layered initrds are supported). The s390x case is # handled lower down + igninfo_json = {} if basearch != 's390x': - with open(os.path.join(tmpisoimages, 'ignition.img'), 'wb') as fdst: + with open(os.path.join(paths["iso/images"], 'ignition.img'), 'wb') as fdst: fdst.write(bytes(IGNITION_IMG_SIZE)) igninfo_json = {'file': 'images/ignition.img'} - # Generate JSON file that lists OS features available to - # coreos-installer {iso|pxe} customize. Put it in the initramfs for - # pxe customize and the ISO for iso customize. - features = json.dumps(get_os_features(tree=deployed_tree), indent=2, sort_keys=True) + '\n' - featurespath = os.path.join(tmpinitrd_base, 'etc/coreos/features.json') - os.makedirs(os.path.dirname(featurespath), exist_ok=True) - with open(featurespath, 'w', encoding='utf8') as fh: - fh.write(features) - with open(os.path.join(tmpisocoreos, 'features.json'), 'w', encoding='utf8') as fh: - fh.write(features) + # For x86_64 legacy boot (BIOS) booting + if basearch == "x86_64": + genisoargs += genisoargs_x86_64(paths) + elif basearch == "ppc64le": + genisoargs = genisoargs_ppc64le(paths, kargs_json, volid) + elif basearch == "s390x": + genisoargs = genisoargs_s390x(paths, kernel_img, test_fixture, volid, name_version) + update_image_offsets_s390x(paths, kargs_json, igninfo_json) + # This name change happened in the code extracted into genisoargs_s390x(). Now it's part of the function, so it + # doesn't leak into the main() scope and we need to set it here again. Splitting the genisoargs_s390x() function + # further, making the file rename part its own function if possible, will fix this issue. + kernel_img = 'kernel.img' - # Add osmet files. Use a chroot here to use the same coreos-installer as is - # in the artifacts we are building. Use the deployed_tree as the chroot since - # that's the easiest thing to do. - for img, sector_size in [(img_metal, 512), (img_metal4k, 4096)]: - with loop_client.device(img, partscan=True, read_only=True, sector_size=sector_size) as loopdev: - img_name = os.path.basename(img) - print(f'Generating osmet file for {img_name} image') - img_checksum = checksum.hexdigest_file(loopdev, "sha256") - img_osmet_file = os.path.join(tmpinitrd_rootfs, f'{img_name}.osmet') - with Chroot(deployed_tree, bind_mounts=["/run", "/tmp"]) as chroot: - cmd = ['coreos-installer', 'pack', 'osmet', loopdev, - '--description', os_release['PRETTY_NAME'], - '--checksum', img_checksum, '--output', img_osmet_file] - chroot.run(cmd, check=True) + # Drop zipl.prm as it was either unused (!s390x) or is now no longer + # needed (s390x) + os.unlink(paths["iso/zipl.prm"]) + # For x86_64 and aarch64 UEFI booting + if basearch in ("x86_64", "aarch64"): + efiboot_path = mkefiboot(paths, deployed_tree, kargs_json, volid, loop_client) + genisoargs += ['-eltorito-alt-boot', '-efi-boot', efiboot_path, '-no-emul-boot'] + + # We've done everything that might affect kargs, so filter out any files + # that no longer exist and write out the kargs JSON if it lists any files + kargs_json['files'] = [f for f in kargs_json['files'] + if os.path.exists(os.path.join(paths["iso"], f['path']))] + kargs_json['files'].sort(key=lambda f: f['path']) + if kargs_json['files']: + # Store the location of "karg embed areas" for use by + # `coreos-installer iso kargs modify` + with open(paths["iso/coreos/kargs.json"], 'w', encoding='utf8') as fh: + json.dump(kargs_json, fh, indent=2, sort_keys=True) + fh.write('\n') + + # Write out the igninfo.json file. This is used by coreos-installer to know + # how to embed the Ignition config. + with open(paths["iso/coreos/igninfo.json"], 'w', encoding='utf8') as fh: + json.dump(igninfo_json, fh, indent=2, sort_keys=True) + fh.write('\n') + + # Define inputs and outputs + genisoargs_final = genisoargs + ['-o', paths["live.iso"], paths["iso"]] + + with open(paths["iso/coreos/miniso.dat"], 'wb') as f: + f.truncate(MINISO_DATA_FILE_SIZE) + + if test_fixture: + trim_for_test_fixtures(paths) + + subprocess.check_call(genisoargs_final) + + # Add MBR, and GPT with ESP, for x86_64 BIOS/UEFI boot when ISO is + # copied to a USB stick + if basearch == "x86_64": + subprocess.check_call(['isohybrid', '--uefi', paths["live.iso"]]) + + # Copy to final output locations the things that won't change from + # this point forward. We do it here because we unlink the the rootfs below. + cp_reflink(paths["iso/images/pxeboot/vmlinuz"], output_kernel) + cp_reflink(paths["iso/images/pxeboot/initrd.img"], output_initramfs) + cp_reflink(paths["iso/images/pxeboot/rootfs.img"], output_rootfs) + + # Here we generate a minimal ISO purely for the purpose of + # calculating some data such that, given the full live ISO in + # future, coreos-installer can regenerate a minimal ISO. + # + # We first generate the minimal ISO and then call coreos-installer + # pack with both the minimal and full ISO as arguments. This will + # delete the minimal ISO (i.e. --consume) and update in place the + # full Live ISO. + # + # The only difference in the minimal ISO is that we drop two files. + # We keep everything else the same to maximize file matching between + # the two versions so we can get the smallest delta. E.g. we keep the + # `coreos.liveiso` karg, even though the miniso doesn't need it. + # coreos-installer takes care of removing it. + os.unlink(paths["iso/images/pxeboot/rootfs.img"]) + os.unlink(paths["iso/coreos/miniso.dat"]) + subprocess.check_call(genisoargs + ['-o', paths["live.iso.minimal"], paths["iso"]]) + if basearch == "x86_64": + subprocess.check_call(['isohybrid', '--uefi', paths["live.iso.minimal"]]) + with Chroot(deployed_tree, bind_mounts=["/run"]) as chroot: + chroot.run(['coreos-installer', 'pack', 'minimal-iso', paths["live.iso"], + paths["live.iso.minimal"], '--consume'], check=True) + + # Finally we can copy the ISO to the final output location + cp_reflink(paths["live.iso"], output_iso) + + +def update_image_offsets_s390x(paths, kargs_json, igninfo_json): + # Get the kargs and initramfs offsets in the cdboot.img. For more info, see: + # https://github.com/ibm-s390-linux/s390-tools/blob/032304d5034e/netboot/mk-s390image#L21-L23 + CDBOOT_IMG_OFFS_INITRD_START_BYTES = 66568 + CDBOOT_IMG_OFFS_KARGS_START_BYTES = 66688 + CDBOOT_IMG_OFFS_KARGS_MAX_SIZE = 896 + + iso_initramfs = paths["iso/images/pxeboot/initrd.img"] + with open(paths["iso/images/cdboot.img"], 'rb') as f: + f.seek(CDBOOT_IMG_OFFS_INITRD_START_BYTES) + offset = struct.unpack(">Q", f.read(8))[0] + + # sanity-check we're at the right spot by comparing a few bytes + f.seek(offset) + with open(iso_initramfs, 'rb') as canonical: + if f.read(1024) != canonical.read(1024): + raise ValueError(f"expected initrd at offset {offset}") + + # kargs are part of 'images/cdboot.img' blob + kargs_json['files'].append({ + 'path': 'images/cdboot.img', + 'offset': CDBOOT_IMG_OFFS_KARGS_START_BYTES, + 'pad': '\0', + 'end': '\0', + }) + kargs_json.update( + size=CDBOOT_IMG_OFFS_KARGS_MAX_SIZE, + ) + + igninfo_json.update({ + 'file': 'images/cdboot.img', + 'offset': offset + os.stat(iso_initramfs).st_size, + 'length': IGNITION_IMG_SIZE, + }) + + +def mkefiboot(paths, deployed_tree, kargs_json, volid, loop_client): + """ + Creates an efi boot image (efiboot.img) file, required for EFI + booting the ISO. This is a fat32 formatted filesystem that contains + all the files needed for EFI boot from an ISO. + + Returns the path to the image file. + """ + # In restrictive environments, setgid, setuid and ownership changes + # may be restricted. This sets the file ownership to root and + # removes the setgid and setuid bits in the tarball. + def strip(tarinfo): + tarinfo.uid = 0 + tarinfo.gid = 0 + if tarinfo.isdir(): + tarinfo.mode = 0o755 + elif tarinfo.isfile(): + tarinfo.mode = 0o0644 + return tarinfo + + efidir = paths["efi"] + shutil.copytree(os.path.join(deployed_tree, 'usr/lib/bootupd/updates/EFI'), efidir) + + # Find name of vendor directory + vendor_ids = [n for n in os.listdir(efidir) if n != "BOOT"] + if len(vendor_ids) != 1: + raise ValueError(f"did not find exactly one EFI vendor ID: {vendor_ids}") + vendor_id = vendor_ids[0] + + # Always replace live/EFI/{vendor} to actual live/EFI/{vendor_id} + # https://github.com/openshift/os/issues/954 + dfd = os.open(paths["iso"], os.O_RDONLY) + grubfilepath = ensure_glob('EFI/*/grub.cfg', dir_fd=dfd) + if len(grubfilepath) != 1: + raise ValueError(f'Found != 1 grub.cfg files: {grubfilepath}') + srcpath = os.path.dirname(grubfilepath[0]) + if srcpath != f'EFI/{vendor_id}': + print(f"Renaming '{srcpath}' to 'EFI/{vendor_id}'") + os.rename(srcpath, f"EFI/{vendor_id}", src_dir_fd=dfd, dst_dir_fd=dfd) + # And update kargs.json + for file in kargs_json['files']: + if file['path'] == grubfilepath[0]: + file['path'] = f'EFI/{vendor_id}/grub.cfg' + os.close(dfd) + + # Delete fallback and its CSV file. Its purpose is to create + # EFI boot variables, which we don't want when booting from + # removable media. + # + # A future shim release will merge fallback.efi into the main + # shim binary and enable the fallback behavior when the CSV + # exists. But for now, fail if fallback.efi is missing. + for path in ensure_glob(os.path.join(efidir, "BOOT", "fb*.efi")): + os.unlink(path) + for path in ensure_glob(os.path.join(efidir, vendor_id, "BOOT*.CSV")): + os.unlink(path) + + # Drop vendor copies of shim; we already have it in BOOT*.EFI in + # BOOT + for path in ensure_glob(os.path.join(efidir, vendor_id, "shim*.efi")): + os.unlink(path) + + # Consolidate remaining files into BOOT. shim needs GRUB to be + # there, and the rest doesn't hurt. + for path in ensure_glob(os.path.join(efidir, vendor_id, "*")): + shutil.move(path, os.path.join(efidir, "BOOT")) + os.rmdir(os.path.join(efidir, vendor_id)) + + # Inject a stub grub.cfg pointing to the one in the main ISO image. + # + # When booting via El Torito, this stub is not used; GRUB reads + # the ISO image directly using its own ISO support. This + # happens when booting from a CD device, or when the ISO is + # copied to a USB stick and booted on EFI firmware which prefers + # to boot a hard disk from an El Torito image if it has one. + # EDK II in QEMU behaves this way. + # + # This stub is used with EFI firmware which prefers to boot a + # hard disk from an ESP, or which cannot boot a hard disk via El + # Torito at all. In that case, GRUB thinks it booted from a + # partition of the disk (a fake ESP created by isohybrid, + # pointing to efiboot.img) and needs a grub.cfg there. + with open(os.path.join(efidir, "BOOT", "grub.cfg"), "w", encoding='utf8') as fh: + fh.write(f'''search --label "{volid}" --set root --no-floppy +set prefix=($root)/EFI/{vendor_id} +echo "Booting via ESP..." +configfile $prefix/grub.cfg +boot +''') + + # Install binaries from boot partition + # Manually construct the tarball to ensure proper permissions and ownership + efitarfile = tempfile.NamedTemporaryFile(suffix=".tar") + with tarfile.open(efitarfile.name, "w:", dereference=True) as tar: + tar.add(efidir, arcname="/EFI", filter=strip) + + # Create the efiboot.img file in the images/ dir + efibootfile = paths["iso/images/efiboot.img"] + make_efi_bootfile(loop_client, input_tarball=efitarfile.name, output_efiboot_img=efibootfile) + return "images/efiboot.img" + + +def mksquashfs_metal(paths, workdir, img_metal, loop_client): + """ + Mounts a copy of the metal image and modifies it accordingly to create a (squashfs) rootfs from its contents for the + live ISO. + + Returns the bls entry kernel arguments for the ISO bootloader. + """ + squashfs_compression = 'zstd' + basearch = os.uname().machine tmp_squashfs_dir = os.path.join(workdir, 'tmp-squashfs-dir') os.mkdir(tmp_squashfs_dir) - # Since inputs are read-only and we want to modify we'll make a + # Since inputs are read-only and we want to modify it, we'll make a # copy of the metal.raw image and then mount that. tmp_img_metal = os.path.join(workdir, os.path.basename(img_metal)) cp_reflink(img_metal, tmp_img_metal) with loop_client.device(tmp_img_metal, partscan=True) as loopdev: - # So we can temporarily see the loopXpX devices - subprocess.check_call(['mount', '-t', 'devtmpfs', 'devtmpfs', '/dev/']) - # Mount manually to avoid conflicts with osmet. - # If mounted via the manifest, the stage begins with mounts already in place, - # but osmet also performs a mount operation, leading to conflicts due to duplicate - # filesystem UUIDs. Perform the manual mount only after the osmet stage - subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p4', tmp_squashfs_dir]) - subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p3', - os.path.join(tmp_squashfs_dir, 'boot')]) - if basearch in ['x86_64', 'aarch64']: - subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p2', - os.path.join(tmp_squashfs_dir, 'boot/efi')]) - squashfs_dir_umount_needed = True + with contextlib.ExitStack() as cm: + # So we can temporarily see the loopXpX devices + subprocess.check_call(['mount', '-t', 'devtmpfs', 'devtmpfs', '/dev/']) + cm.callback(subprocess.run, ['umount', '/dev/'], check=True) + # Mount manually to avoid conflicts with osmet. + # If mounted via the manifest, the stage begins with mounts already in place, + # but osmet also performs a mount operation, leading to conflicts due to duplicate + # filesystem UUIDs. Perform the manual mount only after the osmet stage + subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p4', tmp_squashfs_dir]) + cm.callback(subprocess.run, ['umount', '-R', tmp_squashfs_dir], check=True) + subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p3', + os.path.join(tmp_squashfs_dir, 'boot')]) + if basearch in ['x86_64', 'aarch64']: + subprocess.check_call(['mount', '-o', 'rw', loopdev + 'p2', + os.path.join(tmp_squashfs_dir, 'boot/efi')]) - # Immplements necessary CoreOS adjustments - # including creating hardlinks in the /boot/ filesystem - # and modifying the read-only flag in OSTree configurations - # Where the contents of rootfs image are stored - # Make sure to create it, if it is not created yet. - tmpinitrd_rootfs = os.path.join(workdir, 'initrd-rootfs') - os.makedirs(tmpinitrd_rootfs, exist_ok=True) + # Implements necessary CoreOS adjustments + # including creating hardlinks in the /boot/ filesystem + # and modifying the read-only flag in OSTree configurations + # Where the contents of rootfs image are stored + # Make sure to create it, if it is not created yet. - # Remove the sysroot=readonly flag, see https://github.com/coreos/fedora-coreos-tracker/issues/589 - subprocess.check_call(['sed', '-i', '/readonly=true/d', f'{tmp_squashfs_dir}/ostree/repo/config']) + # Remove the sysroot=readonly flag, see https://github.com/ostreedev/ostree/issues/1921 + subprocess.check_call(['sed', '-i', '/readonly=true/d', f'{tmp_squashfs_dir}/ostree/repo/config']) - # And ensure that the kernel binary and hmac file is in the place that dracut - # expects it to be; xref https://issues.redhat.com/browse/OCPBUGS-15843 + # And ensure that the kernel binary and hmac file is in the place that dracut + # expects it to be; xref https://issues.redhat.com/browse/OCPBUGS-15843 - kernel_binary = glob.glob(f"{tmp_squashfs_dir}/boot/ostree/*/vmlinuz*")[0] - kernel_hmac = glob.glob(f"{tmp_squashfs_dir}/boot/ostree/*/.*.hmac")[0] - kernel_binary_basename = os.path.basename(kernel_binary) - kernel_hmac_basename = os.path.basename(kernel_hmac) + kernel_binary = glob.glob(f"{tmp_squashfs_dir}/boot/ostree/*/vmlinuz*")[0] + kernel_hmac = glob.glob(f"{tmp_squashfs_dir}/boot/ostree/*/.*.hmac")[0] + kernel_binary_basename = os.path.basename(kernel_binary) + kernel_hmac_basename = os.path.basename(kernel_hmac) - # Create hard links in the /boot directory - os.link(kernel_hmac, f"{tmp_squashfs_dir}/boot/{kernel_hmac_basename}") - os.link(kernel_binary, f"{tmp_squashfs_dir}/boot/{kernel_binary_basename}") + # Create hard links in the /boot directory + os.link(kernel_hmac, f"{tmp_squashfs_dir}/boot/{kernel_hmac_basename}") + os.link(kernel_binary, f"{tmp_squashfs_dir}/boot/{kernel_binary_basename}") - print(f"Kernel binary linked: {tmp_squashfs_dir}/boot/{kernel_binary_basename}") - print(f"Kernel HMAC linked: {tmp_squashfs_dir}/boot/{kernel_hmac_basename}") - # Generate root squashfs - print(f'Compressing squashfs with {squashfs_compression}') + print(f"Kernel binary linked: {tmp_squashfs_dir}/boot/{kernel_binary_basename}") + print(f"Kernel HMAC linked: {tmp_squashfs_dir}/boot/{kernel_hmac_basename}") + # Generate root squashfs + print(f'Compressing squashfs with {squashfs_compression}') - try: - # Name must be exactly "root.squashfs" because the 20live dracut module - # makes assumptions about the length of the name in sysroot.mount - tmp_squashfs = os.path.join(tmpinitrd_rootfs, 'root.squashfs') + # Note the filename must be exactly "root.squashfs" because the 20live + # dracut module makes assumptions about the length of the name in sysroot.mount # this matches the set of flags we implicitly passed when doing this # through libguestfs' mksquashfs command - subprocess.check_call(['mksquashfs', tmp_squashfs_dir, tmp_squashfs, - '-root-becomes', tmp_squashfs_dir, '-wildcards', '-no-recovery', + subprocess.check_call(['mksquashfs', tmp_squashfs_dir, + paths["initrd-rootfs/root.squashfs"], + '-root-becomes', tmp_squashfs_dir, + '-wildcards', '-no-recovery', '-comp', squashfs_compression]) # while it's mounted here, also get the kargs - blsentry = ensure_glob(os.path.join(tmp_squashfs_dir, 'boot/loader/entries/*.conf')) - if len(blsentry) != 1: - raise ValueError(f'Found != 1 BLS entries: {blsentry}') - blsentry = blsentry[0] + blsentry = ensure_glob(os.path.join(tmp_squashfs_dir, 'boot/loader/entries/*.conf'), n=1)[0] blsentry_kargs = [] with open(blsentry, encoding='utf8') as f: for line in f: @@ -411,29 +645,11 @@ def main(workdir, tree, inputs, options, loop_client): break if len(blsentry_kargs) == 0: raise ValueError("found no kargs in metal image") - finally: - if squashfs_dir_umount_needed: - subprocess.check_call(['umount', '-R', tmp_squashfs_dir]) - subprocess.check_call(['umount', '/dev/']) - # Generate rootfs image - iso_rootfs = os.path.join(tmpisoimagespxe, rootfs_img) - # The rootfs must be uncompressed because the ISO mounts root.squashfs - # directly from the middle of the file - extend_initramfs(initramfs=iso_rootfs, tree=tmpinitrd_rootfs, compress=False) - # Check that the root.squashfs magic number is in the offset hardcoded - # in sysroot.mount in 20live/live-generator - with open(iso_rootfs, 'rb') as fh: - fh.seek(124) - if fh.read(4) != b'hsqs': - raise ValueError("root.squashfs not at expected offset in rootfs image") - # Save stream hash of rootfs for verifying out-of-band fetches - os.makedirs(os.path.join(tmpinitrd_base, 'etc'), exist_ok=True) - make_stream_hash(iso_rootfs, os.path.join(tmpinitrd_base, 'etc/coreos-live-want-rootfs')) - # Add common content - iso_initramfs = os.path.join(tmpisoimagespxe, initrd_img) - extend_initramfs(initramfs=iso_initramfs, tree=tmpinitrd_base) + return blsentry_kargs + +def copy_configs_and_init_kargs_json(deployed_tree, paths, blsentry_kargs, volid): # Filter kernel arguments for substituting into ISO bootloader kargs_array = [karg for karg in blsentry_kargs if karg.split('=')[0] not in LIVE_EXCLUDE_KARGS] @@ -445,10 +661,12 @@ def main(workdir, tree, inputs, options, loop_client): cmdline = '' karg_embed_area_length = 0 srcdir_prefix = os.path.join(deployed_tree, 'usr/share/coreos-assembler/live/') - # Grab all the contents from the live dir from the configs + # Grab all the contents from the live dir from the configs. This is + # typically just the following dir copied in to the image. + # https://github.com/coreos/fedora-coreos-config/tree/testing-devel/live for srcdir, _, filenames in os.walk(srcdir_prefix): dir_suffix = srcdir.replace(srcdir_prefix, '', 1) - dstdir = os.path.join(tmpisoroot, dir_suffix) + dstdir = os.path.join(paths["iso"], dir_suffix) if not os.path.exists(dstdir): os.mkdir(dstdir) for filename in filenames: @@ -495,343 +713,224 @@ def main(workdir, tree, inputs, options, loop_client): size=karg_embed_area_length, default=cmdline.strip(), ) + return kargs_json - # These sections are based on lorax templates - # see https://github.com/weldr/lorax/tree/master/share/templates.d/99-generic - # Generate the ISO image. Lots of good info here: - # https://fedoraproject.org/wiki/User:Pjones/BootableCDsForBIOSAndUEFI - genisoargs = ['/usr/bin/genisoimage', '-verbose', - '-V', volid, - '-volset', f"{name_version}", - # For greater portability, consider using both - # Joliet and Rock Ridge extensions. Umm, OK :) - '-rational-rock', '-J', '-joliet-long'] +def check_for_test_fixture(deployed_tree): + """ + For dev/test purposes it is useful to create test fixture ISO + images for the coreos-installer CI framework to use [1]. This test + fixture is much smaller than an actual ISO and can be stored in git. + Determine if the user wants a test fixture ISO created by checking + for the coreos-installer-test-fixture file baked in the tree. + [1] https://github.com/coreos/coreos-installer/tree/main/fixtures/iso + """ + return os.path.exists(os.path.join(deployed_tree, 'usr/share/coreos-assembler/coreos-installer-test-fixture')) - # For x86_64 legacy boot (BIOS) booting - if basearch == "x86_64": - # Install binaries from syslinux package - isolinuxfiles = [('/usr/share/syslinux/isolinux.bin', 0o755), - ('/usr/share/syslinux/ldlinux.c32', 0o755), - ('/usr/share/syslinux/libcom32.c32', 0o755), - ('/usr/share/syslinux/libutil.c32', 0o755), - ('/usr/share/syslinux/vesamenu.c32', 0o755)] - for src, mode in isolinuxfiles: - dst = os.path.join(tmpisoisolinux, os.path.basename(src)) - shutil.copyfile(src, dst) - os.chmod(dst, mode) - # for legacy bios boot AKA eltorito boot - genisoargs += ['-eltorito-boot', 'isolinux/isolinux.bin', - '-eltorito-catalog', 'isolinux/boot.cat', - '-no-emul-boot', - '-boot-load-size', '4', - '-boot-info-table'] +def trim_for_test_fixtures(paths): + """ + Replaces the large images (efiboot.img, rootfs.img, etc) with simple text files to aid in test fixture creation. + """ + with open(paths["iso/images/efiboot.img"], 'w', encoding='utf8') as fh: + fh.write('efiboot.img\n') + with open(paths["iso/images/pxeboot/rootfs.img"], 'w', encoding='utf8') as fh: + fh.write('rootfs data\n') + with open(paths["iso/images/pxeboot/initrd.img"], 'w', encoding='utf8') as fh: + fh.write('initrd data\n') + with open(paths["iso/images/pxeboot/vmlinuz"], 'w', encoding='utf8') as fh: + fh.write('the kernel\n') + # this directory doesn't exist on s390x + if os.path.isdir(paths["iso/isolinux"]): + with open(os.path.join(paths["iso/isolinux"], 'isolinux.bin'), 'rb+') as fh: + flen = fh.seek(0, 2) + fh.truncate(0) + fh.truncate(flen) + fh.seek(64) + # isohybrid checks for this magic + fh.write(b'\xfb\xc0\x78\x70') + for f in ensure_glob(os.path.join(paths["iso/isolinux"], '*.c32')): + os.unlink(f) + for f in ensure_glob(os.path.join(paths["iso/isolinux"], '*.msg')): + os.unlink(f) - elif basearch == "ppc64le": - os.makedirs(os.path.join(tmpisoroot, 'boot/grub')) - # can be EFI/fedora or EFI/redhat - grubpath = ensure_glob(os.path.join(tmpisoroot, 'EFI/*/grub.cfg')) - if len(grubpath) != 1: - raise ValueError(f'Found != 1 grub.cfg files: {grubpath}') - shutil.move(grubpath[0], os.path.join(tmpisoroot, 'boot/grub/grub.cfg')) - for f in kargs_json['files']: - if re.match('^EFI/.*/grub.cfg$', f['path']): - f['path'] = 'boot/grub/grub.cfg' - # safely remove things we don't need in the final ISO tree - for d in ['EFI', 'isolinux']: - shutil.rmtree(os.path.join(tmpisoroot, d)) +def mk_osmet_files(deployed_tree, img_metal, img_metal4k, loop_client, paths, os_release): + """ + Add osmet files. Use a chroot here to use the same coreos-installer as is + in the artifacts we are building. Use the deployed_tree as the chroot since + that's the easiest thing to do. + """ + for img, sector_size in [(img_metal, 512), (img_metal4k, 4096)]: + with loop_client.device(img, partscan=True, read_only=True, sector_size=sector_size) as loopdev: + img_name = os.path.basename(img) + print(f'Generating osmet file for {img_name} image') + img_checksum = checksum.hexdigest_file(loopdev, "sha256") + img_osmet_file = os.path.join(paths["initrd-rootfs"], f'{img_name}.osmet') + with Chroot(deployed_tree, bind_mounts=["/run", "/tmp"]) as chroot: + cmd = ['coreos-installer', 'pack', 'osmet', loopdev, + '--description', os_release['PRETTY_NAME'], + '--checksum', img_checksum, '--output', img_osmet_file] + chroot.run(cmd, check=True) - # grub2-mkrescue is a wrapper around xorriso - genisoargs = ['grub2-mkrescue', '-volid', volid] - elif basearch == "s390x": - # Reserve 32MB for the kernel, starting memory address of the initramfs - # See https://github.com/weldr/lorax/blob/master/share/templates.d/99-generic/s390.tmpl - INITRD_ADDRESS = '0x02000000' - lorax_templates = '/usr/share/lorax/templates.d/99-generic/config_files/s390' - shutil.copy(os.path.join(lorax_templates, 'redhat.exec'), tmpisoimages) - with open(os.path.join(lorax_templates, 'generic.ins'), 'r', encoding='utf8') as fp1: - with open(os.path.join(tmpisoroot, 'generic.ins'), 'w', encoding='utf8') as fp2: - _ = [fp2.write(line.replace('@INITRD_LOAD_ADDRESS@', INITRD_ADDRESS)) for line in fp1] - for prmfile in ['cdboot.prm', 'genericdvd.prm', 'generic.prm']: - with open(os.path.join(tmpisoimages, prmfile), 'w', encoding='utf8') as fp1: - with open(os.path.join(tmpisoroot, 'zipl.prm'), 'r', encoding='utf8') as fp2: - fp1.write(fp2.read().strip()) - # s390x's z/VM CMS files are limited to 8 char for filenames and extensions - # Also it is nice to keep naming convetion with Fedora/RHEL for existing users and code - kernel_dest = os.path.join(tmpisoimagespxe, 'kernel.img') - shutil.move(os.path.join(tmpisoimagespxe, kernel_img), kernel_dest) - kernel_img = 'kernel.img' +def mk_paths(workdir): + """ + Returns a dictionary with all the paths under workdir needed throughout multiple functions in the stage. + """ + paths = { + "live.iso": "", + "live.iso.minimal": "", + "efi": "", + "iso": "", + "iso/coreos": "", + "iso/coreos/features.json": "", + "iso/coreos/igninfo.json": "", + "iso/coreos/kargs.json": "", + "iso/coreos/miniso.dat": "", + "iso/images": "", + "iso/images/cdboot.img": "", # s390x only + "iso/images/cdboot.prm": "", # s390x only + "iso/images/efiboot.img": "", # x86_64/aarch64 only + "iso/images/generic.prm": "", # s390x only + "iso/images/genericdvd.prm": "", # s390x only + "iso/images/initrd.addrsize": "", # s390x only + "iso/images/pxeboot": "", + "iso/images/pxeboot/initrd.img": "", + "iso/images/pxeboot/rootfs.img": "", + "iso/images/pxeboot/vmlinuz": "", + "iso/isolinux": "", + "iso/zipl.prm": "", # s390x only, deleted after use + "initrd": "", + "initrd/etc/coreos/features.json": "", + "initrd/etc/coreos-live-want-rootfs": "", + "initrd/etc/coreos-live-initramfs": "", + "initrd-rootfs": "", + "initrd-rootfs/root.squashfs": "", + } + for key in paths: + paths[key] = os.path.join(workdir, key) + return paths - if test_fixture: - # truncate it to 128k so it includes the offsets to the initrd and kargs - # https://github.com/ibm-s390-linux/s390-tools/blob/032304d5034e/netboot/mk-s390image#L21-L24 - with open(kernel_dest, 'rb+') as f: - f.truncate(128 * 1024) - with open(iso_initramfs, 'rb+') as f: - f.truncate(1024) - # On s390x, we reserve space for the Ignition config in the initrd - # image directly since the bootloader doesn't support multiple initrds. - # We do this by inflating the initramfs just for the duration of the - # `mk-s390image` call. - initramfs_size = os.stat(iso_initramfs).st_size - # sanity-check it's 4-byte aligned (see align_initrd_for_uncompressed_append) - assert initramfs_size % 4 == 0 +def mk_workdirs(paths): + for d in (paths["iso"], + paths["iso/coreos"], + paths["iso/isolinux"], + paths["iso/images"], + paths["iso/images/pxeboot"], + paths["initrd"], + paths["initrd-rootfs"]): + os.mkdir(d) - # combine kernel, initramfs and cmdline using the mk-s390image tool - os.truncate(iso_initramfs, initramfs_size + IGNITION_IMG_SIZE) - subprocess.check_call(['/usr/bin/mk-s390image', - kernel_dest, - os.path.join(tmpisoimages, 'cdboot.img'), - '-r', iso_initramfs, - '-p', os.path.join(tmpisoimages, 'cdboot.prm')]) - os.truncate(iso_initramfs, initramfs_size) - # Get the kargs and initramfs offsets in the cdboot.img. For more info, see: - # https://github.com/ibm-s390-linux/s390-tools/blob/032304d5034e/netboot/mk-s390image#L21-L23 - CDBOOT_IMG_OFFS_INITRD_START_BYTES = 66568 - CDBOOT_IMG_OFFS_KARGS_START_BYTES = 66688 - CDBOOT_IMG_OFFS_KARGS_MAX_SIZE = 896 - with open(os.path.join(tmpisoimages, 'cdboot.img'), 'rb') as f: - f.seek(CDBOOT_IMG_OFFS_INITRD_START_BYTES) - offset = struct.unpack(">Q", f.read(8))[0] +def main(workdir, tree, inputs, options, loop_client): + """ + Here we will generate the 4 Live/PXE CoreOS artifacts. + There are four files created and exported from this stage. - # sanity-check we're at the right spot by comparing a few bytes - f.seek(offset) - with open(iso_initramfs, 'rb') as canonical: - if f.read(1024) != canonical.read(1024): - raise ValueError(f"expected initrd at offset {offset}") + 1. live-iso + 2. live-kernel (i.e. images/pxeboot/vmlinuz) + 3. live-initramfs (i.e. images/pxeboot/initrd.img) + 4. live-rootfs (i.e. images/pxeboot/rootfs.img) - igninfo_json = { - 'file': 'images/cdboot.img', - 'offset': offset + initramfs_size, - 'length': IGNITION_IMG_SIZE, - } + The live-iso itself contains the other 3 artifacts inside of + it. A rough approximation of the structure looks like: - # kargs are part of 'images/cdboot.img' blob - kargs_json['files'].append({ - 'path': 'images/cdboot.img', - 'offset': CDBOOT_IMG_OFFS_KARGS_START_BYTES, - 'pad': '\0', - 'end': '\0', - }) - kargs_json.update( - size=CDBOOT_IMG_OFFS_KARGS_MAX_SIZE, - ) - # generate .addrsize file for LPAR - with open(os.path.join(tmpisoimages, 'initrd.addrsize'), 'wb') as addrsize: - addrsize_data = struct.pack(">iiii", 0, int(INITRD_ADDRESS, 16), 0, - os.stat(iso_initramfs).st_size) - addrsize.write(addrsize_data) + live-iso.iso + -> images/pxeboot/vmlinuz + -> images/pxeboot/initrd.img + -> images/pxeboot/rootfs.img + -> metal.osmet + -> metal4k.osmet + -> root.squashfs + -> full filesystem of metal image - # safely remove things we don't need in the final ISO tree - for d in ['EFI', 'isolinux']: - shutil.rmtree(os.path.join(tmpisoroot, d)) + Where the ISO contains the kernel and initrd and the rootfs. The + rootfs contains the osmet file and the root.squashfs, which itself + contains all the files from a full CoreOS system. + """ - genisoargs = ['/usr/bin/xorrisofs', '-verbose', - '-volid', volid, - '-volset', f"{name_version}", - '-rational-rock', '-J', '-joliet-long', - '-no-emul-boot', '-eltorito-boot', - os.path.join(os.path.relpath(tmpisoimages, tmpisoroot), 'cdboot.img')] + img_metal, img_metal4k = parse_metal_inputs(inputs) + # The deployed tree input is a deployment tree just as it would + # appear on a booted OSTree system. We copy some files out of this + # input tree since it is easier and they match what is in the metal + # images anyway. We also use it as a chroot when we run `coreos-installer`. + deployed_tree = inputs['deployed-tree']['path'] - # Drop zipl.prm as it was either unused (!s390x) or is now no longer - # needed (s390x) - os.unlink(os.path.join(tmpisoroot, 'zipl.prm')) + # Determine some basic information about the CoreOS we are operating on. + os_release = osrelease.parse_files(os.path.join(deployed_tree, 'etc', 'os-release')) + version = os_release['OSTREE_VERSION'] - # For x86_64 and aarch64 UEFI booting - if basearch in ("x86_64", "aarch64"): - # Create the efiboot.img file. This is a fat32 formatted - # filesystem that contains all the files needed for EFI boot - # from an ISO. - with tempfile.TemporaryDirectory(): + paths = mk_paths(workdir) + mk_workdirs(paths) - # In restrictive environments, setgid, setuid and ownership changes - # may be restricted. This sets the file ownership to root and - # removes the setgid and setuid bits in the tarball. - def strip(tarinfo): - tarinfo.uid = 0 - tarinfo.gid = 0 - if tarinfo.isdir(): - tarinfo.mode = 0o755 - elif tarinfo.isfile(): - tarinfo.mode = 0o0644 - return tarinfo + # convention for kernel names + kernel_img = 'vmlinuz' - tmpimageefidir = os.path.join(workdir, "efi") - shutil.copytree(os.path.join(deployed_tree, 'usr/lib/bootupd/updates/EFI'), tmpimageefidir) + # Find the directory under `/usr/lib/modules/` where the + # kernel/initrd live. It will be the only entity in there. + modules_dir = ensure_glob(os.path.join(deployed_tree, 'usr/lib/modules/*'), n=1)[0] - # Find name of vendor directory - vendor_ids = [n for n in os.listdir(tmpimageefidir) if n != "BOOT"] - if len(vendor_ids) != 1: - raise ValueError(f"did not find exactly one EFI vendor ID: {vendor_ids}") - vendor_id = vendor_ids[0] + # copy those files out of the ostree into the iso root dir + shutil.copyfile(os.path.join(modules_dir, 'vmlinuz'), + paths["iso/images/pxeboot/vmlinuz"]) + shutil.copyfile(os.path.join(modules_dir, 'initramfs.img'), + paths["iso/images/pxeboot/initrd.img"]) + # initramfs isn't world readable by default so let's open up perms + os.chmod(paths["iso/images/pxeboot/initrd.img"], 0o644) - # Always replace live/EFI/{vendor} to actual live/EFI/{vendor_id} - # https://github.com/openshift/os/issues/954 - dfd = os.open(tmpisoroot, os.O_RDONLY) - grubfilepath = ensure_glob('EFI/*/grub.cfg', dir_fd=dfd) - if len(grubfilepath) != 1: - raise ValueError(f'Found != 1 grub.cfg files: {grubfilepath}') - srcpath = os.path.dirname(grubfilepath[0]) - if srcpath != f'EFI/{vendor_id}': - print(f"Renaming '{srcpath}' to 'EFI/{vendor_id}'") - os.rename(srcpath, f"EFI/{vendor_id}", src_dir_fd=dfd, dst_dir_fd=dfd) - # And update kargs.json - for file in kargs_json['files']: - if file['path'] == grubfilepath[0]: - file['path'] = f'EFI/{vendor_id}/grub.cfg' - os.close(dfd) - # Delete fallback and its CSV file. Its purpose is to create - # EFI boot variables, which we don't want when booting from - # removable media. - # - # A future shim release will merge fallback.efi into the main - # shim binary and enable the fallback behavior when the CSV - # exists. But for now, fail if fallback.efi is missing. - for path in ensure_glob(os.path.join(tmpimageefidir, "BOOT", "fb*.efi")): - os.unlink(path) - for path in ensure_glob(os.path.join(tmpimageefidir, vendor_id, "BOOT*.CSV")): - os.unlink(path) + # Generate initramfs stamp file indicating that this is a live + # initramfs. Store the build ID in it. + stamppath = paths["initrd/etc/coreos-live-initramfs"] + os.makedirs(os.path.dirname(stamppath), exist_ok=True) + with open(stamppath, 'w', encoding='utf8') as fh: + fh.write(version + '\n') - # Drop vendor copies of shim; we already have it in BOOT*.EFI in - # BOOT - for path in ensure_glob(os.path.join(tmpimageefidir, vendor_id, "shim*.efi")): - os.unlink(path) + # Generate rootfs stamp file with the build ID, indicating that the + # rootfs has been appended and confirming that initramfs and rootfs are + # from the same build. + stamppath = os.path.join(paths["initrd-rootfs"], 'etc/coreos-live-rootfs') + os.makedirs(os.path.dirname(stamppath), exist_ok=True) + with open(stamppath, 'w', encoding='utf8') as fh: + fh.write(version + '\n') - # Consolidate remaining files into BOOT. shim needs GRUB to be - # there, and the rest doesn't hurt. - for path in ensure_glob(os.path.join(tmpimageefidir, vendor_id, "*")): - shutil.move(path, os.path.join(tmpimageefidir, "BOOT")) - os.rmdir(os.path.join(tmpimageefidir, vendor_id)) + # Generate JSON file that lists OS features available to + # coreos-installer {iso|pxe} customize. Put it in the initramfs for + # pxe customize and the ISO for iso customize. + features = json.dumps(get_os_features(tree=deployed_tree), indent=2, sort_keys=True) + '\n' + featurespath = paths["initrd/etc/coreos/features.json"] + os.makedirs(os.path.dirname(featurespath), exist_ok=True) + with open(featurespath, 'w', encoding='utf8') as fh: + fh.write(features) + with open(paths["iso/coreos/features.json"], 'w', encoding='utf8') as fh: + fh.write(features) - # Inject a stub grub.cfg pointing to the one in the main ISO image. - # - # When booting via El Torito, this stub is not used; GRUB reads - # the ISO image directly using its own ISO support. This - # happens when booting from a CD device, or when the ISO is - # copied to a USB stick and booted on EFI firmware which prefers - # to boot a hard disk from an El Torito image if it has one. - # EDK II in QEMU behaves this way. - # - # This stub is used with EFI firmware which prefers to boot a - # hard disk from an ESP, or which cannot boot a hard disk via El - # Torito at all. In that case, GRUB thinks it booted from a - # partition of the disk (a fake ESP created by isohybrid, - # pointing to efiboot.img) and needs a grub.cfg there. - with open(os.path.join(tmpimageefidir, "BOOT", "grub.cfg"), "w", encoding='utf8') as fh: - fh.write(f'''search --label "{volid}" --set root --no-floppy -set prefix=($root)/EFI/{vendor_id} -echo "Booting via ESP..." -configfile $prefix/grub.cfg -boot -''') + mk_osmet_files(deployed_tree, img_metal, img_metal4k, loop_client, paths, os_release) - # Install binaries from boot partition - # Manually construct the tarball to ensure proper permissions and ownership - efitarfile = tempfile.NamedTemporaryFile(suffix=".tar") - with tarfile.open(efitarfile.name, "w:", dereference=True) as tar: - tar.add(tmpimageefidir, arcname="/EFI", filter=strip) + blsentry_kargs = mksquashfs_metal(paths, workdir, img_metal, loop_client) - # Create the efiboot.img file in the images/ dir - efibootfile = os.path.join(tmpisoimages, 'efiboot.img') - make_efi_bootfile(loop_client, input_tarball=efitarfile.name, output_efiboot_img=efibootfile) + # Generate rootfs image + # The rootfs must be uncompressed because the ISO mounts root.squashfs + # directly from the middle of the file + extend_initramfs(initramfs=paths["iso/images/pxeboot/rootfs.img"], + tree=paths["initrd-rootfs"], compress=False) + # Check that the root.squashfs magic number is in the offset hardcoded + # in sysroot.mount in 20live/live-generator + with open(paths["iso/images/pxeboot/rootfs.img"], 'rb') as fh: + fh.seek(124) + if fh.read(4) != b'hsqs': + raise ValueError("root.squashfs not at expected offset in rootfs image") + # Save stream hash of rootfs for verifying out-of-band fetches + os.makedirs(os.path.dirname(paths["initrd/etc/coreos-live-want-rootfs"]), exist_ok=True) + make_stream_hash(paths["iso/images/pxeboot/rootfs.img"], + paths["initrd/etc/coreos-live-want-rootfs"]) + # Add common content + extend_initramfs(initramfs=paths["iso/images/pxeboot/initrd.img"], tree=paths["initrd"]) - genisoargs += ['-eltorito-alt-boot', - '-efi-boot', 'images/efiboot.img', - '-no-emul-boot'] - - # We've done everything that might affect kargs, so filter out any files - # that no longer exist and write out the kargs JSON if it lists any files - kargs_json['files'] = [f for f in kargs_json['files'] - if os.path.exists(os.path.join(tmpisoroot, f['path']))] - kargs_json['files'].sort(key=lambda f: f['path']) - if kargs_json['files']: - # Store the location of "karg embed areas" for use by - # `coreos-installer iso kargs modify` - with open(os.path.join(tmpisocoreos, kargs_file), 'w', encoding='utf8') as fh: - json.dump(kargs_json, fh, indent=2, sort_keys=True) - fh.write('\n') - - # Write out the igninfo.json file. This is used by coreos-installer to know - # how to embed the Ignition config. - with open(os.path.join(tmpisocoreos, igninfo_file), 'w', encoding='utf8') as fh: - json.dump(igninfo_json, fh, indent=2, sort_keys=True) # pylint: disable=E0601 - fh.write('\n') - - # Define inputs and outputs - genisoargs_final = genisoargs + ['-o', tmpisofile, tmpisoroot] - - miniso_data = os.path.join(tmpisocoreos, "miniso.dat") - with open(miniso_data, 'wb') as f: - f.truncate(MINISO_DATA_FILE_SIZE) - - if test_fixture: - # Replace or delete anything irrelevant to coreos-installer - with open(os.path.join(tmpisoimages, 'efiboot.img'), 'w', encoding='utf8') as fh: - fh.write('efiboot.img\n') - with open(os.path.join(tmpisoimagespxe, 'rootfs.img'), 'w', encoding='utf8') as fh: - fh.write('rootfs data\n') - with open(os.path.join(tmpisoimagespxe, 'initrd.img'), 'w', encoding='utf8') as fh: - fh.write('initrd data\n') - with open(os.path.join(tmpisoimagespxe, 'vmlinuz'), 'w', encoding='utf8') as fh: - fh.write('the kernel\n') - # this directory doesn't exist on s390x - if os.path.isdir(tmpisoisolinux): - with open(os.path.join(tmpisoisolinux, 'isolinux.bin'), 'rb+') as fh: - flen = fh.seek(0, 2) - fh.truncate(0) - fh.truncate(flen) - fh.seek(64) - # isohybrid checks for this magic - fh.write(b'\xfb\xc0\x78\x70') - for f in ensure_glob(os.path.join(tmpisoisolinux, '*.c32')): - os.unlink(f) - for f in ensure_glob(os.path.join(tmpisoisolinux, '*.msg')): - os.unlink(f) - - subprocess.check_call(genisoargs_final) - - # Add MBR, and GPT with ESP, for x86_64 BIOS/UEFI boot when ISO is - # copied to a USB stick - if basearch == "x86_64": - subprocess.check_call(['/usr/bin/isohybrid', '--uefi', tmpisofile]) - - # Copy to final output locations the things that won't change from - # this point forward. We do it here because we unlink the the rootfs below. - cp_reflink(os.path.join(tmpisoimagespxe, kernel_img), output_kernel) - cp_reflink(os.path.join(tmpisoimagespxe, initrd_img), output_initramfs) - cp_reflink(iso_rootfs, output_rootfs) - - # Here we generate a minimal ISO purely for the purpose of - # calculating some data such that, given the full live ISO in - # future, coreos-installer can regenerate a minimal ISO. - # - # We first generate the minimal ISO and then call coreos-installer - # pack with both the minimal and full ISO as arguments. This will - # delete the minimal ISO (i.e. --consume) and update in place the - # full Live ISO. - # - # The only difference in the minimal ISO is that we drop two files. - # We keep everything else the same to maximize file matching between - # the two versions so we can get the smallest delta. E.g. we keep the - # `coreos.liveiso` karg, even though the miniso doesn't need it. - # coreos-installer takes care of removing it. - os.unlink(iso_rootfs) - os.unlink(miniso_data) - subprocess.check_call(genisoargs + ['-o', f'{tmpisofile}.minimal', tmpisoroot]) - if basearch == "x86_64": - subprocess.check_call(['/usr/bin/isohybrid', '--uefi', f'{tmpisofile}.minimal']) - with Chroot(deployed_tree, bind_mounts=["/run"]) as chroot: - chroot.run(['coreos-installer', 'pack', 'minimal-iso', tmpisofile, - f'{tmpisofile}.minimal', '--consume'], check=True) - - # Copy the ISO to the final output location - shutil.move(tmpisofile, output_iso) + filenames = options['filenames'] + gen_live_artifacts(paths, tree, filenames, deployed_tree, loop_client, kernel_img, version, blsentry_kargs) if __name__ == "__main__": diff --git a/stages/org.osbuild.coreos.live-artifacts.mono.meta.json b/stages/org.osbuild.coreos.live-artifacts.mono.meta.json index 9b41e5b7..95f79c1b 100644 --- a/stages/org.osbuild.coreos.live-artifacts.mono.meta.json +++ b/stages/org.osbuild.coreos.live-artifacts.mono.meta.json @@ -2,7 +2,7 @@ "summary": "Build CoreOS Live ISO and PXE kernel,initramfs,rootfs", "description": [ "This stage builds the CoreOS Live ISO and PXE kernel,initramfs,rootfs", - "artifacts. Input to this stage are metal and metal4k raw disk images", + "artifacts. Inputs to this stage are metal and metal4k raw disk images", "that are then used to generate the Live artifacts." ], "capabilities": [