Wednesday, February 14, 2024

Using FreeBSD jails to debug cloud-init

This was supposed to be an easy-breezy write-up on how ZFS and jails facilitated the development of some code. Instead the journey took my favorite detour: learning things.

cloud-init is a standardized method for cross-platform cloud instance initialization developed by Canonical in Python. And because of some recent work (very cool!), "cross-platform" now includes FreeBSD. The dependency on Python means cloud-init cannot be included as a part of FreeBSD (a.k.a., "base"), and thus, the virtual machine images produced by the release engineering team won't be cloud-init enabled.

While cloud-init can perform a mind-boggling number of first-boot configuration management operations, my needs are more modest. Typically, the configuration tasks I need are:

  • set the host name
  • configure a network interface (IP address + subnet mask, default gateway, DNS)
  • add an ssh key

The FreeBSD base does have a Lua interpreter, and there is an effort underway to implement a subset of the cloud-init operations in Lua. Getting this version into base would allow the VM images from release engineering to be cloud-init enabled for some use cases.

Initially, debugging the code took the normal route: create a VM image, snapshot it, add the current cloud-init bits, boot the image, and debug the result. Lather, rinse, repeat. But I quickly missed my development tools and wondered about using a jailed environment on my system. Since cloud-init focuses on "first boot configuration", being able to return the environment to an unadulterated state is important. By putting the jail on a ZFS dataset, you can create a snapshot (zfs snapshot pool/dataset@name) for the pristine system state, and then restore it (zfs restore pool/dataset@name). Here, my jail is "nuageinit", and the system image will be under the root/ directory.

# zfs create -o mountpoint=/jails zroot/jails
# zfs create -p zroot/jails/nuageinit/root

On to installing the bits in the jail. You would traditionally create the jail image by extracting the base.txz image, but package base allows us to create a smaller image using packages:


# mtree -deUW -f ~ctuffli/dev/freebsd/src.git/etc/mtree/BSD.root.dist -p root/
...
# mkdir -p /jails/nuageinit/root/usr/share/keys/pkg/trusted
# cp /usr/share/keys/pkg/trusted/* /jails/nuageinit/root/usr/share/keys/pkg/trusted/
# pkg -r /jails/nuageinit/root install -r FreeBSD-base \
        FreeBSD-acct \
        FreeBSD-newsyslog \
        FreeBSD-runtime \
        FreeBSD-rc \
        FreeBSD-syslogd \
        FreeBSD-utilities

How did I come up with the list of necessary packages? Trial and error. This is another case where ZFS snapshots shine.

Because we are debugging a script which modifies the system, we'll take a ZFS snapshot so that we can rollback the system state to try each new version of the code.

# zfs snapshot zroot/jails/nuageinit/root@freshinstall

The jail configuration file will perform the remainder of the workflow automation:

$j = "/jails";
$jd = "$j/$name";
$jp = "$jd/root";
$source = "~ctuffli/dev/ctuffli-nuageinit.git/libexec/nuageinit";

nuageinit {
    devfs_ruleset = 14;
    enforce_statfs = 1;
    exec.clean;
    exec.prepare = "mdconfig -a -t vnode -f $jd/seed.iso -u 1";
    exec.created  = "env DESTDIR=$jp BINDIR=/usr/libexec make -C $source install";
    exec.created += "cp $source/../../rc.d/* $jp/etc/rc.d/";
    exec.release = "mdconfig -d -u 1";
    exec.start = '/bin/sh /etc/rc';
    exec.stop = '/bin/sh /etc/rc.shutdown';
    host.hostname = freebsd;
    mount.devfs;
    mount.fstab = $jd/fstab;
    path = $jp;
    allow.mount;
}

Most of the parameters are fairly standard. The interesting ones to note are the exec.prepare and exec.created values as these drive the workflow. cloud-init reads its configuration data from an attached disk volume labeled 'CIDATA' (well, for the NoCloud variant). The contents of the drive must use either a vfat or iso9660 filesystem. The "prepare" parameter takes the ISO image (seed.iso) with the cloud-init data and turns it into a block device passed into the jail (note the devfs_ruleset allows the jail to see the /dev/md1 device). The "created" parameter copies the latest version of cloud-init out of my home directory and installs the files in the jail.

The development workflow looks like:

  1. Hack on the code in my editor of choice
  2. Reset the system state in the jail:
    # zfs rollback zroot/jails/nuageinit/root@freshinstall
  3. Start the jail and get a console
    # jail -c -f /jails/nuageinit/jail.conf
    # jexec nuageinit /bin/sh
  4. Start the service and test
  5. Shut everything down
    # jail -r -f /jails/nuageinit/jail.conf nuageinit
[root@stargate /jails/nuageinit]# jexec nuageinit /bin/sh
# service nuageinit onestart
mount_cd9660: /dev/iso9660/cidata: Operation not permitted
/usr/libexec/flua: /usr/libexec/nuageinit:138: attempt to call a string value (local 'err')
stack traceback:
        /usr/libexec/nuageinit:138: in main chunk
        [C]: in ?
umount: /media/nuageinit: not a file system root directory
# ls -l /dev/iso9660/cidata
crw-r-----  1 root operator 0x320 Feb 12 23:04 /dev/iso9660/cidata
#

Poop, that didn't work the way I wanted. While the jail can see the labeled image, mounting that images fails. The jail(8) documentation explains why:

allow.mount
       privileged users	inside the jail	will be	able to	 mount
       and  unmount file system	types marked as	jail-friendly.
       The lsvfs(1) command can	be used	to  find  file	system
       types  available	 for  mount  from within a jail.  This
       permission is effective only if enforce_statfs  is  set
       to a value lower	than 2.

Running lsvfs on the host shows:

# lsvfs cd9660 msdosfs tmpfs
Filesystem                              Num  Refs  Flags
-------------------------------- ---------- -----  ---------------
cd9660                           0x000000bd     0  read-only
msdosfs                          0x00000032     1
tmpfs                            0x00000087     1  jail

Meaning, it is expected that mounting a cd9660 file system within a jail will fail. Asking on the mailing lists, the reason appears to be safety:

File systems where the kernel parses a binary disk image aren't generally
safe because a bad image can corrupt kernel state.

Digging into the service script, it

  • Figures out if a cloud-init image exists
  • Determines the cloud-init image type
  • Mounts the image to /media/nuageinit 
  • Runs the script which does the configuration (/usr/libexec/nuageinit).
Armed with this knowledge, we can modify the jail configuration to create this directory and mount the ISO in the jail.
nuageinit {
...
    exec.prepare  = "mdconfig -a -t vnode -f $jd/seed.iso -u 1";
    exec.prepare += "mkdir -p $jp/media/nuageinit";
    exec.prepare += "mount_cd9660 /dev/md1 $jp/media/nuageinit";
...
    exec.poststop = "umount $jp/media/nuageinit && rmdir $jp/media/nuageinit";
...
}

The service script will still fail, but we can manually run nuageinit:

[root@stargate /jails/nuageinit]# jail -c -f /jails/nuageinit/jail.conf
nuageinit: created
install  -o root  -g wheel -m 555  nuageinit  /jails/nuageinit/root/usr/libexec/nuageinit
installing DIRS FILESDIR
install  -d -m 0755 -o root  -g wheel  /jails/nuageinit/root/usr/share/flua
install -C  -o root  -g wheel -m 444  nuage.lua /jails/nuageinit/root/usr/share/flua/nuage.lua
install -C  -o root  -g wheel -m 444  yaml.lua /jails/nuageinit/root/usr/share/flua/yaml.lua
ELF ldconfig path: /lib /usr/lib
32-bit compatibility ldconfig path:
Clearing /tmp (X related).
Creating and/or trimming log files.
Updating motd:.
Updating /var/run/os-release done.
Starting syslogd.
/etc/rc: WARNING: failed to start syslogd
Starting cron.

Mon Feb 12 23:56:46 UTC 2024
[root@stargate /jails/nuageinit]# jexec nuageinit /bin/sh
# /usr/libexec/nuageinit /media/nuageinit/ nocloud
# cat /etc/rc.conf.d/hostname
hostname="mysupervm.example.com"
# ls -l /home/freebsd/.ssh/authorized_keys
-rw-r--r--  1 freebsd freebsd 109 Feb 12 23:57 /home/freebsd/.ssh/authorized_keys
#

Fin

No comments: