#!/usr/bin/env bash # control.sh - interactive control panel for this repo. # # Steps you through the pre-setup and day-to-day operations for both the # OpenTofu infra (infra/) and the Ansible config (ansible/), in the order the # firewall rules require (vpn first, then tunnel in, then dev/prod). It only # ever runs `tofu`/`ansible-playbook` for you - it's a menu wrapper, nothing # more, so anything it does you can also do by hand from infra/ or ansible/. # Re-exec under bash if invoked as `sh control.sh` (or another POSIX shell) - # this script relies on bash-only features (indirect expansion, arrays, [[ ]]) # that fail with cryptic errors like "bad substitution" under dash/sh. if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@" fi set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INFRA_DIR="$REPO_ROOT/infra" ANSIBLE_DIR="$REPO_ROOT/ansible" CONTROL_ENV="$REPO_ROOT/.control.env" # --- pretty output -------------------------------------------------------- if [ -t 1 ]; then BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0) RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); CYAN=$(tput setaf 6) else BOLD=""; DIM=""; RESET=""; RED=""; GREEN=""; YELLOW=""; CYAN="" fi ok() { printf "%s ok %s %s\n" "$GREEN" "$RESET" "$1"; } warn() { printf "%s warn%s %s\n" "$YELLOW" "$RESET" "$1"; } bad() { printf "%s miss%s %s\n" "$RED" "$RESET" "$1"; } title() { printf "\n%s%s%s\n" "$BOLD$CYAN" "$1" "$RESET"; } # Pick the OpenTofu binary: prefer `tofu`, fall back to `terraform`. TOFU="" detect_tofu() { if command -v tofu >/dev/null 2>&1; then TOFU="tofu" elif command -v terraform >/dev/null 2>&1; then TOFU="terraform" fi } detect_tofu # --- variable persistence -------------------------------------------------- # Variables set via the "Set variables" menu (HCLOUD_TOKEN, BACKUP_S3_*, # DEPLOY_USER, ANSIBLE_SSH_PRIVATE_KEY_FILE) live in .control.env, gitignored, # so they survive between runs of this script. They are NOT exported into your # normal shell - only into control.sh's own process and whatever it runs. load_control_env() { if [ -f "$CONTROL_ENV" ]; then set -a # shellcheck disable=SC1090 source "$CONTROL_ENV" set +a fi } load_control_env # Upsert KEY=VALUE into .control.env and export it into this process. save_var() { local key="$1" val="$2" touch "$CONTROL_ENV"; chmod 600 "$CONTROL_ENV" local tmp; tmp="$(mktemp)" grep -v "^${key}=" "$CONTROL_ENV" > "$tmp" 2>/dev/null || true mv "$tmp" "$CONTROL_ENV" printf '%s=%q\n' "$key" "$val" >> "$CONTROL_ENV" export "${key?}=${val}" } # Run a command inside a directory, echoing it first. Returns the command's exit code. run_in() { local dir="$1"; shift printf "%s\$ (cd %s && %s)%s\n" "$DIM" "${dir#$REPO_ROOT/}" "$*" "$RESET" ( cd "$dir" && "$@" ) } pause() { read -rp $'\nPress Enter to return to the menu... ' _; } confirm() { local reply read -rp "$1 [y/N] " reply [[ "$reply" == "y" || "$reply" == "Y" ]] } # --- prerequisite checks -------------------------------------------------- check_prereqs() { title "Tools" if [ -n "$TOFU" ]; then ok "OpenTofu ($TOFU: $("$TOFU" version 2>/dev/null | head -n1))" else bad "Neither 'tofu' nor 'terraform' found on PATH"; fi if command -v ansible-playbook >/dev/null 2>&1; then ok "Ansible ($(ansible --version 2>/dev/null | head -n1))" else bad "'ansible-playbook' not found on PATH"; fi title "Environment variables" [ -n "${HCLOUD_TOKEN:-}" ] && ok "HCLOUD_TOKEN set" || bad "HCLOUD_TOKEN not set (needed by both tofu and the ansible inventory)" [ -n "${BACKUP_S3_BUCKET:-}" ] && ok "BACKUP_S3_BUCKET set" || warn "BACKUP_S3_BUCKET not set (needed for deploy/spinup - S3 backup .env)" [ -n "${BACKUP_S3_ACCESS_KEY_ID:-}" ] && ok "BACKUP_S3_ACCESS_KEY_ID set" || warn "BACKUP_S3_ACCESS_KEY_ID not set" [ -n "${BACKUP_S3_SECRET_ACCESS_KEY:-}" ] && ok "BACKUP_S3_SECRET_ACCESS_KEY set" || warn "BACKUP_S3_SECRET_ACCESS_KEY not set" [ -n "${BACKUP_S3_ENDPOINT:-}" ] && ok "BACKUP_S3_ENDPOINT set" || warn "BACKUP_S3_ENDPOINT not set (omit only for real AWS S3)" ok "DEPLOY_USER: ${DEPLOY_USER:-deploy} (default 'deploy' if unset)" local key="${ANSIBLE_SSH_PRIVATE_KEY_FILE:-$HOME/.ssh/id_ed25519}" [ -f "${key/#\~/$HOME}" ] && ok "SSH key present ($key)" || warn "SSH key not found at $key (set ANSIBLE_SSH_PRIVATE_KEY_FILE)" [ -f "$CONTROL_ENV" ] && ok ".control.env present (loaded above)" || warn "No .control.env yet - use Configuration > Set variables to persist the above across runs" title "Files" [ -f "$INFRA_DIR/terraform.tfvars" ] && ok "infra/terraform.tfvars exists" \ || bad "infra/terraform.tfvars missing (Configuration > Copy terraform.tfvars.example)" [ -d "$INFRA_DIR/.terraform" ] && ok "infra/.terraform exists (tofu init has run)" \ || warn "infra/.terraform missing - run OpenTofu > init" if ansible-galaxy collection list hetzner.hcloud >/dev/null 2>&1; then ok "hetzner.hcloud collection installed" else warn "hetzner.hcloud collection not installed - run Ansible > Install collections"; fi } # --- configuration actions ------------------------------------------------- # Prompts for one variable, keeping the current value if the user hits Enter. # $3 = true masks input and the shown current value, for tokens/secrets. prompt_var() { local key="$1" desc="$2" secret="${3:-false}" current input shown current="${!key:-}" shown="$current" [ "$secret" = "true" ] && [ -n "$current" ] && shown="(set, hidden)" if [ "$secret" = "true" ]; then read -rsp "$desc [$shown]: " input; echo else read -rp "$desc [$shown]: " input; fi [ -n "$input" ] && save_var "$key" "$input" } set_variables() { title "Set variables" echo "Leave a prompt blank to keep its current value. Saved to .control.env" \ "(gitignored) and reloaded automatically next time you run control.sh -" \ "not exported to your normal shell outside of it." echo prompt_var HCLOUD_TOKEN "Hetzner Cloud API token" true prompt_var DEPLOY_USER "Non-root deploy user Ansible creates (default: deploy)" false prompt_var ANSIBLE_SSH_PRIVATE_KEY_FILE "SSH private key path (default: ~/.ssh/id_ed25519)" false prompt_var BACKUP_S3_BUCKET "S3 backup bucket name" false prompt_var BACKUP_S3_ACCESS_KEY_ID "S3 backup access key ID" true prompt_var BACKUP_S3_SECRET_ACCESS_KEY "S3 backup secret access key" true prompt_var BACKUP_S3_ENDPOINT "S3 backup endpoint (blank for real AWS S3)" false ok "Variables saved." } copy_tfvars() { local example="$INFRA_DIR/terraform.tfvars.example" local target="$INFRA_DIR/terraform.tfvars" if [ -f "$target" ]; then warn "infra/terraform.tfvars already exists." confirm "Overwrite it with a fresh copy of the example?" || return 0 fi cp "$example" "$target" ok "Copied terraform.tfvars.example -> terraform.tfvars" local keys quoted k read -rp "SSH key name(s) from Hetzner Console > Security > SSH Keys, comma-separated (blank = edit manually later): " keys if [ -n "$keys" ]; then quoted="" IFS=',' read -ra _key_arr <<< "$keys" for k in "${_key_arr[@]}"; do k="$(echo "$k" | xargs)" quoted+="\"$k\", " done quoted="${quoted%, }" sed -i "s|^ssh_key_names = .*|ssh_key_names = [${quoted}]|" "$target" ok "Set ssh_key_names = [${quoted}]" else warn "ssh_key_names still has the example's placeholder values - edit infra/terraform.tfvars before running tofu." fi } # --- OpenTofu actions ----------------------------------------------------- require_tofu() { if [ -z "$TOFU" ]; then bad "No tofu/terraform binary found."; return 1; fi if [ ! -f "$INFRA_DIR/terraform.tfvars" ]; then bad "infra/terraform.tfvars is missing - copy terraform.tfvars.example first."; return 1 fi } tofu_init() { require_tofu && run_in "$INFRA_DIR" "$TOFU" init; } tofu_plan() { require_tofu && run_in "$INFRA_DIR" "$TOFU" plan; } tofu_apply() { require_tofu && run_in "$INFRA_DIR" "$TOFU" apply; } tofu_output() { require_tofu && run_in "$INFRA_DIR" "$TOFU" output; } tofu_destroy() { require_tofu || return 1 confirm "${RED}Destroy ALL infra managed by OpenTofu?${RESET}" && run_in "$INFRA_DIR" "$TOFU" destroy } # --- Ansible actions ------------------------------------------------------ ansible_collections() { run_in "$ANSIBLE_DIR" ansible-galaxy collection install -r requirements.yml; } ansible_inventory() { run_in "$ANSIBLE_DIR" ansible-inventory --graph; } # $1 = playbook, $2 = -l limit expression play() { run_in "$ANSIBLE_DIR" ansible-playbook "playbooks/$1" -l "$2"; } ansible_bootstrap_vpn() { play bootstrap.yml role_vpn; } ansible_deploy_vpn() { play deploy.yml role_vpn && play spinup.yml role_vpn; } ansible_bootstrap_dp() { play bootstrap.yml role_dev,role_prod; } ansible_deploy_dp() { play deploy.yml role_dev,role_prod && play spinup.yml role_dev,role_prod; } ansible_spindown() { local target read -rp "Spindown which hosts? (e.g. role_vpn, role_dev,role_prod, all): " target [ -n "$target" ] && play spindown.yml "$target" } # --- guided walkthrough --------------------------------------------------- guided() { title "Guided full setup" cat < plan -> apply (creates servers, network, firewalls, DNS) 2. Ansible: bootstrap + deploy + spinup for role_vpn 3. YOU: connect to the VPN with an OpenVPN client 4. Ansible: bootstrap + deploy + spinup for role_dev,role_prod EOF confirm "Start the guided walkthrough?" || return 0 echo; title "0/4 Configuration" if confirm "Set/update variables now (HCLOUD_TOKEN, DEPLOY_USER, BACKUP_S3_*, SSH key path)?"; then set_variables fi if [ ! -f "$INFRA_DIR/terraform.tfvars" ] && confirm "infra/terraform.tfvars is missing - create it from the example now?"; then copy_tfvars fi echo; title "1/4 OpenTofu" if confirm "Run $TOFU init/plan/apply now?"; then tofu_init && tofu_plan && tofu_apply || { bad "OpenTofu step failed - stopping."; return 1; } fi echo; title "2/4 Ansible - vpn" if confirm "Bootstrap + deploy + spinup role_vpn now?"; then ansible_bootstrap_vpn && ansible_deploy_vpn || { bad "vpn step failed - stopping."; return 1; } fi echo; title "3/4 Connect the VPN" warn "Connect to the vpn server with your OpenVPN client now, then continue." confirm "Are you connected to the VPN?" || { warn "Stopping - reconnect and re-run this step."; return 0; } echo; title "4/4 Ansible - dev + prod" if confirm "Bootstrap + deploy + spinup role_dev,role_prod now?"; then ansible_bootstrap_dp && ansible_deploy_dp || { bad "dev/prod step failed."; return 1; } fi ok "Guided setup complete." } # --- menu ----------------------------------------------------------------- menu() { clear printf "%s==== server control panel ====%s\n" "$BOLD$CYAN" "$RESET" printf "%srepo: %s tofu: %s%s\n" "$DIM" "$REPO_ROOT" "${TOFU:-none}" "$RESET" title "Setup" echo " 1) Check prerequisites" echo " 2) Guided full setup (vpn -> connect -> dev/prod)" title "Configuration" echo " 3) Set variables (HCLOUD_TOKEN, DEPLOY_USER, BACKUP_S3_*, SSH key path)" echo " 4) Copy terraform.tfvars.example -> terraform.tfvars" title "OpenTofu (infra/)" echo " 5) init 6) plan 7) apply" echo " 8) output 9) destroy" title "Ansible (ansible/)" echo " 10) Install collections 11) Show inventory" echo " 12) Bootstrap vpn 13) Deploy + spinup vpn" echo " 14) Bootstrap dev+prod 15) Deploy + spinup dev+prod" echo " 16) Spindown (choose target)" echo echo " 0) Quit" echo read -rp "Select an option: " choice } while true; do menu case "$choice" in 1) check_prereqs; pause ;; 2) guided; pause ;; 3) set_variables; pause ;; 4) copy_tfvars; pause ;; 5) tofu_init; pause ;; 6) tofu_plan; pause ;; 7) tofu_apply; pause ;; 8) tofu_output; pause ;; 9) tofu_destroy; pause ;; 10) ansible_collections; pause ;; 11) ansible_inventory; pause ;; 12) ansible_bootstrap_vpn; pause ;; 13) ansible_deploy_vpn; pause ;; 14) ansible_bootstrap_dp; pause ;; 15) ansible_deploy_dp; pause ;; 16) ansible_spindown; pause ;; 0) echo "Bye."; exit 0 ;; "") ;; *) warn "Unknown option: $choice"; pause ;; esac done