#!/usr/bin/env bash # # Warmbly worker installer. # # Drops a Warmbly worker on any Linux VPS in one command. The worker runs as a # Docker container under systemd, with config in /etc/warmbly/worker.env. # # Worker identity is derived deterministically from the VPS's public IPv4 # address (UUIDv5, URL namespace), so: # # - same IP -> same worker (reputation persists across reinstalls) # - new IP -> new worker (fresh identity, no inherited reputation) # # Quick start: # # curl -fsSL https://get.warmbly.com/worker | sudo bash -s -- \ # --kafka kafka.warmbly.com:9092 \ # --schema-registry https://schema.warmbly.com \ # --redis redis://cache.warmbly.com:6379 \ # --aws-key AKIA... --aws-secret ... --aws-region us-east-1 # # Or with a pre-built env file: # # curl -fsSL https://get.warmbly.com/worker | sudo bash -s -- \ # --env-file /root/worker.env # # Re-running is safe: existing env values are preserved unless overridden, # and the worker ID will resolve to the same value as long as the IP is stable. set -euo pipefail # ---------- defaults ---------- IMAGE="${WARMBLY_WORKER_IMAGE:-ghcr.io/warmbly/worker:latest}" CONFIG_DIR="/etc/warmbly" ENV_FILE="${CONFIG_DIR}/worker.env" ID_FILE="${CONFIG_DIR}/worker.id" UNIT_FILE="/etc/systemd/system/warmbly-worker.service" CONTAINER_NAME="warmbly-worker" ACTION="install" INTERACTIVE=1 SUPPLIED_ENV_FILE="" declare -A CFG=( [APP_ENV]="prod" [AWS_CONFIG_ENABLED]="true" [AWS_REGION]="" [AWS_ACCESS_KEY_ID]="" [AWS_SECRET_ACCESS_KEY]="" [KAFKA_BOOTSTRAP_SERVERS]="" [KAFKA_SASL_USERNAME]="" [KAFKA_SASL_PASSWORD]="" [SCHEMA_REGISTRY_URL]="" [SCHEMA_REGISTRY_KEY]="" [SCHEMA_REGISTRY_SECRET]="" [REDIS]="" [WORKER_TIER]="shared" ) WORKER_ID_OVERRIDE="" IP_OVERRIDE="" # UUIDv5 URL namespace (RFC 4122) UUID_NS_URL="6ba7b811-9dad-11d1-80b4-00c04fd430c8" # ---------- pretty output ---------- if [[ -t 1 ]]; then C_RED=$'\033[0;31m'; C_GRN=$'\033[0;32m'; C_YLW=$'\033[0;33m' C_BLU=$'\033[0;34m'; C_BLD=$'\033[1m'; C_RST=$'\033[0m' else C_RED=""; C_GRN=""; C_YLW=""; C_BLU=""; C_BLD=""; C_RST="" fi log() { printf "%s==>%s %s\n" "$C_BLU" "$C_RST" "$*"; } ok() { printf "%s ok %s %s\n" "$C_GRN" "$C_RST" "$*"; } warn() { printf "%s !! %s %s\n" "$C_YLW" "$C_RST" "$*" >&2; } die() { printf "%s xx %s %s\n" "$C_RED" "$C_RST" "$*" >&2; exit 1; } # ---------- usage ---------- usage() { cat < Public IP to derive worker identity from (default: auto-detected via ifconfig.me/etc.) --worker-id Pin a specific worker UUID, bypassing IP derivation --tier Worker tier label (default: shared) --image Docker image (default: ${IMAGE}) --env-file Use this env file verbatim, skip prompts --kafka Kafka bootstrap servers (host:port[,host:port]) --kafka-user --kafka-pass --schema-registry --schema-key --schema-secret --redis e.g. redis://default:pass@host:6379 --aws-region --aws-key --aws-secret --env KEY=value Set an arbitrary env var (repeatable) --non-interactive Fail instead of prompting for missing values -h, --help This help EOF } # ---------- arg parsing ---------- EXTRA_ENVS=() while [[ $# -gt 0 ]]; do case "$1" in --install) ACTION="install"; shift ;; --update) ACTION="update"; shift ;; --uninstall) ACTION="uninstall"; shift ;; --purge) ACTION="purge"; shift ;; --status) ACTION="status"; shift ;; --ip) IP_OVERRIDE="$2"; shift 2 ;; --worker-id) WORKER_ID_OVERRIDE="$2"; shift 2 ;; --tier) CFG[WORKER_TIER]="$2"; shift 2 ;; --image) IMAGE="$2"; shift 2 ;; --env-file) SUPPLIED_ENV_FILE="$2"; shift 2 ;; --kafka) CFG[KAFKA_BOOTSTRAP_SERVERS]="$2"; shift 2 ;; --kafka-user) CFG[KAFKA_SASL_USERNAME]="$2"; shift 2 ;; --kafka-pass) CFG[KAFKA_SASL_PASSWORD]="$2"; shift 2 ;; --schema-registry) CFG[SCHEMA_REGISTRY_URL]="$2"; shift 2 ;; --schema-key) CFG[SCHEMA_REGISTRY_KEY]="$2"; shift 2 ;; --schema-secret) CFG[SCHEMA_REGISTRY_SECRET]="$2"; shift 2 ;; --redis) CFG[REDIS]="$2"; shift 2 ;; --aws-region) CFG[AWS_REGION]="$2"; shift 2 ;; --aws-key) CFG[AWS_ACCESS_KEY_ID]="$2"; shift 2 ;; --aws-secret) CFG[AWS_SECRET_ACCESS_KEY]="$2"; shift 2 ;; --env) EXTRA_ENVS+=("$2"); shift 2 ;; --non-interactive) INTERACTIVE=0; shift ;; -h|--help) usage; exit 0 ;; *) die "Unknown flag: $1 (use --help)" ;; esac done # ---------- preflight ---------- require_root() { if [[ $EUID -ne 0 ]]; then die "Run as root (try: sudo $0 ...)" fi } detect_pkg_manager() { if command -v apt-get >/dev/null 2>&1; then echo "apt" elif command -v dnf >/dev/null 2>&1; then echo "dnf" elif command -v yum >/dev/null 2>&1; then echo "yum" elif command -v pacman >/dev/null 2>&1; then echo "pacman" elif command -v apk >/dev/null 2>&1; then echo "apk" else echo "unknown" fi } ensure_docker() { if command -v docker >/dev/null 2>&1; then ok "docker present" return fi log "installing docker via get.docker.com" if ! command -v curl >/dev/null 2>&1; then case "$(detect_pkg_manager)" in apt) apt-get update -qq && apt-get install -y curl ca-certificates ;; dnf) dnf install -y curl ca-certificates ;; yum) yum install -y curl ca-certificates ;; pacman) pacman -Sy --noconfirm curl ca-certificates ;; apk) apk add --no-cache curl ca-certificates ;; *) die "curl missing and package manager unknown" ;; esac fi curl -fsSL https://get.docker.com | sh systemctl enable --now docker || true command -v docker >/dev/null 2>&1 || die "docker install failed" ok "docker installed" } ensure_uuidgen() { if command -v uuidgen >/dev/null 2>&1; then return; fi case "$(detect_pkg_manager)" in apt) apt-get update -qq && apt-get install -y uuid-runtime ;; dnf) dnf install -y util-linux ;; yum) yum install -y util-linux ;; pacman) pacman -Sy --noconfirm util-linux ;; apk) apk add --no-cache util-linux ;; *) ;; esac } valid_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 local IFS=. local -a octets=($1) for o in "${octets[@]}"; do (( o >= 0 && o <= 255 )) || return 1 done return 0 } detect_public_ip() { local candidates=( "https://api.ipify.org" "https://ifconfig.me" "https://checkip.amazonaws.com" "https://icanhazip.com" ) local ip for url in "${candidates[@]}"; do ip="$(curl -fsS --max-time 5 "$url" 2>/dev/null | tr -d '[:space:]')" || continue if valid_ipv4 "$ip"; then echo "$ip" return 0 fi done # last resort: first non-loopback v4 on the host if command -v hostname >/dev/null 2>&1; then ip="$(hostname -I 2>/dev/null | awk '{print $1}')" if valid_ipv4 "$ip"; then warn "using local interface IP $ip (could not reach public IP services)" echo "$ip" return 0 fi fi return 1 } # UUIDv5(URL namespace, ip) — deterministic, so same IP always maps to same UUID. derive_uuid_from_ip() { local ip="$1" if command -v uuidgen >/dev/null 2>&1 && uuidgen --help 2>&1 | grep -q -- '--sha1'; then uuidgen --sha1 --namespace "$UUID_NS_URL" --name "$ip" | tr '[:upper:]' '[:lower:]' return fi if command -v python3 >/dev/null 2>&1; then python3 -c "import uuid;print(uuid.uuid5(uuid.UUID('$UUID_NS_URL'), '$ip'))" return fi if command -v python >/dev/null 2>&1; then python -c "import uuid;print(uuid.uuid5(uuid.UUID('$UUID_NS_URL'), '$ip'))" return fi die "cannot derive UUIDv5: need 'uuidgen --sha1' (util-linux >= 2.34) or python" } # ---------- config helpers ---------- prompt_if_missing() { local key="$1" label="$2" secret="${3:-0}" default="${4:-}" if [[ -n "${CFG[$key]:-}" ]]; then return; fi if [[ $INTERACTIVE -eq 0 ]]; then [[ -n "$default" ]] && CFG[$key]="$default" && return die "missing required value for $key (--$(echo "$key" | tr '[:upper:]_' '[:lower:]-'))" fi local prompt="$label" [[ -n "$default" ]] && prompt+=" [$default]" prompt+=": " local val if [[ $secret -eq 1 ]]; then read -rsp "$prompt" val; echo else read -rp "$prompt" val fi CFG[$key]="${val:-$default}" } merge_existing_env() { [[ -f "$ENV_FILE" ]] || return 0 log "merging existing $ENV_FILE" while IFS='=' read -r k v; do [[ -z "$k" || "$k" =~ ^# ]] && continue # strip surrounding quotes if present v="${v%\"}"; v="${v#\"}" if [[ -z "${CFG[$k]:-}" ]]; then CFG[$k]="$v" fi done < "$ENV_FILE" } write_env_file() { install -d -m 0700 "$CONFIG_DIR" local tmp; tmp="$(mktemp)" { echo "# Warmbly worker config — managed by install-worker.sh" echo "# $(date -u +%Y-%m-%dT%H:%M:%SZ)" for key in APP_ENV AWS_CONFIG_ENABLED AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY \ KAFKA_BOOTSTRAP_SERVERS KAFKA_SASL_USERNAME KAFKA_SASL_PASSWORD \ SCHEMA_REGISTRY_URL SCHEMA_REGISTRY_KEY SCHEMA_REGISTRY_SECRET \ REDIS WORKER_TIER; do printf '%s=%s\n' "$key" "${CFG[$key]}" done for kv in "${EXTRA_ENVS[@]}"; do printf '%s\n' "$kv" done } > "$tmp" install -m 0600 "$tmp" "$ENV_FILE" rm -f "$tmp" ok "wrote $ENV_FILE" } resolve_worker_id() { local ip="${IP_OVERRIDE}" local id if [[ -n "$WORKER_ID_OVERRIDE" ]]; then id="$WORKER_ID_OVERRIDE" ip="manual" else if [[ -z "$ip" ]]; then ip="$(detect_public_ip)" || die "could not detect public IP (pass --ip )" fi valid_ipv4 "$ip" || [[ "$ip" == "manual" ]] || die "invalid IPv4: $ip" id="$(derive_uuid_from_ip "$ip")" fi install -d -m 0700 "$CONFIG_DIR" printf 'id=%s\nip=%s\n' "$id" "$ip" > "$ID_FILE" chmod 0644 "$ID_FILE" WORKER_IP="$ip" echo "$id" } write_unit() { local worker_id="$1" cat > "$UNIT_FILE" </dev/null 2>&1 || true systemctl restart warmbly-worker.service ok "warmbly-worker started" sleep 2 if systemctl is-active --quiet warmbly-worker.service; then ok "worker is running" else warn "worker not active; check: journalctl -u warmbly-worker -n 100 --no-pager" fi cat <\nip=" (current). if grep -q '^id=' "$ID_FILE"; then worker_id="$(grep '^id=' "$ID_FILE" | head -1 | cut -d= -f2- | tr -d '[:space:]')" else worker_id="$(head -1 "$ID_FILE" | tr -d '[:space:]')" fi fi [[ -n "$worker_id" ]] || die "could not read worker id (reinstall required)" WORKER_IP="$(grep '^ip=' "$ID_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '[:space:]')" log "pulling $IMAGE" docker pull "$IMAGE" log "rewriting systemd unit" write_unit "$worker_id" systemctl restart warmbly-worker.service ok "worker updated to $IMAGE" } do_uninstall() { require_root log "stopping warmbly-worker" systemctl stop warmbly-worker.service 2>/dev/null || true systemctl disable warmbly-worker.service 2>/dev/null || true rm -f "$UNIT_FILE" systemctl daemon-reload docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true ok "worker removed (config preserved in $CONFIG_DIR)" } do_purge() { do_uninstall rm -rf "$CONFIG_DIR" ok "purged $CONFIG_DIR" } do_status() { if [[ ! -f "$UNIT_FILE" ]]; then warn "not installed" exit 1 fi systemctl status warmbly-worker.service --no-pager || true echo log "recent logs:" journalctl -u warmbly-worker -n 30 --no-pager || true } # ---------- dispatch ---------- case "$ACTION" in install) do_install ;; update) do_update ;; uninstall) do_uninstall ;; purge) do_purge ;; status) do_status ;; *) die "unknown action: $ACTION" ;; esac