Building Predictable DevOps Command-Line Tools
The final chapter lesson assembles the earlier pieces into one coherent command-line tool. Predictability is the goal: parse once, validate before mutation, keep data and diagnostics separate, build commands as arrays, and make dry-run behavior exact.
Learning objectives
By the end of this lesson
- Design CLI layers from argv to execution.
- Separate global options from subcommands.
- Build external commands with arrays.
- Implement clean logging and dry-run behavior.
- Create an end-to-end deployment CLI skeleton suitable for CI.
1. Separate parsing, validation, construction, and execution
flowchart TD
A["argv"] --> P["parse"]
P --> V["validate"]
V --> C["construct command vector"]
C --> D{"dry-run?"}
D -->|"yes"| R["render plan"]
D -->|"no"| E["execute"]
E --> S["return status"]Each layer has one job. Parsing should not deploy; validation should not mutate; command construction should preserve argument boundaries.
2. Use subcommands for distinct operations
# Example grammar:
# deployctl [-v] [-n] {plan|deploy|status} SERVICE [ENV]Subcommands keep unrelated operations from sharing one overloaded option namespace.
3. Parse global options once
verbose=false
dry_run=false
while getopts ':vnh' option; do
case $option in
v) verbose=true ;;
n) dry_run=true ;;
h) usage; exit 0 ;;
\?) exit 64 ;;
esac
done
shift "$((OPTIND - 1))"
subcommand=${1:-help}
shift || true4. Functions make layer boundaries testable
parse_options() { :; }
validate_environment() { :; }
build_command() { :; }
run_command() { :; }
cmd_plan() { :; }
cmd_deploy() { :; }
main() { :; }This structure lets tests call validation and rendering logic without performing the real deployment.
5. Build external commands as arrays
command=(
kubectl
-n "$namespace"
set image
"deployment/$service"
"$service=$image"
)
"${command[@]}"Do not concatenate one shell command string and feed it to eval. Arrays preserve exact arguments.
6. Render dry-run commands with printf %q
print_command() {
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
}
print_command "${command[@]}"%q is useful for display and debugging. It is not a reason to re-execute text through eval.
7. Keep logs on stderr and results on stdout
log() {
printf '[deployctl] %s\n' "$*" >&2
}
emit_result() {
printf '%s\n' "$1"
}This lets a CI step capture stdout without losing human diagnostics.
8. Validate only the dependencies a command needs
require_command() {
command -v "$1" >/dev/null 2>&1 || {
printf 'missing dependency: %s\n' "$1" >&2
return 69
}
}A help command should work even if deployment dependencies are missing.
9. Define configuration precedence
environment=${DEPLOY_ENV:-staging}
# A later explicit CLI operand/option may replace it.10. Preserve the executed command's status
run_command() {
if [[ $dry_run == true ]]; then
print_command "$@"
return 0
fi
"$@"
}
if run_command "${command[@]}"; then
log "operation succeeded"
else
status=$?
log "operation failed status=$status"
exit "$status"
fi11. Quoting does not replace authorization policy
if [[ ! $service =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]]; then
printf 'invalid service identifier\n' >&2
exit 65
fiQuoting prevents shell reinterpretation. Validation and authorization determine whether the requested operation itself is allowed.
12. Define a CLI test matrix
- help succeeds,
- missing operands fail,
- unknown options and subcommands fail,
- dry-run performs no mutation,
- invalid environment and numeric input fail,
- whitespace-containing operands remain intact,
- external command failure propagates.
Later chapters introduce Bats for automated shell testing.
13. Hands-on lab: build deployctl
mkdir -p "$HOME/devops-academy/bash/chapter06/lesson05"
cd "$HOME/devops-academy/bash/chapter06/lesson05"
cat > deployctl <<'EOF'
#!/usr/bin/env bash
usage() {
printf 'usage: %s [-v] [-n] {plan|deploy|status} SERVICE [ENV]\n' "$0"
}
log() {
[[ $verbose == true ]] || return 0
printf '[deployctl] %s\n' "$*" >&2
}
print_command() {
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
}
validate_environment() {
[[ $1 == dev || $1 == staging || $1 == prod ]]
}
run_command() {
if [[ $dry_run == true ]]; then
print_command "$@"
else
"$@"
fi
}
main() {
verbose=false
dry_run=false
local option
while getopts ':vnh' option; do
case $option in
v) verbose=true ;;
n) dry_run=true ;;
h) usage; return 0 ;;
\?) printf 'unknown option: -%s\n' "$OPTARG" >&2; return 64 ;;
esac
done
shift "$((OPTIND - 1))"
local subcommand=${1:-}
local service=${2:-}
local environment=${3:-${DEPLOY_ENV:-staging}}
[[ -n $subcommand && -n $service ]] || {
usage >&2
return 64
}
validate_environment "$environment" || {
printf 'invalid environment: %s\n' "$environment" >&2
return 65
}
local -a command
case $subcommand in
plan)
printf 'PLAN service=%s env=%s\n' "$service" "$environment"
;;
deploy)
command=(printf 'DEPLOY service=%s env=%s\n' "$service" "$environment")
log "prepared deploy command"
run_command "${command[@]}"
;;
status)
printf 'STATUS service=%s env=%s state=unknown-demo\n' "$service" "$environment"
;;
*)
printf 'unknown command: %s\n' "$subcommand" >&2
usage >&2
return 64
;;
esac
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi
EOF
chmod u+x deployctl
./deployctl plan api staging
./deployctl -vn deploy "api gateway" prod
DEPLOY_ENV=dev ./deployctl status worker
./deployctl unknown api || trueVerification checklist
14. Knowledge check
Question 1. Why separate parsing from execution?
Question 2. Why build commands as arrays?
Question 3. What must dry-run guarantee?
Question 4. What does quoting not replace?
15. Summary
A production-oriented Bash CLI parses once, validates before mutation, constructs commands as arrays, keeps diagnostics separate from result data, preserves exit status, and treats dry-run as a real safety guarantee.
16. Further reading
- GNU Bash Reference Manual — Arrays, Functions, getopts, and printf.
- POSIX Utility Syntax Guidelines.
- Command Line Interface Guidelines (clig.dev).
- ShellCheck and Bats-core documentation.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this address.