You already know how to write Bash, and you already know how to write it well; what is left is putting it all together. This module teaches no new material: it builds. Each lesson takes a real problem at Veloz Envíos and carries it from the statement to a finished tool, explaining at every step why each decision is made and which lesson the technique comes from. We start with the most contained of the five projects, system-info.sh, which is not trivial for all that: it is where you learn the separation between collecting and formatting, the design decision that will reappear in the four projects that follow.

Contents

  1. The problem: three needs, one single script
  2. Requirements written as a checkable list
  3. Design: collecting and formatting are two different things
  4. Version 1: the smallest thing that works
  5. The reliable data sources
  6. Version 2: one function per area returning key=value pairs
  7. The three formatters
  8. Thresholds and colors
  9. The interface: options and help
  10. Robustness: what to do when the data is not there
  11. Putting it to work: inventorying the fleet

  1. The problem: three needs, one single script

At Veloz Envíos, the basic information about a server is needed in three very different situations:

Situation Who reads it What format it needs
Quick diagnosis: "srv-veloz-02 is slow" A person, at a terminal Readable, aligned text with anything anomalous highlighted
Fleet inventory: versions, RAM, disks A spreadsheet or an awk One line per server, separated fields
Context attached to an alert The internal API and the webhook JSON

The temptation is to write three scripts, or one with a giant if and three copies of every query. Both age badly: the day you add "load average" you have to touch it in three places and one gets forgotten. The solution is the one you already used without naming it in 04-02 when returning values through stdout: collection knows nothing about the output format.

  1. Requirements written as a checkable list

Before writing code, the requirements, in the form of a list you can tick off:

  • [ ] Collects identification (host, distribution, kernel, uptime), hardware (CPU, RAM), disk, memory and load, network and service status.
  • [ ] Three output formats: text, tsv, json; and you can request a single section (--section disk).
  • [ ] Does not need root for the essentials and does not fail if something is unavailable.
  • [ ] Marks out-of-threshold values in the text output, with color only if the output is a terminal and NO_COLOR is not defined.
  • [ ] Passes shellcheck -x, finishes in under a second and runs over SSH on all three servers without changes.

This list is the contract. When you doubt whether a function should exist, the answer is here; and when the script is finished, you go through it ticking boxes.

  1. Design: collecting and formatting are two different things

The script has two halves that communicate through a very simple internal format: key=value lines.

collect_identification / _hardware / _disk  ──┐               ──► format_text
collect_memory_load / _network / _services  ──┴─► key=value ──┼─► format_tsv
                                                              ──► format_json

The collection functions write to stdout (04-02) and do not print a single header. The formatters read that stream and decide how it looks. Adding a new datum is adding a line inside a function; adding a new format is adding a function that touches no system query at all.

  1. Version 1: the smallest thing that works

We start with the smallest thing that solves case 1 in the table. No options, no formats.

#!/usr/bin/env bash
# system-info.sh - v1: basic identification in text.
set -euo pipefail
printf 'host=%s\n'   "$(hostname -f)"
printf 'kernel=%s\n' "$(uname -r)"
printf 'uptime=%s\n' "$(uptime -p)"

Twenty seconds of work and it is already useful. This version matters for two reasons. First: it fixes the internal format (key=value) from the very first line, and everything else will grow on top of it. Second: it is runnable and verifiable now, not when it is "finished". Building incrementally means that at no point is there a broken half-written script.

  1. The reliable data sources

In 06-03 we saw the rule: read files, not output meant for humans. Here it applies to every datum.

Datum Chosen source Why not the alternative
Distribution /etc/os-release It is a file in KEY="value" format, suitable for source; lsb_release may not be installed
Kernel uname -r Single-field output, stable for decades
CPUs nproc Counting lines in /proc/cpuinfo gives a different number with hyperthreading and containers
Memory /proc/meminfo free changes columns between versions; MemAvailable is the number that really matters
Load /proc/loadavg A single read versus trimming uptime with sed
Disk df -P -P forces the POSIX format: one line per filesystem, without splitting it when the device name is long
IP and services ip -4 addr, systemctl is-active ifconfig is deprecated in Ubuntu 24.04; is-active returns one word and an exit code

LC_ALL=C at the start of the script (06-03) guarantees that decimals use a dot and that error messages do not change with the server's language.

  1. Version 2: one function per area returning key=value pairs

Each function is short, prints no headers and never aborts the script if its source is missing.

collect_identification() {
  [[ -r /etc/os-release ]] && . /etc/os-release   # shellcheck disable=SC1091
  printf 'host=%s\n'   "$(hostname -f 2>/dev/null || hostname)"
  printf 'os=%s\n'     "${PRETTY_NAME:-unknown}"
  printf 'kernel=%s\n' "$(uname -r)"
  printf 'uptime=%s\n' "$(uptime -p 2>/dev/null || echo n/a)"
}

collect_memory_load() {
  local total avail load
  read -r total avail < <(awk '/^MemTotal:|^MemAvailable:/ {printf "%s ", $2}' /proc/meminfo)
  read -r load _ < /proc/loadavg
  printf 'mem_total_mb=%s\n' "$((total / 1024))"
  printf 'mem_used_pct=%s\n' "$(veloz_percentage "$((total - avail))" "$total")"
  printf 'load_1m=%s\n' "$load"
  printf 'load_per_cpu=%s\n' "$(echo "$load / $(nproc)" | bc -l | cut -c1-4)"
}

collect_disk() {
  df -P -x tmpfs -x devtmpfs 2>/dev/null |
    awk 'NR>1 {gsub(/%/,"",$5); printf "disk_%s_pct=%s\ndisk_%s_free=%s\n", $6, $5, $6, $4}'
}

collect_services() {
  local s
  for s in "${SERVICES[@]}"; do   # veloz-api, nginx, ssh...
    printf 'service_%s=%s\n' "$s" "$(systemctl is-active "$s" 2>/dev/null || echo unknown)"
  done
}

Four details that come from earlier lessons. The . /etc/os-release is the 05-06 technique applied to a data file: instead of trimming it with cut, it is loaded and its variables become available. veloz_percentage comes from lib/common.sh and avoids repeating integer arithmetic (04-06). The awk in collect_disk does in one pass what a while read loop would take twenty lines to do (06-01), and the gsub strips the % so the value is numeric and comparable. And the || echo unknown on systemctl is the answer to the requirement of not failing inside a container without systemd: better an honest value than death by set -e.

  1. The three formatters

They receive the key=value stream on standard input. None of them queries anything from the system.

format_text() {
  local key value
  while IFS='=' read -r key value; do
    printf '  %-22s %s%s%s\n' "$key" "$(color_for "$key" "$value")" "$value" "$RESET"
  done
}

format_tsv() {
  local key value row=()
  while IFS='=' read -r key value; do row+=("$value"); done
  local IFS=$'\t'; printf '%s\n' "${row[*]}"
}

format_json() {
  local args=() key value
  while IFS='=' read -r key value; do args+=(--arg "$key" "$value"); done
  jq -n "${args[@]}" '$ARGS.named'
}

format_text uses printf with the fixed width %-22s (04-04) so the keys line up without column. format_tsv accumulates into an array and takes advantage of the fact that "${row[*]}" joins the elements with the first character of IFS (03-06): assigning IFS=$'\t' as local makes the separator a tab only inside the function. And format_json builds the arguments for jq -n --arg in an array (06-05) so that escaping quotes, accents and backslashes is done by jq and not by us; $ARGS.named is the object with all the --arg values received. Writing that JSON by hand with printf would be the fastest way to generate invalid JSON the day a hostname contains an odd dash.

  1. Thresholds and colors

Thresholds live in a single table, not scattered around the code:

declare -A THRESHOLD=([mem_used_pct]=85 [load_per_cpu]=1.5)

color_for() {  # $1=key $2=value -> color sequence or nothing
  [[ $COLOR == no ]] && return 0
  local limit=${THRESHOLD[$1]:-}
  [[ -z $limit ]] && { [[ $1 == disk_*_pct ]] && limit=90 || return 0; }
  awk -v v="$2" -v l="$limit" 'BEGIN {exit !(v+0 > l)}' && printf '%s' "$RED"
}

The comparison is delegated to awk because the values may be decimals and [[ ]] only compares integers (04-06). The +0 forces a number, so that a non-numeric value such as unknown evaluates as 0 and is never painted red. The decision about color, on the other hand, is taken only once, at startup (03-05):

COLOR=no; RED=''; RESET=''
[[ -t 1 && -z ${NO_COLOR:-} && $FORMAT == text ]] &&
  { COLOR=yes; RED=$'\033[31m'; RESET=$'\033[0m'; }

Three conditions and all three matter. -t 1 detects that stdout is a terminal: if the script is redirected to a file or piped into grep, the escape codes would pollute the result. NO_COLOR is a convention respected by many tools and costs one line. And color is always disabled in TSV and JSON, because there the output is read by a machine.

  1. The interface: options and help

usage() {
  cat <<'EOF'
Usage: system-info.sh [options]
  -f, --format text|tsv|json   Output format (default: text)
  -s, --section NAME           Only one section; repeatable
  -h, --help                   This help
EOF
}

FORMAT=text; SECTIONS=()
while [[ $# -gt 0 ]]; do
  case $1 in
    -f|--format)  FORMAT=${2:?missing value for --format}; shift 2 ;;
    -s|--section) SECTIONS+=("${2:?missing value}"); shift 2 ;;
    -h|--help)    usage; exit 0 ;;
    --)           shift; break ;;
    -*)           veloz_die 2 "unknown option: $1" ;;
    *)            break ;;
  esac
done
[[ $FORMAT =~ ^(text|tsv|json)$ ]] || veloz_die 2 "invalid format: $FORMAT"

The while+case loop is chosen over getopts (03-05) for one concrete reason: we want readable long options in the runbook, and getopts only handles short ones. The ${2:?...} cuts off at the root the classic error of --format with no value. The explicit -- allows for positional arguments one day. And validating $FORMAT with a regular expression (05-04) instead of with a case in the dispatcher means the error is detected at startup, not halfway through the run.

The main dispatcher walks the requested sections and funnels everything into a single formatter:

main() {
  veloz_require jq awk df
  local sec
  [[ ${#SECTIONS[@]} -eq 0 ]] && SECTIONS=("${ALL[@]}")
  {
    for sec in "${SECTIONS[@]}"; do
      declare -F "collect_$sec" >/dev/null || veloz_die 2 "unknown section: $sec"
      "collect_$sec"
    done
  } | "format_$FORMAT"
}
main "$@"

The brace that groups the loop (05-05) lets all the loop's output enter the formatter through a single pipe. And declare -F checks that the function exists before invoking it: the section becomes a function name, but only if that function is defined, which is the allowlist of 08-03 applied without writing the list twice.

  1. Robustness: what to do when the data is not there

set -euo pipefail kills the script on any failure, and that is correct for programming errors but disastrous for a missing datum. The project rule: a missing datum is n/a, not a death.

Situation Effect without protection What we do
Container without systemctl, machine without ip command not found and exit 127 A prior command -v or 2>/dev/null || echo n/a
jq not installed Invalid, half-written JSON veloz_require jq at startup
df over a hung NFS mount The script blocks forever timeout 5 df -P (05-02)

The last case is the most treacherous: data that never arrives is worse than data that fails, because a hung script generates no alert. timeout turns the block into a visible error.

  1. Putting it to work: inventorying the fleet

With the TSV format, the inventory of the three servers is one line reusing fleet.sh (07-06):

~/veloz-ops/bin/fleet.sh 'bash -s' < ~/veloz-ops/bin/system-info.sh --format tsv | column -t -s$'\t'

The script is sent over standard input instead of being installed on each server: that way the inventory always reflects the latest version and there is nothing to keep in sync. And to attach context to an alert, system-info.sh --format json --section memory_load produces exactly the object the 07-04 webhook expects.

Common Mistakes and Tips

  • Mixing collection and format. If a collection function prints printf 'Memory: %s%%', the JSON becomes useless. The acid test: no collect_* function should contain alignment spaces, colons or colors.
  • Building JSON by hand. printf '{"host":"%s"}' works until a value contains a quote or a backslash. Always jq -n --arg.
  • Comparing decimals with [[ ]]. [[ 1.5 -gt 1 ]] does not give a syntax error, it gives a runtime error, and with set -e that kills the script. Decimals go through awk or bc.
  • Forgetting -P in df or LC_ALL=C for decimals. Without -P, a device with a long name splits the line in two and the awk reads the wrong column.
  • Coloring without checking -t 1. The day somebody runs system-info.sh > status.txt, the file fills up with \033[31m.
  • Tip: time the script with time (08-02). If it takes more than a second, it is almost certainly a for launching one process per datum; group it into a single awk.

Exercises

  1. Packages section. Add collect_packages, reporting packages_installed and packages_upgradable (use apt list --upgradable on Ubuntu), and needs_reboot according to whether /var/run/reboot-required exists. It must degrade to n/a on a system without apt.
  2. Compare two runs. Write the --compare FILE subcommand: save one run in key=value form and, when invoked, show only the keys whose value has changed with respect to the file, in the format key: before -> now.
  3. Temperature section. Add collect_temperature reading /sys/class/thermal/thermal_zone*/temp (thousandths of a degree) with globbing (02-05) and $(<file) instead of cat (08-02), and mark in red anything above 75 °C by adding its key to the THRESHOLD table, without touching color_for.

Solutions

1. The key is isolating each query with its own safeguard:

collect_packages() {
  local inst=n/a upg=n/a
  if command -v dpkg-query >/dev/null; then
    inst=$(dpkg-query -f '.\n' -W 2>/dev/null | wc -l)
    upg=$(apt list --upgradable 2>/dev/null | grep -c upgradable || true)
  fi
  printf 'packages_installed=%s\npackages_upgradable=%s\n' "$inst" "$upg"
  printf 'needs_reboot=%s\n' "$([[ -f /var/run/reboot-required ]] && echo yes || echo no)"
}

The || true after grep -c is essential: grep returns 1 when it finds nothing, and with set -e that would be the end of the script even though "zero upgradable" is the best possible news.

2. We take advantage of the internal format already being key=value, so an associative array is enough (04-03):

format_compare() {  # $REFERENCE = file from a previous run
  declare -A before; local k v
  while IFS='=' read -r k v; do before[$k]=$v; done < "$REFERENCE"
  while IFS='=' read -r k v; do
    [[ ${before[$k]:-<new>} != "$v" ]] && printf '%s: %s -> %s\n' "$k" "${before[$k]:-<new>}" "$v"
  done
  return 0
}

That it is just one more formatter is the payoff of having separated the two halves: comparing required touching not a single system query.

3. The loop walks /sys/class/thermal/thermal_zone*/temp with [[ -r $z ]] || continue to skip unreadable zones, and emits printf 'temp_%s=%s\n' "$n" "$(( $(<"$z") / 1000 ))". Adding [temp_0]=75 to THRESHOLD is enough for the coloring to work on its own.

Conclusion

system-info.sh is finished and meets the eight boxes of the contract in section 2. More important than the script is the design decision that structures it: separating collection from formatting, with a minimal internal format (key=value) as the boundary between the two. Thanks to it, three output formats coexist without a single duplicated query, adding a datum is one line, adding a format is one function, and comparing two runs —exercise 2— was solved without touching collection. The rest of the project has been applying what you learned: reliable sources in /proc and files instead of human-readable output (06-03), printf with widths (04-04), jq -n --arg (06-05), an allowlist via declare -F (08-03), timeout against hangs (05-02) and an interface with long options and early validation (03-05). And an operating rule that holds for the whole module: a missing datum is n/a, not an exception.

In 09-02 the input material stops being a handful of /proc files with one line each and becomes a million lines of access.log and app.log. We will build analyze-logs.sh: a single pass with awk, transparent reading of rotated and compressed files, filters by date and level, aggregations, a histogram drawn with printf, anomaly detection and a report in text and JSON —the most demanding project in the course when it comes to text processing.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved