#!/usr/bin/env bash # # UMNI Ecosystem — single-file installer for Ubuntu (Docker, prebuilt release images). # # Based on the official guide: # "Установка UMNI Server в среде виртуализации Proxmox VE на Debian 13 + Docker" # but adapted from Debian to Ubuntu: the Docker APT repository uses # https://download.docker.com/linux/ubuntu (NOT .../debian). # # What it does (all steps are idempotent, existing config is kept by default): # 1. Installs Docker CE + compose plugin from Docker's *Ubuntu* repository. # 2. Creates the UMNI directory layout in the target directory. # 3. Writes docker-compose.release.yml, nginx.host.conf, .env and env_config/.env. # 4. Fixes ownership (containers run as UID 1000) and starts the stack. # 5. Waits for health and prints the web UI URL. # # All progress/error output is logged in English to stdout and to a log file. # # Tested target: Ubuntu 24.04 LTS and Ubuntu 26.04 LTS. # Usage: sudo ./install-umni-ubuntu.sh [options] (see --help) set -Eeuo pipefail # --------------------------------------------------------------------------- # # Defaults # --------------------------------------------------------------------------- # SCRIPT_NAME="$(basename "$0")" SCRIPT_VERSION="1.0.0" INSTALL_DIR="/opt/umni-ecosystem" LOG_FILE="/var/log/umni-install.log" IMAGE_OWNER="sazanof" IMAGE_TAG="latest" WEB_PORT="80" DB_USER="postgres" DB_PASSWORD="postgres" DB_NAME="ecosystem" DB_PORT="5432" BACKEND_LOG_LEVEL="debug" STORAGE_HOST_BASE="/host" STORAGE_MOUNTS=() OPEN_FIREWALL=0 REPLACE_DOCKER=0 SKIP_DOCKER=0 FORCE_CONFIG=0 DO_START=1 HEALTH_TIMEOUT=180 DOCKER_UBUNTU_REPO="https://download.docker.com/linux/ubuntu" # --------------------------------------------------------------------------- # # Logging (English) # --------------------------------------------------------------------------- # C_RESET=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE="" if [ -t 1 ]; then C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[36m' fi _LOG_READY=0 _setup_logging() { [ "$_LOG_READY" -eq 1 ] && return 0 local dir; dir="$(dirname "$LOG_FILE")" mkdir -p "$dir" 2>/dev/null || true : > "$LOG_FILE" 2>/dev/null || true # Everything below (stdout+stderr) goes to the console and to the log file. exec > >(tee -a "$LOG_FILE") 2>&1 _LOG_READY=1 export _LOG_READY } _ts() { date '+%Y-%m-%d %H:%M:%S'; } log() { local level="$1"; shift local color="" case "$level" in INFO) color="$C_GREEN" ;; STEP) color="$C_BLUE" ;; WARN) color="$C_YELLOW" ;; ERROR) color="$C_RED" ;; esac # stderr is redirected into the tee pipe by _setup_logging, so a single # write reaches both the console and the log file. printf '%s[%s] %-5s%s %s\n' "$C_DIM" "$(_ts)" "$level" "$C_RESET${color}" "$*" >&2 } step() { log STEP "==> $*"; } info() { log INFO "$*"; } warn() { log WARN "$*"; } die() { log ERROR "$*"; exit 1; } on_error() { local code="$1" line="$2" log ERROR "Unexpected failure (exit code ${code}) at ${SCRIPT_NAME}:${line}" log ERROR "Check the log: ${LOG_FILE}" exit "$code" } trap 'on_error $? $LINENO' ERR # --------------------------------------------------------------------------- # # Argument parsing # --------------------------------------------------------------------------- # usage() { cat </dev/null 2>&1; then OS_CODENAME="$(lsb_release -sc 2>/dev/null || true)" fi OS_PRETTY="${PRETTY_NAME:-$OS_ID $OS_VERSION}" if [ "$OS_ID" != "ubuntu" ]; then warn "Detected '${OS_PRETTY}' — this script targets Ubuntu and configures the Ubuntu Docker repository." warn "Continuing anyway; Docker packages are fetched from ${DOCKER_UBUNTU_REPO}." else info "Detected OS: ${OS_PRETTY} (codename: ${OS_CODENAME:-unknown})" fi ARCH="$(dpkg --print-architecture 2>/dev/null || true)" [ -n "$ARCH" ] || die "Could not detect CPU architecture (dpkg --print-architecture)." info "Architecture: ${ARCH}" } check_port_available() { local port="$1" if ! command -v ss >/dev/null 2>&1; then warn "'ss' is not available; skipping the port availability check." return 0 fi local owners owners="$(ss -ltnpH "sport = :${port}" 2>/dev/null || true)" [ -n "$owners" ] || { info "Port ${port} is free."; return 0; } # Nothing to do if our own containers already own the port (re-run). if printf '%s' "$owners" | grep -qE 'docker'; then info "Port ${port} is already served by Docker (our stack) — OK." return 0 fi warn "Port ${port} is already in use:" printf '%s\n' "$owners" >&2 # A system web server on :80 would block the UMNI frontend container. local svc for svc in apache2 nginx; do if systemctl is-active --quiet "$svc" 2>/dev/null; then warn "Stopping and disabling system service '${svc}' because it conflicts with the UMNI frontend." systemctl stop "$svc" || true systemctl disable "$svc" >/dev/null 2>&1 || true fi done if ss -ltnH "sport = :${port}" 2>/dev/null | grep -q .; then warn "Port ${port} is still busy. Change it with --web-port or free it, otherwise the frontend container will fail." fi } # --------------------------------------------------------------------------- # # Step 1 — Docker from the Ubuntu repository # --------------------------------------------------------------------------- # detect_docker_suite() { # Returns an existing suite/codename in Docker's Ubuntu repo. # New Ubuntu releases (e.g. 26.04) may not have a dist in the Docker repo yet, # so we fall back to the closest supported LTS codename. local candidates=() [ -n "${OS_CODENAME:-}" ] && candidates+=("$OS_CODENAME") candidates+=(noble jammy focal) local c for c in "${candidates[@]}"; do if curl -fsSI --max-time 15 "${DOCKER_UBUNTU_REPO}/dists/${c}/Release" >/dev/null 2>&1; then printf '%s' "$c" return 0 fi done return 1 } handle_conflicting_packages() { local pkg conflicts=() for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do if dpkg -s "$pkg" >/dev/null 2>&1; then conflicts+=("$pkg") fi done [ "${#conflicts[@]}" -gt 0 ] || return 0 if dpkg -s docker-ce >/dev/null 2>&1; then # docker-ce already installed: apt will resolve/replace conflicts itself. info "docker-ce is installed; conflicting packages present: ${conflicts[*]}" return 0 fi if [ "$REPLACE_DOCKER" -eq 1 ]; then warn "Removing conflicting packages: ${conflicts[*]}" DEBIAN_FRONTEND=noninteractive apt-get remove -y "${conflicts[@]}" else die "Conflicting packages found: ${conflicts[*]}. Re-run with --replace-docker to remove them, or install Docker manually." fi } install_docker() { if [ "$SKIP_DOCKER" -eq 1 ]; then info "Skipping Docker installation (--skip-docker)." return 0 fi step "Installing Docker Engine from the Ubuntu repository" export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y ca-certificates curl gnupg handle_conflicting_packages local suite if ! suite="$(detect_docker_suite)"; then die "Could not find a usable suite in ${DOCKER_UBUNTU_REPO}. Check network/proxy access." fi if [ "$suite" != "${OS_CODENAME:-}" ]; then warn "Docker has no '${OS_CODENAME:-unknown}' distribution yet; using the '${suite}' suite instead." fi info "Docker APT suite: ${suite} (arch: ${ARCH})" step "Adding Docker's official GPG key" install -m 0755 -d /etc/apt/keyrings curl -fsSL --max-time 60 "${DOCKER_UBUNTU_REPO}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc step "Adding the Ubuntu Docker APT repository" # Legacy one-line list from older guides would create duplicate sources. if [ -f /etc/apt/sources.list.d/docker.list ]; then warn "Removing legacy /etc/apt/sources.list.d/docker.list (replaced by docker.sources)." rm -f /etc/apt/sources.list.d/docker.list fi cat > /etc/apt/sources.list.d/docker.sources </dev/null 2>&1; then sleep 3 docker info >/dev/null 2>&1 || die "Docker daemon is not responding. Check: systemctl status docker" fi if ! docker compose version >/dev/null 2>&1; then die "'docker compose' plugin is not available." fi info "Docker: $(docker --version 2>/dev/null | head -n1)" info "Compose: $(docker compose version 2>/dev/null | head -n1)" } # --------------------------------------------------------------------------- # # Step 2 — UMNI files # --------------------------------------------------------------------------- # backup_if_exists() { local f="$1" if [ -e "$f" ]; then cp -a "$f" "${f}.bak.$(date '+%Y%m%d%H%M%S')" info "Backed up existing $(basename "$f")" fi } write_file() { # write_file ...reads content from stdin local target="$1" tmp tmp="$(mktemp)" cat > "$tmp" if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then rm -f "$tmp" info "Keeping existing $(basename "$target") (use --force-config to overwrite)." return 0 fi [ -e "$target" ] && backup_if_exists "$target" install -m 0644 "$tmp" "$target" rm -f "$tmp" info "Wrote $(basename "$target")" } create_layout() { step "Creating the directory layout in ${INSTALL_DIR}" install -d -m 0755 \ "$INSTALL_DIR" \ "$INSTALL_DIR/env_config" \ "$INSTALL_DIR/logs" \ "$INSTALL_DIR/storage" \ "$INSTALL_DIR/plugins" \ "$INSTALL_DIR/plugins/custom" info "Layout ready: env_config/ logs/ storage/ plugins/custom/" } write_compose() { step "Writing docker-compose.release.yml" # Extra host storage mounts (Linux host: container path == host path). local binds_file; binds_file="$(mktemp)" if [ "${#STORAGE_MOUNTS[@]}" -gt 0 ]; then local m for m in "${STORAGE_MOUNTS[@]}"; do printf ' - type: bind\n source: %s\n target: %s\n' "$m" "$m" >> "$binds_file" done info "Extra storage bind mounts: ${STORAGE_MOUNTS[*]}" fi local tmp; tmp="$(mktemp)" cat > "$tmp" <<'YAML' # Deployment from prebuilt Docker Hub images (no build from sources). # # Layout created by install-umni-ubuntu.sh: # docker-compose.release.yml nginx.host.conf .env env_config/.env # # Start: # docker compose -f docker-compose.release.yml pull # docker compose -f docker-compose.release.yml up -d # # HOST MODE (network_mode: host): # backend — needs host networking for multicast DNS (_umni_api._tcp.local.) # and UDP broadcast syslog (port 514) from devices. # frontend — nginx listens on the host port and proxies to the backend on loopback. # postgres — stays on the bridge app-network, published only on 127.0.0.1:5432. # # IMPORTANT: a service with network_mode: host must not define networks:, ports: or expose:. services: postgres: image: postgres:16-alpine container_name: umni_postgres restart: unless-stopped env_file: - ./env_config/.env environment: TZ: Europe/Moscow POSTGRES_DB: ${DB_NAME:-umni} POSTGRES_USER: ${DB_USER:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} ports: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data networks: - app-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] interval: 10s timeout: 5s retries: 5 backend: image: ${IMAGE_OWNER:-sazanof}/umni-ecosystem-backend:${IMAGE_TAG:-latest} container_name: umni_backend restart: unless-stopped network_mode: host command: uvicorn main:app --host 127.0.0.1 --port 8000 depends_on: postgres: condition: service_healthy env_file: - ./env_config/.env environment: DB_HOST: 127.0.0.1 DB_USER: ${DB_USER:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_NAME: ${DB_NAME:-umni} DB_PORT: ${DB_PORT:-5432} LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_DIR: /app/logs STORAGE_HOST_BASE: ${STORAGE_HOST_BASE:-/host} TZ: Europe/Moscow volumes: - ./logs:/app/logs # env_config is mounted read-write: the app persists ENCRYPTION_KEY here. - ./env_config:/app/env_config - ./storage:/app/storage - ./plugins/custom:/app/plugins/custom # Host storage. On a Linux host the path is the container path (same as the bind). # __UMNI_EXTRA_STORAGE_BINDS__ frontend: image: ${IMAGE_OWNER:-sazanof}/umni-ecosystem-frontend:${IMAGE_TAG:-latest} container_name: umni_frontend restart: unless-stopped network_mode: host volumes: - ./nginx.host.conf:/etc/nginx/nginx.conf:ro depends_on: backend: condition: service_healthy volumes: postgres_data: networks: app-network: driver: bridge YAML # Replace the storage placeholder with the actual bind mounts. local out; out="$(mktemp)" awk -v binds="$binds_file" ' /^[[:space:]]*# __UMNI_EXTRA_STORAGE_BINDS__[[:space:]]*$/ { while ((getline line < binds) > 0) print line next } { print } ' "$tmp" > "$out" rm -f "$tmp" "$binds_file" local target="$INSTALL_DIR/docker-compose.release.yml" if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then rm -f "$out" info "Keeping existing docker-compose.release.yml (use --force-config to overwrite)." else [ -e "$target" ] && backup_if_exists "$target" install -m 0644 "$out" "$target" rm -f "$out" info "Wrote docker-compose.release.yml" fi } write_nginx() { step "Writing nginx.host.conf" write_file "$INSTALL_DIR/nginx.host.conf" <:%s)\n' "$WEB_PORT" printf 'WEB_PORT=%s\n' "$WEB_PORT" printf '\n# Backend log level (compose passes it to the container, overriding env_config/.env)\n' printf 'LOG_LEVEL=%s\n' "$lvl_upper" printf '\n# Root used when translating host paths into container paths\n' printf 'STORAGE_HOST_BASE=%s\n' "$STORAGE_HOST_BASE" } > "$tmp" local target="$INSTALL_DIR/.env" if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then rm -f "$tmp" info "Keeping existing .env (use --force-config to overwrite)." else [ -e "$target" ] && backup_if_exists "$target" install -m 0640 "$tmp" "$target" rm -f "$tmp" info "Wrote .env" fi } write_backend_env() { step "Writing env_config/.env (backend configuration)" local tmp; tmp="$(mktemp)" { printf 'APP_MODE=production\n' printf 'DB_HOST=postgres\n' printf 'DB_USER=%s\n' "$DB_USER" printf 'DB_PASSWORD=%s\n' "$DB_PASSWORD" printf 'DB_NAME=%s\n' "$DB_NAME" printf 'DB_PORT=%s\n' "$DB_PORT" printf 'LOG_LEVEL=%s\n' "$BACKEND_LOG_LEVEL" printf 'DEBUG_MODE=app,cameras,devices,rules,mqtt,services,storages,systeminfo,scheduler,tasks,websockets,notifications,plugins\n' printf '\n# ENCRYPTION_KEY is generated by the backend on first start and saved into this file.\n' printf '# ENCRYPTION_KEY=\n' } > "$tmp" local target="$INSTALL_DIR/env_config/.env" if [ -e "$target" ] && [ "$FORCE_CONFIG" -ne 1 ]; then rm -f "$tmp" info "Keeping existing env_config/.env (use --force-config to overwrite)." else [ -e "$target" ] && backup_if_exists "$target" install -m 0640 "$tmp" "$target" rm -f "$tmp" info "Wrote env_config/.env (APP_MODE=production, LOG_LEVEL=${BACKEND_LOG_LEVEL})" fi } fix_ownership() { step "Fixing ownership (containers run as UID 1000)" local d for d in env_config logs storage plugins; do [ -e "$INSTALL_DIR/$d" ] || continue chown -R 1000:1000 "$INSTALL_DIR/$d" || warn "Could not chown ${d} (filesystem may not support it)." done info "Ownership set to 1000:1000 for env_config, logs, storage, plugins" } configure_firewall() { if ! command -v ufw >/dev/null 2>&1; then return 0 fi if ! ufw status 2>/dev/null | grep -q "Status: active"; then return 0 fi if [ "$OPEN_FIREWALL" -eq 1 ]; then step "Adding ufw rules (web, syslog, mDNS)" ufw allow "${WEB_PORT}/tcp" comment 'UMNI web UI' >/dev/null 2>&1 || true ufw allow 514/udp comment 'UMNI device syslog' >/dev/null 2>&1 || true ufw allow 5353/udp comment 'UMNI mDNS device discovery' >/dev/null 2>&1 || true info "ufw rules added for ${WEB_PORT}/tcp, 514/udp, 5353/udp" else warn "ufw is active. Device discovery/syslog need extra rules. Run with --open-firewall or manually:" warn " ufw allow ${WEB_PORT}/tcp && ufw allow 514/udp && ufw allow 5353/udp" fi } # --------------------------------------------------------------------------- # # Step 3 — Start and verify # --------------------------------------------------------------------------- # compose() { docker compose -f "$INSTALL_DIR/docker-compose.release.yml" "$@"; } validate_compose() { command -v docker >/dev/null 2>&1 || die "Docker is not installed. Re-run without --skip-docker." docker compose version >/dev/null 2>&1 || die "The 'docker compose' plugin is not available." step "Validating docker-compose.release.yml" ( cd "$INSTALL_DIR" && compose config -q ) || die "docker-compose.release.yml is invalid." info "Compose configuration is valid." } start_stack() { step "Pulling images (owner: ${IMAGE_OWNER}, tag: ${IMAGE_TAG})" ( cd "$INSTALL_DIR" && compose pull ) || warn "Image pull reported errors; continuing with local images if present." step "Starting the containers" ( cd "$INSTALL_DIR" && compose up -d ) step "Container status" ( cd "$INSTALL_DIR" && compose ps ) || true } wait_for_health() { local deadline=$((SECONDS + HEALTH_TIMEOUT)) local url="http://127.0.0.1:${WEB_PORT}/api/health" step "Waiting for the stack to become healthy (up to ${HEALTH_TIMEOUT}s)" while [ "$SECONDS" -lt "$deadline" ]; do if curl -fsS --max-time 5 "$url" >/dev/null 2>&1; then printf '\n' info "Health check passed: ${url}" return 0 fi printf '.' >&2 sleep 3 done printf '\n' warn "Health check did not pass within ${HEALTH_TIMEOUT}s." warn "Current status:" ( cd "$INSTALL_DIR" && compose ps ) || true warn "Recent backend logs:" ( cd "$INSTALL_DIR" && compose logs --tail 40 backend ) || true return 1 } print_summary() { local ip ip="$(hostname -I 2>/dev/null | awk '{print $1}')" [ -n "$ip" ] || ip="" log STEP "======================================================" info "UMNI Ecosystem installation finished" info "Directory : ${INSTALL_DIR}" info "Log file : ${LOG_FILE}" info "Web UI : http://${ip}:${WEB_PORT}/#/" info "Health : http://${ip}:${WEB_PORT}/api/health" info "Docs : http://${ip}:${WEB_PORT}/docs" printf '\n' >&2 info "Useful commands:" info " cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml ps" info " cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml logs -f backend" info " cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml down" if [ "${#STORAGE_MOUNTS[@]}" -gt 0 ]; then info "Storage binds: ${STORAGE_MOUNTS[*]}" fi log STEP "======================================================" } # --------------------------------------------------------------------------- # # Main # --------------------------------------------------------------------------- # main() { parse_args "$@" _setup_logging log STEP "${SCRIPT_NAME} ${SCRIPT_VERSION} starting (log: ${LOG_FILE})" require_root detect_os install_docker create_layout check_port_available "$WEB_PORT" write_compose write_nginx write_root_env write_backend_env fix_ownership configure_firewall if [ "$DO_START" -eq 1 ]; then validate_compose start_stack wait_for_health || warn "Installation completed, but the stack is not healthy yet — check the logs above." print_summary else info "Files prepared (--no-start). Start manually with:" info " cd ${INSTALL_DIR} && docker compose -f docker-compose.release.yml up -d" fi info "Done." } main "$@"