deploy: add a local drift check for the host config
Ansible is only run to bootstrap and configure the host, so nothing catches the live server drifting from the repo between runs. Add a script that renders the repo configs and diffs them against the server over ssh, plus make targets. It stays local rather than a CI job because it is more useful interactively, and it reads the connection details from the inventory or the environment.
Pedro Lucas Porcellis porcellis@eletrotupi.com · 1 month ago
cacc7caae1c71809cb9f2e1662f9eac1b0406113 Browse filesParents
d6f84eaChanged files
2
Additions
+150
Deletions
0
/dev/null → Makefile
1 +
# Deployment helpers
2 +
#
3 +
# Day-to-day app deploys happen in GitHub Actions
4 +
# (.github/workflows/deploy.yml) These targets are for the host-config work,
5 +
# which is done rarely
6 +
7 +
SHELL := bash
8 +
ANSIBLE_DIR := ansible
9 +
INVENTORY := inventory/hosts.yml
10 +
11 +
.PHONY: help deploy-diff deploy-check provision cert deploy-nginx
12 +
13 +
help:
14 +
@echo "make deploy-diff # pull live configs and diff them against the repo"
15 +
@echo "make deploy-check # ansible --check --diff (dry run, no changes)"
16 +
@echo "make provision # full host bootstrap (setup + deploy + cert + app)"
17 +
@echo "make deploy-nginx # publish the nginx vhost (deploy.yml)"
18 +
@echo "make cert # install uacme / manage certificates (cert.yml)"
19 +
20 +
deploy-diff:
21 +
$(ANSIBLE_DIR)/scripts/pull-live-configs.sh
22 +
23 +
deploy-check:
24 +
cd $(ANSIBLE_DIR) && ansible-playbook -i $(INVENTORY) provision.yml --check --diff
25 +
26 +
provision:
27 +
cd $(ANSIBLE_DIR) && ansible-playbook -i $(INVENTORY) provision.yml --ask-become-pass
28 +
29 +
cert:
30 +
cd $(ANSIBLE_DIR) && ansible-playbook -i $(INVENTORY) cert.yml --ask-become-pass
31 +
32 +
deploy-nginx:
33 +
cd $(ANSIBLE_DIR) && ansible-playbook -i $(INVENTORY) deploy.yml --ask-become-pass
/dev/null → ansible/scripts/pull-live-configs.sh
1 +
#!/usr/bin/env bash
2 +
#
3 +
# Pull the live deployment configs from the VPS and diff them against the repo
4 +
# Read-only on the server. Exits non-zero if anything has drifted
5 +
#
6 +
# INTERNAL tool, run locally (or via `make deploy-diff`). It is deliberately
7 +
# not wired into CI: it is more useful interactively than as a scheduled job
8 +
#
9 +
# ansible/scripts/pull-live-configs.sh
10 +
#
11 +
# Connection details are read from ansible/inventory/hosts.yml (git-ignored), or
12 +
# from the VPS_HOST / VPS_USER / VPS_KEY environment variables
13 +
14 +
set -euo pipefail
15 +
16 +
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
17 +
ansible_dir="$repo_root/ansible"
18 +
inventory="$ansible_dir/inventory/hosts.yml"
19 +
20 +
# first value of a key in the (flat) inventory
21 +
field() { awk -F': *' -v k="^[[:space:]]*$1" '$0 ~ k {print $2; exit}' "$inventory"; }
22 +
23 +
HOST="${VPS_HOST:-}"
24 +
USER="${VPS_USER:-}"
25 +
KEY="${VPS_KEY:-}"
26 +
27 +
if [ -f "$inventory" ]; then
28 +
[ -n "$HOST" ] || HOST="$(field 'ansible_host:')"
29 +
[ -n "$USER" ] || USER="$(field 'ansible_user:')"
30 +
[ -n "$KEY" ] || KEY="$(field 'ansible_ssh_private_key_file:')"
31 +
fi
32 +
33 +
KEY="${KEY/#\~/$HOME}"
34 +
[ -n "$HOST" ] || { echo "no host: set VPS_HOST or provide ansible/inventory/hosts.yml" >&2; exit 2; }
35 +
[ -n "$USER" ] || { echo "no user: set VPS_USER or provide ansible/inventory/hosts.yml" >&2; exit 2; }
36 +
KEY="${KEY:-$HOME/.ssh/id_ed25519}"
37 +
38 +
tmp="$(mktemp -d)"
39 +
trap 'rm -rf "$tmp"' EXIT
40 +
41 +
# First vhost from vars/vhosts.yml, e.g. "usenexo.xyz"
42 +
vhost="$(awk '/^vhosts:/{f=1;next} f&&/^[[:space:]]*- /{print $2; exit}' "$ansible_dir/vars/vhosts.yml")"
43 +
[ -n "$vhost" ] || { echo "could not determine vhost from vars/vhosts.yml" >&2; exit 2; }
44 +
45 +
ssh_opts=(-i "$KEY" -o StrictHostKeyChecking=accept-new)
46 +
47 +
echo "Pulling live configs from ${USER}@${HOST} ..."
48 +
49 +
pull() { # <remote path> <local name>
50 +
if ssh "${ssh_opts[@]}" "${USER}@${HOST}" "cat '$1'" >"$tmp/$2" 2>/dev/null; then
51 +
echo " pulled $1"
52 +
else
53 +
echo " MISSING on server: $1" >&2
54 +
rm -f "$tmp/$2"
55 +
fi
56 +
}
57 +
58 +
pull "/etc/nginx/http.d/${vhost}.conf" nginx.conf
59 +
pull /srv/app/production/docker-compose.yml docker-compose.yml
60 +
pull /srv/app/production/valkey.conf valkey.conf
61 +
62 +
# The nginx config is templated; render it the same way the nginx role would
63 +
render_nginx() {
64 +
sed \
65 +
-e "s/{{ vhosts\[0\] }}/${vhost}/g" \
66 +
-e 's/{{ nginx_cert_dir }}/\/etc\/ssl\/uacme/g' \
67 +
-e 's/{{ nginx_static_root }}/\/srv\/static/g' \
68 +
-e 's/{{ nginx_proxy_pass }}/http:\/\/127.0.0.1:3000/g' \
69 +
"$ansible_dir/roles/nginx/templates/site.https.j2"
70 +
}
71 +
72 +
status=0
73 +
74 +
report() { # <diff exit status> <label>
75 +
if [ "$1" -eq 0 ]; then
76 +
echo "ok $2"
77 +
else
78 +
echo "DRIFT $2"; status=1
79 +
fi
80 +
}
81 +
82 +
show_diff() { # <expected file> <live file>
83 +
diff -u "$1" "$2" | sed 's/^/ /' || true
84 +
}
85 +
86 +
echo
87 +
if [ -f "$tmp/nginx.conf" ]; then
88 +
render_nginx >"$tmp/nginx.expected"
89 +
if diff -q "$tmp/nginx.expected" "$tmp/nginx.conf" >/dev/null; then
90 +
report 0 "nginx server block"
91 +
else
92 +
report 1 "nginx server block"; show_diff "$tmp/nginx.expected" "$tmp/nginx.conf"
93 +
fi
94 +
else
95 +
echo "?? nginx server block — no live copy"
96 +
fi
97 +
98 +
for pair in \
99 +
"ansible/roles/app/files/docker-compose.production.yml:docker-compose.yml:production compose" \
100 +
"ansible/roles/app/files/valkey.conf:valkey.conf:valkey config"; do
101 +
IFS=: read -r repo_rel live name <<<"$pair"
102 +
if [ ! -f "$tmp/$live" ]; then
103 +
echo "?? $name — no live copy"
104 +
elif diff -q "$repo_root/$repo_rel" "$tmp/$live" >/dev/null; then
105 +
report 0 "$name"
106 +
else
107 +
report 1 "$name"; show_diff "$repo_root/$repo_rel" "$tmp/$live"
108 +
fi
109 +
done
110 +
111 +
echo
112 +
if [ "$status" -eq 0 ]; then
113 +
echo "No drift."
114 +
else
115 +
echo "Drift detected: re-run the matching playbook (deploy.yml / app.yml)"
116 +
fi
117 +
exit "$status"