mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-18 16:01:18 +00:00
948 lines
29 KiB
Bash
Executable File
948 lines
29 KiB
Bash
Executable File
#!/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 <<EOF
|
|
${C_BLD}warmbly worker installer${C_RST}
|
|
|
|
Usage:
|
|
install-worker.sh [flags]
|
|
|
|
Actions:
|
|
--install Install or reconfigure the worker (default)
|
|
--update Pull the latest image and restart
|
|
--uninstall Stop and remove the worker (keeps config)
|
|
--purge Stop, remove, and delete /etc/warmbly entirely
|
|
--status Show worker status and recent logs
|
|
|
|
Configuration flags:
|
|
--ip <ipv4> Public IP to derive worker identity from
|
|
(default: auto-detected via ifconfig.me/etc.)
|
|
--ips <ipv4,ipv4,...> 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 <uuid> Pin a specific worker UUID, bypassing IP derivation
|
|
--tier <shared|dedicated> Worker tier label (default: shared)
|
|
--image <ref> Docker image (default: ${IMAGE})
|
|
--env-file <path> Use this env file verbatim, skip prompts
|
|
--enroll <token> Exchange a one-time dashboard enrollment token
|
|
for worker config and install without prompts
|
|
--api-base <url> API base for --enroll (default: ${API_BASE})
|
|
--no-auto-update Do not install the systemd auto-update timer
|
|
|
|
--kafka <bootstrap> Kafka bootstrap servers (host:port[,host:port])
|
|
--kafka-user <user>
|
|
--kafka-pass <pass>
|
|
|
|
--schema-registry <url>
|
|
--schema-key <key>
|
|
--schema-secret <secret>
|
|
|
|
--redis <url> e.g. redis://default:pass@host:6379
|
|
|
|
--aws-region <region>
|
|
--aws-key <key>
|
|
--aws-secret <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" <<EOF
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
tmp="\$(mktemp)"
|
|
curl -fsSL "${API_BASE%/}/worker-install.sh" -o "\$tmp"
|
|
install -m 0755 "\$tmp" "$INSTALLER_BIN"
|
|
rm -f "\$tmp"
|
|
exec "$INSTALLER_BIN" --update --api-base "${API_BASE%/}"
|
|
EOF
|
|
chmod 0755 "$INSTALLER_BIN"
|
|
|
|
cat > "$AUTO_UPDATE_UNIT_FILE" <<EOF
|
|
[Unit]
|
|
Description=Warmbly worker auto-update
|
|
After=network-online.target docker.service
|
|
Wants=network-online.target docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=$INSTALLER_BIN
|
|
EOF
|
|
|
|
cat > "$AUTO_UPDATE_TIMER_FILE" <<EOF
|
|
[Unit]
|
|
Description=Run Warmbly worker auto-update
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* 04:00:00
|
|
RandomizedDelaySec=3600
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
chmod 0644 "$AUTO_UPDATE_UNIT_FILE" "$AUTO_UPDATE_TIMER_FILE"
|
|
systemctl daemon-reload
|
|
systemctl enable --now warmbly-worker-auto-update.timer >/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 <ipv4>)"
|
|
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" <<EOF
|
|
[Unit]
|
|
Description=Warmbly worker
|
|
Documentation=https://docs.warmbly.com/workers
|
|
Wants=network-online.target docker.service
|
|
After=network-online.target docker.service
|
|
Requires=docker.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
Restart=always
|
|
RestartSec=10
|
|
TimeoutStartSec=0
|
|
|
|
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}
|
|
ExecStartPre=/usr/bin/docker pull ${IMAGE}
|
|
ExecStart=/usr/bin/docker run --rm \\
|
|
--name ${CONTAINER_NAME} \\
|
|
--hostname ${worker_id} \\
|
|
--network host \\
|
|
--env-file ${ENV_FILE} \\
|
|
-v ${BLOB_DIR}:/data/blobs \\
|
|
--log-driver=journald \\
|
|
${IMAGE}
|
|
|
|
ExecStop=/usr/bin/docker stop -t 30 ${CONTAINER_NAME}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
chmod 0644 "$UNIT_FILE"
|
|
systemctl daemon-reload
|
|
ok "wrote $UNIT_FILE"
|
|
}
|
|
|
|
# ---------- multi-IP helpers ----------
|
|
#
|
|
# Multi-IP mode runs one worker process per IP, all on the same box. Each
|
|
# instance shares the common ${ENV_FILE} for Kafka/KMS/etc. and adds a
|
|
# per-instance drop-in env file at ${INSTANCES_DIR}/<dashed-ip>.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" <<EOF
|
|
[Unit]
|
|
Description=Warmbly worker (egress %i)
|
|
Documentation=https://docs.warmbly.com/workers
|
|
Wants=network-online.target docker.service
|
|
After=network-online.target docker.service
|
|
Requires=docker.service
|
|
|
|
[Service]
|
|
Type=simple
|
|
Restart=always
|
|
RestartSec=10
|
|
TimeoutStartSec=0
|
|
|
|
ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME}-%i
|
|
ExecStartPre=/usr/bin/docker pull ${IMAGE}
|
|
ExecStart=/usr/bin/docker run --rm \\
|
|
--name ${CONTAINER_NAME}-%i \\
|
|
--network host \\
|
|
--env-file ${ENV_FILE} \\
|
|
--env-file ${INSTANCES_DIR}/%i.env \\
|
|
-v ${BLOB_DIR}:/data/blobs \\
|
|
--log-driver=journald \\
|
|
${IMAGE}
|
|
|
|
ExecStop=/usr/bin/docker stop -t 30 ${CONTAINER_NAME}-%i
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
chmod 0644 "$TEMPLATE_UNIT_FILE"
|
|
systemctl daemon-reload
|
|
ok "wrote $TEMPLATE_UNIT_FILE"
|
|
}
|
|
|
|
list_installed_instances() {
|
|
[[ -d "$INSTANCES_DIR" ]] || return 0
|
|
local f
|
|
for f in "$INSTANCES_DIR"/*.env; do
|
|
[[ -f "$f" ]] || continue
|
|
basename "$f" .env
|
|
done
|
|
}
|
|
|
|
# ---------- actions ----------
|
|
|
|
prepare_common_env() {
|
|
if fetch_enrollment_env; then
|
|
return
|
|
fi
|
|
|
|
if [[ -n "$SUPPLIED_ENV_FILE" ]]; then
|
|
[[ -f "$SUPPLIED_ENV_FILE" ]] || die "env file not found: $SUPPLIED_ENV_FILE"
|
|
install -d -m 0700 "$CONFIG_DIR"
|
|
install -m 0600 "$SUPPLIED_ENV_FILE" "$ENV_FILE"
|
|
ok "installed env file from $SUPPLIED_ENV_FILE"
|
|
return
|
|
fi
|
|
|
|
merge_existing_env
|
|
prompt_if_missing KAFKA_BOOTSTRAP_SERVERS "Kafka bootstrap servers"
|
|
prompt_if_missing SCHEMA_REGISTRY_URL "Schema Registry URL"
|
|
prompt_if_missing REDIS "Redis URL"
|
|
prompt_if_missing AWS_REGION "AWS region" 0 "us-east-1"
|
|
prompt_if_missing AWS_ACCESS_KEY_ID "AWS access key ID"
|
|
prompt_if_missing AWS_SECRET_ACCESS_KEY "AWS secret access key" 1
|
|
|
|
# SASL + schema auth are optional; only prompt if interactive and unset
|
|
if [[ $INTERACTIVE -eq 1 ]]; then
|
|
[[ -z "${CFG[KAFKA_SASL_USERNAME]}" ]] && read -rp "Kafka SASL user (leave blank if none): " CFG[KAFKA_SASL_USERNAME] || true
|
|
[[ -z "${CFG[KAFKA_SASL_PASSWORD]}" && -n "${CFG[KAFKA_SASL_USERNAME]}" ]] && { read -rsp "Kafka SASL pass: " CFG[KAFKA_SASL_PASSWORD]; echo; }
|
|
[[ -z "${CFG[SCHEMA_REGISTRY_KEY]}" ]] && read -rp "Schema Registry key (leave blank if none): " CFG[SCHEMA_REGISTRY_KEY] || true
|
|
[[ -z "${CFG[SCHEMA_REGISTRY_SECRET]}" && -n "${CFG[SCHEMA_REGISTRY_KEY]}" ]] && { read -rsp "Schema Registry secret: " CFG[SCHEMA_REGISTRY_SECRET]; echo; }
|
|
fi
|
|
|
|
write_env_file
|
|
}
|
|
|
|
do_install() {
|
|
require_root
|
|
ensure_docker
|
|
ensure_uuidgen
|
|
|
|
if [[ -n "$IPS_LIST" ]]; then
|
|
do_install_multi
|
|
return
|
|
fi
|
|
|
|
prepare_common_env
|
|
|
|
local worker_id; worker_id="$(resolve_worker_id)"
|
|
log "public ip: ${C_BLD}${WORKER_IP}${C_RST}"
|
|
log "worker id: ${C_BLD}${worker_id}${C_RST} (derived from ip)"
|
|
|
|
write_unit "$worker_id"
|
|
|
|
systemctl enable warmbly-worker.service >/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 <<EOF
|
|
|
|
${C_BLD}Worker installed.${C_RST}
|
|
|
|
public ip ${WORKER_IP}
|
|
worker id ${worker_id}
|
|
image ${IMAGE}
|
|
env file ${ENV_FILE}
|
|
unit ${UNIT_FILE}
|
|
|
|
Same IP always resolves to the same worker id. If this VPS's public IP
|
|
changes, the worker id will change too. Re-run this script to refresh.
|
|
|
|
Register this worker id in the Warmbly control plane to start receiving work.
|
|
|
|
Manage:
|
|
systemctl status warmbly-worker
|
|
journalctl -u warmbly-worker -f
|
|
$0 --update
|
|
$0 --uninstall
|
|
|
|
EOF
|
|
}
|
|
|
|
do_install_multi() {
|
|
# Parse, dedupe, validate the IP list before touching anything on disk.
|
|
local -a ips
|
|
read -ra ips <<< "$(parse_ips_list "$IPS_LIST")"
|
|
local count="${#ips[@]}"
|
|
|
|
log "installing ${C_BLD}${count}${C_RST} worker processes, one per IP"
|
|
|
|
prepare_common_env
|
|
write_template_unit
|
|
|
|
# Track which instance names we end up with so we can disable any stale
|
|
# instances from a previous install with a different IP set.
|
|
local -a desired=()
|
|
local ip inst worker_id
|
|
for ip in "${ips[@]}"; do
|
|
inst="$(ip_to_instance "$ip")"
|
|
worker_id="$(write_instance_env "$ip")"
|
|
desired+=("$inst")
|
|
log "egress ${C_BLD}${ip}${C_RST} -> 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 <<EOF
|
|
|
|
${C_BLD}Multi-IP workers installed.${C_RST}
|
|
|
|
ips ${IPS_LIST}
|
|
instances ${count}
|
|
image ${IMAGE}
|
|
env file ${ENV_FILE}
|
|
per-instance ${INSTANCES_DIR}/<dashed-ip>.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=<uuid>\nip=<ip>" (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
|