Merge in a checklist-style "provisioning script safety" section, adapted from wshobson/agents' bash-defensive-patterns skill (fetched directly from GitHub, MIT licensed) rather than trusted from its catalog summary. Covers: shell-choice caution (#!/bin/bash isn't guaranteed on FreeBSD; bash is a package at /usr/local/bin/bash, not base), a contextual error-handling philosophy instead of a blanket `set -Eeuo pipefail`, trap-based cleanup/logging for scripts interrupted mid-jail-creation (without auto-rollback, which can worsen the known destroy/create IP churn issue), jail-name/IP input validation before destructive `bastille` commands, and FreeBSD `mktemp`/`sed -i`/`date` syntax differences from GNU. Deliberately dropped the upstream's interactive `rm -rI` cleanup pattern (wrong for unattended SSH automation) and did not import retry/locking/ShellCheck-gate advice that wasn't actually present in the real upstream files. Cross-references remote-shell-quoting-safety instead of duplicating its nested-shell-quoting content. Also documents the /usr/local/bastille vs /www/bastille bastille_prefix split already present across this fleet's hosts (gringo/staging vs granja), which the new script-safety guidance assumes readers already know not to hardcode. Full worked patterns and code examples live in the new references/script-safety.md; SKILL.md keeps the load-bearing summary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
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 bashand verify bash is actually present on the target first —bastille cmd <jail> which bashorpkg info bashon 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; usecase,$( ), 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(andset -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-scanwhen it may already be installed, or restarting an optional service that doesn't exist on every base image yet. Blanket-eturns 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 pipefailonly if it's genuinely running under bash), but explicitly wrap any step that's allowed to fail —cmd || trueorif ! cmd; then log_warn "..."; fi— with a comment saying why it's non-fatal. Silent blanket suppression (set +eeverywhere) 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 pipefailand-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).
# 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 <name> 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):
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 <<EOF
$ip
EOF
for o in "$o1" "$o2" "$o3" "$o4"; do
[ -n "$o" ] && [ "$o" -le 255 ] 2>/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.
# 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-ionly takes the suffix glued to the flag (-i.bakor 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-vadjustments (date -v-1d +%F) or-j -f <informat> <string> +<outformat>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:
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/shconstructs for anything that has to run early or on an unknown target. - The upstream
rm -rI"safe cleanup" pattern —-Iprompts 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 flakypkg installin 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/shscript with# shellcheck shell=shsince 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/shscript.