Chapter 06Lesson 05~85 minutes

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.

BeginnerCLI designHands-on lab

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

CLI execution layers
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 || true

4. 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[@]}"
No command strings

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

SourceRolePrecedence
Built-in defaultLowest precedenceConvenient baseline
Environment variableAutomation configurationUseful in CI
Explicit CLI valueHighest precedenceDirect user intent
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"
fi

11. 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
fi
Two defenses

Quoting 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 || true

Verification 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.
Next lesson

Indexed Arrays

Chapter 7 will move into indexed and associative arrays for structured shell state.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.