# Provisioning script safety — FreeBSD/Bastille worked patterns Deeper worked examples for the "Provisioning script safety" section of `SKILL.md`. Read this when the summary there isn't enough to actually write the script. **Provenance**: adapted from wshobson/agents' `bash-defensive-patterns` skill (https://github.com/wshobson/agents, `plugins/shell-scripting/skills/bash-defensive-patterns/{SKILL.md,references/details.md}`, MIT licensed, fetched 2026-08-15). That upstream skill is written for Linux/GNU and knows nothing about FreeBSD base tools, `/usr/local/bin/bash`, Bastille, rc.d, ZFS, or PF. Every pattern below was checked against FreeBSD 15.1-RELEASE behavior and either kept, rewritten, or explicitly dropped — see "What was deliberately left out" at the end. Nothing here was copied from the upstream examples unchanged. For shell-quoting-through-multiple-hops (`ssh` → `bastille cmd` → `su`), see the `remote-shell-quoting-safety` skill instead — not duplicated here. ## Shell choice: don't assume `#!/bin/bash` exists FreeBSD base's `/bin/sh` is a modified `ash` (POSIX-ish), not bash. Bash is **not** part of FreeBSD base — it's a package (`pkg install bash`) that installs to `/usr/local/bin/bash`, **never** `/bin/bash`. A `#!/bin/bash` shebang written for a Linux-oriented example fails immediately with "No such file or directory" on a bare FreeBSD host or a freshly cloned jail — many base jail images don't have bash installed at all. - If bash-only features are genuinely needed (arrays, `[[ ]]`, `mapfile`, process substitution `< <(...)`), use `#!/usr/bin/env bash` **and** verify bash is actually present on the target first — `bastille cmd which bash` or `pkg info bash` on the host. Don't assume. - For anything that runs early in provisioning (before you know what's on the target) or where plain POSIX does the job, prefer `#!/bin/sh` — it's guaranteed present on both the host and every jail, no package dependency. Drop arrays, `[[ ]]`, `mapfile`/`readarray`, and process substitution; use `case`, `$( )`, and POSIX `[ ]` test instead. ## Error-handling philosophy: contextual, not a blanket `set -Eeuo pipefail` The upstream checklist's rule #1 is "always use strict mode." Treating that as an absolute is wrong for provisioning scripts: - `set -e` (and `set -u`) are genuinely valuable for early validation — bad jail name, IP already taken, missing base release — where any failure should hard-stop before anything destructive happens. - Once a script is deep into a multi-step provisioning sequence, some steps are legitimately allowed to fail without aborting the whole run — e.g. `pkg install -y arp-scan` when it may already be installed, or restarting an optional service that doesn't exist on every base image yet. Blanket `-e` turns those into full-script aborts, which is exactly how you get the half-created-jail mess this skill warns about elsewhere. - Practical rule for this fleet: turn strict mode on for the whole script (`set -eu`; add `-o pipefail` only if it's genuinely running under bash), but explicitly wrap any step that's allowed to fail — `cmd || true` or `if ! cmd; then log_warn "..."; fi` — with a comment saying *why* it's non-fatal. Silent blanket suppression (`set +e` everywhere) is worse than blanket strict mode; the goal is a script where every deliberately-tolerated failure is visible in a diff, not one with no error handling at all. - `-o pipefail` and `-E` (ERR trap inherited into functions) are bash extensions, unavailable under POSIX `/bin/sh`. If the script is `/bin/sh`, restructure instead of relying on them — e.g. write a pipeline's output to a temp file and check that command's own exit status, rather than depending on an upstream stage's exit code surviving a pipe. ## Trap-based cleanup for interrupted provisioning A half-created jail is a real mess: `bastille create` partway through, a DB created but no grant, an nginx vhost written but not enabled, a pf table entry added but the jail never started. A script that dies partway (Ctrl-C, a dropped SSH session, a later step's genuine failure) should at minimum log exactly how far it got — automatically rolling back a jail creation from inside a trap is its own risk (see this skill's create/destroy-churn IP-alias gotcha; automating teardown under failure conditions can make the mess worse, not better). ```sh # POSIX-compatible: /bin/sh, no bash-only trap features needed PROVISION_LOG="/tmp/provision-${JAIL_NAME}.log" CREATED_JAIL="" CREATED_DB="" CURRENT_STEP="" cleanup() { status=$? if [ "$status" -ne 0 ]; then { echo "FAILED at step: ${CURRENT_STEP:-unknown} (exit $status)" echo "Partial state: jail=${CREATED_JAIL:-none} db=${CREATED_DB:-none}" echo "Manual cleanup needed before retrying with the same name/IP." } >> "$PROVISION_LOG" echo "Provisioning failed — see $PROVISION_LOG" >&2 fi } trap cleanup EXIT INT TERM CURRENT_STEP="bastille create" bastille create "$JAIL_NAME" 15.1-RELEASE "$JAIL_IP" vtnet0 CREATED_JAIL="$JAIL_NAME" CURRENT_STEP="db grant" # ... etc, set CURRENT_STEP before each major step ``` Set `CURRENT_STEP` before each major step so the log names the actual failed step, not just "something failed." ## Input validation before jail names/IPs reach a destructive command Bastille commands take a jail name and/or IP as a bare positional argument with no confirmation prompt — `bastille destroy ` is irreversible. Validate **before** the value is interpolated into any `bastille` command, especially if it ever originates from outside the script (a ticket description, a generated name, pasted text): ```sh validate_jail_name() { name="$1" case "$name" in ''|*[!a-zA-Z0-9_-]*) echo "ERROR: invalid jail name: '$name' (letters, digits, - and _ only)" >&2 return 1 ;; esac if bastille list all 2>/dev/null | grep -qx "$name"; then echo "NOTE: jail '$name' already exists" >&2 fi } validate_ipv4() { ip="$1" case "$ip" in *[!0-9.]*|'') echo "ERROR: not an IPv4 literal: '$ip'" >&2; return 1 ;; esac IFS=. read -r o1 o2 o3 o4 </dev/null || { echo "ERROR: octet out of range in '$ip'" >&2 return 1 } done } ``` This is intentionally plain `case`/`[` pattern matching, not a regex call — it works identically under `/bin/sh` and bash, and avoids needing `[[ =~ ]]` (bash-only) or GNU `grep -P`. Run `arp-scan` (see the main SKILL.md's IP-conflict section) only **after** `validate_ipv4` passes, not before. ## Safe temporary files: FreeBSD `mktemp` syntax FreeBSD's base `mktemp` is BSD mktemp, not GNU coreutils mktemp — long options like `--tmpdir=DIR` or `--suffix=` don't exist there. ```sh # Portable on FreeBSD and Linux: bare mktemp / mktemp -d, no custom template TMPFILE=$(mktemp) || { echo "ERROR: mktemp failed" >&2; exit 1; } TMPDIR=$(mktemp -d) || { echo "ERROR: mktemp -d failed" >&2; exit 1; } # A custom prefix/path needs an explicit trailing run of X's on BOTH # platforms — a prefix alone (no X's) is not enough on either: TMPFILE=$(mktemp "/tmp/provision-${JAIL_NAME}.XXXXXX") || exit 1 ``` Clean up with a trap (see above), and always quote and use `--`: `rm -rf -- "$TMPDIR"` — the `--` guards against a temp path that could otherwise be parsed as an option if it somehow started with `-`. ## `sed -i` and `date`: don't copy GNU-only invocations into jail.conf/pf.conf edits Two gotchas that bite specifically because provisioning steps edit `jail.conf` and `pf.conf` in place: - **`sed -i`**: FreeBSD/BSD sed requires an explicit (possibly empty) backup-suffix argument as its own token: `sed -i '' -e 's/old/new/' file`. GNU sed's `-i` only takes the suffix glued to the flag (`-i.bak` or bare `-i`) — passing `-i ''` as two separate arguments to GNU sed does **not** mean "no backup"; it's parsed differently and can silently do the wrong thing. These two forms are not cross-compatible. Use the BSD form on this fleet's FreeBSD hosts, don't copy a Linux-written example verbatim. - **`date`**: FreeBSD date has no GNU-style free-text parsing (`date -d "1 day ago"` doesn't work). Use `-v` adjustments (`date -v-1d +%F`) or `-j -f +` to parse a fixed format. A script that only timestamps a log line (`date +'%Y-%m-%d %H:%M:%S'`, fixed format, no relative math) is fine as-is on both platforms — the trap is only when a script tries to compute a relative date. ## Structured logging (portable as written) The upstream timestamped-log-level pattern is fine to reuse as-is — fixed-format `date`, no relative parsing, identical under FreeBSD and GNU date: ```sh log_info() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $*" >&2; } log_warn() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] WARN: $*" >&2; } log_error() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; } ``` ## What was deliberately left out - **Arrays, `mapfile`/`readarray`, `[[ ]]`, process substitution (`< <(...)`)** — all bash-only, and bash isn't guaranteed present on a freshly cloned jail. Only reach for them once you've confirmed the target will always run under a real installed bash; default to POSIX `/bin/sh` constructs for anything that has to run early or on an unknown target. - **The upstream `rm -rI` "safe cleanup" pattern** — `-I` prompts for interactive confirmation, which is the opposite of what an unattended provisioning script run over SSH (often with no TTY) needs; a script waiting on a prompt that can never be answered just hangs. Validate inputs hard (see above) instead, then remove non-interactively (`rm -rf --`). - **Retry/backoff and file-locking patterns** from the general upstream checklist — not reproduced here because the fetched upstream content (`SKILL.md` + `references/details.md`, both fetched directly from GitHub) doesn't actually contain a worked retry or locking pattern specific enough to adapt; those topics only appear as chapter-title-level mentions in secondhand catalog summaries, not in the real file. If a real retry-needing case shows up in provisioning (a flaky `pkg install` in a fresh jail before DNS is confirmed working — see this skill's resolver-failure-after-destroy/create-churn gotcha), write a fleet-specific pattern then, rather than importing a generic one now on spec. - **ShellCheck as a hard gate** — worth running locally before landing a new provisioning script (`pkg install shellcheck`; point it at a `/bin/sh` script with `# shellcheck shell=sh` since its default dialect guess is bash), but not made mandatory here since this fleet has no CI wired to enforce it on these scripts yet. - **The upstream "always use `[[ ]]`" advice as a blanket rule** — true only once bash is confirmed present; POSIX `[ ]` is required, not just "more portable," for any `/bin/sh` script.