#!/usr/bin/env bash # # Kubeara control panel — self-hosted installer (Docker Compose). # # Fully standalone for curl | bash (or curl | sh): requires bash, Docker, Compose, # openssl, and curl. Embeds docker-compose.control-panel.yml and generates # .env.control-panel (no git clone). Images from Docker Hub. # # Usage (macOS / Linux / Windows WSL or Git Bash): # curl -fsSL https://get.kubeara.dev | sh # # Windows PowerShell (native): # irm https://get.kubeara.dev/install.ps1 | iex # # From a git clone: # ./install.sh # # Environment: # KUBEARA_INSTALL_DIR Install directory (default: /opt/kubeara/control-panel or ~/.kubeara/control-panel) # KUBEARA_INSTALL_BASE Optional URL to download compose/env instead of embedded defaults # KUBEARA_VERSION Git ref when using KUBEARA_INSTALL_BASE (default: main) # KUBEARA_CHANNEL Docker image tag when KUBEARA_SET_IMAGE_TAGS=1 (default: prod) # KUBEARA_SET_IMAGE_TAGS 1 = set all images to KUBEARA_CHANNEL; 0 = keep .env.example tags (local default) # KUBEARA_CONSOLE_IMAGE_OVERRIDE Explicit console image (do not export KUBEARA_CONSOLE_IMAGE — same name as .env key) # KUBEARA_CONTROL_PANEL_IMAGE / KUBEARA_AGENT_IMAGE Override API/agent images # VITE_API_URL Browser API URL (incl. /api). Default: auto public IP on VPS, # else http://localhost:3000/api. Override for domain/LAN/custom. # CONTROL_PANEL_URL Agent/onboard URL (default: derived from VITE_API_URL) # IS_CLOUD_VERSION true = agents use CONTROL_PANEL_URL directly (no SSH tunnels); # false/omit = self-host SSH reverse tunnels # KUBEARA_TRACKING_URL Override installation tracking endpoint # Default: https://api.kubeara.dev/api/public/installations/events # KUBEARA_SKIP_DOCKER_INSTALL=1 On Linux, do not auto-install Docker Engine when missing # # Installation lifecycle tracking (POST …/api/public/installations/events): # INSTALL — no .installation-id yet (or id exists but .version never saved) # UPGRADE — .installation-id exists and .version differs from the version # in the repository's package.json # (skip) — same version already reported in .version # UNINSTALL — handled by uninstall.sh; clears .installation-id + .version # # Version files (under KUBEARA_INSTALL_DIR): # .installation-id Stable UUID for this host install (kept across upgrades) # .version Last version successfully reported (INSTALL or UPGRADE) # # ENCRYPTION_SECRET Pre-set secret (default: auto-generate) # SKIP_MIGRATE=1 Skip database migrations + seed # KUBEARA_FORCE_ENV=1 Regenerate .env.control-panel from example # --------------------------------------------------------------------------- # POSIX-safe bootstrap: Ubuntu/Debian `sh` is dash (no pipefail). # # Piping a bash script to dash cannot safely continue the same stdin stream # (dash buffers ahead; forked readers lose those bytes). Supported handoffs: # 1) sh ./install.sh → exec bash on the file # 2) curl URL | bash → no handoff needed # 3) curl URL | sh → re-fetch under bash (defaults to https://get.kubeara.dev; # override with KUBEARA_INSTALL_URL when the script is served from another host) # --------------------------------------------------------------------------- if [ -z "${BASH_VERSION:-}" ]; then if ! command -v bash >/dev/null 2>&1; then echo "[kubeara-install] ERROR: bash is required." >&2 echo " Install bash, then re-run: curl -fsSL https://get.kubeara.dev | bash" >&2 exit 1 fi # File invocation: sh ./install.sh case "$0" in sh | -sh | /bin/sh | /usr/bin/sh | dash | /bin/dash | /usr/bin/dash) ;; *) if [ -f "$0" ]; then exec bash "$0" "$@" fi ;; esac _kubeara_install_url="${KUBEARA_INSTALL_URL:-https://get.kubeara.dev}" if ! command -v curl >/dev/null 2>&1; then echo "[kubeara-install] ERROR: curl is required when piping to sh/dash." >&2 echo " Prefer: curl -fsSL ${_kubeara_install_url} | bash" >&2 exit 1 fi echo "[kubeara-install] Detected sh/dash; re-running under bash from ${_kubeara_install_url}" >&2 exec bash -c 'curl -fsSL "$1" | bash -s -- "${@:2}"' bash "${_kubeara_install_url}" "$@" fi set -euo pipefail readonly LOG_PREFIX="[kubeara-install]" readonly COMPOSE_FILE="docker-compose.control-panel.yml" readonly ENV_FILE=".env.control-panel" readonly ENV_EXAMPLE=".env.control-panel.example" readonly INSTALLATION_ID_FILE=".installation-id" readonly INSTALLATION_VERSION_FILE=".version" readonly DEFAULT_TRACKING_URL="https://api.kubeara.dev/api/public/installations/events" readonly DOCKER_ENGINE_INSTALL_URL="https://get.docker.com" # How we invoke the Docker CLI: direct | sudo | sudo-n | sg KUBEARA_DOCKER_MODE="direct" # Compose invocation: plugin (`docker compose`) | standalone (`docker-compose`) KUBEARA_COMPOSE_KIND="plugin" KUBEARA_REPO="${KUBEARA_REPO:-kubeara/core}" KUBEARA_VERSION="${KUBEARA_VERSION:-main}" KUBEARA_CHANNEL="${KUBEARA_CHANNEL:-prod}" KUBEARA_INSTALL_SOURCE="remote" KUBEARA_DEPLOY_DIR="" info() { echo "${LOG_PREFIX} $*" } warn() { echo "${LOG_PREFIX} WARNING: $*" >&2 } error() { echo "${LOG_PREFIX} ERROR: $*" >&2 exit 1 } usage() { cat </dev/null || true)" in Darwin) echo "macos" ;; Linux) echo "linux" ;; MINGW* | MSYS* | CYGWIN*) echo "windows" ;; *) echo "unknown" ;; esac } # Runs docker with the resolved privilege mode (direct / sudo / sg). run_docker() { case "${KUBEARA_DOCKER_MODE}" in sudo) sudo docker "$@" ;; sudo-n) sudo -n docker "$@" ;; sg) # Quote each arg for safe eval inside sg docker -c. local quoted="" arg for arg in "$@"; do quoted+=" $(printf '%q' "${arg}")" done sg docker -c "docker${quoted}" ;; *) docker "$@" ;; esac } docker_info_ok() { local mode="$1" case "${mode}" in direct) docker info >/dev/null 2>&1 ;; sudo-n) sudo -n docker info >/dev/null 2>&1 ;; sudo) sudo docker info >/dev/null 2>&1 ;; sg) command -v sg >/dev/null 2>&1 && sg docker -c "docker info >/dev/null 2>&1" ;; *) return 1 ;; esac } docker_compose_plugin_ok() { run_docker compose version >/dev/null 2>&1 } # Standalone binary (`docker-compose`), including under the same privilege mode as docker. docker_compose_standalone_ok() { if ! command -v docker-compose >/dev/null 2>&1; then return 1 fi case "${KUBEARA_DOCKER_MODE}" in sudo) sudo docker-compose version >/dev/null 2>&1 \ || sudo docker-compose --version >/dev/null 2>&1 ;; sudo-n) sudo -n docker-compose version >/dev/null 2>&1 \ || sudo -n docker-compose --version >/dev/null 2>&1 ;; sg) sg docker -c "docker-compose version >/dev/null 2>&1" \ || sg docker -c "docker-compose --version >/dev/null 2>&1" ;; *) docker-compose version >/dev/null 2>&1 \ || docker-compose --version >/dev/null 2>&1 ;; esac } resolve_compose_kind() { if docker_compose_plugin_ok; then KUBEARA_COMPOSE_KIND="plugin" return 0 fi if docker_compose_standalone_ok; then KUBEARA_COMPOSE_KIND="standalone" return 0 fi return 1 } # Runs Compose with the resolved docker privilege mode + plugin/standalone kind. run_compose() { case "${KUBEARA_COMPOSE_KIND}" in standalone) case "${KUBEARA_DOCKER_MODE}" in sudo) sudo docker-compose "$@" ;; sudo-n) sudo -n docker-compose "$@" ;; sg) local quoted="" arg for arg in "$@"; do quoted+=" $(printf '%q' "${arg}")" done sg docker -c "docker-compose${quoted}" ;; *) docker-compose "$@" ;; esac ;; *) run_docker compose "$@" ;; esac } # Prefer unprivileged docker; fall back to passwordless sudo, interactive sudo, then sg docker. resolve_docker_mode() { if ! command -v docker >/dev/null 2>&1; then return 1 fi if docker_info_ok direct; then KUBEARA_DOCKER_MODE="direct" return 0 fi if docker_info_ok sudo-n; then KUBEARA_DOCKER_MODE="sudo-n" info "Docker requires elevated privileges; using sudo -n docker" return 0 fi if docker_info_ok sg; then KUBEARA_DOCKER_MODE="sg" info "Docker group is inactive in this session; using sg docker" return 0 fi # sudo reads the password from /dev/tty even when stdin is a curl pipe. if docker_info_ok sudo; then KUBEARA_DOCKER_MODE="sudo" info "Docker requires elevated privileges; using sudo docker" return 0 fi return 1 } wait_for_docker_daemon() { local seconds="${1:-120}" local i for i in $(seq 1 "${seconds}"); do if resolve_docker_mode; then return 0 fi sleep 1 done return 1 } # macOS: never install Docker Desktop from this script — only start it if present. ensure_docker_macos() { if resolve_docker_mode; then return 0 fi if ! command -v docker >/dev/null 2>&1 && [[ ! -d "/Applications/Docker.app" ]]; then error "Docker Desktop is not installed on this Mac. Install it from https://docs.docker.com/desktop/setup/install/mac-install/ then start Docker Desktop and re-run this script." fi info "Docker is installed but the daemon is not running. Starting Docker Desktop…" if [[ -d "/Applications/Docker.app" ]]; then open -a Docker >/dev/null 2>&1 || true else warn "Could not find /Applications/Docker.app — open Docker Desktop manually." fi info "Waiting for Docker Desktop to become ready (up to 2 minutes)…" if wait_for_docker_daemon 120; then return 0 fi error "Docker Desktop did not become ready in time. Open Docker Desktop from Applications, wait until it says Running, then re-run: curl -fsSL https://get.kubeara.dev | bash" } # Linux: optionally install Docker Engine when the CLI is missing. install_docker_engine_linux() { if [[ "${KUBEARA_SKIP_DOCKER_INSTALL:-}" == "1" ]]; then error "Docker is not installed. Install Docker Engine, then re-run this script. https://docs.docker.com/engine/install/ Or unset KUBEARA_SKIP_DOCKER_INSTALL to let this installer run get.docker.com." fi if ! command -v curl >/dev/null 2>&1; then error "curl is required to install Docker. Install curl, then re-run this script." fi info "Docker is not installed. Installing Docker Engine via ${DOCKER_ENGINE_INSTALL_URL}…" if [[ "$(id -u)" -eq 0 ]]; then sh -c "curl -fsSL ${DOCKER_ENGINE_INSTALL_URL} | sh" else if ! command -v sudo >/dev/null 2>&1; then error "Docker is not installed and sudo is unavailable. Install Docker as root, then re-run." fi curl -fsSL "${DOCKER_ENGINE_INSTALL_URL}" | sudo sh fi if command -v systemctl >/dev/null 2>&1; then sudo systemctl enable --now docker >/dev/null 2>&1 || sudo systemctl start docker >/dev/null 2>&1 || true elif command -v service >/dev/null 2>&1; then sudo service docker start >/dev/null 2>&1 || true fi if [[ "$(id -u)" -ne 0 ]] && command -v usermod >/dev/null 2>&1; then if ! id -nG "${USER}" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then info "Adding ${USER} to the docker group (takes effect in new sessions)…" sudo usermod -aG docker "${USER}" >/dev/null 2>&1 || true fi fi } ensure_docker_linux() { if ! command -v docker >/dev/null 2>&1; then install_docker_engine_linux fi if resolve_docker_mode; then return 0 fi info "Starting Docker daemon…" if command -v systemctl >/dev/null 2>&1; then sudo systemctl start docker >/dev/null 2>&1 || true elif command -v service >/dev/null 2>&1; then sudo service docker start >/dev/null 2>&1 || true fi if wait_for_docker_daemon 30; then return 0 fi error "Docker is installed but not usable from this session. Try one of: sudo usermod -aG docker \$USER && newgrp docker sudo docker info Then re-run: curl -fsSL https://get.kubeara.dev | bash" } ensure_docker_windows_shell() { if resolve_docker_mode; then return 0 fi if ! command -v docker >/dev/null 2>&1; then error "Docker is not available in this shell. Install Docker Desktop for Windows, enable WSL2 (or Git Bash) integration, start Docker Desktop, then re-run this script. Native PowerShell: irm https://get.kubeara.dev/install.ps1 | iex" fi error "Docker Desktop appears installed but the daemon is not running. Start Docker Desktop, wait until it is Running, then re-run this script." } require_docker() { local os os="$(detect_host_os)" case "${os}" in macos) ensure_docker_macos ;; linux) ensure_docker_linux ;; windows) ensure_docker_windows_shell ;; *) if ! resolve_docker_mode; then error "Unsupported OS ($(uname -s 2>/dev/null || echo unknown)). Supported: macOS, Linux, Windows (WSL / Git Bash / PowerShell)." fi ;; esac if ! resolve_compose_kind; then error "Docker Compose v2 is required (docker compose plugin or docker-compose). Found docker-compose: $(command -v docker-compose 2>/dev/null || echo 'no') Plugin check: $(run_docker compose version 2>&1 | head -n1 || true) Standalone check: $(docker-compose --version 2>&1 | head -n1 || true) Plugin: https://docs.docker.com/compose/install/linux/ Standalone: https://docs.docker.com/compose/install/standalone/" fi info "Docker ready (mode: ${KUBEARA_DOCKER_MODE}, compose: ${KUBEARA_COMPOSE_KIND})" } fetch_deploy_file() { local name="$1" local dest="$2" if [[ "${KUBEARA_INSTALL_SOURCE}" == "local" ]]; then cp "${KUBEARA_DEPLOY_DIR}/${name}" "${dest}" else local base="${KUBEARA_INSTALL_BASE:-https://raw.githubusercontent.com/${KUBEARA_REPO}/${KUBEARA_VERSION}/apps/control-panel-app/deploy}" curl -fsSL "${base}/${name}" -o "${dest}" fi } set_env_var() { local key="$1" local value="$2" local file="$3" local tmp="${file}.tmp.$$" if grep -q "^${key}=" "${file}" 2>/dev/null; then awk -v k="${key}" -v v="${value}" ' BEGIN { FS=OFS="=" } $1 == k { print k "=" v; next } { print } ' "${file}" > "${tmp}" mv "${tmp}" "${file}" else echo "${key}=${value}" >> "${file}" fi } default_local_api_url() { echo "http://127.0.0.1:${PORT:-3000}" } normalize_vite_api_url() { local base="${1%/}" [[ -z "${base}" ]] && return 1 if [[ "${base}" == */api ]]; then echo "${base}" else echo "${base}/api" fi } # Origin used by agents / CORS (strip trailing /api). vite_api_url_to_origin() { local u="${1%/}" echo "${u%/api}" } is_private_or_local_ipv4() { local ip="$1" case "${ip}" in 127.* | 10.* | 192.168.* | 169.254.*) return 0 ;; 172.1[6-9].* | 172.2[0-9].* | 172.3[0-1].*) return 0 ;; "") return 0 ;; *) return 1 ;; esac } # Outbound default-route IPv4 (public on many VPS images; private on laptops). detect_default_route_ipv4() { local ip="" if command -v ip >/dev/null 2>&1; then ip="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit } }' || true)" fi if [[ -z "${ip}" ]] && [[ "$(uname -s)" == "Darwin" ]]; then local iface="" iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}' || true)" if [[ -n "${iface}" ]]; then ip="$(ipconfig getifaddr "${iface}" 2>/dev/null || true)" fi fi if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && [[ "${ip}" != 127.* ]]; then echo "${ip}" return 0 fi return 1 } # Browser-facing API URL. Only knob users need to override for remote/domain/LAN. # Default: public default-route IP → http://IP:PORT/api; else localhost. default_vite_api_url() { local ip="" if [[ -n "${VITE_API_URL:-}" ]]; then normalize_vite_api_url "${VITE_API_URL}" return 0 fi ip="$(detect_default_route_ipv4 || true)" if [[ -n "${ip}" ]] && ! is_private_or_local_ipv4 "${ip}"; then info "Using detected public IP for VITE_API_URL: ${ip}" echo "http://${ip}:${PORT:-3000}/api" return 0 fi echo "http://localhost:${PORT:-3000}/api" } # Agent URL: explicit CONTROL_PANEL_URL, else same host as VITE_API_URL (127.0.0.1 when local). # Pass the resolved VITE_API_URL as $1 when known (avoids a second detect). default_control_panel_url() { local origin="" local vite_api_url="${1:-}" if [[ -n "${CONTROL_PANEL_URL:-}" ]]; then echo "${CONTROL_PANEL_URL%/}" return 0 fi if [[ -z "${vite_api_url}" ]]; then vite_api_url="$(default_vite_api_url)" fi origin="$(vite_api_url_to_origin "${vite_api_url}")" case "${origin}" in http://localhost:* | http://127.0.0.1:* | https://localhost:* | https://127.0.0.1:*) default_local_api_url ;; *) echo "${origin}" ;; esac } # Add console + API origins from VITE_API_URL when it is not loopback. cors_origins_with_vite_api() { local cors_origins="$1" local console_port="${2:-8080}" local vite_api_url="$3" local origin host scheme origin="$(vite_api_url_to_origin "${vite_api_url}")" case "${origin}" in http://localhost:* | http://127.0.0.1:* | https://localhost:* | https://127.0.0.1:* | "") echo "${cors_origins}" return 0 ;; esac cors_origins="${cors_origins},${origin}" case "${origin}" in http://* | https://*) host="${origin#*://}" host="${host%%/*}" host="${host%%:*}" scheme="${origin%%://*}" cors_origins="${cors_origins},${scheme}://${host}:${console_port}" ;; esac echo "${cors_origins}" } generate_encryption_secret() { if [[ -n "${ENCRYPTION_SECRET:-}" ]]; then echo "${ENCRYPTION_SECRET}" return 0 fi if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 return 0 fi error "openssl is required to generate ENCRYPTION_SECRET (or set ENCRYPTION_SECRET yourself)" } # Ensures .env uses kubeara/console:${KUBEARA_CHANNEL} (default prod). Does not read KUBEARA_CONSOLE_IMAGE # from the shell — same name as the .env key; exporting it breaks installs after "source .env.control-panel". apply_console_image() { local env_path="$1" local image tmp image="${KUBEARA_CONSOLE_IMAGE_OVERRIDE:-kubeara/console:${KUBEARA_CHANNEL}}" if [[ -n "${KUBEARA_CONSOLE_IMAGE_OVERRIDE:-}" ]]; then info "Console image override: ${image}" fi tmp="${env_path}.tmp.$$" grep -vE '^[[:space:]]*KUBEARA_CONSOLE_IMAGE=' "${env_path}" > "${tmp}" echo "KUBEARA_CONSOLE_IMAGE=${image}" >> "${tmp}" mv "${tmp}" "${env_path}" } prepare_install_dir() { mkdir -p "${KUBEARA_INSTALL_DIR}" cd "${KUBEARA_INSTALL_DIR}" } # Keep in sync with apps/control-panel-app/deploy/docker-compose.control-panel.yml write_embedded_compose_file() { local dest="$1" cat >"${dest}" <<'COMPOSE_EOF' # Kubeara control panel — pull images from Docker Hub (no source code required). services: postgres: image: postgres:16-alpine container_name: kubeara-postgres environment: POSTGRES_USER: ${DB_USERNAME:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} POSTGRES_DB: ${DB_DATABASE:-kubeara} ports: - "${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres}"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped migrate: profiles: ["migrate"] image: ${KUBEARA_CONTROL_PANEL_IMAGE} platform: ${DOCKER_PLATFORM:-linux/amd64} env_file: - .env.control-panel environment: # Self-hosted compose Postgres has no TLS. Default development so current # Hub images (which enable SSL when NODE_ENV=production) can connect. NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" DB_HOST: ${DB_HOST:-postgres} DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} volumes: - ./.env.control-panel:/app/apps/control-panel-app/.env:ro working_dir: /app command: [ "sh", "-c", "node ./node_modules/typeorm/cli.js migration:run -d dist/apps/control-panel-app/config/typeorm.config.js && node dist/apps/control-panel-app/seeders/seed.js", ] depends_on: postgres: condition: service_healthy restart: "no" control-panel-app: image: ${KUBEARA_CONTROL_PANEL_IMAGE} platform: ${DOCKER_PLATFORM:-linux/amd64} container_name: kubeara-control-panel ports: - "${PORT:-3000}:3000" env_file: - .env.control-panel environment: NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" PORT: ${PORT:-3000} DB_HOST: ${DB_HOST:-postgres} DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env.control-panel} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET in .env.control-panel} ACCESS_TOKEN_COOKIE_NAME: ${ACCESS_TOKEN_COOKIE_NAME:-kubeara_access_token} REFRESH_TOKEN_COOKIE_NAME: ${REFRESH_TOKEN_COOKIE_NAME:-kubeara_refresh_token} ACCESS_TOKEN_EXPIRES_IN: ${ACCESS_TOKEN_EXPIRES_IN:-15m} REFRESH_TOKEN_EXPIRES_IN: ${REFRESH_TOKEN_EXPIRES_IN:-7d} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SAME_SITE: ${COOKIE_SAME_SITE:-lax} CONTROL_PANEL_URL: ${CONTROL_PANEL_URL:-http://localhost:3000} IS_CLOUD_VERSION: ${IS_CLOUD_VERSION:-false} AGENT_SOCKET_TUNNEL_PORT: ${AGENT_SOCKET_TUNNEL_PORT:-1111} KUBEARA_AGENT_IMAGE: ${KUBEARA_AGENT_IMAGE:-kubeara/agent:prod} GRAFANA_CLOUD_LOKI_URL: ${GRAFANA_CLOUD_LOKI_URL:-} GRAFANA_CLOUD_LOKI_USER: ${GRAFANA_CLOUD_LOKI_USER:-} GRAFANA_CLOUD_LOKI_API_KEY: ${GRAFANA_CLOUD_LOKI_API_KEY:-} KUBEARA_ENV: ${KUBEARA_ENV:-} KUBEARA_HOST_LABEL: ${KUBEARA_HOST_LABEL:-control-panel} LOG_LEVEL: ${LOG_LEVEL:-info} volumes: - ./.env.control-panel:/app/apps/control-panel-app/.env:ro depends_on: postgres: condition: service_healthy restart: unless-stopped console: image: ${KUBEARA_CONSOLE_IMAGE:-kubeara/console:prod} platform: ${DOCKER_PLATFORM:-linux/amd64} container_name: kubeara-console ports: - "${CONSOLE_PORT:-8080}:80" environment: VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api} depends_on: control-panel-app: condition: service_started restart: unless-stopped volumes: postgres_data: COMPOSE_EOF } materialize_deploy_files() { local compose_dest="${KUBEARA_INSTALL_DIR}/${COMPOSE_FILE}" if [[ "${KUBEARA_INSTALL_SOURCE}" == "local" ]]; then cp "${KUBEARA_DEPLOY_DIR}/${COMPOSE_FILE}" "${compose_dest}" if [[ -f "${KUBEARA_DEPLOY_DIR}/${ENV_EXAMPLE}" ]]; then cp "${KUBEARA_DEPLOY_DIR}/${ENV_EXAMPLE}" "${KUBEARA_INSTALL_DIR}/${ENV_EXAMPLE}" fi return 0 fi if [[ -n "${KUBEARA_INSTALL_BASE:-}" ]]; then info "Downloading compose files from ${KUBEARA_INSTALL_BASE}…" fetch_deploy_file "${COMPOSE_FILE}" "${compose_dest}" fetch_deploy_file "${ENV_EXAMPLE}" "${KUBEARA_INSTALL_DIR}/${ENV_EXAMPLE}" || true return 0 fi info "Writing embedded ${COMPOSE_FILE} (no external download)" write_embedded_compose_file "${compose_dest}" } write_fresh_env_file() { local env_path="$1" local secret platform vite_api_url control_panel_url port console_port local jwt_access jwt_refresh local cp_image console_image agent_image local cors_origins is_cloud_version secret="$(generate_encryption_secret)" platform="${DOCKER_PLATFORM:-$(detect_docker_platform)}" port="${PORT:-3000}" console_port="${CONSOLE_PORT:-8080}" vite_api_url="$(default_vite_api_url)" control_panel_url="$(default_control_panel_url "${vite_api_url}")" jwt_access="${JWT_SECRET:-$(openssl rand -hex 32)}" jwt_refresh="${JWT_REFRESH_SECRET:-$(openssl rand -hex 32)}" cp_image="${KUBEARA_CONTROL_PANEL_IMAGE:-kubeara/control-panel:${KUBEARA_CHANNEL}}" console_image="${KUBEARA_CONSOLE_IMAGE_OVERRIDE:-kubeara/console:${KUBEARA_CHANNEL}}" agent_image="${KUBEARA_AGENT_IMAGE:-kubeara/agent:${KUBEARA_CHANNEL}}" cors_origins="${CORS_ALLOWED_ORIGINS:-http://localhost:${port},http://localhost:${console_port},http://127.0.0.1:${port},http://127.0.0.1:${console_port}}" cors_origins="$(cors_origins_with_vite_api "${cors_origins}" "${console_port}" "${vite_api_url}")" if [[ "${IS_CLOUD_VERSION:-}" == "true" ]]; then is_cloud_version="true" else is_cloud_version="false" fi cat >"${env_path}" </dev/null | head -n1 | cut -d= -f2- || echo 3000)" console_port="$(grep -E '^[[:space:]]*CONSOLE_PORT=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || echo 8080)" # VITE_API_URL is the primary browser knob — set when missing or explicitly overridden. current="$(grep -E '^[[:space:]]*VITE_API_URL=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -n "${VITE_API_URL:-}" || -z "${current}" ]]; then vite_api_url="$(default_vite_api_url)" set_env_var "VITE_API_URL" "${vite_api_url}" "${env_path}" elif [[ "${current}" == *localhost* || "${current}" == *127.0.0.1* ]]; then # Upgrade localhost → detected public IP on VPS re-installs. vite_api_url="$(default_vite_api_url)" if [[ "${vite_api_url}" != *localhost* && "${vite_api_url}" != *127.0.0.1* ]]; then set_env_var "VITE_API_URL" "${vite_api_url}" "${env_path}" else vite_api_url="${current}" fi else vite_api_url="${current}" fi current="$(grep -E '^[[:space:]]*CONTROL_PANEL_URL=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -n "${CONTROL_PANEL_URL:-}" || -n "${VITE_API_URL:-}" || -z "${current}" ]]; then control_panel_url="$(default_control_panel_url "${vite_api_url}")" set_env_var "CONTROL_PANEL_URL" "${control_panel_url}" "${env_path}" elif [[ "${current}" == *localhost* || "${current}" == *127.0.0.1* ]]; then control_panel_url="$(default_control_panel_url "${vite_api_url}")" if [[ "${control_panel_url}" != *localhost* && "${control_panel_url}" != *127.0.0.1* ]]; then set_env_var "CONTROL_PANEL_URL" "${control_panel_url}" "${env_path}" fi fi cors_origins="http://localhost:${port},http://localhost:${console_port},http://127.0.0.1:${port},http://127.0.0.1:${console_port}" cors_origins="$(cors_origins_with_vite_api "${cors_origins}" "${console_port}" "${vite_api_url}")" current="$(grep -E '^[[:space:]]*JWT_SECRET=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -z "${current}" || "${current}" == change-me-jwt-secret ]]; then set_env_var "JWT_SECRET" "${JWT_SECRET:-$(openssl rand -hex 32)}" "${env_path}" fi current="$(grep -E '^[[:space:]]*JWT_REFRESH_SECRET=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -z "${current}" || "${current}" == change-me-jwt-refresh-secret ]]; then set_env_var "JWT_REFRESH_SECRET" "${JWT_REFRESH_SECRET:-$(openssl rand -hex 32)}" "${env_path}" fi if ! grep -qE '^[[:space:]]*ACCESS_TOKEN_EXPIRES_IN=' "${env_path}"; then set_env_var "ACCESS_TOKEN_EXPIRES_IN" "${ACCESS_TOKEN_EXPIRES_IN:-15m}" "${env_path}" fi if ! grep -qE '^[[:space:]]*REFRESH_TOKEN_EXPIRES_IN=' "${env_path}"; then set_env_var "REFRESH_TOKEN_EXPIRES_IN" "${REFRESH_TOKEN_EXPIRES_IN:-7d}" "${env_path}" fi if ! grep -qE '^[[:space:]]*ACCESS_TOKEN_COOKIE_NAME=' "${env_path}"; then set_env_var "ACCESS_TOKEN_COOKIE_NAME" \ "${ACCESS_TOKEN_COOKIE_NAME:-kubeara_access_token}" \ "${env_path}" fi if ! grep -qE '^[[:space:]]*REFRESH_TOKEN_COOKIE_NAME=' "${env_path}"; then set_env_var "REFRESH_TOKEN_COOKIE_NAME" \ "${REFRESH_TOKEN_COOKIE_NAME:-kubeara_refresh_token}" \ "${env_path}" fi if ! grep -qE '^[[:space:]]*COOKIE_DOMAIN=' "${env_path}"; then set_env_var "COOKIE_DOMAIN" "${COOKIE_DOMAIN:-}" "${env_path}" fi if ! grep -qE '^[[:space:]]*COOKIE_SECURE=' "${env_path}"; then set_env_var "COOKIE_SECURE" "${COOKIE_SECURE:-false}" "${env_path}" fi if ! grep -qE '^[[:space:]]*COOKIE_SAME_SITE=' "${env_path}"; then set_env_var "COOKIE_SAME_SITE" "${COOKIE_SAME_SITE:-lax}" "${env_path}" fi if ! grep -qE '^[[:space:]]*NODE_ENV=' "${env_path}"; then set_env_var "NODE_ENV" "${NODE_ENV:-development}" "${env_path}" fi if ! grep -qE '^[[:space:]]*OTP_EXPIRES_IN=' "${env_path}"; then set_env_var "OTP_EXPIRES_IN" "${OTP_EXPIRES_IN:-2m}" "${env_path}" fi if ! grep -qE '^[[:space:]]*PUBLIC_API_ALLOWED_ORIGINS=' "${env_path}"; then set_env_var "PUBLIC_API_ALLOWED_ORIGINS" \ "${PUBLIC_API_ALLOWED_ORIGINS:-${cors_origins}}" \ "${env_path}" elif [[ -n "${VITE_API_URL:-}" ]]; then set_env_var "PUBLIC_API_ALLOWED_ORIGINS" \ "${PUBLIC_API_ALLOWED_ORIGINS:-${cors_origins}}" \ "${env_path}" fi if ! grep -qE '^[[:space:]]*CORS_ALLOWED_ORIGINS=' "${env_path}"; then set_env_var "CORS_ALLOWED_ORIGINS" \ "${CORS_ALLOWED_ORIGINS:-${cors_origins}}" \ "${env_path}" elif [[ -n "${VITE_API_URL:-}" || -n "${CORS_ALLOWED_ORIGINS:-}" ]]; then set_env_var "CORS_ALLOWED_ORIGINS" \ "${CORS_ALLOWED_ORIGINS:-${cors_origins}}" \ "${env_path}" fi if ! grep -qE '^[[:space:]]*DB_SSL=' "${env_path}"; then set_env_var "DB_SSL" "${DB_SSL:-false}" "${env_path}" fi if ! grep -qE '^[[:space:]]*IS_CLOUD_VERSION=' "${env_path}"; then if [[ "${IS_CLOUD_VERSION:-}" == "true" ]]; then set_env_var "IS_CLOUD_VERSION" "true" "${env_path}" else set_env_var "IS_CLOUD_VERSION" "false" "${env_path}" fi fi # Older published images call getOrThrow('BREVO_*') at startup. Ensure the keys # exist (even empty) so self-host boots until those images are replaced. if ! grep -qE '^[[:space:]]*BREVO_API_KEY=' "${env_path}"; then set_env_var "BREVO_API_KEY" "${BREVO_API_KEY:-}" "${env_path}" fi if ! grep -qE '^[[:space:]]*BREVO_FROM_EMAIL=' "${env_path}"; then set_env_var "BREVO_FROM_EMAIL" "${BREVO_FROM_EMAIL:-}" "${env_path}" fi if ! grep -qE '^[[:space:]]*BREVO_FROM_NAME=' "${env_path}"; then set_env_var "BREVO_FROM_NAME" "${BREVO_FROM_NAME:-Kubeara}" "${env_path}" fi } compose() { run_compose -f "${KUBEARA_INSTALL_DIR}/${COMPOSE_FILE}" --env-file "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" "$@" } run_migrate() { if [[ "${SKIP_MIGRATE:-}" == "1" ]]; then info "Skipping migrations (SKIP_MIGRATE=1)" return 0 fi info "Running database migrations and seeding templates…" # -T: no TTY — required when install is piped (curl | bash) or run non-interactively. compose --profile migrate run -T --rm migrate } wait_for_control_panel() { local port="${PORT:-3000}" local i info "Waiting for control panel on port ${port}…" for i in $(seq 1 60); do if curl -fsS "http://127.0.0.1:${port}/api/health" -o /dev/null 2>/dev/null; then return 0 fi sleep 2 done warn "Control panel did not respond on http://127.0.0.1:${port}/api/health within 2 minutes. Check: docker compose -f ${COMPOSE_FILE} logs" } # --- Installation tracking (best-effort; never fails the installer) --- generate_uuid() { local hex if command -v uuidgen >/dev/null 2>&1; then uuidgen | tr '[:upper:]' '[:lower:]' return 0 fi if command -v openssl >/dev/null 2>&1; then # RFC 4122 version-4 UUID from 16 random bytes. hex="$(openssl rand -hex 16)" printf '%s-%s-4%s-%s%s-%s\n' \ "${hex:0:8}" \ "${hex:8:4}" \ "${hex:13:3}" \ "$(printf '%x' "$((0x${hex:16:1} & 0x3 | 0x8))")" \ "${hex:17:3}" \ "${hex:20:12}" return 0 fi return 1 } get_or_create_installation_id() { local id_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" local id if [[ -f "${id_file}" ]]; then id="$(tr -d '[:space:]' <"${id_file}" || true)" if [[ -n "${id}" ]]; then printf '%s\n' "${id}" return 0 fi fi if ! id="$(generate_uuid)"; then warn "Could not generate installation ID." return 1 fi printf '%s\n' "${id}" >"${id_file}" printf '%s\n' "${id}" } get_previous_version() { local version_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" if [[ ! -f "${version_file}" ]]; then return 0 fi tr -d '[:space:]' <"${version_file}" || true } # Resolve the Release Please-managed version from the public repository. # If GitHub is unavailable, fall back to the configured image tag/channel. get_current_version() { local package_url repository_version last_reported_version image tag package_url="https://raw.githubusercontent.com/${KUBEARA_REPO}/${KUBEARA_VERSION}/package.json" repository_version="$( curl -fsSL \ --connect-timeout 5 \ --max-time 10 \ "${package_url}" 2>/dev/null | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*$/\1/p' | head -n1 || true )" if [[ -n "${repository_version}" ]]; then printf '%s\n' "${repository_version}" return 0 fi # Avoid a false UPGRADE (for example, 0.0.15 → prod) during a temporary # GitHub outage. A fresh offline install still falls back to the image tag. last_reported_version="$(get_previous_version || true)" if [[ -n "${last_reported_version}" ]]; then printf '%s\n' "${last_reported_version}" return 0 fi image="$(grep -E '^[[:space:]]*KUBEARA_CONTROL_PANEL_IMAGE=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -n "${image}" && "${image}" == *:* ]]; then tag="${image##*:}" tag="${tag%%@*}" if [[ -n "${tag}" ]]; then printf '%s\n' "${tag}" return 0 fi fi printf '%s\n' "${KUBEARA_CHANNEL:-prod}" } # Prints INSTALL, UPGRADE, or empty (skip — same version already reported). # had_installation_id: 1 if .installation-id already existed before this run. # previous_version comes from .version, which is written only after a successful # tracking POST — so a failed report is retried on the next installer run. detect_installation_event() { local current_version="$1" local previous_version="$2" local had_installation_id="$3" if [[ "${had_installation_id}" != "1" ]]; then printf '%s\n' "INSTALL" return 0 fi # Never successfully reported (no .version) — retry as INSTALL. if [[ -z "${previous_version}" ]]; then printf '%s\n' "INSTALL" return 0 fi if [[ "${previous_version}" == "${current_version}" ]]; then printf '%s\n' "" return 0 fi printf '%s\n' "UPGRADE" } get_os_info() { local name="" if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 name="$(. /etc/os-release && printf '%s' "${NAME:-${ID:-}}")" fi printf '%s\n' "${name}" } get_os_version() { local version="" if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 version="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")" fi printf '%s\n' "${version}" } get_architecture() { case "$(uname -m 2>/dev/null || true)" in x86_64 | amd64) printf '%s\n' "amd64" ;; aarch64 | arm64) printf '%s\n' "arm64" ;; *) uname -m 2>/dev/null || true ;; esac } get_docker_version() { run_docker version --format '{{.Server.Version}}' 2>/dev/null || true } # Handles both "5.1.4" (--short) and "Docker Compose version v5.1.4". get_compose_version() { local raw raw="$(run_compose version --short 2>/dev/null || run_compose version 2>/dev/null || true)" raw="$(printf '%s' "${raw}" | head -n1 | sed -E 's/^[^0-9]*v?([0-9]+(\.[0-9]+)*).*$/\1/' || true)" printf '%s\n' "${raw}" } json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/'"$(printf '\t')"'/\\t/g' | tr -d '\n\r' } # Append ,"key":"value" when value is non-empty; no-op otherwise. json_append_string_field() { local key="$1" local value="$2" if [[ -z "${value}" ]]; then return 0 fi printf ',"%s":"%s"' "${key}" "$(json_escape "${value}")" } save_installation_version() { local version="$1" local version_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" printf '%s\n' "${version}" >"${version_file}" } # Posts to DEFAULT_TRACKING_URL unless KUBEARA_TRACKING_URL is set. resolve_tracking_url() { printf '%s\n' "${KUBEARA_TRACKING_URL:-${DEFAULT_TRACKING_URL}}" } track_installation_event() { local installation_id="$1" local event_type="$2" local version="$3" local previous_version="${4:-}" local tracking_url payload previous_json local os_name os_version architecture docker_version compose_version local http_code response_file if [[ -z "${installation_id}" || -z "${event_type}" || -z "${version}" ]]; then return 0 fi tracking_url="$(resolve_tracking_url)" os_name="$(get_os_info || true)" os_version="$(get_os_version || true)" architecture="$(get_architecture || true)" docker_version="$(get_docker_version || true)" compose_version="$(get_compose_version || true)" if [[ "${event_type}" == "UPGRADE" ]]; then if [[ -z "${previous_version}" ]]; then previous_version="unknown" fi previous_json="\"$(json_escape "${previous_version}")\"" else previous_json="null" fi payload="$( printf '{"installationId":"%s","eventType":"%s","version":"%s","previousVersion":%s,"userAgent":"%s"' \ "$(json_escape "${installation_id}")" \ "$(json_escape "${event_type}")" \ "$(json_escape "${version}")" \ "${previous_json}" \ "$(json_escape "kubeara-install.sh")" json_append_string_field "os" "${os_name}" json_append_string_field "osVersion" "${os_version}" json_append_string_field "architecture" "${architecture}" json_append_string_field "dockerVersion" "${docker_version}" json_append_string_field "composeVersion" "${compose_version}" printf '}' )" response_file="$(mktemp)" info "Reporting ${event_type} event to ${tracking_url}…" if [[ "${event_type}" == "UPGRADE" ]]; then info "Tracking payload: id=${installation_id} ${previous_version} → ${version} os=${os_name:-?} arch=${architecture:-?} docker=${docker_version:-?}" else info "Tracking payload: id=${installation_id} version=${version} os=${os_name:-?} arch=${architecture:-?} docker=${docker_version:-?}" fi http_code="$( curl -sS \ --connect-timeout 10 \ --max-time 30 \ -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d "${payload}" \ -o "${response_file}" \ -w '%{http_code}' \ "${tracking_url}" \ 2>/dev/null || printf '000' )" if [[ "${http_code}" =~ ^2 ]]; then info "Installation event recorded (HTTP ${http_code})." rm -f "${response_file}" return 0 fi warn "Could not report installation event (HTTP ${http_code}). Will retry on next install." if [[ -s "${response_file}" ]]; then warn "Tracking response: $(tr -d '\n' <"${response_file}" | head -c 300)" fi rm -f "${response_file}" return 1 } print_success() { local port console_port vite_api_url console_url api_host port="$(grep -E '^PORT=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2- || echo 3000)" console_port="$(grep -E '^CONSOLE_PORT=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2- || echo 8080)" vite_api_url="$(grep -E '^VITE_API_URL=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2- || echo "http://localhost:${port}/api")" # Console must be opened on the same host the API URL uses (cookies). console_url="http://localhost:${console_port}" case "${vite_api_url}" in http://* | https://*) api_host="${vite_api_url#*://}" api_host="${api_host%%/*}" api_host="${api_host%%:*}" if [[ -n "${api_host}" && "${api_host}" != "localhost" && "${api_host}" != "127.0.0.1" ]]; then console_url="http://${api_host}:${console_port}" fi ;; esac cat <