Feat/open tofu #8
+1
-1
Submodule .devcontainer updated: fc4ccde0ac...7a54748c0e
@@ -13,6 +13,10 @@
|
|||||||
# services/docker-compose.yml carve-out above.
|
# services/docker-compose.yml carve-out above.
|
||||||
**/.env
|
**/.env
|
||||||
|
|
||||||
|
# Variables set through control.sh's "Set variables" menu (HCLOUD_TOKEN,
|
||||||
|
# BACKUP_S3_*, etc.) - never committed.
|
||||||
|
.control.env
|
||||||
|
|
||||||
### Ansible ###
|
### Ansible ###
|
||||||
*.retry
|
*.retry
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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
|
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
|
`~/.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
|
different key. It must match one of the SSH keys OpenTofu installed on the
|
||||||
servers (`var.ssh_key_names`).
|
servers (`var.ssh_key_names`).
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
# Keep in sync with infra/variables.tf's var.deploy_user (default "deploy") -
|
# Non-root sudo user the bootstrap role creates on each host - purely an
|
||||||
# the two aren't wired together automatically, since Ansible no longer reads
|
# Ansible-side concept, OpenTofu has no equivalent variable. Override with the
|
||||||
# any Terraform state or output.
|
# DEPLOY_USER env var (control.sh's "Set variables" menu writes it) if you
|
||||||
deploy_user: deploy
|
# don't want the default.
|
||||||
|
deploy_user: "{{ lookup('env', 'DEPLOY_USER') | default('deploy', true) }}"
|
||||||
ansible_user: "{{ deploy_user }}"
|
ansible_user: "{{ deploy_user }}"
|
||||||
ansible_ssh_private_key_file: "{{ lookup('env', 'ANSIBLE_SSH_PRIVATE_KEY_FILE') | default('~/.ssh/id_ed25519', true) }}"
|
ansible_ssh_private_key_file: "{{ lookup('env', 'ANSIBLE_SSH_PRIVATE_KEY_FILE') | default('~/.ssh/id_ed25519', true) }}"
|
||||||
|
|
||||||
|
|||||||
+127
-19
@@ -7,11 +7,19 @@
|
|||||||
# ever runs `tofu`/`ansible-playbook` for you - it's a menu wrapper, nothing
|
# 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/.
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
INFRA_DIR="$REPO_ROOT/infra"
|
INFRA_DIR="$REPO_ROOT/infra"
|
||||||
ANSIBLE_DIR="$REPO_ROOT/ansible"
|
ANSIBLE_DIR="$REPO_ROOT/ansible"
|
||||||
|
CONTROL_ENV="$REPO_ROOT/.control.env"
|
||||||
|
|
||||||
# --- pretty output --------------------------------------------------------
|
# --- pretty output --------------------------------------------------------
|
||||||
if [ -t 1 ]; then
|
if [ -t 1 ]; then
|
||||||
@@ -35,6 +43,32 @@ detect_tofu() {
|
|||||||
}
|
}
|
||||||
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 a command inside a directory, echoing it first. Returns the command's exit code.
|
||||||
run_in() {
|
run_in() {
|
||||||
local dir="$1"; shift
|
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_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_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)"
|
[ -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}"
|
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 "${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"
|
title "Files"
|
||||||
[ -f "$INFRA_DIR/terraform.tfvars" ] && ok "infra/terraform.tfvars exists" \
|
[ -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)" \
|
[ -d "$INFRA_DIR/.terraform" ] && ok "infra/.terraform exists (tofu init has run)" \
|
||||||
|| warn "infra/.terraform missing - run OpenTofu > init"
|
|| warn "infra/.terraform missing - run OpenTofu > init"
|
||||||
if ansible-galaxy collection list hetzner.hcloud >/dev/null 2>&1; then ok "hetzner.hcloud collection installed"
|
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
|
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 -----------------------------------------------------
|
# --- OpenTofu actions -----------------------------------------------------
|
||||||
require_tofu() {
|
require_tofu() {
|
||||||
if [ -z "$TOFU" ]; then bad "No tofu/terraform binary found."; return 1; fi
|
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.
|
connected to it (OpenVPN) before dev/prod are reachable.
|
||||||
|
|
||||||
Steps, in order:
|
Steps, in order:
|
||||||
|
0. Configuration: variables + terraform.tfvars (skip if already done)
|
||||||
1. OpenTofu: init -> plan -> apply (creates servers, network, firewalls, DNS)
|
1. OpenTofu: init -> plan -> apply (creates servers, network, firewalls, DNS)
|
||||||
2. Ansible: bootstrap + deploy + spinup for role_vpn
|
2. Ansible: bootstrap + deploy + spinup for role_vpn
|
||||||
3. YOU: connect to the VPN with an OpenVPN client
|
3. YOU: connect to the VPN with an OpenVPN client
|
||||||
@@ -127,6 +221,14 @@ Steps, in order:
|
|||||||
EOF
|
EOF
|
||||||
confirm "Start the guided walkthrough?" || return 0
|
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"
|
echo; title "1/4 OpenTofu"
|
||||||
if confirm "Run $TOFU init/plan/apply now?"; then
|
if confirm "Run $TOFU init/plan/apply now?"; then
|
||||||
tofu_init && tofu_plan && tofu_apply || { bad "OpenTofu step failed - stopping."; return 1; }
|
tofu_init && tofu_plan && tofu_apply || { bad "OpenTofu step failed - stopping."; return 1; }
|
||||||
@@ -158,15 +260,19 @@ menu() {
|
|||||||
echo " 1) Check prerequisites"
|
echo " 1) Check prerequisites"
|
||||||
echo " 2) Guided full setup (vpn -> connect -> dev/prod)"
|
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/)"
|
title "OpenTofu (infra/)"
|
||||||
echo " 3) init 4) plan 5) apply"
|
echo " 5) init 6) plan 7) apply"
|
||||||
echo " 6) output 7) destroy"
|
echo " 8) output 9) destroy"
|
||||||
|
|
||||||
title "Ansible (ansible/)"
|
title "Ansible (ansible/)"
|
||||||
echo " 8) Install collections 9) Show inventory"
|
echo " 10) Install collections 11) Show inventory"
|
||||||
echo " 10) Bootstrap vpn 11) Deploy + spinup vpn"
|
echo " 12) Bootstrap vpn 13) Deploy + spinup vpn"
|
||||||
echo " 12) Bootstrap dev+prod 13) Deploy + spinup dev+prod"
|
echo " 14) Bootstrap dev+prod 15) Deploy + spinup dev+prod"
|
||||||
echo " 14) Spindown (choose target)"
|
echo " 16) Spindown (choose target)"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo " 0) Quit"
|
echo " 0) Quit"
|
||||||
@@ -179,18 +285,20 @@ while true; do
|
|||||||
case "$choice" in
|
case "$choice" in
|
||||||
1) check_prereqs; pause ;;
|
1) check_prereqs; pause ;;
|
||||||
2) guided; pause ;;
|
2) guided; pause ;;
|
||||||
3) tofu_init; pause ;;
|
3) set_variables; pause ;;
|
||||||
4) tofu_plan; pause ;;
|
4) copy_tfvars; pause ;;
|
||||||
5) tofu_apply; pause ;;
|
5) tofu_init; pause ;;
|
||||||
6) tofu_output; pause ;;
|
6) tofu_plan; pause ;;
|
||||||
7) tofu_destroy; pause ;;
|
7) tofu_apply; pause ;;
|
||||||
8) ansible_collections; pause ;;
|
8) tofu_output; pause ;;
|
||||||
9) ansible_inventory; pause ;;
|
9) tofu_destroy; pause ;;
|
||||||
10) ansible_bootstrap_vpn; pause ;;
|
10) ansible_collections; pause ;;
|
||||||
11) ansible_deploy_vpn; pause ;;
|
11) ansible_inventory; pause ;;
|
||||||
12) ansible_bootstrap_dp; pause ;;
|
12) ansible_bootstrap_vpn; pause ;;
|
||||||
13) ansible_deploy_dp; pause ;;
|
13) ansible_deploy_vpn; pause ;;
|
||||||
14) ansible_spindown; pause ;;
|
14) ansible_bootstrap_dp; pause ;;
|
||||||
|
15) ansible_deploy_dp; pause ;;
|
||||||
|
16) ansible_spindown; pause ;;
|
||||||
0) echo "Bye."; exit 0 ;;
|
0) echo "Bye."; exit 0 ;;
|
||||||
"") ;;
|
"") ;;
|
||||||
*) warn "Unknown option: $choice"; pause ;;
|
*) warn "Unknown option: $choice"; pause ;;
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ If you'd rather not remember the exact command order, [`control.sh`](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/`)
|
## Provisioning the infrastructure (`infra/`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user