Any task you do by hand three times is a script waiting to be written: rotating logs, calling an API, checking fifty servers, gluing two tools together in a pipeline. Bash is the glue that is always available; Python is what you move to when the logic gets real. This module teaches both to a production standard, which mostly means scripts that fail loudly, clean up after themselves, and can be run twice without damage.
- Write Bash scripts that stop on errors using
set -euo pipefail, quoting, and explicit exit codes - Use variables, conditionals, loops and functions in Bash without the classic quoting bugs
- Process JSON with
jqand call HTTP APIs from the shell - Decide when to switch from Bash to Python, and write a small Python CLI with
argparse,subprocessandpathlib - Make automation idempotent, so that re-running it is always safe
A Bash script that fails properly
By default Bash carries on after a command fails, which is exactly wrong for automation: a failed cd followed by rm -rf * deletes the wrong directory. Start every script with the same three lines.
#!/usr/bin/env bash
set -euo pipefail
# -e exit as soon as a command fails
# -u treat use of an unset variable as an error
# -o pipefail a pipeline fails if ANY command in it fails, not just the lastThe first line, the shebang, tells the kernel which interpreter to use. Make the file executable with chmod +x and run it as ./script.sh. Every command returns an exit code, 0 for success and non-zero for failure, and your script should too: CI systems, cron and && chains all decide what to do next from that number.
#!/usr/bin/env bash
set -euo pipefail
usage() { echo "usage: $0 ENVIRONMENT" >&2; exit 2; }
[[ $# -eq 1 ]] || usage
env_name="$1"
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT # runs on success, failure, or Ctrl-C
echo "Deploying to ${env_name}, scratch space ${workdir}"Two habits are visible there. Errors and usage messages go to stderr (>&2) so they do not pollute output that another program may be parsing. And trap ... EXIT registers cleanup that runs however the script ends, so temporary files never leak.
Variables, quoting, tests and loops
Assign with no spaces around =, read with $name or ${name}. The single most important Bash rule is: quote your variables. Unquoted, a value containing spaces is split into several words and any * in it is expanded against the filesystem.
file="monthly report.txt"
rm $file # WRONG: tries to delete 'monthly' and 'report.txt'
rm "$file" # right
today="$(date +%F)" # command substitution
region="${AWS_REGION:-eu-west-1}" # default if unset or empty
: "${API_TOKEN:?API_TOKEN must be set}" # abort with a message if missingUse [[ ... ]] for tests. if does not test a boolean; it runs a command and checks its exit code, which means any command can be a condition.
if [[ -f "$config" ]]; then echo "found"; fi # -f file, -d dir, -z empty string
if [[ "$env_name" == "prod" && -z "${FORCE:-}" ]]; then
echo "refusing to touch prod without FORCE=1" >&2
exit 1
fi
if ! grep -q "^server" "$config"; then echo "no server line" >&2; fi
for host in web1 web2 web3; do
ssh "$host" uptime || echo "failed: $host" >&2
done
while IFS= read -r line; do # read a file line by line, safely
echo "got: $line"
done < hosts.txtRun shellcheck script.sh on everything you write. It catches unquoted variables, useless cat, broken tests and dozens of other mistakes, and explains each one. Add it to CI next to your other linters.
JSON and APIs from the shell
Almost every tool you automate can emit JSON: cloud CLIs, kubectl -o json, docker inspect, REST APIs. Do not parse it with grep and cut. jq is a small language for slicing JSON.
curl -fsS https://api.example.com/v1/servers > servers.json
jq '.' servers.json # pretty-print
jq -r '.servers[].name' servers.json # every name, raw (no quotes)
jq -r '.servers[] | select(.status == "down") | .name' servers.json
jq '.servers | length' servers.json # count
jq -r '.servers[] | [.name, .ip] | @tsv' servers.json # table for the shellThe curl flags matter in scripts. Without -f, curl exits 0 even when the server returns 500, and your script marches on with an error page in a variable. -sS hides the progress bar but still prints errors.
response="$(curl -fsS --max-time 10 \
-H "Authorization: Bearer ${API_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"name": "web4", "size": "small"}' \
https://api.example.com/v1/servers)"
server_id="$(jq -r '.id' <<< "$response")"
echo "created ${server_id}"For YAML, the equivalent tool is yq. In Python, YAML needs the third-party PyYAML package; always use yaml.safe_load, never plain yaml.load on input you do not control.
When to switch to Python
Bash is excellent at running programs and connecting them. It is poor at data structures, arithmetic, error handling and testing. A useful rule: move to Python when the script passes about fifty lines, needs a list of dictionaries, has to retry or handle errors selectively, or will be maintained by someone else.
#!/usr/bin/env python3
"""Report certificates that expire soon."""
import argparse
import json
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("inventory", type=Path, help="JSON file of certificates")
parser.add_argument("--days", type=int, default=30)
args = parser.parse_args()
certs = json.loads(args.inventory.read_text())
expiring = [c for c in certs if c["days_left"] < args.days]
for cert in sorted(expiring, key=lambda c: c["days_left"]):
print(f"{cert['host']}: {cert['days_left']} days", file=sys.stderr)
return 1 if expiring else 0
if __name__ == "__main__":
sys.exit(main())Notice the same conventions as in Bash: a shebang, arguments parsed properly (you get --help for free), diagnostics on stderr, and a meaningful exit code returned through sys.exit. That exit code is what lets a pipeline or a monitoring check act on the result.
To run other programs, use subprocess.run with a list of arguments. Passing a list means no shell is involved, so a filename containing spaces or a ; cannot be misinterpreted. check=True raises an exception when the command fails, which is Python's equivalent of set -e.
import subprocess
result = subprocess.run(
["kubectl", "get", "pods", "-n", "shop", "-o", "json"],
capture_output=True, text=True, check=True, timeout=30,
)
print(len(result.stdout), "bytes of JSON")Avoid shell=True and os.system, especially with any value that came from a user, a file or an API. Building a command string from outside input is how command-injection vulnerabilities are written.
Calling APIs with retries
Networks fail briefly all the time, so a script that calls an API needs a timeout on every request and a bounded retry for failures that are worth retrying: connection errors and 5xx or 429 responses. A 4xx means your request is wrong, and retrying it will not help. The widely used requests library keeps this short.
import os
import time
import requests
TOKEN = os.environ["API_TOKEN"] # KeyError if missing: fail early
def get_json(url: str, attempts: int = 4) -> dict:
for attempt in range(attempts):
try:
resp = requests.get(
url, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=10
)
if resp.status_code < 500 and resp.status_code != 429:
resp.raise_for_status() # raises on 4xx: do not retry those
return resp.json()
except requests.ConnectionError:
pass
time.sleep(2 ** attempt) # 1s, 2s, 4s, 8s
raise RuntimeError(f"gave up on {url} after {attempts} attempts")Secrets come from the environment or a secret manager, never from the source file. Install third-party packages into a virtual environment (python3 -m venv .venv) and record them in requirements.txt, so the script runs the same on your laptop and in CI.
Idempotency: safe to run twice
Automation gets re-run: after a failure halfway, by a scheduler, by a colleague who was not sure it worked. An idempotent script produces the same end state however many times it runs. The technique is to describe the state you want and check before you act, instead of blindly performing an action.
| Fragile | Idempotent |
|---|---|
mkdir /srv/app | mkdir -p /srv/app |
echo "line" >> file | grep -qxF "line" file || echo "line" >> file |
ln -s target link | ln -sfn target link |
useradd app | id app >/dev/null 2>&1 || useradd app |
rm file | rm -f file |
This idea is the foundation of the tools later in this track. Ansible modules, Terraform resources and Kubernetes manifests are all declarations of desired state that can be applied repeatedly. Also add a --dry-run flag to anything destructive, so you can see what it would do before it does it.