How to run an unattended install of Alpine Linux


Update 2023-10-06: Guido Trotter reached out to me with alpine-autosetup that should work much better for preseeding libvirt-based VMs1. Maybe check it out, if you’re in that predicament.

Problem statement

Unattended installation (preseeding) of Alpine Linux2 isn’t exactly straightforward.

It took me a few tries to figure it out. Here’s how I do it.

Discussion

There are few pieces to this puzzle:

  1. We need a way to run a script upon a system boot.
  2. We need the preseed script itself3.
  3. We need an image with the preseed setup (to plop in the machine).

The first item is easy, since /etc/local.d is a thing. Just plop an appropriate preseed.start script in there, enable local on boot and Bob’s your uncle.

It’s the last part – getting the script into an *.iso image (that doubles for an image you dd onto a USB stick) that seemed harder.

Up until I read How to make a custom ISO image with mkimage on Alpine wiki.

Since you can create your own *.iso with just a few steps, it’s relatively easy to go from A to Z. Not as trivial as adding an inst.ks to your isolinux.cfg (hello, CentOS). Not a rocket surgery either.

Solution

The following script is supposed to be run as root on an Alpine Linux distro, and will create a new build user and under this user it will create a custom *.iso image (in ~builder/iso/ that has a minimal “preseed” script).

Coming up with a specific preseed script is left as an exercise to the reader – since every system is different. But given that I wrote an entire one-shot install script in the Setting up secure boot with fully encrypted filesystems on Alpine Linux article, it shouldn’t be that hard to gather some inspiration there.

Also, for my love of APU2 boards, the custom iso-generator-profile below uses ttyS0 as a default console4.

#!/bin/sh

# Preflight
if test $(id -u) -ne 0; then
  echo "Maybe you want to run this as root, eh?"
  exit 111
fi

if test ! -f /etc/apk/world; then
  echo "Maybe you should be running this from alpine, dawg?"
  exit 111
fi

# =--------------------------------------------------------------------=
# This is mostly from the "How to make a custom ISO image" wiki article.
# My claim to fame is the preseed part.
# =--------------------------------------------------------------------=

# Packages you'll need
apk add alpine-sdk build-base apk-tools alpine-conf busybox fakeroot 
apk add syslinux xorriso squashfs-tools sudo

# User setup
adduser build -G abuild
# You can't -D here ^^ because you'll need to sudo. So set a password.

# Grant unrestricted sudo to abuild group
echo "%abuild ALL=(ALL) ALL" > /etc/sudoers.d/abuild

# Also, update apk
apk update

Now su - build (or login as build) and continue:

#!/bin/sh

# Create local RSA signing key (which is IMO useless in this case, but WTHDIK)
abuild-keygen -i -a

# Shallow clone aports (to get the scripts)
git clone --depth 1 https://gitlab.alpinelinux.org/alpine/aports.git

# We name the profile `preseed` (there's a shocker)
export PROFILENAME=preseed

# Basic profile data -- inherits from standard, but the main thing to make
# it work is the `apkovl=`. This is the script that configures most of the
# iso creation and allows you to control precooked packages and stuff.
cat << EOF > ~/aports/scripts/mkimg.$PROFILENAME.sh
profile_$PROFILENAME() {
        profile_standard
        kernel_cmdline="unionfs_size=512M console=tty0 console=ttyS0,115200"
        syslinux_serial="0 115200"
        apks="\$apks vim util-linux curl coreutils strace dhcp dhcpcd
                "
        local _k _a
        for _k in \$kernel_flavors; do
                apks="\$apks linux-\$_k"
                for _a in \$kernel_addons; do
                        apks="\$apks \$_a-\$_k"
                done
        done
        apks="\$apks linux-firmware"
	hostname="preseed"
	apkovl="genapkovl-preseed.sh"
}
EOF
chmod +x ~/aports/scripts/mkimg.$PROFILENAME.sh

# This is the script that will generate an `$HOSTNAME.apkovl.tar.gz` that
# will get baked into the `*.iso`. You could say this is the good stuff.
#
# And most of it is stolen^Wcopied from: scripts/genapkovl-dhcp.sh
#
# Notice:
# I'm setting up DHCP networking and `/etc/local.d/preseed.start`
# as the main course. But I'm skimping on the loaded packages. You might
# not want that.
#
cat << 'EOP' > ~/aports/scripts/genapkovl-preseed.sh
#!/bin/sh -e

HOSTNAME="$1"
if [ -z "$HOSTNAME" ]; then
	echo "usage: $0 hostname"
	exit 1
fi

cleanup() {
	rm -rf "$tmp"
}

makefile() {
	OWNER="$1"
	PERMS="$2"
	FILENAME="$3"
	cat > "$FILENAME"
	chown "$OWNER" "$FILENAME"
	chmod "$PERMS" "$FILENAME"
}

rc_add() {
	mkdir -p "$tmp"/etc/runlevels/"$2"
	ln -sf /etc/init.d/"$1" "$tmp"/etc/runlevels/"$2"/"$1"
}

tmp="$(mktemp -d)"
trap cleanup EXIT

mkdir -p "$tmp"/etc
makefile root:root 0644 "$tmp"/etc/hostname <<EOF
$HOSTNAME
EOF

mkdir -p "$tmp"/etc/network
makefile root:root 0644 "$tmp"/etc/network/interfaces <<EOF
auto lo
iface lo inet loopback

auto eth0
iface eth0 inet dhcp
EOF

mkdir -p "$tmp"/etc/apk
makefile root:root 0644 "$tmp"/etc/apk/world <<EOF
alpine-base
EOF

mkdir -p "$tmp"/etc/local.d
# =------------------------------------------------------------=
# Hello preseed script, my new friend.
#
# Note the single quotes around the EOF, to avoid evaluation
# at the time genapkovl runs.
# =------------------------------------------------------------=
makefile root:root 0755 "$tmp"/etc/local.d/preseed.start <<'EOF'
#!/bin/sh
# Fail fast, if we make it onto a live system.
test "$(hostname)" = "preseed" || exit 111
# Here would be the preseed script in earnest. One that sets
# the hostname to something else than `preseed`, or at least
# makes sure the /etc/local.d/preseed.start isn't carried over.
# Lest you're a glutton for punishment.
echo "preseeded at $(date)" >> /root/preseeded.txt
EOF

rc_add devfs sysinit
rc_add dmesg sysinit
rc_add mdev sysinit
rc_add hwdrivers sysinit
rc_add modloop sysinit

rc_add hwclock boot
rc_add modules boot
rc_add sysctl boot
rc_add hostname boot
rc_add bootmisc boot
rc_add syslog boot
# we want our preseed to run & have network while at it
rc_add networking boot
rc_add local boot

rc_add mount-ro shutdown
rc_add killprocs shutdown
rc_add savecache shutdown

tar -c -C "$tmp" etc | gzip -9n > $HOSTNAME.apkovl.tar.gz
EOP
chmod +x ~/aports/scripts/genapkovl-preseed.sh

# Create output dir
mkdir -p ~/iso

# Pat-a-cake, pat-a-cake, mkimage man,
# Bake me an iso, as fast as you can;
# Fetch stuff, and mold it, and augment it with preseed,
# Put it in the ~/iso folder, for I have that need.
cd ~/aports/scripts/
sh mkimage.sh --tag edge \
 --outdir ~/iso \
 --arch x86_64 \
 --repository http://dl-cdn.alpinelinux.org/alpine/edge/main \
 --profile $PROFILENAME

Closing words

Looking back at the script, I can’t shake off an Inception-y feeling. We indeed must have gone deeper. The whole EOP and EOF nesting is simply delish5.

But in the end, what’s another layer of indirection between friends. :-)

Oh, before I forget: let me know if you manage to use this for your unattended install needs.

  1. Allows you to generate apkovl, swap, root image, libvirt template. In short, allows you to spin up customized QEMU virtuals in a single step. (tbf, the VM needs to reboot to finish preseeding, but that’s entirely reasonable, ain’t it?)

  2. I love Alpine Linux. I install it to most devices these days, instead of Bloatian. You should too. And not just within Docker.

  3. I love the smell of “this is out of scope of this article”. :-)

  4. Because APU2 is a headless machine. So it makes sense to chat with it via USB serial. You’ve been warned. But you wouldn’t mindlessly copy and paste code from some rando’s website, wouldya?

  5. Yes. It could have been a separate *.sh; do I look reasonable to you?