#!/usr/bin/env bash # uvlava — infranet Task Runner # Usage: ./run [args...] # # Manages the services under services/ (each a dir with deploy.sh + compose.yml, # deployed to /opt/ on its target droplet) and wraps the DO terraform. # # ./run services List discovered services + their targets. # ./run deploy [user@host] Run services//deploy.sh (build+ship+wire). # ./run status [target] docker compose ps (+ health probe if defined). # ./run logs [target] [-- ] # ./run restart [target] docker compose restart on the droplet. # ./run tf terraform in terraform/do/ (token from vault). # # A service's target host resolves from services//.target (one line, # user@host) else DEFAULT_TARGET. Health probe: services//.health (one line, # a URL curl'd on the droplet, e.g. http://127.0.0.1:8090/healthz). # # Follows the ./run task-runner convention (cf. @applications/prospector/run). set -uo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SERVICES_DIR="$REPO_ROOT/services" DEFAULT_TARGET="root@134.199.243.61" # com.uvlava.quinn.artifacts (forge droplet) RED=$'\e[31m' YELLOW=$'\e[33m' BLUE=$'\e[34m' GREEN=$'\e[32m' NC=$'\e[0m' || true die() { echo -e "${RED}uvlava: $*${NC}" >&2; exit 1; } # Fleet SSH. Cloud droplets get rebuilt (IP reused, host key changes), so we keep # a DEDICATED known_hosts for the fleet (never conflicts with the user's main # file), TOFU-accept new keys, and self-heal a changed key (rebuilt host) by # dropping the stale entry and retrying once. Uses the fleet key when present. FLEET_KH="$HOME/.ssh/known_hosts_uvlava" SSH="ssh -o StrictHostKeyChecking=accept-new -o CheckHostIP=no -o UserKnownHostsFile=$FLEET_KH" [ -f "$HOME/.ssh/id_ed25519_1984" ] && SSH="$SSH -i $HOME/.ssh/id_ed25519_1984" fssh() { # fssh user@host "remote command..." local target="$1"; shift local host="${target#*@}" err err="$(mktemp)" if $SSH "$target" "$@" 2>"$err"; then rm -f "$err"; return 0; fi if grep -qiE "host key.*changed|REMOTE HOST IDENTIFICATION HAS CHANGED" "$err"; then ssh-keygen -R "$host" -f "$FLEET_KH" >/dev/null 2>&1 || true rm -f "$err"; $SSH "$target" "$@"; return $? fi cat "$err" >&2; rm -f "$err"; return 1 } # A service = a dir under services/ that has a deploy.sh. list_services() { [ -d "$SERVICES_DIR" ] || return 0 for d in "$SERVICES_DIR"/*/; do [ -f "${d}deploy.sh" ] && basename "$d" done } require_service() { local svc="$1" [ -n "$svc" ] || die "missing (see: ./run services)" [ -f "$SERVICES_DIR/$svc/deploy.sh" ] || die "unknown service '$svc' (see: ./run services)" } service_target() { # resolves a service's deploy target local svc="$1" f="$SERVICES_DIR/$1/.target" [ -f "$f" ] && head -1 "$f" || echo "$DEFAULT_TARGET" } usage() { echo -e "${BLUE}uvlava${NC} — infranet Task Runner" echo "" echo "Usage: ./run [args...]" echo "" echo -e "${YELLOW}Services (services//)${NC}" echo " services List services + resolved targets." echo " deploy [target] Build + ship + wire (delegates to its deploy.sh)." echo " status [target] docker compose ps (+ health probe if defined)." echo " logs [target] docker compose logs (pass extra args after --)." echo " restart [target] docker compose restart on the droplet." echo "" echo -e "${YELLOW}Infrastructure${NC}" echo " tf terraform in terraform/do/ (do_token from ~/.vault)." echo "" if [ -n "$(list_services)" ]; then echo -e "${YELLOW}Discovered services${NC}" while read -r s; do printf ' %-16s -> %s\n' "$s" "$(service_target "$s")"; done < <(list_services) fi } cmd_services() { local any=0 while read -r s; do any=1; printf '%-16s -> %s\n' "$s" "$(service_target "$s")"; done < <(list_services) [ "$any" = 1 ] || echo "(no services under $SERVICES_DIR — a service is a dir with deploy.sh)" } cmd_deploy() { local svc="${1:-}"; require_service "$svc"; shift echo -e "${GREEN}==> deploy $svc${NC}" exec "$SERVICES_DIR/$svc/deploy.sh" "$@" } cmd_status() { local svc="${1:-}"; require_service "$svc" local target="${2:-$(service_target "$svc")}" echo -e "${GREEN}==> $svc @ $target${NC}" fssh "$target" "cd /opt/$svc && docker compose ps" || die "compose ps failed on $target" local health="$SERVICES_DIR/$svc/.health" if [ -f "$health" ]; then local url; url="$(head -1 "$health")" echo "--- health: $url ---" fssh "$target" "curl -fsS '$url'" && echo || echo -e "${RED}(health probe failed)${NC}" fi } cmd_logs() { local svc="${1:-}"; require_service "$svc" local target="${2:-$(service_target "$svc")}"; shift 2>/dev/null || true; shift 2>/dev/null || true # everything after `--` is forwarded to `docker compose logs` [ "${1:-}" = "--" ] && shift local extra="${*:---tail=100}" fssh "$target" "cd /opt/$svc && docker compose logs $extra" } cmd_restart() { local svc="${1:-}"; require_service "$svc" local target="${2:-$(service_target "$svc")}" echo -e "${GREEN}==> restart $svc @ $target${NC}" fssh "$target" "cd /opt/$svc && docker compose restart" } cmd_tf() { local tfdir="$REPO_ROOT/terraform/do" [ -d "$tfdir" ] || die "no terraform dir at $tfdir" local tok="" for c in "$HOME/.vault/do-pat-ct.token" "$HOME/.vault/do_pat_ct" "$HOME/.vault/do_pat_cocotte"; do [ -f "$c" ] && { tok="$(cat "$c")"; break; } done [ -n "$tok" ] || die "no DO token found in ~/.vault (do-pat-ct.token / do_pat_cocotte)" (cd "$tfdir" && TF_VAR_do_token="$tok" terraform "$@") } COMMAND="${1:-}"; shift 2>/dev/null || true case "$COMMAND" in help|--help|-h|"") usage; exit 0 ;; services|ls) cmd_services "$@" ;; deploy) cmd_deploy "$@" ;; status) cmd_status "$@" ;; logs) cmd_logs "$@" ;; restart) cmd_restart "$@" ;; tf|terraform) cmd_tf "$@" ;; *) echo -e "${RED}Unknown command: $COMMAND${NC}" >&2; usage; exit 1 ;; esac