#!/usr/bin/env bash # # moments deployment script. # # ./script/deploy.sh [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 <&2 usage: $(basename "$0") [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 -aHAX --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 -aHAX --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 -aHAX --delete --rsync-path="sudo rsync" "$stage/var/www/rob.tn/" "${host}:/var/www/rob.tn/" rsync -aHAX --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"