Simplicity has a cost

I'm really fond of minimalistic tools, they allow you to keep an eye on the overall complexity of your projects. They keep in reach the possibility to tweak things to fit your need without having to rewrite everything.

Unfortunately, packaging tools are not that way. Even if individualy, some of them are quickly understandable, the whole process is quite complex afterall.

This is the tradeoff of all software: either make it minimalistic but people using it are required to have a minimal background to handle them, or hide as much as possible the internals and make it procedural to ensure anyone following a copy/paste style documentation can make it.

On projects requiring a lot of people to get involved, you can't make it minimalistic because the pool of people fitting your human resources needs is not big enough. But on small team projects, this is probably the best option.

To create the method I will explain in this article, I took a lot of inspiration from the packaging system, which is minimalistic: as you can see here, the whole tool explanation takes less than 200 lines. For this reason, I will give the same example for and for , this will show you the inspiration.

Let's use docker for the show

In order to spare you the need to install a machine with and , I will demo everything from the following two Dockerfile based containers.

FROM "debian:10"

RUN apt update && \
    apt install -y \
      curl \
      debhelper \
      wget \
    && rm -fr /var/cache/apt

## To build the container, run this in a terminal:
# docker build -t debian-package-demo 'https://duriez.info/d/s7VjMjTf'
FROM "archlinux"

RUN pacman -Suy --noconfirm --disable-download-timeout && \
    pacman -S --noconfirm --disable-download-timeout \
      base-devel \
    && rm -fr /var/cache

## To build the container, run this in a terminal:
# docker build -t archlinux-package-demo 'https://duriez.info/d/OuLIzbQs'

After this, you should have the following two images:

  • archlinux-package-demo:latest
  • debian-package-demo:latest

To ensure it, run the following command:

docker images | grep package-demo

Let's make packages for lua

Lua has a fairly small source code and few dependencies, then it's a good choice to make a first demo.

Archlinux Example

First, create a directory for our example, let's say /tmp/lua-archlinux. And put the following content in the /tmp/lua-archlinux/PKGBUILD file:

pkgname=lua-example
pkgver=5.3.6
pkgrel=1
pkgdesc='Lua is a powerful, efficient, lightweight, embeddable scripting language'
arch=('x86_64')
url='https://www.lua.org'
depends=('readline')
license=('MIT')
source=("https://www.lua.org/ftp/lua-${pkgver}.tar.gz")
md5sums=('83f23dbd5230140a3770d5f54076948d')

prepare() {
  cd "$srcdir"
  echo "Nothing to do..."
}

build() {
  cd "$srcdir/lua-$pkgver"
  make linux
}

package() {
  cd "$srcdir/lua-$pkgver"
  make INSTALL_TOP="$pkgdir/usr" install
}

## You can use this command to download the file:
# wget 'https://duriez.info/d/OoQoAS66' -O PKGBUILD

Then start the image we made earlier and mount the current directory inside the container to keep the result after the container will stop.

After this, you should find a lua-example-5.3.6-1-x86_64.pkg.tar.zst file in the /tmp/lua-archlinux directory which is the expected package.

Debian Example

Now our goal will be to do something narrowing this behavior. I propose this:

#!/bin/bash
set -e

MAINTAINER="Franck Duriez <franck@duriez.info>"
VERSION="5.3.6"
NAME="lua-example"
ARCHITECTURE="amd64"
DESCRIPTION="Lua is a powerful, efficient, lightweight, embeddable scripting language"
DEPENDENCIES=("libc6" "libedit2" "libreadline7")
BUILD_DEPENDENCIES=("libedit-dev" "libreadline-dev")

SOURCE="https://www.lua.org/ftp/lua-${VERSION}.tar.gz"
MD5SUM='83f23dbd5230140a3770d5f54076948d'

build() {
  cd "$SOURCE_DIR/lua-$VERSION"
  make linux
}

package() {
  cd "$SOURCE_DIR/lua-$VERSION"
  make INSTALL_TOP="$PACKAGE_DIR/usr" install
}

###############
###############

echo "Building version $VERSION"

CURRENT_DIRECTORY="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
cd "${CURRENT_DIRECTORY}"

echo "## Extract sources"
SOURCE_DIR="$PWD/tmp"
rm -fr "$SOURCE_DIR"
mkdir -p "$SOURCE_DIR"
cd "$SOURCE_DIR"
wget -O "archive.tar.gz" "$SOURCE"
echo "${MD5SUM} archive.tar.gz" > "archive.md5"
md5sum -c "archive.md5" # Check integrity
tar xzf "archive.tar.gz"
cd -

PACKAGE_DIR="${CURRENT_DIRECTORY}/pkg"
rm -fr "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"

apt update
apt install -y "${DEPENDENCIES[@]}" "${BUILD_DEPENDENCIES[@]}"

echo "## Build from sources"
build
echo "## Package built files"
package

mkdir -p "$PACKAGE_DIR/DEBIAN"
echo "Package: $NAME
Version: $VERSION
Architecture: $ARCHITECTURE
Depends: $({ IFS=","; echo "${DEPENDENCIES[*]}"; })
Maintainer: $MAINTAINER
Description: ${DESCRIPTION}" \
  > "$PACKAGE_DIR/DEBIAN/control"

rm -fr "${CURRENT_DIRECTORY}/pkg.deb"
dpkg-deb --build "$PACKAGE_DIR"
mv "${CURRENT_DIRECTORY}/pkg.deb" "${CURRENT_DIRECTORY}/${NAME}_${VERSION}_${ARCHITECTURE}.deb"
rm -fr "$PACKAGE_DIR"

## You can use this command to download the file:
# wget 'https://duriez.info/d/kmQoHvN9' -O build.sh

As you can see, before the separator, the file is mostly equivalent to the archlinux one.

Now, let's run it in the debian container.

After this, you should find a lua-example_5.3.6_amd64.deb file in the /tmp/lua-debian directory which is the expected package.

Differences

There are few subtle differences between the two build.

Maybe you didn't notice but the two docker commands are slightly different:

# Here is the archlinux one:
docker run -v"$PWD:/repo" -u"$(id -u):$(id -g)" -it archlinux-package-demo

# Here is the debian one:
docker run -v"$PWD:/repo" -it debian-package-demo

As you can see the -u"$(id -u):$(id -g)" part is absent for debian. It means than the user in the docker is root, unlike in the other which use a user with the same UID (user id) and GID (group id). This means each time a file is created in the debian environment, it's owned by root (and then requires root access to delete), and each time you create a file in the archlinux environment it's owned by you.

Archlinux packaging is performed inside a chroot environment, removing the need to be root and avoiding to alter the host system.

As long as you stay in docker, not using a chroot is not a problem because docker itself is a chroot++. Therefore, this method is especially suitable in docker based continuous integration context.

Another difference is that archlinux packaging, add some post process to the built executables. For example, it strips all the binaries, removes garbage files... This can be seen after Tidying install.

About deb files

deb files are ar archives that contain the following content:

  • control.tar.xz contains the content of the control directory we made in the script
  • data.tar.xz contains the files it will add to your file system
  • debian-binary contains the version of the package format

To extract these file from a deb file, you can use the following command:

ar x <path/to/file.deb>

In the control.tar.xz, you can find a bunch of things, but the only mandatory is the control file we made in the example:

  • control the package metadata
  • preinst the package pre installation script
  • postinst the package post installation script
  • prerm the package pre uninstallation script
  • postrm the package post uninstallation script
  • ... and other less useful scripts. You can find a complete list of available scripts here.

Let's make packages for a service

Now, we will make something a little more "complex". Some packages require to perform some pre/post installation scripts. We will use the available mechanism of the debian files we talk about at the end of the previous section.

On based distributions, the philosophy is that the user should not need to understand it's system to use it properly. That's why these are great distributions to start with. But, this implies to do the "default" configuration of any software at the installation for the user.

For example, if the package contains a service, you should enable it and start it after installation (in the postinst script) without even asking the user.

This is not true for , but for the sake of the example, we will make an equivalent package.

Archlinux Example

First, create a directory for our example, let's say /tmp/service-archlinux. And put the following content in the /tmp/service-archlinux/PKGBUILD file:

pkgname=service-example
pkgver=0.0.1
pkgrel=1
pkgdesc='Just a service example'
arch=('x86_64')
depends=('systemd')
license=('MIT')
install=service.install

prepare() {
  echo "Nothing to do..."
}

build() {
  echo "Nothing to do..."
}

package() {
  mkdir -p "$pkgdir/usr/bin"
  cat > "$pkgdir/usr/bin/dummy-executable" << EOF
#!/bin/bash
while sleep 5
do
  echo "Hello world!"
done
EOF

  chmod 755 "$pkgdir/usr/bin/dummy-executable"

  mkdir -p "$pkgdir/usr/lib/systemd/system"
  cat > "$pkgdir/usr/lib/systemd/system/dummy-service.service" << EOF
[Unit]
Description=A dummy service for the sake of teaching

[Service]
ExecStart=/usr/bin/dummy-executable

[Install]
WantedBy=multi-user.target
EOF
}

## You can use this command to download the file:
# wget 'https://duriez.info/d/OHBFYSfj' -O PKGBUILD

And this content in the /tmp/service-archlinux/service.install file:

# The script is run right before files are extracted.
# One argument is passed: new package version.
pre_install() {
  echo "pre_install called"
}

# The script is run right after files are extracted.
# One argument is passed: new package version.
post_install() {
  echo "post_install called"
  systemctl enable dummy-service
  # Check if system is active and then start the service.
  # Inside docker, for example, systemd is not active.
  if systemctl is-system-running --quiet; then systemctl start dummy-service; fi
}

# The script is run right before files are extracted.
# Two arguments are passed in the following order: new package version, old package version.
pre_upgrade() {
  echo "pre_upgrade called"
}

# The script is run right after files are extracted.
# Two arguments are passed in the following order: new package version, old package version.
post_upgrade() {
  echo "post_upgrade called"
}

# The script is run right before files are removed.
# One argument is passed: old package version.
pre_remove() {
  echo "pre_remove called"
  if systemctl is-system-running --quiet; then systemctl stop dummy-service; fi
  systemctl disable dummy-service
}

# The script is run right after files are removed.
# One argument is passed: old package version.
post_remove() {
  echo "post_remove called"
}

## You can use this command to download the file:
# wget 'https://duriez.info/d/l4Jm885t' -O service.install

Then start the image as previously:

After this, you should find a service-example-0.0.1-1-x86_64.pkg.tar.zst file in the /tmp/service-archlinux directory which is the expected package.

Debian Example

Now our goal will be to do something narrowing this behavior. I propose this:

#!/bin/bash
set -e

MAINTAINER="Franck Duriez <franck@duriez.info>"
VERSION="0.0.1"
NAME="service-example"
ARCHITECTURE="amd64"
DESCRIPTION="Just a simple example"
DEPENDENCIES=("systemd")
BUILD_DEPENDENCIES=()

build() {
  echo "Nothing to do..."
}

package() {
  mkdir -p "$PACKAGE_DIR/usr/bin"
  cat > "$PACKAGE_DIR/usr/bin/dummy-executable" << EOF
#!/bin/bash
while sleep 5
do
  echo "Hello world!"
done
EOF

  chmod 755 "$PACKAGE_DIR/usr/bin/dummy-executable"

  mkdir -p "$PACKAGE_DIR/lib/systemd/system"
  cat > "$PACKAGE_DIR/lib/systemd/system/dummy-service.service" << EOF
[Unit]
Description=A dummy service for the sake of teaching

[Service]
ExecStart=/usr/bin/dummy-executable

[Install]
WantedBy=multi-user.target
EOF
}

###############
###############

echo "Building version $VERSION"

CURRENT_DIRECTORY="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
cd "${CURRENT_DIRECTORY}"

PACKAGE_DIR="${CURRENT_DIRECTORY}/pkg"
rm -fr "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR"

apt update
apt install -y "${DEPENDENCIES[@]}" "${BUILD_DEPENDENCIES[@]}"

echo "## Build from sources"
build
echo "## Package built files"
package

mkdir -p "$PACKAGE_DIR/DEBIAN"
echo "Package: $NAME
Version: $VERSION
Architecture: $ARCHITECTURE
Depends: $({ IFS=","; echo "${DEPENDENCIES[*]}"; })
Maintainer: $MAINTAINER
Description: ${DESCRIPTION}" \
  > "$PACKAGE_DIR/DEBIAN/control"

cat > "$PACKAGE_DIR/DEBIAN/postinst" << EOF
#!/bin/bash
systemctl enable dummy-service
# Check if system is active and then start the service.
# Inside docker, for example, systemd is not active.
if systemctl is-system-running --quiet; then systemctl start dummy-service; fi
EOF
chmod 755 "$PACKAGE_DIR/DEBIAN/postinst"

cat > "$PACKAGE_DIR/DEBIAN/prerm" << EOF
#!/bin/bash
if systemctl is-system-running --quiet; then systemctl stop dummy-service; fi
systemctl disable dummy-service
EOF
chmod 755 "$PACKAGE_DIR/DEBIAN/prerm"

rm -fr "${CURRENT_DIRECTORY}/pkg.deb"
dpkg-deb --build "$PACKAGE_DIR"
mv "${CURRENT_DIRECTORY}/pkg.deb" "${CURRENT_DIRECTORY}/${NAME}_${VERSION}_${ARCHITECTURE}.deb"
rm -fr "$PACKAGE_DIR"

## You can use this command to download the file:
# wget 'https://duriez.info/d/PkGvF950' -O build.sh

As before, the file is still greatly inspired from PKGBUILD format.

Now, let's run it in the debian container.

After this, you should find a service-example_0.0.1_amd64.deb file in the /tmp/service-debian directory which is the expected package.

Conclusion

With the content of this article you should be able to create 99% of the debian packages you may think of.

As explained before, this method is especially suited for docker based continuous integration environments.