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
- The problem: three needs, one single script
- Requirements written as a checkable list
- Design: collecting and formatting are two different things
- Version 1: the smallest thing that works
- The reliable data sources
- Version 2: one function per area returning key=value pairs
- The three formatters
- Thresholds and colors
- The interface: options and help
- Robustness: what to do when the data is not there
- Putting it to work: inventorying the fleet
- 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.
- 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
rootfor 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_COLORis 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.
- 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_jsonThe 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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: nocollect_*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. Alwaysjq -n --arg. - Comparing decimals with
[[ ]].[[ 1.5 -gt 1 ]]does not give a syntax error, it gives a runtime error, and withset -ethat kills the script. Decimals go throughawkorbc. - Forgetting
-PindforLC_ALL=Cfor decimals. Without-P, a device with a long name splits the line in two and theawkreads the wrong column. - Coloring without checking
-t 1. The day somebody runssystem-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 aforlaunching one process per datum; group it into a singleawk.
Exercises
- Packages section. Add
collect_packages, reportingpackages_installedandpackages_upgradable(useapt list --upgradableon Ubuntu), andneeds_rebootaccording to whether/var/run/reboot-requiredexists. It must degrade ton/aon a system withoutapt. - Compare two runs. Write the
--compare FILEsubcommand: save one run inkey=valueform and, when invoked, show only the keys whose value has changed with respect to the file, in the formatkey: before -> now. - Temperature section. Add
collect_temperaturereading/sys/class/thermal/thermal_zone*/temp(thousandths of a degree) with globbing (02-05) and$(<file)instead ofcat(08-02), and mark in red anything above 75 °C by adding its key to theTHRESHOLDtable, without touchingcolor_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
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
