Files
moments/script/deploy.sh

381 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# moments deployment script.
#
# ./script/deploy.sh <environment> [component...]
# ./script/deploy.sh prod api worker web
# ./script/deploy.sh prod all
#
# Builds artifacts locally, resolves secrets from `pass`, renders config
# templates, rsyncs everything to the target hosts, and reloads systemd /
# nginx / firewalld / SELinux state idempotently.
set -euo pipefail
shopt -s nullglob
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
manifest="${repo_root}/asset/manifest.yml"
dry_run=0
usage() {
cat <<EOF >&2
usage: $(basename "$0") <environment> [component...] [--dry-run]
$(basename "$0") prod api worker web
$(basename "$0") prod all
$(basename "$0") prod default # api + web (worker isn't restarted unless asked)
EOF
exit 2
}
log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*" >&2; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; exit 1; }
run() {
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m %s\n' "$*" >&2
else
"$@"
fi
}
ssh_run() {
local host="$1"; shift
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m ssh %s -- %s\n' "$host" "$*" >&2
else
ssh -o BatchMode=yes "$host" "$@"
fi
}
[[ $# -ge 1 ]] || usage
environment="$1"; shift
components=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
*) components+=("$1") ;;
esac
shift
done
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
command -v yq >/dev/null 2>&1 || die "yq is required"
command -v pass >/dev/null 2>&1 || die "pass is required"
command -v rsync >/dev/null 2>&1 || die "rsync is required"
command -v cargo >/dev/null 2>&1 || die "cargo is required"
# Resolve component list ----------------------------------------------------
env_path=".environments.${environment}"
yq -e "${env_path}" "$manifest" >/dev/null \
|| die "environment '$environment' not found in manifest"
mapfile -t all_components < <(yq -r "${env_path}.components | keys | .[]" "$manifest")
if [[ ${#components[@]} -eq 0 ]]; then
usage
fi
case "${components[0]:-}" in
all) components=("${all_components[@]}") ;;
default) components=(api web) ;;
esac
# Build artifacts -----------------------------------------------------------
needs_rust=0
needs_web=0
for c in "${components[@]}"; do
case "$c" in
api|worker) needs_rust=1 ;;
web) needs_web=1 ;;
esac
done
if (( needs_rust )); then
log "cargo build --release (api, worker)"
run cargo build --release --bin moments-api --bin moments-worker --manifest-path "${repo_root}/Cargo.toml"
fi
if (( needs_web )); then
log "vite build (ui)"
run sh -c "cd '${repo_root}/ui' && pnpm install --frozen-lockfile && pnpm run build"
fi
# Per-component deploy ------------------------------------------------------
deploy_api() {
local host="$1"
log "api -> $host"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s) + units, rsync to %s:/, run sysusers/restorecon/semanage/systemctl on %s\n' \
"$host" "$host" "$host" >&2
return 0
fi
local fqdn
fqdn="$host"
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/etc/firewalld/services" "$stage/usr/local/bin"
# Render env file with hostname substitution.
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/config/api.env.tmpl" \
> "$stage/etc/moments/api.env"
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-api-cert.path" \
> "$stage/etc/systemd/system/moments-api-cert.path"
install -m 0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install -m 0644 "${repo_root}/asset/firewalld/moments-api.xml" "$stage/etc/firewalld/services/moments-api.xml"
install -m 0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
# Permissions on the rendered env: root-owned, moments group readable.
chmod 0640 "$stage/etc/moments/api.env"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
"$stage/" \
"${host}:/"
fi
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
set -euo pipefail
fqdn="$(hostname -f)"
systemd-sysusers /etc/sysusers.d/moments.conf
install -d -o root -g moments -m 0750 /etc/moments
install -d -o moments -g moments -m 0750 /var/lib/moments
chown root:moments /etc/moments/api.env
chmod 0640 /etc/moments/api.env
# Grant the moments user read access to the host private key — required for
# the postgres mTLS connection.
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
# Label the API port. Idempotent — the -m fallback turns "already labelled"
# into a no-op.
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
semanage port -a -t http_port_t -p tcp 42424 || \
semanage port -m -t http_port_t -p tcp 42424
fi
# Firewalld: install the named service and enable it in the default zone.
firewall-cmd --reload
zone="$(firewall-cmd --get-default-zone)"
if ! firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
firewall-cmd --permanent --zone="$zone" --add-service=moments-api
firewall-cmd --zone="$zone" --add-service=moments-api
fi
restorecon -Rv /usr/local/bin/moments-api /etc/moments /var/lib/moments
systemctl daemon-reload
systemctl enable --now moments-api-cert.path
systemctl enable --now moments-api.service
systemctl restart moments-api.service
# Health probe — hit the bound interface, not loopback, so we exercise the
# same path nginx will use from oolon.
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://${fqdn}:42424/v1/healthz" >/dev/null; then
echo "moments-api healthy"
exit 0
fi
sleep 1
done
echo "moments-api did not become healthy" >&2
journalctl -u moments-api.service -n 50 --no-pager >&2
exit 1
REMOTE_EOF
}
deploy_worker() {
local host="$1"
log "worker -> $host"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, GITHUB_TOKEN from pass) + units, rsync to %s:/, run sysusers/restorecon/systemctl on %s\n' \
"$host" "$host" "$host" >&2
return 0
fi
local fqdn
fqdn="$host"
local github_token=""
if pass show github.com/grenade/admin-token >/dev/null 2>&1; then
github_token="$(pass show github.com/grenade/admin-token)"
else
warn "no github admin-token in pass; worker will run without GITHUB_TOKEN"
fi
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/usr/local/bin"
sed -e "s|{{HOSTNAME}}|${fqdn}|g" \
-e "s|{{GITHUB_TOKEN}}|${github_token}|g" \
"${repo_root}/asset/config/worker.env.tmpl" > "$stage/etc/moments/worker.env"
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-worker-cert.path" \
> "$stage/etc/systemd/system/moments-worker-cert.path"
install -m 0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install -m 0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
chmod 0640 "$stage/etc/moments/worker.env"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
"$stage/" \
"${host}:/"
fi
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
set -euo pipefail
fqdn="$(hostname -f)"
systemd-sysusers /etc/sysusers.d/moments.conf
install -d -o root -g moments -m 0750 /etc/moments
install -d -o moments -g moments -m 0750 /var/lib/moments
chown root:moments /etc/moments/worker.env
chmod 0640 /etc/moments/worker.env
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
restorecon -Rv /usr/local/bin/moments-worker /etc/moments /var/lib/moments
systemctl daemon-reload
systemctl enable --now moments-worker-cert.path
systemctl enable --now moments-worker.service
systemctl restart moments-worker.service
# Liveness probe — worker doesn't expose a port, so check is-active.
if ! systemctl is-active --quiet moments-worker.service; then
journalctl -u moments-worker.service -n 50 --no-pager >&2
exit 1
fi
echo "moments-worker active"
REMOTE_EOF
}
deploy_web() {
local host="$1"
log "web -> $host"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync ui/dist/ to %s:/var/www/rob.tn/ + nginx config, run nginx -t/reload on %s\n' \
"$host" "$host" >&2
return 0
fi
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
install -d "$stage/var/www/rob.tn" "$stage/etc/nginx/conf.d"
rsync -a "${repo_root}/ui/dist/" "$stage/var/www/rob.tn/"
install -m 0644 "${repo_root}/asset/nginx/rob.tn.conf" "$stage/etc/nginx/conf.d/rob.tn.conf"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
--delete \
"$stage/var/www/rob.tn/" \
"${host}:/var/www/rob.tn/"
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
"$stage/etc/nginx/conf.d/rob.tn.conf" \
"${host}:/etc/nginx/conf.d/rob.tn.conf"
fi
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
set -euo pipefail
# Allow nginx to make outbound connections to the moments-api upstream
# across the WG mesh.
setsebool -P httpd_can_network_connect on
# Label the upstream port so httpd_t may name_connect to it.
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
semanage port -a -t http_port_t -p tcp 42424 || \
semanage port -m -t http_port_t -p tcp 42424
fi
restorecon -Rv /var/www/rob.tn /etc/nginx/conf.d/rob.tn.conf
if ! nginx -t; then
echo "nginx config check failed" >&2
exit 1
fi
systemctl reload nginx
echo "nginx reloaded"
REMOTE_EOF
}
# Dispatch ------------------------------------------------------------------
failed=()
for component in "${components[@]}"; do
mapfile -t hosts < <(yq -r "${env_path}.components.${component}.hosts[]" "$manifest")
for host in "${hosts[@]}"; do
case "$component" in
api) deploy_api "$host" || failed+=("api@$host") ;;
worker) deploy_worker "$host" || failed+=("worker@$host") ;;
web) deploy_web "$host" || failed+=("web@$host") ;;
*) warn "unknown component: $component" ;;
esac
done
done
if [[ ${#failed[@]} -gt 0 ]]; then
die "failed: ${failed[*]}"
fi
log "deploy complete"