Adapting for your environment
After installing and configuring the server, and setting up DHCP we have to prepare the boot environment. A minimal environment for automating the installation of a Linux distribution typically consists of three components:
The boot loader and its configuration files.
The kernel image and its associated initial ramdisk.
The preseed or kickstart file that tells the installer what to do.
We are going to use GRUB 2 as the boot loader. GRUB 2 is a boot loader that works both for a traditional BIOS-based boot process and the UEFI boot process used by newer systems.
Note
This section discusses how to install Ubuntu 18.04 using the Debian installer and the GRUB boot loader. Newer versions of Ubuntu use the Subiquity installer, which uses an entirely different file syntax, which is based on cloud-init.
However, the primary intention of this section is not to explain how to install a certain operationg system (there simply are too many different operating systems in the first place, so the documentation for the respective system has to be studied for information on that), but rather to show how the various parts of Vinegar can be used to generate and serve the necessary files for setting up an auto-installation environment.
For example, Vinegar is known to be successfully used to automatically
install Windows systems using the iPXE and wimboot boot-loaders and serving
an unattend.xml file for Windows setup.
Installing GRUB 2
We starting by putting the binary files for GRUB in the right locations. The easiest way of getting these binaries is taking a running Ubuntu 18.04 LTS system and installing the following packages:
grub-efi-amd64-signed
grub-efi-ia32-bin
grub-pc-bin
shim-signed
At least one of the three is probably already installed on the system. We only need the other ones to copy some files. After that, we can remove the packages from the system again.
After installing these packages, we create the target directory and copy the files that we need:
grub-mknetdir --net-directory=/srv/vinegar/tftp --subdir=grub
This creates the files needed by GRUB 2 for i386-pc platform (PC BIOS boot),
the i386-efi platform (UEFI 32-bit boot), and the x86_64-efi platform
(UEFI 64-bit boot).
As we also want to support UEFI secure boot for the x86_64-efi platform, we
have to take a few extra steps:
cp -a \
/usr/lib/grub/x86_64-efi-signed/grubnetx64.efi.signed \
/srv/vinegar/tftp/grub/x86_64-efi/grubx64.efi
cp -a \
/usr/lib/shim/shimx64.efi.signed \
/srv/vinegar/tftp/grub/x86_64-efi/shimx64.efi
After copying these files, the packages that were not installed before can be uninstalled again.
Configuring GRUB 2
In order to configure GRUB, we have to create two configuration files. The first file has the same content for all systems. Its main purpose is to setup some basic things that are shared for all configurations and load a second, system-specific file.
The first file shared by all systems is saved as
/srv/vinegar/tftp/grub/grub.cfg:
# On the PC BIOS platform, we need the biosdisk module so that chain loading
# from the local hard disk will work.
if [ x$grub_platform = xpc ]; then
insmod biosdisk
fi
# The gfxmode is needed because some Linux installers will not work correctly
# if GRUB has not been set to gfxmode.
if loadfont $prefix/fonts/unicode.pf2 ; then
set gfxmode=auto
insmod efi_gop
insmod efi_uga
insmod gfxterm
terminal_output gfxterm
fi
# We define some color settings to ensure that the menu is displayed properly.
set menu_color_normal=white/black
set menu_color_highlight=black/light-gray
# This loads the system-specific configuration file.
source /templates/$net_default_mac/grub.cfg
This file does not do much. It does some basic configuration for GRUB’s
gfxmode and loads the second configuration file. We specify the MAC address
of the interface that was used to load GRUB (which is available as
$net_default_mac) as part of the file path, so that the corresponding
request handler can determine the system ID.
There are more variables that are available in GRUB (please refer to the GRUB manual for more information), but the MAC and IP address are about the only ones that are available regardless of the DHCP server configuration.
For the second file, we use Jinja template syntax to make the content depend on
the system that is requesting it. We save this file as
/srv/vinegar/tftp/templates/grub.cfg:
set timeout=2
{% if data is not defined or not data.get('state:netboot_enabled', False) %}
menuentry "Boot from local disk" {
set root=(hd0)
chainloader +1
}
{% else %}
menuentry "{{ data.get('boot:description') }}" {
{% if data.get('boot:gfx_payload_keep', False) %}
set gfxpayload=keep
{% endif %}
linux {{ data.get('boot:kernel') }} \
{{ data.get('boot:kernel_options', []) | join(' ') }}
initrd {{ data.get('boot:kernel_initrd') }}
}
{% endif %}
This file does a number of things, so let’s go through it step by step.
The set timeout=2 has the effect that GRUB will automatically select the
first menu entry after two seconds. We could set the timeout to zero if did not
want the menu to be shown at all. This makes sense once everything is running,
but for debugging, it can be useful to show the menu for a short amount of time
so that the process can be interrupted at that point.
Next, we use a Jinja if expression. We can use Jinja code in this file
because we selected that template engine when configuring the request handler
for the templates directory.
We use that if expression to distinguish between two cases: If the data
context variable is not available (e.g. if the system is not known to us or if
there was problem when compiling the data), we boot from the local disk. If the
netboot_enabled flag is not set for the system, we also boot from the local
disk. We will discuss this flag in more detail in
Changing the netboot_enabled flag.
If the netboot_enabled flag is set, we generate a menu entry that uses the
data compiled for the system in order to determine the path to the kernel and
the initial ramdisk as well as the options passed to the kernel. We will see
in the next section how these settings are configured.
Creating a profile for Ubuntu 18.04 LTS server
As an example, we are going to create a configuration for Ubuntu 18.04 LTS server. Basically, the same process applies to all versions of Ubuntu or Debian.
For other distributions (e.g. CentOS) the process might look a bit different due to the installer systems being different, but most steps will be very similar: Get the kernel image, get the initial ramdisk, find out the kernel options, and create a preseed or kickstart file.
We can get the files that we need from the
Ubuntu Netboot Images archive. After
choosing the Ubuntu release and architecture (we choose the amd64
architecture for the moment), we are directed to a
directory
with the files. We can download the netboot.tar.gz to get all files in a
single download or we can just download the individual files that we actually
need. For the moment, we are going to assume that we downloaded the
netboot.tar.gz archive and are now inside the directory where we extracted
it.
We copy the files linux and initrd.gz from the
ubuntu-installer/amd64 sub-directory to
/srv/vinegar/tftp/images/ubuntu/bionic/amd64:
mkdir -p /srv/vinegar/tftp/images/ubuntu/bionic/amd64
cp \
netboot/ubuntu-installer/amd64/{linux,initrd.gz} \
/srv/vinegar/tftp/images/ubuntu/bionic/amd64
In order to have configuration data that we can use in our template for the
preseed file (and in the already existing template for the GRUB configuration
file), we create some files that are going to be used by the
yaml_target source that we defined earlier in the server
configuration file. We start with the file that controls the targeting of
systems. This file is saved in /srv/vinegar/datatree/top.yaml:
'*':
- common
'myhost.mydomain.example.com or *.otherdomain.example.com':
- ubuntu.bionic.amd64.server
This top file does two things: It defines that the data from the common file
shall be applied to all systems and it also defines that the data from the
ubuntu.bionic.amd64.server file shall be used for the system with the ID
my.host.example.com and all systems with IDs that end with
.subdomain.example.com.
We create the file /srv/vinegar/datatree/common/init.yaml with the following
content:
{% set http_url_prefix = 'http://192.2.0.99' %}
common:
http_url_prefix: {{ http_url_prefix | yaml }}
This file does two things: It defines a variable for the URL prefix and it uses
this variable to create an entry for common:http_url_prefix in the resulting
data tree.
For obvious reasons, the IP address used in this file has to be changed to match the IP address of the Vinegar server and if the HTTP server is not listening on its default port (port 80), the port number has to be added to the URL.
There is a simple reason to why we first define a variable and than use that
variable instead of simply specifying the value directly: By doing things this
way, another template in the tree can import this template and use the
variable that we defined. This means that another template can create a value
that is based on this variable (e.g. a URL that starts with that prefix).
If we did not have this variable, the final URL would have to be assembled in the template that is processed by the file handler, which would make things more complex because that template would need to know when it has to add the prefix.
Next, we create the other file that we reference from top.yaml in
/srv/vinegar/datatree/ubuntu/bionic/amd64/server.yaml:
{% from '../../../common/init.yaml' import http_url_prefix %}
{% from 'init.yaml' import ubuntu_boot as _boot %}
{% set default_preseed_url =
http_url_prefix ~ '/templates/' ~ id
~ '/ubuntu/bionic/ubuntu-server.seed' %}
{% macro ubuntu_boot(
kopts_install=[],
kopts_permanent=[],
preseed_url=default_preseed_url) -%}
{{ _boot(['url=' ~ preseed_url, 'quiet'] + kopts_install, kopts_permanent) }}
{%- endmacro %}
{{ ubuntu_boot() }}
This file references init.yaml from the common directory to import the
http_url_prefix macro and it references init.yaml from the same
directory (a file that we still have to create) to import the ubuntu_boot
macro.
Using macros allows us to concentrate generic information in one file while still being able to create customized versions for different scenarios.
The file creats its own version of the ubuntu_boot macro that adds the
url and quiet parameters to the kernel options and uses the new macro.
Using the new macro (instead of just defining it) means that the file can
directly be referenced from top.yaml. However, it can also be imported by
another file in order to call the macro with different arguments.
There are two types of kernel options. The first ones (kopts_install) are
only used by the installer system. Other second ones (kopts_permanent) are
used by the installer system and are also copied to the boot configuration of
the newly installed system. In the final kernel command line, they are separated
by --- (see the Debian GNU/Linux Installation Guide for details).
We create the referenced file init.yaml as
/srv/vinegar/datatree/ubuntu/bionic/amd64/init.yaml:
{% from '../init.yaml' import ubuntu_boot as _boot %}
{% macro ubuntu_boot(kopts_install=[], kopts_permanent=[]) -%}
{{ _boot('amd64', kopts_install, kopts_permanent) }}
{%- endmacro %}
That file references another init.yaml file from the parent directory. It
delegates to the ubuntu_boot macro from that file, but sets that macro’s
arch argument to amd64.
We create the referenced file /srv/vinegar/datatree/ubuntu/bionic/init.yaml
with the following content:
{% macro ubuntu_boot(arch, kopts_install=[], kopts_permanent=[]) %}
boot:
description: "Install Ubuntu 18.04 ({{ architecture }})"
gfx_payload_keep: True
kernel: "/images/ubuntu/bionic/{{ arch }}/linux"
kernel_initrd: "/images/ubuntu/bionic/{{ arch }}/initrd.gz"
kernel_options:
{% for option in kopts_install %}
- {{ option | yaml }}
{% endfor %}
- "---"
{% for option in kopts_permanent %}
- {{ option | yaml }}
{% endfor %}
{% endmacro %}
Finally, we create the preseed file that we specify through the url kernel
option in /srv/vinegar/http/templates/ubuntu/bionic/ubuntu-server.seed. We
simply copy this file from the Ubuntu Server installer CD:
# Suggest LVM by default.
d-i partman-auto/init_automatically_partition string some_device_lvm
d-i partman-auto/init_automatically_partition seen false
# Install the Ubuntu Server seed.
tasksel tasksel/force-tasks string server
# Only install basic language packs. Let tasksel ask about tasks.
d-i pkgsel/language-pack-patterns string
# No language support packages.
d-i pkgsel/install-language-support boolean false
# Only ask the UTC question if there are other operating systems installed.
d-i clock-setup/utc-auto boolean true
# Verbose output and no boot splash screen.
d-i debian-installer/quiet boolean false
d-i debian-installer/splash boolean false
# Install the debconf oem-config frontend (if in OEM mode).
d-i oem-config-udeb/frontend string debconf
# Wait for two seconds in grub
d-i grub-installer/timeout string 2
# Add the network and tasks oem-config steps by default.
oem-config oem-config/steps multiselect language, timezone, keyboard, user, network, tasks
This configuration is already sufficient to boot into the Ubuntu installer
system. If we set the netboot_enabled flag for one of the systems targeted
by top.yaml, it would boot right into the Ubuntu installer.
However, there are still two things to be taken care of: The netboot_enabled
flag should be reset automatically when the installation is finished and you
probably do not want to set all installer options manually.
We can take care of resetting the netboot_enabled flag by using a “late
command”. This command is going to be run by the installer when the installation
process has almost finished. We do this by adding the following line to the
preseed file (ubuntu-server.seed):
d-i preseed/late_command string \
wget -O - "{{ data.get('common:http_url_prefix') }}/templates/{{ id }}/ubuntu/bionic/late-command.sh" | sh
Of course, we also have to create the shell script that is downloaded and
executed by that command. We save the shell script in
/srv/vinegar/http/templates/ubuntu/bionic/late-command.sh:
#!/bin/sh
wget \
-O - \
--method=POST \
"{{ data.get('common:http_url_prefix') }}/reset-netboot-enabled/{{ id }}" \
>/dev/null || true
Note how we use templating code in both the preseed file and the late command script. This allows us to make the preseed file and shell script look different for each system.
In addition to resetting the netboot_enabled flag, we want some of the
questions usually asked by the installer to be answered automatically. Usually,
we can achieve this by setting the respective answers inside the preseed file.
Some questions, however, are asked before the preseed file can even be loaded. As the preseed file is loaded over the network, it can only be loaded once the network configuration has finished. This means that all answers relating to the network configuration have to be specified in the kernel command line.
For now, we automatically want to set the system’s hostname and we want to delay
some questions until after the preseed file is loaded. In order to achieve this,
we edit /srv/vinegar/datatree/ubuntu/bionic/amd64/server.yaml and add the
auto and the hostname option to the kernel command line:
{% from '../../../common/init.yaml' import http_url_prefix %}
{% from 'init.yaml' import ubuntu_boot as _boot %}
{% set default_preseed_url =
http_url_prefix ~ '/templates/' ~ id
~ '/ubuntu/bionic/ubuntu-server.seed' %}
{% macro ubuntu_boot(
kopts_install=[],
kopts_permanent=[],
preseed_url=default_preseed_url) -%}
{{ _boot(['url=' ~ preseed_url, 'quiet'] + kopts_install, kopts_permanent) }}
{%- endmacro %}
{% set hostname_option = 'hostname=' ~ data.get('net:hostname') %}
{{ ubuntu_boot(kopts_install=['auto', hostname_option]) }}
Now, the installer should not ask us for the hostname any longer when configuring the network.
Changing the netboot_enabled flag
In order to boot a system into the installer environment, we need to set the
netboot_enabled flag under the state key. In theory, we could set this
flag by adding an appropriate file to the yaml_target data source, but this
would be bothersome as we would have to edit that file (or top.yaml) each
time we wanto to enable or disable the flag for a system. More importantly,
there would be no way to automatically reset that flag from a late command
script running inside the installer system.
For these reasons, we rather store the flag inside an SQLite database. We have
already added the sqlite data source to the server,
configuration, now we only need a simple way of changing that database from the
command line.
We create a simple Python script that helps us with this job. For example we can
save this script to /usr/local/sbin/vinegar-netboot:
#!/usr/bin/python3
import argparse
import sys
from vinegar.utils.sqlite_store import open_data_store
parser = argparse.ArgumentParser(
description='Check or change netboot_enabled flag.')
parser.add_argument(
'--enable',
action='store_true',
dest='enable',
help='set the netboot_enabled flag')
parser.add_argument(
'--disable',
action='store_true',
dest='disable',
help='clear the netboot_enabled flag')
parser.add_argument(
'system_id',
help='system ID')
args = parser.parse_args()
if args.enable and args.disable:
print(
'Only one of --enable or --disable may be specified.', file=sys.stderr)
sys.exit(1)
with open_data_store('/var/lib/vinegar/system-state.db') as store:
if args.enable:
store.set_value(args.system_id, 'netboot_enabled', True)
print('Enabled netboot for system %s.' % args.system_id)
elif args.disable:
store.delete_value(args.system_id, 'netboot_enabled')
print('Disabled netboot for system %s.' % args.system_id)
else:
try:
netboot_enabled = store.get_value(args.system_id, 'netboot_enabled')
except KeyError:
netboot_enabled = False
print(
'Netboot is %s for system %s.' % (
('enabled' if netboot_enabled else 'disabled'), args.system_id))
This script uses the vinegar.utils.sqlite_store module to open the database
and read or update the netboot_enabled flag for the specified system. After
marking the script as executable
(chmod a+x /usr/local/sbin/vinegar-netboot), we can use it like this:
$ vinegar-netboot myhost.example.com
Netboot is disabled for system myhost.example.com.
$ vinegar-netboot --enable myhost.example.com
Enabled netboot for system myhost.example.com.
$ vinegar-netboot myhost.example.com
Netboot is enabled for system myhost.example.com.
$ vinegar-netboot --disable myhost.example.com
Disabled netboot for system myhost.example.com.
Testing the setup
Now we are ready to test our setup. We have to make sure that the list of
systems in /srv/vinegar/systems/list.txt contains a line for the system that
we want to install. For this example, we are going to assume that the system’s
FQDN and system ID is myhost.mydomain.example.com and it has the MAC address
02:00:00:00:00:01 and the IP address 192.2.0.1. For a real environment,
you will of course have to adjust this values and ensure that the pattern in
top.yaml matches the actual system ID.
For the example case, the line in /srv/vinegar/systems/list.txt looks like
this:
02:00:00:00:00:01;192.2.0.1;myhost
We set the netboot_enabled flag in order to make the system boot into the
installer environment:
vinegar-netboot --enable myhost.mydomain.example.com
If we reboot the system now (and it is configured to boot from the network), we should end up inside the installer environment.
Next steps
In many scenarios, you will want to run the installer without any kind of interaction. This can be achieved by choosing the appropriate preseed options. We cannot discuss all possible preseed options supported by the Debian Installer here.
A good starting point to learn more about automating Debian and Ubuntu installations is Appendix B of the Debian GNU/Linux Installation Guide. Even though this guide is written for Debian, most (if not everything) of it also applies to Ubuntu. You might also find the preseed examples from the Ubuntu Community Help Wiki helpful.
At some point, you might also want to add support for more architectures (e.g.
i386). Thanks to the modular design that we chose for this example
configuration, this is not very hard. Basically, you can repeat the instructions
above for that architecture (of course only adding those files that actually
depend on the architecture) and you should be good to go.
The GRUB configuration that we created is already prepared to work with a traditional PC BIOS based boot environment as well as 32 and 64 bit UEFI boot, so you most likely will not have to make any changes to the GRUB configuration.
You might also want to add other distributions (be it other releases of Ubuntu or completely different distributions like Debian or CentOS). In every case, you can choose which parts of the configuration you want to share and which parts are specific to certain profiles.
Before you start with this, it is a good idea to read the Concepts part of this documentation because it will give you a much better understanding of how things work together.