#!/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://api.warmbly.com/worker-install.sh | 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://api.warmbly.com/worker-install.sh | sudo bash -s -- \ # --env-file /root/worker.env # # Or with a one-time enrollment token from the dashboard: # # curl -fsSL https://api.warmbly.com/worker-install.sh | sudo bash -s -- \ # --enroll wmenroll_... # # 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" TEMPLATE_UNIT_FILE="/etc/systemd/system/warmbly-worker@.service" INSTANCES_DIR="${CONFIG_DIR}/instances" # Host path backing BLOB_FS_ROOT. The worker runs as uid 1000 and cannot create # a top-level directory itself, so without this mount it exits at boot with # "mkdir /data: permission denied" whenever BLOB_PROVIDER=filesystem. BLOB_DIR="/var/lib/warmbly/blobs" CONTAINER_NAME="warmbly-worker" INSTALLER_BIN="/usr/local/bin/warmbly-worker-installer" AUTO_UPDATE_UNIT_FILE="/etc/systemd/system/warmbly-worker-auto-update.service" AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/warmbly-worker-auto-update.timer" ACTION="install" INTERACTIVE=1 SUPPLIED_ENV_FILE="" ENROLL_TOKEN="" API_BASE="${WARMBLY_API_BASE:-https://api.warmbly.com}" AUTO_UPDATE=1 # Comma-separated list of IPv4 addresses for multi-IP install. When non-empty, # the installer drops one templated systemd unit per IP, each bound to that IP # and with a worker_id derived from it (UUIDv5). Empty == legacy single-IP. IPS_LIST="" 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.) --ips Run one worker process per IP, each bound to its own egress (multi-IP box). When set, --ip and --worker-id are ignored. --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 --enroll Exchange a one-time dashboard enrollment token for worker config and install without prompts --api-base API base for --enroll (default: ${API_BASE}) --no-auto-update Do not install the systemd auto-update timer --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 ;; --ips) IPS_LIST="$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 ;; --enroll) ENROLL_TOKEN="$2"; INTERACTIVE=0; shift 2 ;; --api-base) API_BASE="$2"; shift 2 ;; --no-auto-update) AUTO_UPDATE=0; shift ;; --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" } env_file_value() { local file="$1" key="$2" grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- } fetch_enrollment_env() { [[ -n "$ENROLL_TOKEN" ]] || return 1 command -v curl >/dev/null 2>&1 || die "curl is required for --enroll" local ip="${IP_OVERRIDE}" if [[ -z "$ip" ]]; then ip="$(detect_public_ip || true)" fi local tmp; tmp="$(mktemp)" local body body="{\"token\":\"${ENROLL_TOKEN}\"" if [[ -n "$ip" ]]; then body+=",\"public_ip\":\"${ip}\"" fi body+="}" log "exchanging enrollment token at ${API_BASE}" curl -fsS \ -X POST \ -H "Content-Type: application/json" \ -H "Accept: text/plain" \ --data "$body" \ "${API_BASE%/}/api/v1/workers/enroll" > "$tmp" || { rm -f "$tmp" die "enrollment failed" } local worker_id image worker_id="$(env_file_value "$tmp" WORKER_ID)" image="$(env_file_value "$tmp" WARMBLY_WORKER_IMAGE)" [[ -n "$worker_id" ]] || die "enrollment response did not include WORKER_ID" install -d -m 0700 "$CONFIG_DIR" install -m 0600 "$tmp" "$ENV_FILE" rm -f "$tmp" WORKER_ID_OVERRIDE="$worker_id" [[ -n "$image" ]] && IMAGE="$image" ok "enrollment config installed" return 0 } 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 \ ENCRYPTED_KEYS_PROVIDER ENCRYPTED_KEYS_BACKEND_URL ENCRYPTED_KEYS_WORKER_TOKEN \ KAFKA_BOOTSTRAP_SERVERS KAFKA_SASL_USERNAME KAFKA_SASL_PASSWORD \ SCHEMA_REGISTRY_URL SCHEMA_REGISTRY_KEY SCHEMA_REGISTRY_SECRET \ REDIS EVENTBUS_PROVIDER NATS_URL CODEC_PROVIDER WORKER_TIER WORKER_PUBLIC_IP WORKER_EGRESS_KIND; 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_update_image() { local configured="" if [[ -f "$ENV_FILE" ]]; then configured="$(env_file_value "$ENV_FILE" WARMBLY_WORKER_IMAGE || true)" fi if [[ -n "$configured" ]]; then IMAGE="$configured" fi } install_auto_update_timer() { [[ "$AUTO_UPDATE" -eq 1 ]] || return 0 command -v curl >/dev/null 2>&1 || return 0 cat > "$INSTALLER_BIN" < "$AUTO_UPDATE_UNIT_FILE" < "$AUTO_UPDATE_TIMER_FILE" </dev/null 2>&1 || true ok "auto-update timer enabled" } 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" # Owned by uid/gid 1000 to match the worker user inside the container. install -d -m 0755 -o 1000 -g 1000 "$BLOB_DIR" 2>/dev/null || install -d -m 0777 "$BLOB_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" <.env that # carries WORKER_BIND_IP (and the derived WORKER_ID). systemd's template # instancing (%i) picks the right env file at start. # Turns 1.2.3.4 into 1-2-3-4 so it can be used as a systemd instance name. ip_to_instance() { echo "${1//./-}" } # Inverse of ip_to_instance. instance_to_ip() { echo "${1//-/.}" } parse_ips_list() { local raw="$1" raw="${raw// /}" [[ -n "$raw" ]] || die "--ips given but empty" local -a ips IFS=',' read -ra ips <<< "$raw" local seen="" for ip in "${ips[@]}"; do valid_ipv4 "$ip" || die "invalid IPv4 in --ips: $ip" if [[ ",$seen," == *",$ip,"* ]]; then die "duplicate IP in --ips: $ip" fi seen="${seen},${ip}" done echo "${ips[@]}" } write_instance_env() { local ip="$1" local worker_id; worker_id="$(derive_uuid_from_ip "$ip")" install -d -m 0700 "$INSTANCES_DIR" local inst; inst="$(ip_to_instance "$ip")" local path="${INSTANCES_DIR}/${inst}.env" local tmp; tmp="$(mktemp)" { echo "# Per-instance config for warmbly-worker@${inst}.service" echo "# Generated by install-worker.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "WORKER_BIND_IP=${ip}" echo "WORKER_ID=${worker_id}" } > "$tmp" install -m 0600 "$tmp" "$path" rm -f "$tmp" echo "$worker_id" } write_template_unit() { # Container name is per-instance so multiple containers can coexist. # %i is the systemd instance specifier, e.g. warmbly-worker@1-2-3-4 # gives %i=1-2-3-4. We pass it through to the container as the docker # name suffix. cat > "$TEMPLATE_UNIT_FILE" </dev/null 2>&1 || true systemctl restart warmbly-worker.service install_auto_update_timer 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 < instance ${C_BLD}${inst}${C_RST} worker_id ${worker_id}" done # Stop any previously-installed instance that's no longer in the desired set. local existing while IFS= read -r existing; do [[ -z "$existing" ]] && continue local keep=0 for d in "${desired[@]}"; do [[ "$d" == "$existing" ]] && keep=1 && break done if [[ $keep -eq 0 ]]; then log "removing stale instance: $existing" systemctl stop "warmbly-worker@${existing}.service" 2>/dev/null || true systemctl disable "warmbly-worker@${existing}.service" 2>/dev/null || true docker rm -f "${CONTAINER_NAME}-${existing}" >/dev/null 2>&1 || true rm -f "${INSTANCES_DIR}/${existing}.env" fi done < <(list_installed_instances) # If a legacy single-IP unit exists, take it down so it doesn't fight the # template instances over network identity. if [[ -f "$UNIT_FILE" ]]; then warn "legacy ${UNIT_FILE} found; disabling in favour of template instances" 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 fi for inst in "${desired[@]}"; do systemctl enable "warmbly-worker@${inst}.service" >/dev/null 2>&1 || true systemctl restart "warmbly-worker@${inst}.service" done install_auto_update_timer ok "started ${count} worker instances" sleep 2 local healthy=0 for inst in "${desired[@]}"; do if systemctl is-active --quiet "warmbly-worker@${inst}.service"; then healthy=$((healthy + 1)) else warn "instance ${inst} not active; check: journalctl -u warmbly-worker@${inst} -n 100 --no-pager" fi done ok "${healthy}/${count} instances healthy" cat <.env unit template ${TEMPLATE_UNIT_FILE} Each instance derives its worker id from its bound IP, so reinstalling with the same IP set is idempotent. Bringing one IP down only stops that one instance; the rest keep sending. Hosted Warmbly tip: do not put more than ~25% of your fleet IPs on a single box (blast radius). Manage: systemctl status 'warmbly-worker@*' journalctl -u warmbly-worker@1-2-3-4 -f $0 --status $0 --update --ips ${IPS_LIST} $0 --uninstall EOF } do_update() { require_root resolve_update_image local -a instances=() while IFS= read -r line; do [[ -n "$line" ]] && instances+=("$line") done < <(list_installed_instances) if [[ ${#instances[@]} -gt 0 ]]; then log "pulling $IMAGE" docker pull "$IMAGE" log "rewriting template unit" write_template_unit local inst for inst in "${instances[@]}"; do systemctl restart "warmbly-worker@${inst}.service" done ok "${#instances[@]} worker instances updated to $IMAGE" return fi [[ -f "$UNIT_FILE" ]] || die "worker not installed (no $UNIT_FILE)" # Recover worker id from /etc/warmbly/worker.id so the regenerated unit # keeps the same hostname and therefore the same Kafka topic subscription. local worker_id="" if [[ -f "$ID_FILE" ]]; then # ID_FILE is either a bare UUID (legacy) or "id=\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 # Multi-IP: stop every templated instance and remove the template + drop-ins. local -a instances=() while IFS= read -r line; do [[ -n "$line" ]] && instances+=("$line") done < <(list_installed_instances) if [[ ${#instances[@]} -gt 0 ]]; then log "stopping ${#instances[@]} worker instances" local inst for inst in "${instances[@]}"; do systemctl stop "warmbly-worker@${inst}.service" 2>/dev/null || true systemctl disable "warmbly-worker@${inst}.service" 2>/dev/null || true docker rm -f "${CONTAINER_NAME}-${inst}" >/dev/null 2>&1 || true rm -f "${INSTANCES_DIR}/${inst}.env" done rm -f "$TEMPLATE_UNIT_FILE" rmdir "$INSTANCES_DIR" 2>/dev/null || true fi # Legacy single-IP cleanup (also runs when no instances exist). if [[ -f "$UNIT_FILE" ]] || systemctl is-active --quiet warmbly-worker.service 2>/dev/null; then 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" docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true fi systemctl daemon-reload systemctl disable --now warmbly-worker-auto-update.timer 2>/dev/null || true rm -f "$AUTO_UPDATE_UNIT_FILE" "$AUTO_UPDATE_TIMER_FILE" "$INSTALLER_BIN" systemctl daemon-reload ok "worker removed (config preserved in $CONFIG_DIR)" } do_purge() { do_uninstall rm -rf "$CONFIG_DIR" ok "purged $CONFIG_DIR" } do_status() { local -a instances=() while IFS= read -r line; do [[ -n "$line" ]] && instances+=("$line") done < <(list_installed_instances) if [[ ${#instances[@]} -gt 0 ]]; then log "multi-IP mode: ${#instances[@]} instance(s)" printf "\n %-18s %-20s %-12s %s\n" "IP" "INSTANCE" "STATE" "WORKER_ID" printf " %-18s %-20s %-12s %s\n" "------------------" "--------------------" "------------" "------------------------------------" local inst ip state wid for inst in "${instances[@]}"; do ip="$(instance_to_ip "$inst")" state="$(systemctl is-active "warmbly-worker@${inst}.service" 2>/dev/null || true)" wid="$(grep '^WORKER_ID=' "${INSTANCES_DIR}/${inst}.env" 2>/dev/null | head -1 | cut -d= -f2- || true)" printf " %-18s %-20s %-12s %s\n" "$ip" "$inst" "${state:-unknown}" "${wid:-?}" done echo log "tail one instance: ${C_BLD}journalctl -u warmbly-worker@${instances[0]} -f${C_RST}" return fi 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