This post documents an Ansible process for building custom Rocky Linux (but should work for an Red Hat Enterprise Linux compatible distribution) installation isos, set to automatically kickstart with optional embedded kickstart within the ISO image and host-specific kickstart generation.

Before this post begins, you should heed this warning from Red Hat:

Modifying Red Hat Enterprise Linux installation media is not a supported process, but this handy workaround may be useful to you. Please see this solution on the Red Hat Customer Portal for more information and other options that you may wish to consider for your environment.

Prerequisites

To extract and rebuild the ISO file as a normal user, a number of programs are required:

  • genisoimage
  • isomd5sum
  • isohybrid

isohybrid is inside one of those packages that has different names on different distributions. To get around this, I created a vars directory and setup an include_vars task in my play that includes the first match of:

  • distribution-major_version.yaml
  • distribution.yaml
  • os_family.yaml
  • default.yaml

This means it will include the most specific, going from the distribution version down to the family (e.g. on Debian 11.6 is would try Debian-11.yaml then Debian.yaml then Debian.yaml again and finally default.yaml, as Debian is in the Debian family - if it were Ubuntu I would expect it to try Ubuntu.yaml then Debian.yaml for distribution and family respectively).

In vars I created RedHat.yaml and Debian.yaml (for each family) which set the variable isohybrid_package_name to syslinux or syslinux-utils respectively.

Using the first found means that the variables will not “stack” (that is, if something is in default.yaml but not the others it will not be found if one of the more specific files is created). An alternative would be to look over the full list of files and include any that exist (using ignore_errors: true to suppress the missing file errors) from least to most specific (i.e. the reverse order to this approach) - this relies on the “last defined wins” logic to allow more specifically defined variables to overwrite earlier ones.

The full playbook (I kept it as a separate playbook as the ISO generate tasks do not require elevated access but installing packages does) to install the prerequisites is:

---
- hosts: localhost
  tasks:
    - include_vars: "{{ item }}"
      with_first_found:
        - "vars/{{ ansible_distribution }}-{{ ansible_distribution_major_version | int}}.yaml"
        - "vars/{{ ansible_distribution }}.yaml"
        - "vars/{{ ansible_os_family }}.yaml"
        - "vars/default.yaml"
      when: isohybrid_package_name is not defined
    - name: Install packages required to re-package ISO
      become: yes
      ansible.builtin.package:
        name:
          - genisoimage
          - isomd5sum
        state: present
    - name: Install isohybrid
      become: yes
      ansible.builtin.package:
        name: "{{ isohybrid_package_name }}"
        state: present
...

Jumping right in

This has only been tested with a boot ISO (some efforts are made to further minimise the size, so it doesn’t make sense to use any other than the already smallest.)

Ansible plays are gloriously self-documenting, so given these arguments/variables:

  • iso_base_file (Local location of the source ISO file, will be downloaded if does not exist. E.g. /tmp/Rocky-8.7-x86_64-boot.iso.)
  • iso_source (URL where the ISO file can be downloaded from if iso_base_file does not exist locally. E.g. https://some-rocky-mirror/isos/rocky/8/Rocky-8.7-x86_64-boot.iso)
  • iso_target (Where to save the built ISO file to. E.g. /tmp/Local-Rocky-8.7-x86_64-base-install-myhost.iso.)
  • install_stage2 (Where to tell the installer to find its stage 2 (as we delete it from the ISO to reduce size). E.g. https://some-rocky-mirror/rocky/8/8.7/.)
  • kickstart (Dictionary of arguments for the kickstart behaviour.)
    • embed (Whether to embed the kickstart into the iso - boolean.)
    • hostname (Hostname to embed in the ISO’s kickstart file (meaningless unless embed is set to true). E.g. myhost.domain.tld.)
    • url (URL to set as the kickstart in the bootloader configuration (conflicts with embed).)
  • distribution (Dictionary of information about the distribution.)
    • name (Name of the distribution (used for menus etc.). E.g. Rocky.)
    • version (Version number of the distribution (used for menus, determining kickstart file and how to update e.g. bootloader configuration). E.g. 8.7.)

This is the task file for generating the iso:

---
# Given the right permissions on the output folders, nothing in this
# set of tasks requires elevated privileges. Please do NOT add
# `become: yes` anywhere in this role!
- name: Check if output ISO exists
  ansible.builtin.stat:
    path: '{{ iso_target }}'
  register: iso_target_stat
- name: Output if target exists
  ansible.builtin.debug: msg='{{ iso_target }} already exists, remove if you want to rebuild it.'
  when: iso_target_stat.stat.exists
- name: Build iso
  block:
  - name: Check ISO file exists
    ansible.builtin.stat:
      path: '{{ iso_base_file }}'
    register: iso_base_stat
  - name: Fetch the ISO
    ansible.builtin.get_url:
      dest: '{{ iso_base_file }}'
      url: '{{ iso_source }}'
      timeout: 60  # Default 10s not working in virtual environment
    async: 120  # Launch asynchronously, allowed to run up-to 2 minutes
    poll: 5  # Check every 5 seconds to see if it finished
    when: not iso_base_stat.stat.exists
  - name: Create temporary folder to build to
    ansible.builtin.tempfile:
      state: directory
    register: build_directory
  - name: Report where building to, useful if it dies
    ansible.builtin.debug:
      msg: 'Building new image in {{ build_directory.path }}'
  - name: Extract ISO content to build directory
    ansible.builtin.shell:
      cmd: >
        isoinfo
        -f -R
        -i {{ iso_base_file | quote }}
        |
        while read line; do
          d=$( dirname $line )
          od={{ build_directory.path }}$d;
          [ -f $od ] && rm -f $od;
          [ -d $od ] || mkdir -p $od;
          isoinfo -R -i {{ iso_base_file | quote }} -x $line > {{ build_directory.path }}$line;
        done
    - name: Remove installer image
      # Done to reduce the size of the iso, will be downloaded from the webserver.
      ansible.builtin.file:
        path: '{{ build_directory.path }}/images/install.img'
        state: absent
    ## Isolinux bootloader configuration updates
    - name: Create an end marker in isolinux.cfg for Ansible to use
      ansible.builtin.lineinfile:
        path: '{{ build_directory.path }}/isolinux/isolinux.cfg'
        insertafter: 'menu end'
        line: '# End of menu entries'
    - name: Update isolinux bootloader to use kickstart file
      ansible.builtin.blockinfile:
        path: '{{ build_directory.path }}/isolinux/isolinux.cfg'
        block: |
          menu label ^Install {{ distribution.name }} {{ distribution.version }} base build
          kernel vmlinuz
          append initrc=initrd.img {{ stage2 }} inst.waitfornet=300 ip=dhcp {{ kickstart_config }} quiet
        marker: '{mark}'  # Bit of a hack to use the start/end bits directory
        marker_begin: 'label linux'
        marker_end: '# End of menu entries'
      vars:
        kickstart_config: "{% if kickstart %}{% if distribution.version | int < 8 %}ks{% else %}inst.ks{% endif %}={% if kickstart.embed | default(false) %}cdrom:/kickstart.ks{% else %}{{ kickstart.url }}{% endif %}"
        stage2: "{% if install_stage2 %}inst.stage2={{ install_stage2 }}{% endif %}"
    - name: Update isolinux bootloader to wait indefinitely for a menu option to be selected if not embedding kickstart
      ansible.builtin.replace:
        path: '{{ build_directory.path }}/isolinux/isolinux.cfg'
        regexp: '^timeout [0-9]+$'
        replace: 'timeout 0'
      when: not kickstart.embed | default(false)
    - name: Update isolinux bootloader to wait 10s for a menu option to be selected if using an embedded kickstart
      ansible.builtin.replace:
        path: '{{ build_directory.path }}/isolinux/isolinux.cfg'
        regexp: '^timeout [0-9]+$'
        replace: 'timeout 100'
      when: kickstart.embed | default(false)
    ## EFI (grub) bootloader configuration updates
    - name: Create an end marker in BOOT.conf for Ansible to use
      ansible.builtin.lineinfile:
        path: '{{ build_directory.path }}/EFI/BOOT/BOOT.conf'
        line: '# End of menu entries'
    - name: Update grub (EFI) bootloader to use kickstart file
      ansible.builtin.blockinfile:
        path: '{{ build_directory.path }}/EFI/BOOT/BOOT.conf'
        block: |
          menuentry 'Install {{ distribution.name }} {{ distribution.version }} base build' --class fedora --class gnu-linux --class os {
            linuxefi /images/pxeboot/vmlinuz {{ stage2 }} inst.waitfornet=300 ip=dhcp {{ kickstart_config }} quiet
            initrdefi /images/pxeboot/initrd.img
          }
        marker: '{mark}'  # Bit of a hack to use the start/end bits directly
        marker_begin: '### BEGIN /etc/grub.d/10_linux ###'
        marker_end: '# End of menu entries'
      vars:
        kickstart_config: "{% if kickstart %}{% if distribution.version | int < 8 %}ks{% else %}inst.ks{% endif %}={% if kickstart.embed | default(false) %}cdrom:/kickstart.ks{% else %}{{ kickstart.url }}{% endif %}"
        stage2: "{% if install_stage2 %}inst.stage2={{ install_stage2 }}{% endif %}"
    - name: Update grub (EFI) bootloader to wait indefinitely for a menu option to be selected if not embedding kickstart
      ansible.builtin.replace:
        path: '{{ build_directory.path }}/EFI/BOOT/BOOT.conf'
        regexp: '^set timeout=-?[0-9]+$'
        replace: 'set timeout=-1'
      when: not kickstart.embed | default(false)
    - name: Update grub (EFI) bootloader to wait 10s for a menu option to be selected if using an embedded kickstart
      ansible.builtin.replace:
        path: '{{ build_directory.path }}/isolinux/isolinux.cfg'
        regexp: '^set timeout=-?[0-9]+$'
        replace: 'set timeout=10'
      when: kickstart.embed | default(false)
    - name: Update grub (EFI) grub.cfg from BOOT.conf
      # since these two files are identical, cheat and copy rather than patch the 2nd one
      ansible.builtin.copy:
        src: '{{ build_directory.path }}/EFI/BOOT/BOOT.conf'
        dest: '{{ build_directory.path }}/EFI/BOOT/grub.cfg'
        mode: preserve
        remote_src: yes
    ## End of bootloader configurations.
    - name: Embed kickstart file
      ansible.builtin.template:
        src: 'kickstart/{{ distribution.name | lower }}/{{ distribution.version | int }}/kickstart-minimal.j2'
        dest: '{ build_directory.path }}/kickstart.ks'
      vars:
        hostname: '{{ kickstart.hostname | default(undef) }}'
      when: kickstart.embed | default(false)
    - name: Make a temporary file for building the new ISO file
      ansible.builtin.tempfile:
      register: new_iso_working_file
    - name: Build base ISO file
      # Based on https://www.redhat.com/sysadmin/optimized-iso-image
      ansible.builtin.command:
        cmd: >
          genisoimage
          -o {{ new_iso_working_file.path }}
          -b isolinux/isolinux.bin
          -J -R -l
          -c isolinux/boot.cat
          -no-emul-boot
          -boot-load-size 4
          -boot-info-table
          -eltorito-alt-boot
          -e images/efiboot.img
          -no-emul-boot
          -graft-points
          -V '{{ distribution.name }} {{ distribution.version }} Local base image'
          -jcharset utf-8
          {{ build_directory.path }}
    # Done with build directory
    - name: Remove build directory
      ansible.builtin.file:
        path: '{{ build_directory.path }}'
        state: absent
    - name: Implant MD5 into new ISO
      ansible.builtin.command:
        cmd: implantisomd5 {{ new_iso_working_file.path }}
    - name: Make ISO hybrid (supporting USB booting)
      # Probably unnecessary but might be useful and no more effort
      ansible.builtin.command:
        cmd: isohybrid -uefi {{ new_iso_working_file.path }}
    - name: Copy built ISO to destination
      ansible.builtin.copy:
        src: '{{ new_iso_working_file.path }}'
        dest: '{{ iso_target }}'
        remote_src: yes
        mode: 0o644
    - name: Remove built (working) ISO file
      ansible.builtin.file:
        path: '{{ new_iso_working_file.path }}'
        state: absent
  when: not iso_target_stat.stat.exists
...

The kickstart template is not particularly magical, the only dynamic bit is the hostname:

network --device=link{% if hostname | default(false) %} --hostname={{ hostname }}{% endif %} --bootproto=dhcp --mtu=8000 --noipv6 --activate