Newer
Older
navi-1 / deploy / install.sh
#!/usr/bin/env bash
# Navi — one-command deployment for a server module.
#
#   ssh server
#   git clone -b deploy <repo-url> navi-1 && cd navi-1
#   bash deploy/install.sh
#
# What this does (idempotent — safe to re-run):
#   1. checks prerequisites (python 3.11+, docker, docker compose)
#   2. provisions .env from deploy/env.template (generates a DB password)
#   3. starts dockerized PostgreSQL (pgvector, restart: always, 127.0.0.1 only)
#      and installs the vector + pg_trgm extensions
#   4. builds the venv and installs navi (navi-server + navi-code entry points)
#   5. writes + enables + starts the systemd unit (Restart=always — the
#      server lives from installation and survives reboots)
#   6. waits for /health, symlinks navi-code/navi-server into PATH
#
# Default deployed state: web UI off, auth off, terminal client only.
# Re-enable the web panel later: set NAVI_WEBCLIENT_ENABLED=true in .env
# and `sudo systemctl restart navi`.

set -euo pipefail

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_DIR"

say()  { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
err()  { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; }

SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"

# ── 1. prerequisites ────────────────────────────────────────────────
say "Checking prerequisites"

# Pick the first system interpreter >= 3.11. Ubuntu 22.04's system python3
# is 3.10 (and must NOT be replaced — apt depends on it); a newer Python
# installed alongside (deadsnakes PPA) lives under its own name (python3.12).
PYBIN=""
for cand in python3.13 python3.12 python3.11 python3; do
    command -v "$cand" >/dev/null 2>&1 || continue
    if "$cand" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)'; then
        PYBIN="$cand"
        break
    fi
done

# Nothing usable on the system (Ubuntu 18.04 ships 3.6, EOL distros have no
# 3.11+ packages)? Fetch a standalone CPython build instead — self-contained,
# needs only glibc >= 2.17, so it runs on any distro old or new. It lives in
# .python/ inside the repo and is reused on re-runs.
PY_STANDALONE_TAG="20260901"
PY_STANDALONE_VER="3.12.14"
if [ -z "$PYBIN" ]; then
    say "No system python >= 3.11 — fetching standalone CPython ${PY_STANDALONE_VER}"
    case "$(uname -m)" in
        x86_64)    PY_TARGET="x86_64-unknown-linux-gnu" ;;
        aarch64|arm64) PY_TARGET="aarch64-unknown-linux-gnu" ;;
        *) err "unsupported architecture: $(uname -m) — no standalone build"; exit 1 ;;
    esac
    PY_HOME="$REPO_DIR/.python"
    TARBALL="cpython-${PY_STANDALONE_VER}+${PY_STANDALONE_TAG}-${PY_TARGET}-install_only_stripped.tar.gz"
    if [ ! -x "$PY_HOME/python/bin/python3" ]; then
        mkdir -p "$PY_HOME"
        # A tarball next to install.sh wins (offline servers / manual download).
        SRC=""
        if [ -f "$REPO_DIR/$TARBALL" ]; then
            SRC="$REPO_DIR/$TARBALL"
        else
            URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PY_STANDALONE_TAG}/${TARBALL}"
            if command -v curl >/dev/null; then
                curl -fsSL --retry 3 -o "$REPO_DIR/$TARBALL" "$URL" || { err "download failed: $URL"; exit 1; }
            elif command -v wget >/dev/null; then
                wget -q -O "$REPO_DIR/$TARBALL" "$URL" || { err "download failed: $URL"; exit 1; }
            else
                err "no curl/wget on the host. Download on another machine:"
                err "  $URL"
                err "put the file next to install.sh and re-run."
                exit 1
            fi
            SRC="$REPO_DIR/$TARBALL"
        fi
        tar -xzf "$SRC" -C "$PY_HOME" || { err "tarball extraction failed"; exit 1; }
        # keep the tarball — re-runs reuse it instead of re-downloading
    fi
    PYBIN="$PY_HOME/python/bin/python3"
    echo "Standalone python ready: $PYBIN"
fi

if ! command -v docker >/dev/null; then
    err "docker not found — install Docker (https://docs.docker.com/engine/install/) first"; exit 1
fi

# compose v2: use the existing plugin, or auto-install it (single static
# binary; works on any distro with docker CLI >= 18.09). System-wide path —
# so `sudo docker compose` sees it too. Pre-downloaded binary next to
# install.sh wins on offline servers.
if ! $SUDO docker compose version >/dev/null 2>&1; then
    say "docker compose v2 not found — installing the plugin"
    case "$(uname -m)" in
        x86_64)       COMPOSE_ASSET="docker-compose-linux-x86_64" ;;
        aarch64|arm64) COMPOSE_ASSET="docker-compose-linux-aarch64" ;;
        *) err "unsupported architecture for compose: $(uname -m)"; exit 1 ;;
    esac
    $SUDO mkdir -p /usr/local/lib/docker/cli-plugins
    DEST="/usr/local/lib/docker/cli-plugins/docker-compose"
    if [ -f "$REPO_DIR/$COMPOSE_ASSET" ]; then
        $SUDO cp "$REPO_DIR/$COMPOSE_ASSET" "$DEST"
    else
        URL="https://github.com/docker/compose/releases/latest/download/$COMPOSE_ASSET"
        if command -v curl >/dev/null; then
            $SUDO curl -fsSL --retry 3 -o "$DEST" "$URL" || { err "compose download failed: $URL"; exit 1; }
        elif command -v wget >/dev/null; then
            $SUDO wget -q -O "$DEST" "$URL" || { err "compose download failed: $URL"; exit 1; }
        else
            err "no curl/wget on the host. Download on another machine:"
            err "  $URL"
            err "put the file next to install.sh and re-run."
            exit 1
        fi
    fi
    $SUDO chmod +x "$DEST"
    if ! $SUDO docker compose version >/dev/null 2>&1; then
        err "compose plugin installed but docker doesn't pick it up —"
        err "the docker CLI is probably older than 18.09 (no plugin support)."
        err "Report the output of: docker --version"
        exit 1
    fi
fi
echo "OK: python ($($PYBIN --version 2>&1)), docker + compose"

# ── 2. .env ────────────────────────────────────────────────────────
say "Provisioning .env"
NEED_PASS=0
if [ ! -f .env ]; then
    cp deploy/env.template .env
    NEED_PASS=1
    echo "Created .env from deploy/env.template."
elif grep -q "CHANGEME" .env; then
    # The deploy branch ships a committed .env with placeholder secrets —
    # regenerate the DB password on first install.
    NEED_PASS=1
    echo ".env carries placeholders — generating real secrets."
else
    echo ".env already exists — keeping it."
fi
if [ "$NEED_PASS" = "1" ]; then
    DB_PASS="$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)"
    sed -i "s|^NAVI_DB_PASSWORD=.*|NAVI_DB_PASSWORD=${DB_PASS}|" .env
    sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgresql://navi:${DB_PASS}@127.0.0.1:5432/navi|" .env
    echo "Generated DB password."
fi
grep -qE "^OLLAMA_API_KEY=.\+" .env || echo "TODO left in .env: OLLAMA_API_KEY (Ollama Cloud key)."

# ── 3. PostgreSQL ───────────────────────────────────────────────────
say "Starting dockerized PostgreSQL (pgvector)"
$SUDO docker compose -f deploy/docker-compose.yml --env-file .env up -d
for i in $(seq 1 30); do
    if $SUDO docker exec navi-postgres pg_isready -U navi -d navi >/dev/null 2>&1; then break; fi
    sleep 2
done
$SUDO docker exec navi-postgres psql -U navi -d navi -q \
    -c "CREATE EXTENSION IF NOT EXISTS vector;" \
    -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
echo "PostgreSQL ready (127.0.0.1:5432, volume navi-pgdata)."

# ── 4. venv + install ───────────────────────────────────────────────
say "Building venv and installing navi"
if [ ! -d .venv ]; then
    "$PYBIN" -m venv .venv
fi
./.venv/bin/pip install --upgrade pip -q
./.venv/bin/pip install -e .
echo "Installed entry points: navi-server, navi-code"

# ── 5. systemd unit ─────────────────────────────────────────────────
say "Installing systemd unit (navi.service)"
RUN_USER="$(id -un)"
cat > /tmp/navi.service <<EOF
[Unit]
Description=Navi server (agent system API)
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=simple
User=${RUN_USER}
WorkingDirectory=${REPO_DIR}
ExecStart=${REPO_DIR}/.venv/bin/navi-server
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
EOF
$SUDO mv /tmp/navi.service /etc/systemd/system/navi.service
$SUDO systemctl daemon-reload
$SUDO systemctl enable --now navi
echo "navi.service enabled and started (restarts on failure, starts on boot)."

# ── 6. health check + PATH symlinks ────────────────────────────────
say "Waiting for the server to become healthy"
NAVI_PORT_CFG="$(grep -E '^NAVI_PORT=' .env | tail -1 | cut -d= -f2 || true)"
NAVI_PORT_CFG="${NAVI_PORT_CFG:-8000}"
HEALTH="ok"
for i in $(seq 1 60); do
    if curl -sf -o /dev/null "http://127.0.0.1:${NAVI_PORT_CFG}/health" 2>/dev/null; then break; fi
    if [ "$i" = "60" ]; then HEALTH="fail"; fi
    sleep 2
done
if [ "$HEALTH" != "ok" ]; then
    err "server did not become healthy — check: journalctl -u navi -e"
    exit 1
fi
echo "Server healthy at http://127.0.0.1:${NAVI_PORT_CFG}/health"

say "Symlinking navi-code and navi-server into /usr/local/bin"
for bin in navi-code navi-server; do
    $SUDO ln -sf "$REPO_DIR/.venv/bin/$bin" "/usr/local/bin/$bin"
done

cat <<'EOF'

Deployment complete.
  • navi-code        — terminal client (run it from any shell)
  • systemctl status navi — server supervision
  • journalctl -u navi -f   — live logs

Still TODO (if not filled yet): OLLAMA_API_KEY in .env, then
    sudo systemctl restart navi

Re-enable the web panel later: set NAVI_WEBCLIENT_ENABLED=true in .env
and restart the unit. Nothing else is needed — the code is all here.
EOF