We reach the module's milestone, and a promise we have been carrying since 04-02. daily-report.sh is today a robust program: it validates its environment, protects itself with flock, aborts with a diagnosis, validates text with regex and directs its output precisely. But it is also a single file of several hundred lines where log_info, die, validate_env and percentage coexist —functions that have nothing to do with reports and that any other script in the toolkit would need—. The temptation, when you write archive-history.sh next week, will be to copy and paste. And that is where the decay begins: two copies that diverge, a bug fixed in one and not in the other, three different logging conventions. In this lesson you turn that file into a project with an architecture.
Contents
sourceversus executing- What a library is in Bash
- Locating the library without paths betraying you
- Include guards
- Names: what is public and what is private
- Configuration files
- Precedence order
- The toolkit's final structure
lib/common.shdaily-report.shas an orchestrator- Testing the library by hand
source versus executing
source versus executingIn 03-01 you saw the ways to run a script. The one that matters now is the one that does not create a process: as opposed to ./script.sh or bash script.sh, which launch a new process from which nothing comes back, source script.sh (or its POSIX form . script.sh) runs the file in this shell.
| Executing | source |
|
|---|---|---|
| Process | A new one (fork + exec) | The current one |
| Variables and functions it defines | Die with the child | Stay available |
Any cd it does |
Does not affect you | Changes your directory |
Needs +x permission and a shebang |
Yes | No |
exit inside it |
Ends the script | Ends your shell |
The last two rows explain why a library is not a normal script. Since it is loaded with source, it needs neither an executable shebang nor execute permission. And above all: an exit in a library kills whoever loads it, including your interactive session if you were testing it; inside a library you use return. This is nothing new: it is the same mechanism by which ~/.bashrc defines functions your shell knows about (01-02). A library is a .bashrc for your scripts.
- What a library is in Bash
A library is a file containing definitions only: functions, constants and, at most, default values. Nothing that acts on its own.
# lib/common.sh — CORRECT: it only defines
log_info() { printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2; }
# lib/bad.sh — INCORRECT: all of this ACTS when loaded
log_info "loading library" # pollutes the output of whoever uses it
set -euo pipefail # IMPOSES its policy on whoever loads it
cd /srv/veloz # changes the directory of whoever loads itThe rule is that loading the library must be silent and without side effects. The set -euo pipefail inside a library is especially treacherous: when you source it, it applies to the shell that loads it, so it can change the error behavior of a script that was not expecting it. The strict policy is decided by the executable, not by the library. Conventions: .sh extension (or .bash), no executable shebang —optionally a #!/usr/bin/env bash as a hint for editors and for shellcheck, but without +x permission— and a comment header saying what it offers.
- Locating the library without paths betraying you
Here is the real technical problem. How does bin/daily-report.sh find lib/common.sh?
source lib/common.sh # WRONG: relative to the CURRENT directory, not the script
source ~/veloz-ops/lib/common.sh # Works, but only for you and in your $HOMERelative paths are resolved from the current working directory, which is wherever the person launching the script happens to be, not where the script lives. Since ~/veloz-ops/bin is on the PATH (we put it there in 01-02), the normal thing is to invoke it from anywhere, and the source fails. The canonical idiom is this:
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/common.sh"Taken apart from the inside out:
${BASH_SOURCE[0]}is the path of the file being executed. It is used instead of$0for two reasons:$0isbashwhen the script is invoked asbash script.sh, and inside a file loaded withsource,$0is still the main script's whileBASH_SOURCE[0]is the library. For a library that needs to know where it is,$0always gives the wrong answer.dirnamegives the directory containing it (04-04) and/..climbs to the project root;cd ... && pwdturns that path, which may be relative (./bin/..), into an absolute, normalized one; and it is all quoted, in case the path contains a space.
With that, the script works when invoked as ./bin/daily-report.sh, as ~/veloz-ops/bin/daily-report.sh, from the PATH or from cron with any working directory. And if you also want to follow symbolic links down to the real file, readlink -f (05-01) over ${BASH_SOURCE[0]} before the dirname resolves it.
- Include guards
If daily-report.sh loads common.sh and also report.sh, which in turn loads common.sh, the file is processed twice. Redefining functions is harmless, but a duplicated readonly causes a fatal error under strict mode (VELOZ_VERSION: readonly variable). The solution is the same idea as C's include guards:
[[ -n "${_COMMON_SH:-}" ]] && return 0 # already loaded: leave without doing anything
readonly _COMMON_SH=1A top-level return 0 in a file loaded with source is legal and means "stop reading this file". The :- is mandatory because the script loading it has set -u (05-03) and the variable does not yet exist the first time around. The leading underscore marks the variable as internal.
- Names: what is public and what is private
Bash has no namespaces: everything lives in a single global scope. If your library defines log() and your colleague's does too, the last one loaded silently wins. The remedy is a prefix:
veloz::log_info() { ... } # double-colon style
veloz_log_info() { ... } # underscore style, more portable
_veloz_normalize() { ... } # leading underscore: INTERNAL use, do not call itBash allows :: in function names and it reads very nicely, but it is not POSIX and some old tools choke on it; veloz_ is the safe option. Pick one and be consistent. The public/private distinction is by convention, the interpreter does not enforce it: the leading underscore is a promise that "this may change without warning, do not depend on it". What you really can control is variable scope: every variable inside a function goes with local (04-02), and that way it does not pollute the script using it.
- Configuration files
~/veloz-ops/etc/veloz-ops.conf has existed since the start of the course with permissions 600. Its simplest form is a file of assignments (VELOZ_CSV=/srv/veloz/data/shipments.csv, VELOZ_LOG_LEVEL=info, VELOZ_CITIES="Valencia Sevilla Bilbao Madrid") loaded with [[ -r "$BASE_DIR/etc/veloz-ops.conf" ]] && source "$BASE_DIR/etc/veloz-ops.conf". It is convenient —it accepts comments, quotes, even $(...)— and it carries a risk you must be aware of: source executes arbitrary code. A line rm -rf ~ in that file runs with your permissions. That is why the 600 is not decorative: if someone can write to your configuration file, they can run whatever they want as you. The full implications are covered in 08-03.
The safe alternative is to parse key=value without executing anything, using what you learned in 05-04:
# _veloz_load_conf — Loads key=value without executing code. Usage: ... <file>
_veloz_load_conf() {
local file="${1:?}" key value line
[[ -r "$file" ]] || return 0
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue
key="${BASH_REMATCH[1]}"; [[ "$key" == VELOZ_* ]] || continue # only ours
value="${BASH_REMATCH[2]}"; value="${value%\"}"; value="${value#\"}"
printf -v "$key" '%s' "$value" # indirect assignment
done < "$file"
}Three modules meet here: the regex with BASH_REMATCH from 05-04 (which additionally discards comments and empty lines by not matching), the ${v%\"} expansions from 04-04 and the while read loop from 04-01. The VELOZ_* filter stops the file from redefining PATH or HOME, and printf -v assigns to the variable whose name is in $key: the safe way of doing indirect assignment without eval.
- Precedence order
Here we close the option design started in 03-05. When the same value can come from four places, you need an explicit and documented order, from lowest to highest priority:
| Level | Source | Example | Beats |
|---|---|---|---|
| 1 | Default value in the code | VELOZ_CSV=/srv/veloz/data/shipments.csv |
Nothing |
| 2 | Configuration file | etc/veloz-ops.conf |
The default |
| 3 | Environment variable | VELOZ_CSV=/tmp/x.csv daily-report.sh |
The previous ones |
| 4 | Command-line option | --csv /tmp/x.csv |
All of them |
The principle: the closer to the user and to the moment of execution, the higher the priority. Implementing it is simple if you respect the loading order, and : "${VAR:=value}" is the idiom for "assign only if it is empty or undefined" —the := assigns, unlike the :- which only substitutes, and the leading : is the null command— so that an already-defined environment variable survives:
: "${VELOZ_CSV:=/srv/veloz/data/shipments.csv}" # 1. default, only if it does not exist
_veloz_load_conf "$BASE_DIR/etc/veloz-ops.conf" # 2. config (respects what is already set)
# 3. the environment was already set before the script started
while [[ $# -gt 0 ]]; do case "$1" in # 4. options (03-05)
--csv) VELOZ_CSV="$2"; shift 2 ;; *) break ;;
esac; done
- The toolkit's final structure
~/veloz-ops/ ├── bin/ # executables (+x, with shebang). They are on the PATH │ ├── daily-report.sh │ └── archive-history.sh ├── lib/ # libraries (no +x, no shebang, definitions only) │ ├── common.sh # logging, errors, validations, utilities │ └── report.sh # report-specific logic ├── etc/veloz-ops.conf # configuration (permissions 600) └── logs/ # output: reports, traces, lock files
The allocation rule in one sentence: bin/ holds what is invoked, lib/ what is reused, etc/ what changes between machines and logs/ what is generated. It is the same FHS logic you saw in 01-03, applied at project scale, and that is why it feels familiar to anyone opening the repository. A practical criterion for deciding where a function goes: if a script that had nothing to do with reports would need it, it goes in common.sh; if it only makes sense when talking about shipments and cities, it goes in report.sh.
lib/common.sh
lib/common.sh#!/usr/bin/env bash
# common.sh — Shared utilities for the Veloz Envíos toolkit.
# Load with: source "$BASE_DIR/lib/common.sh"
[[ -n "${_COMMON_SH:-}" ]] && return 0
readonly _COMMON_SH=1 VELOZ_VERSION='1.0'
readonly _VELOZ_RE_DATE='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
# veloz_log_info — Informational message to stderr. Usage: veloz_log_info <text...>
veloz_log_info() {
[[ "${VELOZ_LOG_LEVEL:-info}" == quiet ]] && return 0
printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2
}
# veloz_log_error — Error message to stderr. Usage: veloz_log_error <text...>
veloz_log_error() { printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2; }
# veloz_die — Fatal error and exit. Usage: veloz_die <code> <text...>
veloz_die() { local c="${1:?}"; shift; veloz_log_error "$*"; exit "$c"; }
# veloz_validate_date — Is it YYYY-MM-DD and does it exist? Usage: veloz_validate_date <date>
veloz_validate_date() { [[ "${1:-}" =~ $_VELOZ_RE_DATE ]] && date -d "$1" &>/dev/null; }
# veloz_percentage — Computes a/b*100 with 2 decimals. Usage: veloz_percentage <a> <b>
veloz_percentage() {
(( ${2:?} == 0 )) && { printf '0.00\n'; return 0; }
bc -l <<< "scale=2; ${1:?} * 100 / $2"
}
# veloz_validate_env — Checks dependencies and paths. Usage: veloz_validate_env
veloz_validate_env() {
local cmd
for cmd in bc date find flock; do
command -v "$cmd" > /dev/null || veloz_die 69 "Missing utility: $cmd"
done
[[ -r "${VELOZ_CSV:?not defined}" ]] || veloz_die 66 "Cannot read $VELOZ_CSV"
mkdir -p "${VELOZ_LOG_DIR:?}" || veloz_die 73 "Cannot create $VELOZ_LOG_DIR"
}Notice what is not there: no set -euo pipefail, no executable shebang, not a single line that acts when loaded beyond the guard and the constants. veloz_die is the only one that calls exit, and that is correct because it is designed to be used from an executable, not from an interactive session.
daily-report.sh as an orchestrator
daily-report.sh as an orchestrator#!/usr/bin/env bash
# daily-report.sh — Veloz Envíos daily delivery report.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; readonly BASE_DIR
source "$BASE_DIR/lib/common.sh"
source "$BASE_DIR/lib/report.sh"
: "${VELOZ_CSV:=/srv/veloz/data/shipments.csv}"
: "${VELOZ_LOG_DIR:=$BASE_DIR/logs}"
_veloz_load_conf "$BASE_DIR/etc/veloz-ops.conf"
main() {
local report_date subcommand; report_date=$(date +%F)
while [[ $# -gt 0 ]]; do case "$1" in # the option loop from 03-05
--date) veloz_validate_date "${2:-}" || veloz_die 64 "Invalid date: ${2:-}"
report_date="$2"; shift 2 ;;
--debug) enable_debug; shift ;;
-h|--help) usage; exit 0 ;;
*) break ;;
esac; done
subcommand="${1:-summary}"
exec 9> "$VELOZ_LOG_DIR/.report.lock"
flock -n 9 || veloz_die 75 "A report is already running"
WORKDIR=$(mktemp -d); readonly WORKDIR
trap 'rm -rf "$WORKDIR"' EXIT INT TERM
veloz_validate_env
accumulate_day "$report_date"
case "$subcommand" in
summary) generate_report "$report_date" "$VELOZ_LOG_DIR/report-$report_date.txt" ;;
detail) detail_table "$report_date" ;;
cities) cities_table ;;
*) veloz_die 64 "Unknown subcommand: $subcommand" ;;
esac
}
main "$@"This is the result of five modules. The executable barely implements anything anymore: it locates its base, loads its libraries, resolves the configuration, processes options, protects itself with flock and a trap, and dispatches. All the reusable logic lives in lib/. When you write archive-history.sh, the first three lines will be identical and you will get logging, errors and validations for free.
- Testing the library by hand
A well-made library can be loaded in an interactive session, and that is what makes it verifiable:
$ source ~/veloz-ops/lib/common.sh
$ veloz_percentage 11 128 # 8.59
$ veloz_validate_date 2026-02-31 && echo ok || echo bad # bad
$ declare -F | grep veloz # every function defined
$ type veloz_percentage # see a function's codedeclare -F lists the names of the defined functions and type shows the body (01-04). If any output appears when you source it, there is top-level code that should not be there. For real automated tests —assertions, edge cases, a results report— the tool is bats, in 08-06. And shellcheck -x follows the source calls so it analyzes the libraries too, in 08-05.
Common Mistakes and Tips
source lib/common.shwith a relative path. It works in your directory and fails fromcronor from thePATH.- Using
$0to locate the script. It givesbashwithbash script.shand the wrong file inside a library. Use${BASH_SOURCE[0]}. exitinside a library loaded into your shell. It closes your terminal. Usereturn.- Top-level code in the library, and especially a
set -euo pipefail: it pollutes the output and imposes its policy on the script loading it, perhaps without it expecting so. readonlywithout an include guard, which gives a fatal error when loaded twice, and functions withoutlocal, which clobber variables of the script calling them weeks later.- Tip: document every function with a line
# name — what it does. Usage: name <args>right above it. It is what you will read six months from now, and it is what lets you generate the toolkit's help with a simplegrep '^# [a-z]' lib/*.sh.
Exercises
Exercise 1. Write the complete header for archive-history.sh (in bin/) that locates BASE_DIR robustly, loads lib/common.sh, applies strict mode and fails with a clear message if the library is not where it should be.
Exercise 2. Add to common.sh a function veloz_config that returns the value of a configuration key respecting the precedence order (default < file < environment), with an include guard and without using eval.
Exercise 3. Split the toolkit: decide for each of these functions whether it goes in common.sh, in report.sh or in the executable itself, and justify it: veloz_log_info, cities_table, veloz_percentage, accumulate_day, usage, veloz_validate_date.
Solutions
Solution 1.
#!/usr/bin/env bash
# archive-history.sh — Archives the Veloz Envíos monthly history.
set -Eeuo pipefail
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 78
readonly BASE_DIR LIB="$BASE_DIR/lib/common.sh"
[[ -r "$LIB" ]] || { printf 'Missing library %s\n' "$LIB" >&2; exit 78; }
source "$LIB"The check before the source matters: without it, the message would be source: lib/common.sh: No such file or directory, which does not say where it looked. Code 78 (EX_CONFIG, from the table in 05-03) is the right one: the installation is wrong, not the data. And the message uses a direct printf because veloz_log_error does not exist yet.
Solution 2.
# veloz_config — Returns the value of a key. Usage: veloz_config <KEY> [default]
veloz_config() {
local key="${1:?}" default="${2:-}" value l
value="${!key:-}" # 1) is it in the environment or already loaded?
if [[ -z "$value" && -r "${VELOZ_CONF:-}" ]]; then
while IFS= read -r l; do # 2) look in the file
[[ "$l" =~ ^[[:space:]]*"$key"=\"?([^\"]*)\"?$ ]] || continue
value="${BASH_REMATCH[1]}"; break
done < "$VELOZ_CONF"
fi
printf '%s\n' "${value:-$default}" # 3) default as the last resort
}${!key} is indirect expansion: it obtains the value of the variable whose name is in $key. It is the read-side equivalent of the printf -v from section 6, and both avoid eval, which would execute the contents of the configuration file —exactly the risk we are running from—. The order of the three branches is the precedence table.
Solution 3.
| Function | Destination | Reason |
|---|---|---|
veloz_log_info, veloz_percentage, veloz_validate_date |
lib/common.sh |
Generic logging, arithmetic and validation: they know nothing about shipments |
accumulate_day, cities_table |
lib/report.sh |
They know the CSV format, the shipment statuses and how to present them |
usage |
The executable | Every script has its own options; it is not reusable |
The criterion is a single question: would a script that does not talk about shipments need it? If the answer is yes, it goes in common.sh. usage is the interesting case: even though every script has one, its content is different in each, so what is shareable is not the function but the convention that it exists.
Conclusion
source runs a file in the current shell, and everything else follows from that: the functions and variables it defines stay available, no shebang or execute permission is needed, and an exit would kill whoever loads it —inside a library you use return—. A library contains definitions only, with no code that acts, no set -euo pipefail imposing someone else's policies and no messages when loaded. It is located with BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)", never with $0 nor with relative paths, because a script gets invoked from any directory and from the PATH. A guard [[ -n ${_COMMON_SH:-} ]] && return 0 prevents double loading and the duplicated-readonly error, and a veloz_ prefix stands in for the namespaces Bash does not have, with the leading underscore marking what is private. The configuration lives in etc/veloz-ops.conf with permissions 600 —because loading it with source executes whatever it contains— or is parsed as key=value with BASH_REMATCH and printf -v, and it resolves its conflicts with an explicit precedence: default < file < environment < command line. The result is ~/veloz-ops/ with bin/, lib/, etc/ and logs/, and a daily-report.sh that no longer implements: it orchestrates.
With this Module 5 closes, and with it the part of the course devoted to what Bash can do on its own. The Veloz Envíos toolkit is today robust, safe with its files and processes, able to diagnose its own failures, precise when validating text, master of its input and output, and modular. But it is still clumsy in one particular area: every time it has to do column arithmetic over the CSV, it chains cut, sort, uniq and loops that read the file several times; every time text has to be rewritten, the tools are missing; and when veloz-api stops being a process you check with pgrep and becomes an API you have to query, Bash alone does not reach.
In Module 6 in come the external tools that multiply what Bash can do: awk, which processes columns and aggregates in a single pass what today costs you twenty lines (06-01); sed, for transforming text in a stream with the regexes you already master (06-02); the commands for interrogating the system —disk, memory, users, hardware— (06-03); the networking tools and the /dev/tcp we left noted (06-04); and curl with jq to talk to veloz-api and handle real JSON, which is where your toolkit will stop reading files and start integrating with the rest of the system (06-05).
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
