Generating Rocky install media with embedded kickstarts using Ansible
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