This commit is contained in:
Bart Sjerps
2026-06-10 14:41:31 +02:00
parent 0a01479d24
commit 4f366bcddd
4 changed files with 639 additions and 1 deletions
+1
View File
@@ -37,3 +37,4 @@
# Custom
*.iso
*.cfg
View File
+1 -1
View File
@@ -33,5 +33,5 @@ rpm -q --quiet genisoimage || die "genisoimage not installed"
test -f $target && sudo rm -f $target
mkksiso -a $WORKDIR/custom --ks=$WORKDIR/$(basename $ks) $isopath $target
$topdir/bin/mkksiso -a $WORKDIR/custom --ks=$WORKDIR/$(basename $ks) --custom_cfg $topdir/isolinux.cfg $isopath $target
Executable
+637
View File
@@ -0,0 +1,637 @@
#!/usr/bin/python3
#
# Copyright (C) 2019 Red Hat, Inc.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from collections import OrderedDict
import logging as log
import os
import shutil
import shlex
import subprocess
import sys
import tempfile
# Maximum filename length
MAX_FNAME = 253
def SplitCmdline(cmdline):
"""
Split the commandline into an OrderedDict of arguments
If the argument appears multiple times its entry will have a list of the values,
and its position in the dictionary will be that of the first occurrence.
If the argument has no value its entry will be [None]
Arguments with a single value will be [VALUE]
Quotes strings are maintained, and values may contain equals signs which
will be preserved.
"""
kernel_args = OrderedDict()
for a in shlex.split(cmdline):
if "=" in a:
k,v = a.split("=", 1)
else:
k, v = a, None
if k in kernel_args:
kernel_args[k].append(v)
else:
kernel_args[k] = [v]
return kernel_args
def quote(s):
"""
Return a quoted string if it contains spaces.
Otherwise just return the string.
"""
if " " in s:
return f"\"{s}\""
return s
def ListKernelArgs(kernel_args):
"""
Return a list of quoted kernel arguments and values
The dictionary is the output from SplitCmdline.
Values with spaces are quoted with ""
"""
kernel_cmdline = []
for k, vs in kernel_args.items():
for v in vs:
if v is None:
kernel_cmdline.append(f"{k}")
else:
kernel_cmdline.append(f"{k}={quote(v)}")
return kernel_cmdline
def JoinKernelArgs(kernel_args):
"""
Return a string of quoted kernel arguments and values
The dictionary is the output from SplitCmdline.
Values with spaces are quoted with ""
"""
return " ".join(ListKernelArgs(kernel_args))
def AlterKernelArgs(kernel_args, rm_args, add_args):
"""
Modify the kernel argument OrderedDict
This first removes all arguments listed in rm_args. It will ignore
arguments that are not present.
It then extends the arguments listed in the add_args dictionary if they are
already present, otherwise it adds them to kernel_args
It returns the modified kernel_args OrderedDict.
"""
# Remove arguments
for k in rm_args:
if k in kernel_args:
del kernel_args[k]
# Add new arguments and update existing ones
for k, vs in add_args.items():
if k in kernel_args:
kernel_args[k].extend(vs)
else:
kernel_args[k] = vs
return kernel_args
def WrapKernelArgs(kernel_args, width=80):
"""
Return a wordwrapped string
It breaks the lines at spaces inbetween commands,
preserving quoted strings verbatim.
"""
result = kernel_args[0]
cols = len(result)
for arg in kernel_args[1:]:
if 1 + cols + len(arg) > width:
result += "\n"
result += arg
cols = len(arg)
else:
result += " " + arg
cols = cols + len(arg) + 1
result += "\n"
return result
def GetISODetails(isopath):
"""
Use xorriso to list the contents of the iso and get the volume id
Metadata about the iso is output to stderr, file listing is sent to stdout.
Returns a tuple of volume id, and the list of files on the iso
"""
cmd = ["xorriso", "-indev", isopath, "-pkt_output", "on", "-find"]
out = subprocess.run(cmd, check=True, capture_output=True, env={"LANG": "C"})
volid = ""
for line in out.stderr.decode("utf-8").splitlines():
if line.startswith("Volume id"):
volid = shlex.split(line)[-1]
if not volid:
raise RuntimeError(f"{isopath} is missing a volume id")
# Files on the iso
# The files are on stdout and are prefixed with 'R:1:' for result lines with trailing newlines.
files = []
for line in out.stdout.decode("utf-8").splitlines():
if line.startswith("R:1:"):
files.append(os.path.normpath(shlex.split(line)[-1]))
return volid, files
def ExtractISOFiles(isopath, files, tmpdir):
"""
Extract the given files (which must exist on the iso) into the temporary
directory.
"""
# Make sure the user can write to any extracted directories using -chmod_r
cmd = ["osirrox", "-indev", isopath, "-chmod_r", "u+rwx", "/", "--"]
for f in files:
cmd.extend(["-extract", f, tmpdir + "/" + f])
cmd.extend(["-rollback_end"])
subprocess.run(cmd, check=True, capture_output=False, env={"LANG": "C"})
# From pylorax/treebuilder.py
# udev whitelist: 'a-zA-Z0-9#+.:=@_-' (see is_whitelisted in libudev-util.c)
udev_blacklist=' !"$%&\'()*,/;<>?[\\]^`{|}~' # ASCII printable, minus whitelist
udev_blacklist += ''.join(chr(i) for i in range(32)) # ASCII non-printable
def udev_escape(label):
"""
Escape the volume id label characters so they can be used on the ISO
"""
out = ''
for ch in label:
out += ch if ch not in udev_blacklist else '\\x%02x' % ord(ch)
return out
def CheckBigFiles(add_paths):
"""
Check file size and filename length for problems
Returns True if any file exceeds 4GiB
Raises a RuntimeError if any filename is longer than MAX_FNAME
This examines all the files included, so may take some time.
"""
big_file = False
for src in add_paths:
if os.path.isdir(src):
for top, dirs, files in os.walk(src):
for f in files + dirs:
if len(f) > MAX_FNAME:
raise RuntimeError("iso contains filenames that are too long: %s" % f)
if os.stat(top + "/" + f).st_size >= 4*1024**3:
big_file = True
else:
if len(src) > MAX_FNAME:
raise RuntimeError("iso contains filenames that are too long: %s" % f)
if os.stat(src).st_size >= 4*1024**3:
big_file = True
return big_file
def ImplantMD5(output_iso):
"""
Add md5 checksums to the final iso
"""
cmd = ["implantisomd5", output_iso]
log.debug(" ".join(cmd))
try:
subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
log.error(str(e))
raise RuntimeError("implantisomd5 failed")
def RebuildEFIBoot(input_iso, tmpdir):
"""
On x86 the efiboot.img needs to be rebuilt from the new /EFI/BOOT/ files
returns new efiboot.img file with a temporary name.
"""
if not os.path.exists(tmpdir+"/EFI/BOOT"):
raise RuntimeError("Missing mkefiboot requirement: EFI/BOOT")
# Extract the EFI directory files from the iso
with tempfile.TemporaryDirectory(prefix="mkksiso-") as tmpefi:
ExtractISOFiles(input_iso, ["EFI"], tmpefi)
# Copy the modified config files over
shutil.copytree(tmpdir+"/EFI", tmpefi+"/EFI", dirs_exist_ok=True)
efibootimg = tempfile.NamedTemporaryFile(prefix="efibootimg-")
cmd = ["mkefiboot", "--label=ANACONDA"]
if log.root.level < log.INFO:
cmd.append("--debug")
cmd.extend([tmpefi + "/EFI/BOOT", efibootimg.name])
log.debug(" ".join(cmd))
try:
subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
log.error(str(e))
raise RuntimeError("Running mkefiboot")
return efibootimg
def RebuildS390CDBoot(tmpdir):
"""
On s390x the cdboot.img needs to be rebuilt with the new cmdline arguments
"""
# First check for the needed files
missing = []
for f in ["images/kernel.img", "images/initrd.img", "images/cdboot.prm"]:
if not os.path.exists(tmpdir + "/" + f):
log.debug("Missing file %s", f)
missing.append(f)
if missing:
raise RuntimeError("Missing requirement %s" % ", ".join(missing))
cmd = ["mk-s390image", tmpdir + "/images/kernel.img", tmpdir + "/images/cdboot.img",
"-r", tmpdir + "/images/initrd.img",
"-p", tmpdir + "/images/cdboot.prm"]
log.debug(" ".join(cmd))
try:
subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
log.error(str(e))
raise RuntimeError("Running mk-s390image")
def GetCmdline(line, commands):
"""
Get the cmdline portion of a line that starts with a command
returns the indented command prefix, and the cmdline.
"""
for c in commands:
if c not in line:
continue
# The command must be the first non-whitespace word
first, _ = line.strip().split(" ", 1)
if first != c:
continue
indent, prefix, cmdline = line.partition(c)
return indent+prefix, cmdline.strip()
return "", line
def EditIsolinux(rm_args, add_args, replace_list, tmpdir, custom_cfg):
"""
Modify the cmdline for an isolinux.cfg
Remove args, add new arguments and change existing volid if requested
"""
orig_cfg = tmpdir + "/isolinux/isolinux.cfg"
if not os.path.exists(orig_cfg):
log.warning("No isolinux/isolinux.cfg file found")
return
# Edit the config file, save the new one as .new
with open(orig_cfg, "r") as in_fp:
with open(orig_cfg + ".new", "w") as out_fp:
for line in in_fp:
for from_item, to_item in replace_list:
if from_item in line:
line = line.replace(from_item, to_item)
prefix, cmdline = GetCmdline(line, ["append"])
if prefix:
args = SplitCmdline(cmdline)
new_args = AlterKernelArgs(args, rm_args, add_args)
out_fp.write(prefix+" "+JoinKernelArgs(new_args))
out_fp.write("\n")
else:
out_fp.write(line)
out_fp.close()
os.replace(orig_cfg + ".new", orig_cfg)
if custom_cfg:
log.info('using custom config %s', custom_cfg)
with open(custom_cfg, 'r') as src, open(orig_cfg, 'w') as tgt:
tgt.write(src.read())
else:
log.info('using default config')
def EditGrub2(rm_args, add_args, replace_list, tmpdir):
"""
Modify the cmdline for GRUB2 UEFI and BIOS config files
Add the new arguments and change existing volid if requested
"""
grub_cfgs = ["EFI/BOOT/grub.cfg", "EFI/BOOT/BOOT.conf",
"boot/grub2/grub.cfg", "boot/grub/grub.cfg"]
if not any(os.path.exists(tmpdir + "/" + c) for c in grub_cfgs):
log.warning("No grub config files found")
return
for cfg in grub_cfgs:
orig_cfg = tmpdir + "/" + cfg
if not os.path.exists(orig_cfg):
continue
with open(orig_cfg, "r") as in_fp:
with open(orig_cfg + ".new", "w") as out_fp:
for line in in_fp:
for from_item, to_item in replace_list:
if from_item in line:
line = line.replace(from_item, to_item)
# Some start with linux (BIOS/aarch64), others with linuxefi (x86_64)
prefix, cmdline = GetCmdline(line, ["linuxefi", "linux"])
if prefix:
args = SplitCmdline(cmdline)
new_args = AlterKernelArgs(args, rm_args, add_args)
out_fp.write(prefix+" "+JoinKernelArgs(new_args))
out_fp.write("\n")
else:
out_fp.write(line)
out_fp.close()
os.replace(orig_cfg + ".new", orig_cfg)
def EditS390(rm_args, add_args, replace_list, tmpdir):
"""
Modify the cmdline for s390 config files
Add the new arguments and change existing volid if requested
"""
s390_cfgs = ["images/generic.prm", "images/cdboot.prm"]
if not any(os.path.exists(tmpdir + "/" + c) for c in s390_cfgs):
log.warning("No s390 config files found")
return
for cfg in s390_cfgs:
orig_cfg = tmpdir + "/" + cfg
if not os.path.exists(orig_cfg):
log.warning("No %s file found", cfg)
continue
with open(orig_cfg, "r") as in_fp:
# Read the config file and turn it into a line
lines = in_fp.readlines()
cmdline = " ".join(l.strip() for l in lines)
# Replace the volid
for from_item, to_item in replace_list:
if from_item in cmdline:
cmdline = cmdline.replace(from_item, to_item)
args = SplitCmdline(cmdline)
new_args = AlterKernelArgs(args, rm_args, add_args)
cmdline = ListKernelArgs(new_args)
# Write the new config file, breaking at 80 columns
with open(orig_cfg + ".new", "w") as out_fp:
out_fp.write(WrapKernelArgs(cmdline, width=80))
out_fp.close()
os.replace(orig_cfg + ".new", orig_cfg)
def CheckDiscinfo(path):
"""
If the ISO contains a .discinfo file, check the arch against the host arch
Raises a RuntimeError if the arch does not match
"""
## TODO -- is this even needed with the new method of rebuilding the iso?
if os.path.exists(path):
with open(path) as f:
discinfo = [l.strip() for l in f.readlines()]
log.info("iso arch = %s", discinfo[2])
if os.uname().machine != discinfo[2]:
raise RuntimeError("iso arch does not match the host arch.")
def MakeKickstartISO(input_iso, output_iso, ks="", updates_image="", add_paths=None,
cmdline="", rm_args="", new_volid="", replace_list=None, implantmd5=True,
skip_efi=False, custom_cfg=None):
"""
Make a kickstart ISO from a boot.iso or dvd
"""
if add_paths is None:
add_paths = []
if replace_list is None:
replace_list = []
# Gather information about the input iso
old_volid, files = GetISODetails(input_iso)
if not old_volid and not new_volid:
raise RuntimeError("No volume id found, cannot create iso.")
log.debug("ISO files:")
for f in files:
log.debug(" %s", f)
# Extract files that match the known config files.
known_configs = set([".discinfo", "isolinux/isolinux.cfg",
"boot/grub2/grub.cfg", "boot/grub/grub.cfg",
"EFI/BOOT/BOOT.conf", "EFI/BOOT/grub.cfg",
"images/generic.prm", "images/cdboot.prm",
"images/kernel.img", "images/initrd.img"])
extract_files = set(files) & known_configs
with tempfile.TemporaryDirectory(prefix="mkksiso-") as tmpdir:
ExtractISOFiles(input_iso, extract_files, tmpdir)
CheckDiscinfo(tmpdir + "/.discinfo")
new_volid = new_volid or old_volid
log.info("Volume Id = %s", new_volid)
# Escape the volume ids
new_volid = udev_escape(new_volid)
old_volid = udev_escape(old_volid)
remove_args = rm_args.split(' ') if rm_args else []
add_args = SplitCmdline(cmdline)
if ks:
add_args["inst.ks"] = ["hd:LABEL=%s:/%s" % (new_volid or old_volid, os.path.basename(ks))]
add_paths.append(ks)
if updates_image:
add_args["inst.updates"] = ["hd:LABEL=%s:/%s" % (new_volid or old_volid, os.path.basename(updates_image))]
add_paths.append(updates_image)
replace_list.append((old_volid, new_volid))
log.debug(replace_list)
# Add kickstart command and optionally change the volid of the available config files
EditIsolinux(remove_args, add_args, replace_list, tmpdir, custom_cfg)
EditGrub2(remove_args, add_args, replace_list, tmpdir)
EditS390(remove_args, add_args, replace_list, tmpdir)
if os.uname().machine.startswith("s390"):
RebuildS390CDBoot(tmpdir)
# If this is a UEFI iso, rebuild the efiboot.img file and put it in /efiboot.img
efibootimg = None
if not skip_efi and ("EFI/BOOT/grub.cfg" in files or "EFI/BOOT/BOOT.conf" in files):
if os.getuid() != 0:
raise RuntimeError("mkefiboot requires root privileges")
efibootimg = RebuildEFIBoot(input_iso, tmpdir)
# Build the command to rebuild the iso with the changes and additions
cmd = ["xorriso", "-indev", input_iso, "-outdev", output_iso, "-boot_image", "any", "replay"]
if new_volid != old_volid:
cmd.extend(["-volid", new_volid])
# Replace the embedded efiboot.img on partition 2
if efibootimg:
cmd.extend(["-append_partition", "2", "C12A7328-F81F-11D2-BA4B-00A0C93EC93B", efibootimg.name])
# Update the config files that were extracted and modified
for root, _, files in os.walk(tmpdir, topdown=False):
isoroot = root.replace(tmpdir, "")
for f in files:
cmd.extend(["-update", root + "/" + f, isoroot + "/" + f])
# Add the kickstart and the new files and directories
for p in add_paths:
cmd.extend(["-map", p, os.path.basename(p)])
check_paths = add_paths
if CheckBigFiles(check_paths):
if "-as" not in cmd:
cmd.extend(["-as", "mkisofs"])
cmd.extend(["-iso-level", "3"])
if os.uname().machine.startswith("ppc64le"):
if "-as" not in cmd:
cmd.extend(["-as", "mkisofs"])
cmd.extend(["-U"])
## NOTE: If any xorriso commands need to be added after this they need
## to be preceeded by an '--' entry to exit the '-as mkisofs' mode
log.debug("Running: %s", " ".join(cmd))
subprocess.run(cmd, check=True, capture_output=False, env={"LANG": "C"})
if implantmd5:
ImplantMD5(output_iso)
# Generate a report of the layout of the new iso
if log.root.level < log.INFO:
cmd = ["xorriso", "-indev", output_iso, "-report_el_torito", "plain", "-report_system_area", "plain"]
log.debug("Running: %s", " ".join(cmd))
subprocess.run(cmd, check=False, capture_output=False, env={"LANG": "C"})
def setup_arg_parser():
""" Return argparse.Parser object of cmdline."""
parser = argparse.ArgumentParser(description="Add a kickstart and files to an iso")
parser.add_argument("-a", "--add", action="append", dest="add_paths", default=[],
type=os.path.abspath,
help="File or directory to add to ISO (may be used multiple times)")
parser.add_argument("-c", "--cmdline", dest="cmdline", metavar="CMDLINE", default="",
help="Arguments to add to kernel cmdline")
parser.add_argument("-r", "--rm-args", dest="rm_args", metavar="ARGS", default="",
help="Space separated list of arguments to remove from the kernel cmdline")
parser.add_argument("--debug", action="store_const", const=log.DEBUG,
dest="loglevel", default=log.INFO,
help="print debugging info")
parser.add_argument("--no-md5sum", action="store_false", default=True,
help="Do not run implantisomd5 on the ouput iso")
parser.add_argument("--ks", type=os.path.abspath, metavar="KICKSTART",
help="Optional kickstart to add to the ISO (adding inst.ks automatically)")
parser.add_argument("-u", "--updates", type=os.path.abspath, metavar="IMAGE",
help="Optional updates image to add to the ISO (adding inst.updates automatically)")
parser.add_argument("-V", "--volid", dest="volid", help="Set the ISO volume id, defaults to input's", default=None)
parser.add_argument("-R", "--replace", nargs=2, action="append", metavar=("FROM", "TO"),
help="Replace string in grub.cfg. Can be used multiple times")
parser.add_argument("--skip-mkefiboot", action="store_true", dest="skip_efi",
help="Skip running mkefiboot")
parser.add_argument("ks_pos", nargs="?", type=os.path.abspath, metavar="KICKSTART",
help="Optional kickstart to add to the ISO")
parser.add_argument("input_iso", type=os.path.abspath, help="ISO to modify")
parser.add_argument("output_iso", type=os.path.abspath, help="Full pathname of iso to be created")
parser.add_argument("--custom_cfg", type=os.path.abspath, help="Full pathname of custom isolinux.cfg", metavar='CFG')
return parser
def main():
parser = setup_arg_parser()
args = parser.parse_args()
log.basicConfig(format='%(levelname)s:%(message)s', level=args.loglevel)
try:
errors = False
for t in ["xorriso", "osirrox"]:
if not shutil.which(t):
log.error("%s binary is missing", t)
errors = True
files = [args.input_iso, *args.add_paths]
if args.ks or args.ks_pos:
files += [args.ks or args.ks_pos]
for f in files:
if not os.path.exists(f):
log.error("%s is missing", f)
errors = True
if os.path.exists(args.output_iso):
log.error("%s already exists", args.output_iso)
errors = True
if "=" in args.rm_args:
log.error("--rm-args should only list the arguments to remove, not values")
errors = True
if args.ks and args.ks_pos:
log.error("Use either --ks KICKSTART or positional KICKSTART but not both")
errors = True
if not any([args.ks or args.ks_pos, args.updates, args.add_paths, args.cmdline, args.rm_args, args.volid, args.replace]):
log.error("Nothing to do - pass one or more of --ks, --updates, --add, --cmdline, --rm-args, --volid, --replace")
errors = True
if errors:
raise RuntimeError("Problems running %s" % sys.argv[0])
MakeKickstartISO(args.input_iso, args.output_iso, args.ks or args.ks_pos, args.updates,
args.add_paths, args.cmdline, args.rm_args,
args.volid, args.replace, args.no_md5sum, args.skip_efi, args.custom_cfg)
except RuntimeError as e:
log.error(str(e))
return 1
return 0
if __name__ == '__main__':
sys.exit(main())