Files
Gianluca Gernone 353efbad01 fix(deploy): use network_mode host to fix loopback guard on Linux Docker (#4973)
* fix(deploy): use network_mode host to fix loopback guard on Linux Docker

* fix(deploy): address review feedback — wire OD_PORT and tighten auth note

* fix(deploy): wire OD_PORT in Linux override and include linux override in systemd unit and updater

* fix(deploy): reject unsupported Compose implementations for Linux override

* fix(deploy): lock OD_BIND_HOST to loopback for host networking in Linux

---------

Co-authored-by: kemycrome <luca.gernone@gmail.com>
2026-07-03 03:05:22 +00:00

539 lines
18 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Open Design — One-Click Installer
# Docker Compose wrapper for Linux and macOS
#
# Usage:
# Clone this repo, then run:
# ./install.sh [--non-interactive] [--port 7456] [--image <ref>] [--skip-docker-install] [--no-systemd]
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_PORT=7456
DEFAULT_IMAGE="ghcr.io/nexu-io/od:latest"
DEFAULT_MEM_LIMIT="384m"
HEALTH_TIMEOUT=60
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEPLOY_DIR="$(dirname "$SCRIPT_DIR")"
COMPOSE_FILE="${DEPLOY_DIR}/docker-compose.yml"
OVERRIDE_FILE="${DEPLOY_DIR}/docker-compose.override.yml"
LINUX_OVERRIDE_FILE="${DEPLOY_DIR}/docker-compose.linux.yml"
# Build the -f argument list: always include the base file,
# add the Linux host-network override, and add docker-compose.override.yml if present
# (used by tests for isolation).
COMPOSE_FILES=(-f "$COMPOSE_FILE")
if [ "$(uname -s)" = "Linux" ] && [ -f "$LINUX_OVERRIDE_FILE" ]; then
COMPOSE_FILES+=(-f "$LINUX_OVERRIDE_FILE")
fi
if [ -f "$OVERRIDE_FILE" ]; then
COMPOSE_FILES+=(-f "$OVERRIDE_FILE")
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "WARN: docker-compose.yml not found at ${COMPOSE_FILE}" >&2
echo " Clone the repository and run this script from deploy/scripts/." >&2
exit 1
fi
ENV_FILE="${DEPLOY_DIR}/.env"
# ---------------------------------------------------------------------------
# Colors & formatting
# ---------------------------------------------------------------------------
BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" RESET=""
if [ -t 1 ]; then
BOLD="\033[1m" DIM="\033[2m" RED="\033[31m" GREEN="\033[32m"
YELLOW="\033[33m" CYAN="\033[36m" RESET="\033[0m"
fi
step() { printf " ${DIM}${RESET} %s\n" "$1"; }
ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
warn() { printf " ${YELLOW}!${RESET} %s\n" "$1" >&2; }
error() { printf " ${RED}${RESET} %s\n" "$1" >&2; }
info() { printf " ${CYAN}${RESET} %s\n" "$1"; }
prompt_text() {
_prompt="$1" _default="$2"
printf "%s [%s]: " "$_prompt" "$_default" >&2
read -r _val
PROMPT_RESULT="${_val:-$_default}"
}
prompt_confirm() {
_question="$1" _default="$2"
if [ "$NON_INTERACTIVE" = "1" ]; then
return 0
fi
_yn_default="y"
if [ "$_default" = "0" ]; then _yn_default="n"; fi
printf "%s [%s]: " "$_question" "$_yn_default" >&2
read -r _yn
case "$_yn" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
*) [ "$_default" = "1" ] && return 0; return 1 ;;
esac
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
NON_INTERACTIVE=0
OPT_PORT=""
OPT_IMAGE=""
OPT_SKIP_DOCKER=0
OPT_NO_SYSTEMD=0
while [ $# -gt 0 ]; do
case "$1" in
--non-interactive) NON_INTERACTIVE=1 ;;
--port) shift; OPT_PORT="$1" ;;
--port=*) OPT_PORT="${1#--port=}" ;;
--image) shift; OPT_IMAGE="$1" ;;
--image=*) OPT_IMAGE="${1#--image=}" ;;
--skip-docker-install) OPT_SKIP_DOCKER=1 ;;
--no-systemd) OPT_NO_SYSTEMD=1 ;;
--help|-h)
echo "Usage: install.sh [options]"
echo ""
echo "Options:"
echo " --non-interactive Use defaults for all prompts"
echo " --port <n> Host port (default: ${DEFAULT_PORT})"
echo " --image <ref> Docker image reference"
echo " --skip-docker-install Do not attempt to install Docker"
echo " --no-systemd Skip systemd unit creation"
echo " --help Show this help"
exit 0
;;
*)
warn "Unknown argument: $1 (ignored)"
;;
esac
shift
done
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD} ┌──────────────────────────────────────┐${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${CYAN}${RESET} ${BOLD}Open Design${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${DIM}One-Click Installer${RESET} ${BOLD}${RESET}\n"
printf "${BOLD}${RESET} ${BOLD}${RESET}\n"
printf "${BOLD} └──────────────────────────────────────┘${RESET}\n"
printf "\n"
# ---------------------------------------------------------------------------
# 1. OS detection
# ---------------------------------------------------------------------------
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux)
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
DISTRO="$ID"
DISTRO_VERSION="$VERSION_ID"
else
DISTRO="unknown"
DISTRO_VERSION=""
fi
step "OS: Linux ${DISTRO} ${DISTRO_VERSION} (${ARCH})"
;;
Darwin)
step "OS: macOS $(sw_vers -productVersion) (${ARCH})"
DISTRO="macos"
;;
*)
error "Unsupported OS: ${OS}. This script supports Linux and macOS."
exit 1
;;
esac
# ---------------------------------------------------------------------------
# 2. Container runtime detection (Docker or Podman)
# ---------------------------------------------------------------------------
CONTAINER_CMD=""
CONTAINER_RUNTIME=""
detect_container_runtime() {
if command -v docker >/dev/null 2>&1; then
CONTAINER_CMD="docker"
CONTAINER_RUNTIME="docker"
return 0
fi
if command -v podman >/dev/null 2>&1; then
CONTAINER_CMD="podman"
CONTAINER_RUNTIME="podman"
return 0
fi
return 1
}
detect_compose() {
if $CONTAINER_CMD compose version >/dev/null 2>&1; then
COMPOSE_CMD="${CONTAINER_CMD} compose"
return 0
fi
if [ "$CONTAINER_RUNTIME" = "podman" ] && command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD="podman-compose"
return 0
fi
if [ "$CONTAINER_RUNTIME" = "docker" ] && command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
return 0
fi
return 1
}
check_runtime_running() {
$CONTAINER_CMD info >/dev/null 2>&1
}
if ! detect_container_runtime; then
if [ "$OPT_SKIP_DOCKER" = "1" ]; then
error "No container runtime found (Docker or Podman) and --skip-docker-install was set."
exit 1
fi
warn "No container runtime found (Docker or Podman)."
case "$DISTRO" in
ubuntu|debian)
install_cmd="sudo apt-get update && sudo apt-get install -y docker.io docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo apt-get install -y podman podman-compose"
;;
fedora)
install_cmd="sudo dnf install -y docker docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo dnf install -y podman podman-compose"
;;
centos|rhel|rocky|alma)
install_cmd="sudo yum install -y docker docker-compose-plugin"
step "Install Docker with: ${install_cmd}"
step "Or Podman: sudo yum install -y podman podman-compose"
;;
macos)
install_cmd="brew install --cask docker"
step "Install Docker with: ${install_cmd}"
step "Or Podman: brew install podman && podman machine init && podman machine start"
;;
*)
error "Cannot auto-detect install method for: ${DISTRO}"
step "Install Docker: https://docs.docker.com/get-docker/"
step "Or Podman: https://podman.io/getting-started/installation"
exit 1
;;
esac
if [ "$NON_INTERACTIVE" = "0" ]; then
if prompt_confirm "Install Docker now?" 0; then
step "Running: ${install_cmd}"
eval "$install_cmd"
if ! detect_container_runtime; then
error "Installation did not succeed. Install manually and re-run."
exit 1
fi
else
error "A container runtime is required. Install Docker or Podman and re-run."
exit 1
fi
else
error "A container runtime is required in non-interactive mode. Install Docker or Podman first."
exit 1
fi
fi
if ! detect_compose; then
error "Container Compose is not available."
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
case "$OS" in
Darwin) step "Install with: brew install podman-compose" ;;
*) step "Install with: sudo apt-get install podman-compose (or equivalent for your distro)" ;;
esac
else
step "Install the compose plugin: https://docs.docker.com/compose/install/"
fi
exit 1
fi
# The Linux host-network override uses the !reset YAML tag (Docker Compose
# v2.17+). Reject legacy docker-compose v1 and podman-compose early so users
# get a clear message instead of a YAML parse error mid-install.
if [ "$OS" = "Linux" ] && [ -f "$LINUX_OVERRIDE_FILE" ]; then
if [ "$COMPOSE_CMD" != "docker compose" ]; then
error "The Linux host-network override requires 'docker compose' v2."
error "Found: ${COMPOSE_CMD}"
step "Install the Docker Compose plugin: https://docs.docker.com/compose/install/"
exit 1
fi
_compose_ver="$($COMPOSE_CMD version --short 2>/dev/null || echo "0.0.0")"
_compose_major="$(echo "$_compose_ver" | cut -d. -f1)"
_compose_minor="$(echo "$_compose_ver" | cut -d. -f2)"
if [ "$_compose_major" -lt 2 ] || { [ "$_compose_major" -eq 2 ] && [ "$_compose_minor" -lt 17 ]; }; then
error "Docker Compose v2.17 or later required for the Linux override (found v${_compose_ver})."
step "Upgrade: https://docs.docker.com/compose/install/"
exit 1
fi
fi
if ! check_runtime_running; then
warn "${CONTAINER_RUNTIME} is not running."
case "$DISTRO" in
ubuntu|debian|fedora|centos|rhel|rocky|alma)
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
step "Start with: podman machine init && podman machine start"
else
step "Start with: sudo systemctl start docker"
fi
;;
macos)
if [ "$CONTAINER_RUNTIME" = "podman" ]; then
step "Start with: podman machine start"
else
step "Open Docker Desktop from your Applications folder."
fi
;;
esac
if [ "$NON_INTERACTIVE" = "0" ]; then
if prompt_confirm "Retry after starting ${CONTAINER_RUNTIME}?" 1; then
if ! check_runtime_running; then
error "${CONTAINER_RUNTIME} is still not running. Start it and re-run."
exit 1
fi
else
exit 1
fi
else
exit 1
fi
fi
RUNTIME_VERSION="$(${CONTAINER_CMD} --version 2>/dev/null || echo 'unknown')"
COMPOSE_VERSION="$(${COMPOSE_CMD} version 2>/dev/null || echo 'unknown')"
step "Runtime: ${CONTAINER_RUNTIME} ${RUNTIME_VERSION}"
step "Compose: ${COMPOSE_VERSION}"
# ---------------------------------------------------------------------------
# 2b. Check if Open Design is already running
# ---------------------------------------------------------------------------
if $CONTAINER_CMD ps --filter "name=open-design" --format '{{.Names}}' 2>/dev/null | grep -q 'open-design'; then
STATUS="$($CONTAINER_CMD inspect --format '{{.State.Status}}' open-design 2>/dev/null || echo 'unknown')"
IMAGE="$($CONTAINER_CMD inspect --format '{{.Config.Image}}' open-design 2>/dev/null || echo 'unknown')"
PORTS="$($CONTAINER_CMD port open-design 2>/dev/null || echo 'unknown')"
error "Open Design is already running."
printf "\n"
printf " Container: open-design\n"
printf " Status: %s\n" "$STATUS"
printf " Image: %s\n" "$IMAGE"
printf " Ports: %s\n" "$PORTS"
printf "\n"
step "To update: ${SCRIPT_DIR}/update.sh"
step "To remove: ${SCRIPT_DIR}/uninstall.sh"
printf "\n"
exit 1
fi
# ---------------------------------------------------------------------------
# 3. Port conflict detection
# ---------------------------------------------------------------------------
PORT="${OPT_PORT:-$DEFAULT_PORT}"
check_port_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | grep -q ":${1} " && return 0
elif command -v lsof >/dev/null 2>&1; then
lsof -i :"$1" 2>/dev/null | grep -q LISTEN && return 0
fi
return 1
}
if check_port_in_use "$PORT"; then
warn "Port ${PORT} is already in use."
if [ "$NON_INTERACTIVE" = "0" ]; then
_suggest=$((PORT + 1))
prompt_text "Enter a different port" "$_suggest"
PORT="$PROMPT_RESULT"
if check_port_in_use "$PORT"; then
error "Port ${PORT} is also in use. Please pick a free port."
exit 1
fi
else
error "Port ${PORT} is occupied. Use --port to specify a different one."
exit 1
fi
fi
# ---------------------------------------------------------------------------
# 4. Interactive prompts
# ---------------------------------------------------------------------------
IMAGE="${OPT_IMAGE:-$DEFAULT_IMAGE}"
ALLOWED_ORIGINS=""
MEM_LIMIT="$DEFAULT_MEM_LIMIT"
if [ "$NON_INTERACTIVE" = "0" ]; then
printf "\n"
prompt_text "Docker image" "$DEFAULT_IMAGE"
IMAGE="$PROMPT_RESULT"
prompt_text "Port" "$PORT"
PORT="$PROMPT_RESULT"
prompt_text "Allowed origins (CORS, comma-separated, or empty)" ""
ALLOWED_ORIGINS="$PROMPT_RESULT"
prompt_text "Memory limit" "$DEFAULT_MEM_LIMIT"
MEM_LIMIT="$PROMPT_RESULT"
if check_port_in_use "$PORT"; then
error "Port ${PORT} is already in use. Aborting."
exit 1
fi
fi
# ---------------------------------------------------------------------------
# 5. Generate .env
# ---------------------------------------------------------------------------
if [ -f "$ENV_FILE" ]; then
BACKUP="${ENV_FILE}.$(date +%Y%m%d%H%M%S).bak"
step "Existing .env found. Backing up to ${BACKUP}"
cp "$ENV_FILE" "$BACKUP"
fi
GENERATED_TOKEN=$(openssl rand -hex 32 2>/dev/null || od -vAn -N32 -tx1 /dev/urandom | tr -d ' \n' 2>/dev/null)
cat > "$ENV_FILE" << ENVFILE
# Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
OPEN_DESIGN_IMAGE=${IMAGE}
OPEN_DESIGN_PORT=${PORT}
OPEN_DESIGN_ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
OPEN_DESIGN_MEM_LIMIT=${MEM_LIMIT}
NODE_OPTIONS=--max-old-space-size=192
OD_API_TOKEN=${GENERATED_TOKEN}
ENVFILE
ok "Written ${ENV_FILE}"
# ---------------------------------------------------------------------------
# 6. Pull and start
# ---------------------------------------------------------------------------
step "Pulling image: ${IMAGE}"
$COMPOSE_CMD "${COMPOSE_FILES[@]}" pull
step "Starting Open Design..."
$COMPOSE_CMD "${COMPOSE_FILES[@]}" up -d --no-build
# ---------------------------------------------------------------------------
# 7. Health check
# ---------------------------------------------------------------------------
step "Waiting for health check (up to ${HEALTH_TIMEOUT}s)..."
HEALTH_URL="http://127.0.0.1:${PORT}/api/health"
HEALTH_OK=0
ELAPSED=0
while [ "$ELAPSED" -lt "$HEALTH_TIMEOUT" ]; do
if command -v curl >/dev/null 2>&1; then
HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo '000')"
elif command -v wget >/dev/null 2>&1; then
HTTP_CODE="$(wget -q -O /dev/null --server-response "$HEALTH_URL" 2>&1 | grep 'HTTP/' | tail -1 | awk '{print $2}')"
else
HTTP_CODE="000"
fi
if [ "$HTTP_CODE" = "200" ]; then
HEALTH_OK=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ "$HEALTH_OK" = "1" ]; then
ok "Daemon is healthy (${HTTP_CODE} OK)"
else
warn "Health check did not pass within ${HEALTH_TIMEOUT}s."
step "Check status: ${COMPOSE_CMD} \"${COMPOSE_FILES[@]}\" logs"
fi
# ---------------------------------------------------------------------------
# 8. systemd unit (Linux only)
# ---------------------------------------------------------------------------
if [ "$OS" = "Linux" ] && [ "$OPT_NO_SYSTEMD" = "0" ]; then
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_DIR="${HOME}/.config/systemd/user"
SYSTEMD_UNIT="${SYSTEMD_DIR}/open-design.service"
mkdir -p "$SYSTEMD_DIR"
CONTAINER_BIN="$(command -v "$CONTAINER_CMD")"
{
echo "[Unit]"
echo "Description=Open Design daemon (${CONTAINER_RUNTIME} Compose)"
if [ "$CONTAINER_RUNTIME" = "docker" ]; then
echo "After=${CONTAINER_RUNTIME}.service"
echo "Requires=${CONTAINER_RUNTIME}.service"
fi
echo ""
echo "[Service]"
echo "Type=oneshot"
echo "RemainAfterExit=yes"
echo "WorkingDirectory=${DEPLOY_DIR}"
# Build the compose -f list to match what the installer used at install
# time, so systemd restarts use the same file set (including the Linux
# host-network override when present).
_COMPOSE_ARGS="-f ${COMPOSE_FILE}"
if [ -f "$LINUX_OVERRIDE_FILE" ]; then
_COMPOSE_ARGS="${_COMPOSE_ARGS} -f ${LINUX_OVERRIDE_FILE}"
fi
if [ -f "$OVERRIDE_FILE" ]; then
_COMPOSE_ARGS="${_COMPOSE_ARGS} -f ${OVERRIDE_FILE}"
fi
echo "ExecStart=${CONTAINER_BIN} compose ${_COMPOSE_ARGS} up -d --no-build"
echo "ExecStop=${CONTAINER_BIN} compose ${_COMPOSE_ARGS} down"
echo "TimeoutStartSec=120"
echo ""
echo "[Install]"
echo "WantedBy=default.target"
} > "$SYSTEMD_UNIT"
systemctl --user daemon-reload
systemctl --user enable open-design 2>/dev/null || true
ok "systemd unit installed: open-design.service"
step "Manage with: systemctl --user start|stop|status open-design"
else
warn "systemd not found. Skipping service installation."
fi
fi
# ---------------------------------------------------------------------------
# 9. Summary
# ---------------------------------------------------------------------------
printf "\n"
printf "${BOLD}${GREEN} ── Installation Complete ──────────────────────────${RESET}\n"
printf "\n"
printf " URL: http://127.0.0.1:%s\n" "$PORT"
printf " Image: %s\n" "$IMAGE"
printf " Data volume: open_design_data\n"
printf " Config: %s\n" "$ENV_FILE"
if [ "$OS" = "Linux" ] && [ -f "${HOME}/.config/systemd/user/open-design.service" ]; then
printf " Service: systemd (open-design.service)\n"
fi
printf "\n"
printf " Next steps:\n"
printf " Update: %s/update.sh\n" "$SCRIPT_DIR"
printf " Uninstall: %s/uninstall.sh\n" "$SCRIPT_DIR"
printf " Logs: %s logs -f\n" "$COMPOSE_CMD"
printf "\n"
printf " Open http://127.0.0.1:%s in your browser\n\n" "$PORT"