diff --git a/.devcontainer b/.devcontainer index fc4ccde..7a54748 160000 --- a/.devcontainer +++ b/.devcontainer @@ -1 +1 @@ -Subproject commit fc4ccde0acf64b80e074bcc07a69cc653e60b4d9 +Subproject commit 7a54748c0e189afb715d4409b24e7e6122e44dab diff --git a/.gitignore b/.gitignore index 6936989..c2bd0a0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ # services/docker-compose.yml carve-out above. **/.env +# Variables set through control.sh's "Set variables" menu (HCLOUD_TOKEN, +# BACKUP_S3_*, etc.) - never committed. +.control.env + ### Ansible ### *.retry diff --git a/ansible/README.md b/ansible/README.md index 289a2be..f0ff35a 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -37,7 +37,13 @@ export BACKUP_S3_SECRET_ACCESS_KEY=your-secret-access-key export BACKUP_S3_ENDPOINT=your-s3-endpoint # omit for real AWS S3 ``` -`ansible_ssh_private_key_file` (see `group_vars/all.yml`) defaults to +`../control.sh`'s **Set variables** menu does the same thing interactively and +persists your answers to `../.control.env` (gitignored) so you don't have to +re-export them every session. + +`deploy_user` (see `group_vars/all.yml`) defaults to `deploy` - override with +the `DEPLOY_USER` env var if you want a different non-root user created on each +host. `ansible_ssh_private_key_file` (same file) defaults to `~/.ssh/id_ed25519` - override with `ANSIBLE_SSH_PRIVATE_KEY_FILE` if you use a different key. It must match one of the SSH keys OpenTofu installed on the servers (`var.ssh_key_names`). diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index d3308cf..ee4289f 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -1,8 +1,9 @@ --- -# Keep in sync with infra/variables.tf's var.deploy_user (default "deploy") - -# the two aren't wired together automatically, since Ansible no longer reads -# any Terraform state or output. -deploy_user: deploy +# Non-root sudo user the bootstrap role creates on each host - purely an +# Ansible-side concept, OpenTofu has no equivalent variable. Override with the +# DEPLOY_USER env var (control.sh's "Set variables" menu writes it) if you +# don't want the default. +deploy_user: "{{ lookup('env', 'DEPLOY_USER') | default('deploy', true) }}" ansible_user: "{{ deploy_user }}" ansible_ssh_private_key_file: "{{ lookup('env', 'ANSIBLE_SSH_PRIVATE_KEY_FILE') | default('~/.ssh/id_ed25519', true) }}" diff --git a/control.sh b/control.sh index 50cb4f6..aaf05d4 100755 --- a/control.sh +++ b/control.sh @@ -7,11 +7,19 @@ # 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 @@ -35,6 +43,32 @@ detect_tofu() { } 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 @@ -64,19 +98,78 @@ check_prereqs() { [ -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 (copy terraform.tfvars.example and fill in ssh_key_names)" + || 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 @@ -120,6 +213,7 @@ accept SSH from the vpn server's public IP, so vpn must exist and you must be connected to it (OpenVPN) before dev/prod are reachable. Steps, in order: + 0. Configuration: variables + terraform.tfvars (skip if already done) 1. OpenTofu: init -> 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 @@ -127,6 +221,14 @@ Steps, in order: 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; } @@ -158,15 +260,19 @@ menu() { 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 " 3) init 4) plan 5) apply" - echo " 6) output 7) destroy" + echo " 5) init 6) plan 7) apply" + echo " 8) output 9) destroy" title "Ansible (ansible/)" - echo " 8) Install collections 9) Show inventory" - echo " 10) Bootstrap vpn 11) Deploy + spinup vpn" - echo " 12) Bootstrap dev+prod 13) Deploy + spinup dev+prod" - echo " 14) Spindown (choose target)" + 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" @@ -179,18 +285,20 @@ while true; do case "$choice" in 1) check_prereqs; pause ;; 2) guided; pause ;; - 3) tofu_init; pause ;; - 4) tofu_plan; pause ;; - 5) tofu_apply; pause ;; - 6) tofu_output; pause ;; - 7) tofu_destroy; pause ;; - 8) ansible_collections; pause ;; - 9) ansible_inventory; pause ;; - 10) ansible_bootstrap_vpn; pause ;; - 11) ansible_deploy_vpn; pause ;; - 12) ansible_bootstrap_dp; pause ;; - 13) ansible_deploy_dp; pause ;; - 14) ansible_spindown; 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 ;; diff --git a/readme.md b/readme.md index 483b19e..d2bb5a7 100644 --- a/readme.md +++ b/readme.md @@ -124,7 +124,12 @@ If you'd rather not remember the exact command order, [`control.sh`](control.sh) ./control.sh ``` -It offers a **Check prerequisites** option (tools, `HCLOUD_TOKEN`/`BACKUP_S3_*` env vars, `terraform.tfvars`, SSH key, the `hetzner.hcloud` collection), individual OpenTofu (`init`/`plan`/`apply`/`output`/`destroy`) and Ansible (bootstrap/deploy/spinup/spindown per role) actions, and a **Guided full setup** that runs everything in the firewall-imposed order — vpn first, pause for you to connect the VPN, then dev/prod. It's only a wrapper around the same `tofu` and `ansible-playbook` commands documented below, so you can always fall back to running them by hand. +It offers a **Check prerequisites** option (tools, `HCLOUD_TOKEN`/`DEPLOY_USER`/`BACKUP_S3_*` env vars, `terraform.tfvars`, SSH key, the `hetzner.hcloud` collection), a **Configuration** section, individual OpenTofu (`init`/`plan`/`apply`/`output`/`destroy`) and Ansible (bootstrap/deploy/spinup/spindown per role) actions, and a **Guided full setup** that runs everything in the firewall-imposed order — vpn first, pause for you to connect the VPN, then dev/prod. It's only a wrapper around the same `tofu` and `ansible-playbook` commands documented below, so you can always fall back to running them by hand. + +The **Configuration** section covers every variable both tools need: + +- **Set variables** prompts for `HCLOUD_TOKEN`, `DEPLOY_USER` (the non-root user Ansible creates — defaults to `deploy`), `ANSIBLE_SSH_PRIVATE_KEY_FILE`, and the `BACKUP_S3_*` credentials, one at a time (leave blank to keep the current value, secrets are input-masked). They're saved to `.control.env` at the repo root — gitignored, never committed — which is loaded automatically the next time you run `control.sh`, so you only enter them once. This is scoped to `control.sh` itself, not your regular shell. +- **Copy terraform.tfvars.example -> terraform.tfvars** creates `infra/terraform.tfvars` for you and prompts for `ssh_key_names` (the one required field with no default) so you don't leave the example's placeholder key names in place by accident. Everything else in that file (`location`, server types, `dns_zones`, etc.) still has sensible defaults you can edit by hand afterwards - see [Provisioning the infrastructure](#provisioning-the-infrastructure-infra) below. ## Provisioning the infrastructure (`infra/`)