Bash and Linux

Detect the OS and Package Manager

easySystem monitoring

Problem statement

Read /etc/os-release to find which Linux a machine runs and which package manager it uses, so one script can install a tool on Ubuntu, Rocky, Amazon Linux or Alpine. Setup scripts, Dockerfiles and CI images all need this, because apt, dnf and apk are not interchangeable.

The script saves four sample os-release files, one per system:

ubuntu.txt

TEXT
NAME="Ubuntu"
VERSION_ID="24.04"
ID=ubuntu
ID_LIKE=debian

rocky.txt

TEXT
NAME="Rocky Linux"
VERSION_ID="9.4"
ID="rocky"
ID_LIKE="rhel centos fedora"

amazon.txt

TEXT
NAME="Amazon Linux"
VERSION_ID="2023"
ID="amzn"
ID_LIKE="fedora"

alpine.txt

TEXT
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3

For each file, print the OS name and version, and the command that would install curl there. Finally print the kernel type of the machine running the script.

Expected output:

Bash
== Ubuntu 24.04 ==
install: apt-get install -y curl
== Rocky Linux 9.4 ==
install: dnf install -y curl
== Amazon Linux 2023 ==
install: dnf install -y curl
== Alpine Linux 3.20.3 ==
install: apk add curl
== this machine ==
kernel: Linux

Hints

Hint 1: os-release is written as shell variables, so . file (source) loads NAME, ID and the others into your shell. Do it inside ( ) so one file's values do not leak into the next.

Approach

Optimal: Source os-release, then case

Covers: /etc/os-release, sourcing a file with ., subshells ( ), case with patterns, ID vs ID_LIKE, uname -s, command -v.

Every modern Linux has /etc/os-release. It is a small text file of KEY=value lines describing the system. Because it is written in shell syntax, the shell can load it directly. The keys that matter most:

Key Example Use it for
NAME Rocky Linux showing to people
VERSION_ID 9.4 version checks
ID rocky the exact distribution
ID_LIKE rhel centos fedora the family it belongs to

Why ID_LIKE matters. There are dozens of distributions, but only a few families. Rocky, AlmaLinux and Amazon Linux are not rhel, but their ID_LIKE says they behave like it. Matching the family covers systems you have never heard of:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB F["ID + ID_LIKE"]:::purple F --> D["contains debian or ubuntu
apt-get"]:::blue F --> R["contains rhel, fedora or centos
dnf"]:::red F --> A["contains alpine
apk"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Loading the file safely. . file (also written source file) runs the file's lines in the current shell, so $NAME and $ID become available. Doing it inside ( ) runs a subshell, a copy of the shell, so the variables disappear at the ) and the next file starts clean. On a real server you would source /etc/os-release once, without the loop.

case picks the first matching pattern. case "$ID $ID_LIKE" in joins both values into one string, then each pattern is tried in order. *debian*|*ubuntu*) means "contains debian or ubuntu". *) at the end catches everything else. Each branch ends with ;;.

Walking through the code. The # Setup: lines only write the four samples, so skip past them.

  1. The for loop visits each sample file.
  2. Inside ( ), . "$f" loads its variables, echo prints name and version, and case picks the package manager.
  3. uname -s prints the kernel name of the machine itself: Linux here, Darwin on a Mac.
%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph SUB["( . file; case ... )"] direction TB S1["load variables"]:::blue --> S2["print and choose"]:::green --> S3["variables vanish at )"]:::gray end classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style SUB fill:transparent,stroke:#7c3aed,stroke-width:2px

Edge cases. Very old systems may lack os-release; fall back to /etc/redhat-release or /etc/debian_version. Containers built FROM scratch have no os-release at all. Before installing, it is often simpler to check whether the tool already exists: command -v curl > /dev/null || install it.

# Setup: four sample os-release files in a fresh temporary folder
cd "$(mktemp -d)"
printf 'NAME="Ubuntu"\nVERSION_ID="24.04"\nID=ubuntu\nID_LIKE=debian\n'                 > ubuntu.txt
printf 'NAME="Rocky Linux"\nVERSION_ID="9.4"\nID="rocky"\nID_LIKE="rhel centos fedora"\n' > rocky.txt
printf 'NAME="Amazon Linux"\nVERSION_ID="2023"\nID="amzn"\nID_LIKE="fedora"\n'          > amazon.txt
printf 'NAME="Alpine Linux"\nID=alpine\nVERSION_ID=3.20.3\n'                             > alpine.txt

for f in ubuntu.txt rocky.txt amazon.txt alpine.txt; do
  (
    . "./$f"                                   # load NAME, ID, ID_LIKE, VERSION_ID
    echo "== $NAME $VERSION_ID =="
    case "$ID $ID_LIKE" in
      *debian*|*ubuntu*)        echo "install: apt-get install -y curl" ;;
      *rhel*|*fedora*|*centos*) echo "install: dnf install -y curl" ;;
      *alpine*)                 echo "install: apk add curl" ;;
      *)                        echo "install: unknown package manager" ;;
    esac
  )
done

echo "== this machine =="
echo "kernel: $(uname -s)"

Interview follow-ups

  • Print the install command for nginx, whose package name differs by family.

    Keep the family detection, and add a variable per branch: in the debian branch set pkg=nginx and mgr="apt-get install -y"; in the rhel branch the package is also nginx, but for Apache it would be httpd instead of apache2. Then run $mgr $pkg once at the end. Keeping a small table of names per family in one place is cleaner than many if checks spread through the script.

Frequently asked questions

Check for the tool you need when you can: command -v apt-get tells you apt exists, and command -v curl tells you curl is already installed. That keeps working on systems your case never listed. Check the OS when behaviour really differs by family, like package names (httpd vs apache2) or service names (ssh vs sshd). Many scripts do both: detect the family, then confirm the command exists.

dnf replaced yum on Fedora, RHEL 8 and later, Rocky, AlmaLinux and Amazon Linux 2023. On those systems yum is kept as a link to dnf, so old scripts still work. Older systems like CentOS 7 and Amazon Linux 2 have only yum. A safe pattern is if command -v dnf > /dev/null; then dnf ...; else yum ...; fi.

Sourcing runs every line as shell code, so a file could run any command. /etc/os-release is owned by root and written by the OS, so it is trusted on that machine. Never source a file that users or downloads can change. To read untrusted KEY=value files, parse them instead, for example with grep '^ID=' file | cut -d= -f2 | tr -d '"'.