In order to write some portable health-checking scripts, I needed to reliably detect both distribution (or at least distribution family) and version (e.g. to cope with rpm moving from /bin to /usr/bin in Red Hat 7). I based this on my previous Lua distribution detection script for Lmod. In contrast to the Lmod script, I am not interested in any CPU/architecture detection (at present). I only need this for Red Hat Enterprise Linux family (including CentOS, Scientific Linux and Rocky), Ubuntu and Debian distributions. Adding others would be trivial, it is just a case of finding their lsb_release return values and an appropriate fallback (file) method.

Aside: Announcement on the end of Scientific Linux with version 7

These are the variables I am trying to populate:

  • DIST_FAMILY - in my case, either “RedHat” or “Debian”
  • DIST_DISTRIBUTION - the actual distribution, e.g. “RedHatEnterpriseLinux”, “Rocky”, “Ubuntu”, “Debian” etc.
  • DIST_VERSION - the full version
  • DIST_VERSION_MAJOR - just the major version number

To complicate things slightly, unlike the systems I wrote the Lmod version for, some systems I need this to work on are missing the lsb_release command so I do need the fall-back “old” detection methods.

Testing with Docker

Docker has fallen out of favour with many due to the changes to the Docker Desktop licence that suddenly made it very expensive (previously free) in anything other than very small organisations that do not sell many or high-value goods (as the limit is based on employees and annual revenue, i.e. sales). Docker Engine (which is still free everywhere) is, however, still resting unused on my laptop so is convenient for this “hobby project” work until I get around to moving to something more open, like Podman.

Fetching containers

To get containers for all distributions and versions under test:

# redhat/ubi[89]-micro have no package manager, which I need to create -lsb variants
# hjd48/redhat - claimed to be Red Hat 6,2 (but seems to be 6.3)
# ubuntu:22.04 == ubuntu:latest (current LTS)
for image in sl:7 \
  redhat/ubi9 redhat/ubi8 yjjy0921/redhat7.2 hjd48/redhat \
  centos:7 centos:6 \
  rockylinux:9 rockylinux:8 \
  debian:11-slim debian:10-slim debian:9-slim \
  ubuntu:22.10 ubuntu:22.04 ubuntu:20.04 ubuntu:18.04
do
  docker pull ${image}
done

Neither of the Red Hat 6 based (Red Hat 6 and CentOS 6) containers would run bash (or yum), they gave a segmentation fault error (exit status 139) when I tried. However cat worked, so I was able to see their respective /etc/redhat-release files contained Red Hat Enterprise Linux Server release 6.3 (Santiago) and CentOS release 6.10 (Final). For comparison, Red Hat 7 and CentOS 7 were Red Hat Enterprise Linux Server release 7.2 (Maipo) and CentOS Linux release 7.9.2009 (Core).

Creating lsb-release variants

Out of the box, none of these minimal images provided lsb_release so I created variants (tagged with existing tag plus suffix -lsb) with them installed. The Red Hat 7 image I used has no functioning repositories configured, so I did not do that one, lsb_release has been removed from Red Hat 9 (and by extension Rocky 9).

for image in sl:7 \
  redhat/ubi8 \
  centos:7 \
  rockylinux:8 \
  debian:11-slim debian:10-slim debian:9-slim \
  ubuntu:22.10 ubuntu:22.04 ubuntu:20.04 ubuntu:18.04
do
  docker image build -t $( if [[ $image = *:* ]]; then echo ${image}-lsb; else echo ${image}:lsb; fi ) - <<EOF
FROM ${image}
RUN /bin/bash -c 'set -e; if command -v apt-get &>/dev/null; \
then \
  echo "Installing lsb_release with apt-get..."; \
  apt-get update &>/dev/null; \
  apt-get -y install lsb-release &>/dev/null; \
elif command -v dnf &>/dev/null; \
then \
  echo "Installing lsb_release with dnf..."; \
  dnf -q -y install redhat-lsb-core; \
elif command -v yum &>/dev/null; \
then \
  echo "Installing lsb_release with yum..."; \
  yum -q -y install redhat-lsb-core; \
else \
  echo "Unable to install lsb_release - could not locate package manager" >&2; \
  exit 1; \
fi; \
command -v lsb_release'
EOF
done

Testing with all (working) containers

Presuming the script to test is in the current directory, called detection_test and is executable; this will test with each image in turn:

for image in sl:7 \
  redhat/ubi9 redhat/ubi8 yjjy0921/redhat7.2 \
  centos:7 \
  rockylinux:9 rockylinux:8 \
  debian:11-slim debian:10-slim debian:9-slim \
  ubuntu:22.10 ubuntu:22.04 ubuntu:20.04 ubuntu:18.04
do
  echo -e "\e[1;36m>\e[1;0m Running test script in ${image}..."
  if docker run --rm --mount type=bind,source=${PWD},destination=/script,readonly ${image} /script/detection_test
  then
    echo -e "\e[1;32m\u2713\e[1;0m ${image} \e[1;32mpassed\e[1;0m"
  else
    echo -e "\e[1;31m\u2717\e[1;0m ${image} \e[1;31mFAILED\e[1;0m"
  fi
  lsb_variant=$( if [[ $image = *:* ]]; then echo ${image}-lsb; else echo ${image}:lsb; fi )
  # Is there an lsb variant to test?
  if [[ -n "$( docker images -q ${lsb_variant} )" ]]
  then
    echo -e "\e[1;36m>>\e[1;0m Running test script in ${lsb_variant}..."
    if docker run --rm --mount type=bind,source=${PWD},destination=/script,readonly ${lsb_variant} /script/detection_test
    then
      echo -e "\e[1;32m\u2713\e[1;0m ${lsb_variant} \e[1;32mpassed\e[1;0m"
    else
      echo -e "\e[1;31m\u2717\e[1;0m ${lsb_variant} \e[1;31mFAILED\e[1;0m"
    fi
  else
    echo -e "\e[1;33m?\e[1;0m No LSB variant for ${image}." >&2
  fi
done

To help distinguish each test, and success from failure, I’ve used colour (and some unicode characters). For running interactively by copy & pasting into a shell, this is fine but good practice would be to test if the output is a terminal and supports colour if this is to be turned into a script.

I use --mount based on what is described as good practice in Docker’s documentation:

New users should use the –mount syntax. Experienced users may be more familiar with the -v or –volume syntax, but are encouraged to use –mount, because research has shown it to be easier to use.

Doing the detection

Mapping distribution to families

Using Bash’s associative array, a direct replica of the lookup table in my Lmod version can be made.

Unlike the older lsb_release and distribution-specific release file methods, os-release mandates that distribution ID values are lowercase. I thought long and hard about whether to follow this pattern or map it to the older mixed-case form favoured by lsb_release and decided to go with lowercase for these reasons:

  1. Harder to read for a human (this is the major ‘con’ - i.e. it is less pretty)
  2. “All values will be lowercase” is easier for script-writers (e.g. do not need to know if it’s “Centos” or “CentOS”, “RedHatEnterprise” or “RedhatEnterprise”)
  3. As much as I hate aspects of it, systemd is here to stay and this is therefore the current standard. Complying with standards is usually good for portability and future maintenance.
# Map of distributions to their family
declare -A distribution_family=([debian]=debian [ubuntu]=debian \
  [scientific]=redhat [redhatenterpriseserver]=redhat [centos]=redhat \
  [redhatenterprise]=redhat [rocky]=redhat)

Detecting distribution with os-release files

These files were introduced by systemd as an alternative to distribution-specific release files and are mandatory for systemd support. Most systems now run systemd, making this file mandatory.

# /etc/os-release has precedence over /usr/lib/os-release, so source second
DIST_DISTRIBUTION="$( source /usr/lib/os-release; source /etc/os-release ; echo $ID )"
DIST_VERSION="$( source /usr/lib/os-release; source /etc/os-release ; echo $VERSION_ID )"

Detecting distribution with lsb_release

This is an easy case, since lsb_release provides a consistent interface to looking this up across distributions.

lsb_dist="$( lsb_release -i -s )"
DIST_DISTRIBUTION="${lsb_dist,,}"  # Convert to lowercase
# Assuming version is numeric therefore doesn't need lowercasing
DIST_VERSION="$( lsb_release -r -s )"

Detecting distribution with release files

Red Hat family distributions (at least all the ones I care about) all have /etc/redhat-release, which means there’s one recipe for all of them. This is inspired slightly by a discussion on Red Hat’s website.

# RedHat family distributions all have this file
# sed to make it conform to what `lsb_release -d` would produce if it were available
file_dist=$( sed 's/ release [0-9].*$//; s/ linux$//i; s/ linux //ig; s/ //g' /etc/redhat-release )
echo "Got dist ${file_dist,,}"
DIST_DISTRIBUTION="${file_dist,,}"
DIST_VERSION=$( sed 's/^.* release \([0-9][0-9.]*\).*$/\1/' /etc/redhat-release )

On Ubuntu, /etc/lsb-release exists even if lsb_release (lsb-release package) is not installed. So this can be used on that distribution:

# Convert to lowercase
DIST_DISTRIBUTION="$( source /etc/lsb-release ; echo ${DISTRIB_ID,,} )"
# Assuming version is numeric therefore doesn't need lowercasing
DIST_VERSION="$( source /etc/lsb-release ; echo ${DISTRIB_RELEASE} )"

Finally, on “vanilla” Debian, /etc/debian_version holds the version number (although not on sid, a.k.a. unstable):

# Can only be Debian (Ubuntu doesn't have this file)
DIST_DISTRIBUTION=debian
DIST_VERSION="$( cat /etc/debian_version)"

Mapping distribution to family and major version number

The last piece, once the DIST_DISTRIBUTION and DIST_VERSION variables have been discovered, is to use the lookup table defined at the start to find the family and extract the major part from the version:

DIST_FAMILY="${distribution_family[$DIST_DISTRIBUTION]}"
if [[ ${DIST_DISTRIBUTION} = "ubuntu" ]]
then
  # Every release of Ubuntu is a major release
  DIST_VERSION_MAJOR="${DIST_VERSION}"
else
  # Everything up to first '.' is the major version
  DIST_VERSION_MAJOR="${DIST_VERSION%%.*}"
fi

Exporting the variables

Finally, to make the variables available in sub-shells (including other scripts run from this shell session), the variables need to be exported. This is optional, if they are to be consumed in the script that does the detection. I explicitly export them, as a list, at the end as it provides a single place to see all of the variables this script exports (as opposed to exporting them as they are defined).

export DIST_FAMILY DIST_DISTRIBUTION DIST_VERSION DIST_VERSION_MAJOR

Putting it together

I started by using os-release as the first thing used to attempt detection, due to it being required (and therefore available) on all systems with systemd. However, I quickly discovered that this has less detailed information (particularly for version) on many distributions (Red Hat 7 and other RHEL 7 clones, Debian), so moved it to being the last resort.

In the end, the detections in my script follow this sequence:

  1. lsb_release
  2. os-specific release files:
    1. /etc/redhat-release
    2. /etc/lsb-release (for Ubuntu without lsb-release package installed, to avoid mis-detection as Debian by next check)
    3. /etc/debian_version
  3. os-release (systemd)

The full script looks like this:

#!/bin/bash

# Standard bash safety - errors are fatal, no accidental globbing,
# unset variables are errors, errors in pipes cause whole pipe to
# error.
set -efuo pipefail

# distribution -> family lookup
declare -A distribution_family=([debian]=debian [ubuntu]=debian \
  [scientific]=redhat [redhatenterpriseserver]=redhat [centos]=redhat \
  [redhatenterprise]=redhat [rocky]=redhat)

# lsb_release for easiest, complete, cross-distro discovery
if command -v lsb_release &>/dev/null
then
  lsb_dist="$( lsb_release -i -s )"
  DIST_DISTRIBUTION="${lsb_dist,,}"  # Convert to lowercase
  # Assuming version is numeric therefore doesn't need lowercasing
  DIST_VERSION="$( lsb_release -r -s )"
# release file methods
elif [ -e /etc/redhat-release ]
then
  # RedHat family distributions all have this file
  # sed to make it conform to what `lsb_release -d` would produce if it were available
  file_dist=$( sed 's/ release [0-9].*$//; s/ linux$//i; s/ linux //ig; s/ //g' /etc/redhat-release )
  DIST_DISTRIBUTION="${file_dist,,}"
  DIST_VERSION=$( sed 's/^.* release \([0-9][0-9.]*\).*$/\1/' /etc/redhat-release )
elif [ -e /etc/lsb-release ]
then
  # Convert to lowercase
  DIST_DISTRIBUTION="$( source /etc/lsb-release ; echo ${DISTRIB_ID,,} )"
  # Assuming version is numeric therefore doesn't need lowercasing
  DIST_VERSION="$( source /etc/lsb-release ; echo ${DISTRIB_RELEASE} )"
elif [ -e /etc/debian_version ]
then
  # Can only be Debian (Ubuntu caught by /etc/lsb-release, above)
  DIST_DISTRIBUTION=debian
  DIST_VERSION="$( cat /etc/debian_version)"
# Last ditch attempt - systemd's os-release (might not have full version number0
elif [ -e /etc/os-release ] || [ -e /usr/lib/os-release ]
then
  # /etc/os-release has precedence over /usr/lib/os-release, so source second
  DIST_DISTRIBUTION="$( source /usr/lib/os-release; source /etc/os-release ; echo $ID )"
  DIST_VERSION="$( source /usr/lib/os-release; source /etc/os-release ; echo $VERSION_ID )"
else
  echo "Distribution detection failed!" >&2
  exit 1
fi

# Determine family and major version from distribution and full version
DIST_FAMILY="${distribution_family[$DIST_DISTRIBUTION]}"
if [[ ${DIST_DISTRIBUTION} = "ubuntu" ]]
then
  # Every release of Ubuntu is a major release
  DIST_VERSION_MAJOR="${DIST_VERSION}"
else
  # Everything up to first '.' is major version
  DIST_VERSION_MAJOR="${DIST_VERSION%%.*}"
fi

[[ -n ${DIST_FAMILY} ]] || echo "Missing DIST_FAMILY" >&2
[[ -n ${DIST_DISTRIBUTION} ]] || echo "Missing DIST_DISTRIBUTION" >&2
[[ -n ${DIST_VERSION} ]] || echo "Missing DIST_VERSION" >&2
[[ -n ${DIST_VERSION_MAJOR} ]] || echo "Missing DIST_VERSION_MAJOR" >&2

# unset temporary variables to avoid environment pollution when sourced.
unset distribution_family lsb_dist file_dist

# Keep exports together to easily see what this script intentionally exports.
export DIST_FAMILY DIST_DISTRIBUTION DIST_VERSION DIST_VERSION_MAJOR

#echo "Detected ${DIST_DISTRIBUTION} (${DIST_FAMILY} family) version ${DIST_VERSION} (major version number ${DIST_VERSION_MAJOR})."