From 87699f2dee1cb3cd006ec93cefde38c353c63d72 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 14 May 2026 14:45:34 +0530 Subject: [PATCH] chore: refactor setup scrpts (#288) * refactor setup scrpts * update docker compose to use dograh-init * avoid creating unnecessary conf files * fix local setup script * add agents.md --- deploy/templates/nginx.remote.conf.template | 82 +++ .../templates/turnserver.remote.conf.template | 28 + docker-compose.yaml | 79 ++- docs/deployment/custom-domain.mdx | 49 +- docs/deployment/docker.mdx | 17 +- docs/deployment/scaling.mdx | 48 +- docs/deployment/update.mdx | 18 +- remote_up.sh | 72 +++ scripts/AGENTS.md | 55 ++ scripts/CLAUDE.md | 31 +- scripts/lib/setup_common.sh | 425 ++++++++++++++ scripts/run_dograh_init.sh | 38 ++ scripts/setup_custom_domain.sh | 237 +++----- scripts/setup_local.sh | 98 ++-- scripts/setup_remote.sh | 296 +++------- scripts/update_remote.sh | 395 ++++--------- ui/src/components/layout/AppLayout.tsx | 4 +- ui/src/components/layout/AppSidebar.tsx | 527 ++++++++---------- 18 files changed, 1321 insertions(+), 1178 deletions(-) create mode 100644 deploy/templates/nginx.remote.conf.template create mode 100644 deploy/templates/turnserver.remote.conf.template create mode 100755 remote_up.sh create mode 100644 scripts/AGENTS.md create mode 100644 scripts/lib/setup_common.sh create mode 100755 scripts/run_dograh_init.sh diff --git a/deploy/templates/nginx.remote.conf.template b/deploy/templates/nginx.remote.conf.template new file mode 100644 index 0000000..fa51c4b --- /dev/null +++ b/deploy/templates/nginx.remote.conf.template @@ -0,0 +1,82 @@ +__DOGRAH_UPSTREAM_BLOCK__ + +server { + listen 80; + server_name __DOGRAH_PUBLIC_HOST__; + + # Redirect all HTTP to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name __DOGRAH_PUBLIC_HOST__; + + ssl_certificate /etc/nginx/certs/local.crt; + ssl_certificate_key /etc/nginx/certs/local.key; + + # Basic TLS settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + # Backend API and WebSockets - bypass the UI, go straight to the + # api workers via the least_conn upstream defined above. + location /api/v1/ { + proxy_pass http://dograh_api; + proxy_http_version 1.1; + + # Retry on a dead/restarting worker + proxy_next_upstream error timeout http_502 http_503 http_504; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Long-lived WebSockets (audio streaming, signaling) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Don't buffer streamed responses + proxy_buffering off; + client_max_body_size 100M; + } + + location / { + proxy_pass http://ui:3010; + proxy_http_version 1.1; + + # Important for WebSockets / hot reload etc. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Rewrite localhost MinIO URLs in API responses to use current domain + sub_filter 'http://localhost:9000/voice-audio/' 'https://$host/voice-audio/'; + sub_filter_once off; + sub_filter_types application/json text/html; + } + + location /voice-audio/ { + proxy_pass http://minio:9000/voice-audio/; + + proxy_http_version 1.1; + + # Headers for file downloads from MinIO + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Allow large file downloads + proxy_buffering off; + client_max_body_size 100M; + } +} diff --git a/deploy/templates/turnserver.remote.conf.template b/deploy/templates/turnserver.remote.conf.template new file mode 100644 index 0000000..6e6c222 --- /dev/null +++ b/deploy/templates/turnserver.remote.conf.template @@ -0,0 +1,28 @@ +# Coturn TURN Server - Docker Configuration +# Auto-generated by Dograh remote config renderer. + +# Listener ports +listening-port=3478 +tls-listening-port=5349 + +# Relay port range +min-port=49152 +max-port=49200 + +# Network - external IP / host for NAT traversal +external-ip=__DOGRAH_TURN_EXTERNAL_IP__ + +# Realm +realm=dograh.com + +# Authentication (TURN REST API with time-limited credentials) +use-auth-secret +static-auth-secret=__DOGRAH_TURN_SECRET__ + +# Security +fingerprint +no-cli +no-multicast-peers + +# Logging +log-file=stdout diff --git a/docker-compose.yaml b/docker-compose.yaml index 307c6d6..f453449 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -59,21 +59,69 @@ services: networks: - app-network + dograh-init: + image: bash:5.2 + container_name: dograh_init + profiles: ["remote", "local-turn"] + environment: + ENVIRONMENT: "${ENVIRONMENT:-local}" + SERVER_IP: "${SERVER_IP:-}" + PUBLIC_HOST: "${PUBLIC_HOST:-}" + PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}" + BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + TURN_HOST: "${TURN_HOST:-}" + TURN_SECRET: "${TURN_SECRET:-}" + FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" + volumes: + - ./scripts:/workspace/scripts:ro + - ./deploy:/workspace/deploy:ro + - ./certs:/certs:ro + - nginx-generated:/generated/nginx + - coturn-generated:/generated/coturn + command: + - /workspace/scripts/run_dograh_init.sh + nginx: image: nginx:alpine container_name: nginx_https profiles: ["remote"] depends_on: - - ui + dograh-init: + condition: service_completed_successfully + ui: + condition: service_started ports: - "80:80" - "443:443" volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - nginx-generated:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro networks: - app-network + coturn: + image: coturn/coturn:4.8.0 + container_name: coturn + restart: unless-stopped + profiles: ["remote", "local-turn"] + depends_on: + dograh-init: + condition: service_completed_successfully + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + - "5349:5349/udp" + - "5349:5349/tcp" + - "49152-49200:49152-49200/udp" + volumes: + - coturn-generated:/etc/coturn:ro + command: + - -c + - /etc/coturn/turnserver.conf + networks: + - app-network + api: image: ${REGISTRY:-dograhai}/dograh-api:latest volumes: @@ -108,8 +156,8 @@ services: MINIO_SECURE: "false" # Number of uvicorn worker processes (each is its own process bound to a - # distinct port starting at 8000). nginx load-balances across them with - # least_conn — see setup_remote.sh. + # distinct port starting at 8000). dograh-init renders nginx upstreams + # from this value and nginx load-balances across them with least_conn. FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" # Langfuse — credentials can be set here or per-organization via the UI @@ -195,25 +243,6 @@ services: networks: - app-network - coturn: - image: coturn/coturn:4.8.0 - container_name: coturn - restart: unless-stopped - profiles: ["remote", "local-turn"] - ports: - - "3478:3478/udp" - - "3478:3478/tcp" - - "5349:5349/udp" - - "5349:5349/tcp" - - "49152-49200:49152-49200/udp" - volumes: - - ./turnserver.conf:/etc/coturn/turnserver.conf:ro - command: - - -c - - /etc/coturn/turnserver.conf - networks: - - app-network - volumes: postgres_data: redis_data: @@ -221,6 +250,10 @@ volumes: driver: local shared-tmp: driver: local + nginx-generated: + driver: local + coturn-generated: + driver: local networks: app-network: diff --git a/docs/deployment/custom-domain.mdx b/docs/deployment/custom-domain.mdx index b25dacc..7cc050c 100644 --- a/docs/deployment/custom-domain.mdx +++ b/docs/deployment/custom-domain.mdx @@ -75,9 +75,10 @@ It will automatically: - Verify DNS configuration - Install Certbot - Generate Let's Encrypt SSL certificates -- Update nginx configuration +- Update the canonical public host/base URL settings in `.env` +- Validate the runtime config that `dograh-init` will render from `.env` - Configure automatic certificate renewal -- Restart Dograh services +- Restart Dograh services through the validated startup wrapper Once complete, your application will be available at `https://voice.yourcompany.com`. @@ -130,7 +131,7 @@ Replace `voice.yourcompany.com` with your actual domain name. Certbot will: 1. Verify that you control the domain 2. Generate SSL certificates -3. Store them in `/etc/letsencrypt/archive/voice.yourcompany.com/` +3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/` You'll be prompted to enter an email address for renewal notifications and agree to the terms of service. @@ -142,44 +143,31 @@ Copy the generated certificates to the dograh certs directory: ```bash cd dograh -sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem certs/local.crt -sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem certs/local.key +sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt +sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key sudo chmod 644 certs/local.crt certs/local.key ``` -### Update nginx Configuration +### Update Canonical Public URL Settings -Update the nginx configuration to use your domain name. Open the nginx configuration file: +Update `.env` so the canonical remote settings point at your domain: ```bash -nano dograh/nginx.conf +nano dograh/.env ``` -Update the `server_name` directive with your domain: - -```nginx -server { - listen 443 ssl; - server_name voice.yourcompany.com; - - ssl_certificate /etc/nginx/certs/local.crt; - ssl_certificate_key /etc/nginx/certs/local.key; - - # ... rest of the configuration remains the same -} +```bash +PUBLIC_HOST=voice.yourcompany.com +PUBLIC_BASE_URL=https://voice.yourcompany.com ``` -### Add environment variable - -Replace `BACKEND_API_ENDPOINT` environment variable the `docker-compose.yaml` with your custom domain with the scheme. - ### Start Dograh Services -Start Dograh with the updated configuration: +Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts: ```bash cd dograh -sudo docker compose --profile remote up -d --pull always +./remote_up.sh ``` ### Access Your Application @@ -207,8 +195,8 @@ Add the following content (replace paths as needed): ```bash #!/bin/bash # Copy renewed certificates to dograh certs directory -cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem /home/ubuntu/dograh/certs/local.crt -cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem /home/ubuntu/dograh/certs/local.key +cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem /home/ubuntu/dograh/certs/local.crt +cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem /home/ubuntu/dograh/certs/local.key chmod 644 /home/ubuntu/dograh/certs/local.crt /home/ubuntu/dograh/certs/local.key # Restart nginx to load new certificates @@ -243,7 +231,7 @@ If Certbot fails to generate certificates: If you see SSL errors after setup: 1. Verify the certificates were copied correctly: `ls -la dograh/certs/` -2. Check that `nginx.conf` points to `/etc/nginx/certs/local.crt` and `/etc/nginx/certs/local.key` +2. Run `./remote_up.sh --preflight-only` in `dograh/` to verify the `dograh-init` runtime render matches `.env` 3. Restart the nginx container: `sudo docker compose --profile remote restart nginx` ### WebRTC Connection Issues @@ -251,5 +239,4 @@ If you see SSL errors after setup: If voice calls don't connect after domain setup: 1. Ensure TCP/UDP ports 3478, 5349, and UDP 49152-49200 are still open -2. Update the `.env` file with your domain name if needed for TURN server configuration - +2. Check that `PUBLIC_HOST` / `PUBLIC_BASE_URL` in `.env` match your domain, then re-run `./remote_up.sh` diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index f235d5c..b4f3aae 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -66,7 +66,7 @@ The script will prompt you for: - The host browsers should use to reach TURN (press Enter for `127.0.0.1`; use your LAN IP if testing from another device on the same network) - A shared secret for the TURN server (press Enter to generate a random one) -It creates `docker-compose.yaml`, `turnserver.conf`, and a `.env` file with TURN credentials. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: +It creates `docker-compose.yaml`, a `.env` file with TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: ```bash docker compose --profile local-turn up --pull always @@ -118,9 +118,10 @@ The script will prompt you for: It will automatically: - Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode) -- Create and configure nginx.conf with your IP address +- Download the validated remote deployment helper bundle - Generate SSL certificates - Create an environment file with TURN server configuration +- Validate the runtime config that `dograh-init` will render from `.env` - Write a `docker-compose.override.yaml` with build directives (build mode only) ### Start the Application @@ -134,11 +135,11 @@ After the setup script completes, start Dograh. The script prints the exact comm ```bash Prebuilt mode cd dograh -sudo docker compose --profile remote up --pull always +./remote_up.sh ``` ```bash Build mode cd dograh -sudo docker compose --profile remote up -d --build +./remote_up.sh --build ``` @@ -174,12 +175,14 @@ The setup script creates the following files in the `dograh/` directory: |------|---------| | `docker-compose.yaml` | Main Docker Compose configuration | | `docker-compose.override.yaml` | Build directives for `api` and `ui` (**build mode only**) | -| `turnserver.conf` | Configuration for TURN server | -| `nginx.conf` | nginx reverse proxy configuration with your IP | +| `remote_up.sh` | Validated startup wrapper for the remote stack | +| `scripts/run_dograh_init.sh` | One-shot init renderer/validator used by Docker Compose | +| `scripts/lib/setup_common.sh` | Shared deployment helper library | +| `deploy/templates/` | nginx and coturn runtime config templates | | `generate_certificate.sh` | Script to regenerate SSL certificates | | `certs/local.crt` | Self-signed SSL certificate | | `certs/local.key` | SSL private key | -| `.env` | Environment variables (TURN secret, JWT secret, FastAPI worker count) | +| `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) | ### Building from source diff --git a/docs/deployment/scaling.mdx b/docs/deployment/scaling.mdx index b544539..b3910ed 100644 --- a/docs/deployment/scaling.mdx +++ b/docs/deployment/scaling.mdx @@ -8,7 +8,7 @@ By default, the Dograh API container runs a single uvicorn worker. For productio This page covers how the multi-worker setup works, how to choose a worker count at install time, and how to change it on a running stack. -Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and ship a different `setup_remote.sh` / `start_services_docker.sh` / `nginx.conf` layout — the steps below will not work on them. If your stack is older, [update first](/deployment/update) and then come back to this page. +Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and a different remote deployment layout. If your stack is older, [update first](/deployment/update) and then come back to this page. ## How it works @@ -58,23 +58,11 @@ Press Enter for the default (`4`) or enter a different positive integer. Non-int SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh ``` -The script wires the value into two places: - -- **`.env`** — sets `FASTAPI_WORKERS=N`, which `docker-compose.yaml` substitutes into the API container's environment. -- **`nginx.conf`** — generates an `upstream dograh_api` block with one `server api:800X` entry per worker. - -Both must agree, which is why the script generates them together. +The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned. ## Changing the worker count on a running stack -Once Dograh is running, increasing or decreasing the worker count is a two-file edit plus a restart. You'll touch: - -1. **`.env`** — controls how many uvicorn processes the API container spawns. -2. **`nginx.conf`** — controls which worker ports nginx forwards to. - - -Both files must stay in sync. If `.env` says `FASTAPI_WORKERS=8` but `nginx.conf` only lists 4 upstream servers, half your workers will be idle. If `nginx.conf` lists more upstreams than there are workers, those upstreams will throw connection errors and trip the `proxy_next_upstream` fallback. - +Once Dograh is running, increasing or decreasing the worker count is a one-file edit plus a restart. Change `.env`, then start through `./remote_up.sh` so `dograh-init` regenerates nginx runtime config before Docker starts the stack. ### Steps @@ -90,41 +78,21 @@ FASTAPI_WORKERS=4 FASTAPI_WORKERS=8 ``` -**2. Edit `nginx.conf`** and update the `upstream dograh_api` block so it has exactly one `server api:800X` line per worker, with ports starting at `8000`: - -```nginx -upstream dograh_api { - least_conn; - server api:8000 max_fails=3 fail_timeout=10s; - server api:8001 max_fails=3 fail_timeout=10s; - server api:8002 max_fails=3 fail_timeout=10s; - server api:8003 max_fails=3 fail_timeout=10s; - server api:8004 max_fails=3 fail_timeout=10s; # ← new - server api:8005 max_fails=3 fail_timeout=10s; # ← new - server api:8006 max_fails=3 fail_timeout=10s; # ← new - server api:8007 max_fails=3 fail_timeout=10s; # ← new - keepalive 32; -} -``` - -To **scale down**, remove the trailing `server` lines so the list matches the new `FASTAPI_WORKERS` value. - -**3. Recreate the affected containers.** The simplest path — brief downtime, no surprises: +**2. Recreate the stack through the validated wrapper.** The simplest path — brief downtime, no surprises: ```bash -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d +./remote_up.sh ``` If you want to avoid downtime and your stack is healthy, you can recreate only the `api` and `nginx` containers: ```bash -sudo docker compose --profile remote up -d --force-recreate api nginx +./remote_up.sh -- api nginx ``` -`--force-recreate` ensures the api container picks up the new `FASTAPI_WORKERS` value and nginx re-reads the updated `nginx.conf` (which is mounted read-only from disk). +`remote_up.sh` validates `.env`, runs the same `dograh-init` render that Compose will use at startup, runs `docker compose config -q`, and then starts the requested services. -**4. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead: +**3. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead: ```bash sudo docker compose --profile remote top api | grep uvicorn diff --git a/docs/deployment/update.mdx b/docs/deployment/update.mdx index bbea9a0..091288a 100644 --- a/docs/deployment/update.mdx +++ b/docs/deployment/update.mdx @@ -37,8 +37,8 @@ Always update **`dograh-api`** and **`dograh-ui`** to the **same tag**. The two - Asks for a target version (defaults to the latest release tag on GitHub). - Pulls `docker-compose.yaml` at that version and pins both `api` and `ui` images to it. -- Regenerates `nginx.conf` and `turnserver.conf` from the upstream templates, so newer features (like [multi-worker scaling](/deployment/scaling)) are wired up correctly without manual editing. -- Reads your existing `.env` and appends any new required keys with safe defaults — your `OSS_JWT_SECRET`, `TURN_SECRET`, and other values are never touched. +- Refreshes the remote helper bundle (`remote_up.sh` plus shared templates/helpers). +- Synchronizes the canonical remote keys in `.env` and validates the runtime config that `dograh-init` will render from it. - Backs up every file it changes with a `.bak.` suffix. From your install directory: @@ -55,15 +55,14 @@ You'll be prompted for the target version, defaulting to the most recent release TARGET_VERSION=1.28.0 DOGRAH_UPDATE_YES=1 bash update_remote.sh ``` -After the script finishes, apply the update by recreating the stack: +After the script finishes, apply the update through the validated startup wrapper: ```bash -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d --pull always +./remote_up.sh ``` -The script overwrites `docker-compose.yaml`, `nginx.conf`, and `turnserver.conf` from upstream templates. If you've made local edits to any of these (extra environment variables, custom ports, modified nginx routes), check the `.bak.` files after the update and re-apply your edits. +The script overwrites `docker-compose.yaml` and the remote helper bundle (`remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`) from the shared upstream deployment bundle. If you've made local edits to any of these, check the `.bak.` files after the update and re-apply your edits. ## Local deployment @@ -100,11 +99,10 @@ curl http://localhost:8000/api/v1/health # local ```bash cd dograh -for f in docker-compose.yaml nginx.conf turnserver.conf .env; do +for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do [[ -f "$f.bak." ]] && cp "$f.bak." "$f" done -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d +./remote_up.sh ``` Your Postgres data volume persists across `down`/`up` cycles, so agents and call history are preserved. @@ -138,6 +136,6 @@ sudo docker compose --profile remote up -d If you update the `pipecat` submodule, you **must** run `git submodule update --init --recursive` before rebuilding, or the Docker build will not pick up `pipecat` changes. -If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `nginx.conf`, `turnserver.conf`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth. +If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `deploy/templates/*`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth. The same migration warning above applies: rolling back across a schema change can leave the DB in a state the older API can't read. diff --git a/remote_up.sh b/remote_up.sh new file mode 100755 index 0000000..1b01ef1 --- /dev/null +++ b/remote_up.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/scripts/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + +DOGRAH_DEPLOY_PROJECT_DIR="$SCRIPT_DIR" + +VALIDATE_ONLY=0 +MODE="pull" +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --build) + MODE="build" + ;; + --preflight-only|--validate-only) + VALIDATE_ONLY=1 + ;; + --) + shift + EXTRA_ARGS=("$@") + break + ;; + *) + EXTRA_ARGS+=("$1") + ;; + esac + shift +done + +cd "$SCRIPT_DIR" + +dograh_info "Running Dograh remote preflight..." +dograh_prepare_remote_install "$SCRIPT_DIR" +docker compose config -q +dograh_success "✓ dograh-init preflight validated" + +if [[ "$VALIDATE_ONLY" == "1" ]]; then + exit 0 +fi + +if [[ $EUID -eq 0 ]] || ! command -v sudo >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +else + COMPOSE_CMD=(sudo docker compose) +fi + +if [[ "$MODE" == "build" ]]; then + exec "${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate "${EXTRA_ARGS[@]}" +else + exec "${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate "${EXTRA_ARGS[@]}" +fi diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..59a4f3c --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,55 @@ +# scripts/ + +## Bash ↔ PowerShell parity — keep them in sync + +Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`. + +Current pairs: + +- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files) +- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install +- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait) +- `stop_services.{sh,ps1}` +- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers + +Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors): + +- `start_services.sh` — VM production +- `start_services_docker.sh` — Docker image CMD +- `rolling_update.sh` — zero-downtime VM redeploy +- `setup_local.sh` / `setup_remote.sh` — OSS Docker-compose setup +- `format.sh` / `lint.sh` / `pre_commit.sh` +- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py` + +## Deployment Memory — current OSS Docker state + +This directory now has a shared deployment model for OSS Docker installs. If you touch any of the scripts below, assume they are coupled and review them together: + +- `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`. +- `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers. +- `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around. +- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. +- `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack. +- `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`. +- `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`. +- Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them. +- `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. +- `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path. +- `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`. +- `setup_local.sh` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config. +- `setup_local.sh` must remain safe under unset env vars; use `${VAR:-}` guards for optional inputs like `ENABLE_COTURN`, `TURN_HOST`, `TURN_SECRET`, `DOGRAH_SKIP_DOWNLOAD`. +- `run_dograh_init.sh` is an executable entrypoint, not a library. Compose runs it directly. If it ever gets refactored, keep the distinction between sourced helper logic (`lib/`) and executable entrypoints. +- `dograh_prepare_remote_install` in `setup_common.sh` currently does three things: sync canonical `.env` keys, reject legacy compose layouts that do not use `dograh-init`, and preflight the init render in a temp directory. +- `dograh_uses_init_compose_layout` / `dograh_require_init_compose_layout` are the guardrails for old installs. If a remote install still bind-mounts host `nginx.conf` / `turnserver.conf`, the intended fix path is `./update_remote.sh`. +- Templates live under `deploy/templates/`. `nginx.remote.conf.template` contains the static shape and `dograh_render_remote_nginx_conf` expands the multi-worker upstream block dynamically. `turnserver.remote.conf.template` is also rendered from env. +- If you rename/move any of these deployment files, update all of: bootstrap curl URLs inside scripts, helper-bundle download paths in `setup_common.sh`, backup lists in `update_remote.sh`, docs under `docs/deployment/`, and any existence checks in `setup_local.sh` / `setup_custom_domain.sh`. + +## The three "start" scripts — pick the right one + +| Script | Where it runs | Key behavior | +| -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. | +| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. | +| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. | + +If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file. diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 1c3e942..43c994c 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -1,30 +1 @@ -# scripts/ - -## Bash ↔ PowerShell parity — keep them in sync - -Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`. - -Current pairs: -- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files) -- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install -- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait) -- `stop_services.{sh,ps1}` -- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers - -Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors): -- `start_services.sh` — VM production -- `start_services_docker.sh` — Docker image CMD -- `rolling_update.sh` — zero-downtime VM redeploy -- `setup_local.sh` / `setup_remote.sh` — OSS Docker-compose setup -- `format.sh` / `lint.sh` / `pre_commit.sh` -- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py` - -## The three "start" scripts — pick the right one - -| Script | Where it runs | Key behavior | -| ----------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. | -| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. | -| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. | - -If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file. +@AGENTS.md diff --git a/scripts/lib/setup_common.sh b/scripts/lib/setup_common.sh new file mode 100644 index 0000000..e22b9ee --- /dev/null +++ b/scripts/lib/setup_common.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash + +DOGRAH_DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOGRAH_DEPLOY_REPO_ROOT="$(cd "$DOGRAH_DEPLOY_LIB_DIR/../.." 2>/dev/null && pwd || true)" + +: "${RED:=\033[0;31m}" +: "${GREEN:=\033[0;32m}" +: "${YELLOW:=\033[1;33m}" +: "${BLUE:=\033[0;34m}" +: "${NC:=\033[0m}" + +dograh_info() { + echo -e "${BLUE}$*${NC}" +} + +dograh_success() { + echo -e "${GREEN}$*${NC}" +} + +dograh_warn() { + echo -e "${YELLOW}$*${NC}" +} + +dograh_fail() { + echo -e "${RED}Error: $*${NC}" >&2 + exit 1 +} + +dograh_project_dir() { + if [[ -n "${DOGRAH_DEPLOY_PROJECT_DIR:-}" ]]; then + printf '%s\n' "$DOGRAH_DEPLOY_PROJECT_DIR" + else + pwd + fi +} + +dograh_template_path() { + local template_name=$1 + local candidate="" + local project_dir + + project_dir="$(dograh_project_dir)" + + for candidate in \ + "$project_dir/deploy/templates/$template_name" \ + "$DOGRAH_DEPLOY_REPO_ROOT/deploy/templates/$template_name" + do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + dograh_fail "Template '$template_name' not found" +} + +dograh_init_script_path() { + local candidate="" + local project_dir + + project_dir="$(dograh_project_dir)" + + for candidate in \ + "$project_dir/scripts/run_dograh_init.sh" \ + "$DOGRAH_DEPLOY_REPO_ROOT/scripts/run_dograh_init.sh" + do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + dograh_fail "run_dograh_init.sh not found" +} + +dograh_load_env_file() { + local env_file=${1:-.env} + + [[ -f "$env_file" ]] || dograh_fail "$env_file not found" + + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + +dograh_host_from_url() { + local url=$1 + + url="${url#https://}" + url="${url#http://}" + url="${url%%/*}" + + printf '%s\n' "$url" +} + +dograh_is_ipv4() { + [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] +} + +dograh_infer_server_ip() { + local project_dir=${1:-$(dograh_project_dir)} + local turn_conf="$project_dir/turnserver.conf" + local ip="" + + if [[ -n "${SERVER_IP:-}" ]]; then + printf '%s\n' "$SERVER_IP" + return 0 + fi + + if [[ -f "$turn_conf" ]]; then + ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)" + if [[ -n "$ip" ]]; then + printf '%s\n' "$ip" + return 0 + fi + fi + + if [[ -n "${TURN_HOST:-}" ]] && dograh_is_ipv4 "$TURN_HOST"; then + printf '%s\n' "$TURN_HOST" + return 0 + fi + + if [[ -n "${PUBLIC_HOST:-}" ]] && dograh_is_ipv4 "$PUBLIC_HOST"; then + printf '%s\n' "$PUBLIC_HOST" + return 0 + fi + + return 1 +} + +dograh_infer_public_base_url() { + if [[ -n "${PUBLIC_BASE_URL:-}" ]]; then + printf '%s\n' "${PUBLIC_BASE_URL%/}" + return 0 + fi + + if [[ -n "${BACKEND_API_ENDPOINT:-}" ]]; then + printf '%s\n' "${BACKEND_API_ENDPOINT%/}" + return 0 + fi + + if [[ -n "${PUBLIC_HOST:-}" ]]; then + printf 'https://%s\n' "$PUBLIC_HOST" + return 0 + fi + + if [[ -n "${SERVER_IP:-}" ]]; then + printf 'https://%s\n' "$SERVER_IP" + return 0 + fi + + return 1 +} + +dograh_infer_public_host() { + local public_base_url="" + + if [[ -n "${PUBLIC_HOST:-}" ]]; then + printf '%s\n' "$PUBLIC_HOST" + return 0 + fi + + public_base_url="$(dograh_infer_public_base_url 2>/dev/null || true)" + if [[ -n "$public_base_url" ]]; then + dograh_host_from_url "$public_base_url" + return 0 + fi + + if [[ -n "${TURN_HOST:-}" ]]; then + printf '%s\n' "$TURN_HOST" + return 0 + fi + + return 1 +} + +dograh_set_env_key() { + local env_file=$1 + local key=$2 + local value=$3 + local tmp_file="${env_file}.tmp.$$" + + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ "^" key "=" { + print key "=" value + updated = 1 + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$env_file" > "$tmp_file" + + mv "$tmp_file" "$env_file" +} + +dograh_delete_env_key() { + local env_file=$1 + local key=$2 + local tmp_file="${env_file}.tmp.$$" + + awk -v key="$key" '$0 !~ "^" key "=" { print }' "$env_file" > "$tmp_file" + mv "$tmp_file" "$env_file" +} + +dograh_sync_remote_env_file() { + local env_file=${1:-.env} + local project_dir + local public_base_url="" + local public_host="" + local server_ip="" + + project_dir="$(cd "$(dirname "$env_file")" && pwd)" + dograh_load_env_file "$env_file" + + public_base_url="$(dograh_infer_public_base_url)" || dograh_fail "Could not determine PUBLIC_BASE_URL" + public_base_url="${public_base_url%/}" + public_host="$(dograh_infer_public_host)" || dograh_fail "Could not determine PUBLIC_HOST" + server_ip="$(dograh_infer_server_ip "$project_dir")" || dograh_fail "Could not determine SERVER_IP" + + [[ "$public_base_url" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" + dograh_is_ipv4 "$server_ip" || dograh_fail "SERVER_IP must be an IPv4 address (got: $server_ip)" + + dograh_set_env_key "$env_file" ENVIRONMENT "${ENVIRONMENT:-production}" + dograh_set_env_key "$env_file" SERVER_IP "$server_ip" + dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host" + dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url" + dograh_set_env_key "$env_file" BACKEND_API_ENDPOINT "$public_base_url" + dograh_set_env_key "$env_file" MINIO_PUBLIC_ENDPOINT "$public_base_url" + dograh_set_env_key "$env_file" TURN_HOST "$public_host" +} + +dograh_validate_remote_runtime_env() { + [[ "${FASTAPI_WORKERS:-}" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer" + [[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing" + [[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing" + [[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing" + [[ -n "${BACKEND_API_ENDPOINT:-}" ]] || dograh_fail "BACKEND_API_ENDPOINT is missing" + [[ -n "${MINIO_PUBLIC_ENDPOINT:-}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT is missing" + [[ -n "${TURN_HOST:-}" ]] || dograh_fail "TURN_HOST is missing" + dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address" + [[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" + [[ "${BACKEND_API_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "BACKEND_API_ENDPOINT must match PUBLIC_BASE_URL" + [[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL" + [[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST" +} + +dograh_uses_init_compose_layout() { + local project_dir=${1:-$(dograh_project_dir)} + local compose_file="$project_dir/docker-compose.yaml" + + [[ -f "$compose_file" ]] || return 1 + grep -q "dograh-init:" "$compose_file" \ + && grep -q "nginx-generated:/etc/nginx/conf.d:ro" "$compose_file" \ + && grep -q "coturn-generated:/etc/coturn:ro" "$compose_file" +} + +dograh_require_init_compose_layout() { + local project_dir=${1:-$(dograh_project_dir)} + + if ! dograh_uses_init_compose_layout "$project_dir"; then + dograh_fail "This install uses the legacy remote compose layout. Run ./update_remote.sh first so Docker uses dograh-init generated config." + fi +} + +dograh_render_remote_nginx_conf() { + local project_dir=${1:-$(dograh_project_dir)} + local destination=${2:-"$project_dir/nginx.conf"} + local template="" + local tmp_upstream="" + + template="$(dograh_template_path "nginx.remote.conf.template")" + tmp_upstream="$(mktemp)" + + { + echo "# Backend API workers - one uvicorn process per port, balanced by least_conn." + echo "# Auto-generated by Dograh remote config renderer. Do not edit manually." + echo "upstream dograh_api {" + echo " least_conn;" + for ((i=0; i "$tmp_upstream" + + awk -v public_host="$PUBLIC_HOST" -v upstream_file="$tmp_upstream" ' + BEGIN { + while ((getline line < upstream_file) > 0) { + upstream = upstream line ORS + } + close(upstream_file) + } + { + gsub(/__DOGRAH_PUBLIC_HOST__/, public_host) + if ($0 == "__DOGRAH_UPSTREAM_BLOCK__") { + printf "%s", upstream + } else { + print + } + } + ' "$template" > "$destination" + + rm -f "$tmp_upstream" +} + +dograh_render_remote_turn_conf() { + local project_dir=${1:-$(dograh_project_dir)} + local destination=${2:-"$project_dir/turnserver.conf"} + local template="" + local external_ip="${TURN_EXTERNAL_IP:-${SERVER_IP:-}}" + + template="$(dograh_template_path "turnserver.remote.conf.template")" + [[ -n "$external_ip" ]] || dograh_fail "TURN external IP/host is missing" + + awk \ + -v external_ip="$external_ip" \ + -v turn_secret="$TURN_SECRET" \ + ' + { + gsub(/__DOGRAH_TURN_EXTERNAL_IP__/, external_ip) + gsub(/__DOGRAH_TURN_SECRET__/, turn_secret) + print + } + ' "$template" > "$destination" +} + +dograh_preflight_remote_init_render() { + local project_dir=${1:-$(dograh_project_dir)} + local env_file="$project_dir/.env" + local cert_dir="$project_dir/certs" + local init_script="" + local tmp_root="" + local nginx_conf="" + local turn_conf="" + local nginx_workers=0 + local rendered_secret="" + local rendered_ip="" + local rendered_server_name="" + + dograh_load_env_file "$env_file" + dograh_validate_remote_runtime_env + [[ -f "$cert_dir/local.crt" ]] || dograh_fail "certs/local.crt not found" + [[ -f "$cert_dir/local.key" ]] || dograh_fail "certs/local.key not found" + + init_script="$(dograh_init_script_path)" + tmp_root="$(mktemp -d)" + nginx_conf="$tmp_root/nginx/default.conf" + turn_conf="$tmp_root/coturn/turnserver.conf" + + ( + export ENVIRONMENT SERVER_IP PUBLIC_HOST PUBLIC_BASE_URL BACKEND_API_ENDPOINT MINIO_PUBLIC_ENDPOINT TURN_HOST TURN_SECRET FASTAPI_WORKERS + export DOGRAH_INIT_WORKSPACE_DIR="$project_dir" + export DOGRAH_INIT_OUTPUT_ROOT="$tmp_root" + export DOGRAH_INIT_CERTS_DIR="$cert_dir" + bash "$init_script" >/dev/null + ) + + [[ -f "$nginx_conf" ]] || dograh_fail "dograh-init did not render nginx config" + [[ -f "$turn_conf" ]] || dograh_fail "dograh-init did not render coturn config" + + nginx_workers=$(awk '/^[[:space:]]*server api:[0-9]+/ { count += 1 } END { print count + 0 }' "$nginx_conf") + [[ "$nginx_workers" -eq "$FASTAPI_WORKERS" ]] || dograh_fail "FASTAPI_WORKERS=$FASTAPI_WORKERS but nginx.conf has $nginx_workers upstream servers" + + rendered_server_name="$(awk '/^[[:space:]]*server_name / { print $2; exit }' "$nginx_conf" | sed 's/;$//')" + [[ "$rendered_server_name" == "$PUBLIC_HOST" ]] || dograh_fail "nginx.conf server_name ($rendered_server_name) does not match PUBLIC_HOST ($PUBLIC_HOST)" + + rendered_secret="$(sed -n 's/^static-auth-secret=//p' "$turn_conf" | head -1)" + [[ "$rendered_secret" == "$TURN_SECRET" ]] || dograh_fail "TURN_SECRET in .env does not match turnserver.conf" + + rendered_ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)" + [[ "$rendered_ip" == "$SERVER_IP" ]] || dograh_fail "SERVER_IP in .env does not match turnserver.conf" + + rm -rf "$tmp_root" +} + +dograh_prepare_remote_install() { + local project_dir=${1:-$(dograh_project_dir)} + local env_file="$project_dir/.env" + + dograh_sync_remote_env_file "$env_file" + dograh_require_init_compose_layout "$project_dir" + dograh_preflight_remote_init_render "$project_dir" +} + +dograh_download_bundle_file_for_ref() { + local destination=$1 + local remote_path=$2 + local ref=${3:-main} + local raw_base="https://raw.githubusercontent.com/dograh-hq/dograh/$ref" + local fallback_base="https://raw.githubusercontent.com/dograh-hq/dograh/main" + + if ! curl -fsSL -o "$destination" "$raw_base/$remote_path"; then + dograh_warn "Warning: '$remote_path' not found at '$ref' - falling back to main" + curl -fsSL -o "$destination" "$fallback_base/$remote_path" + fi +} + +dograh_download_init_support_bundle() { + local project_dir=$1 + local ref=${2:-main} + + mkdir -p "$project_dir/scripts/lib" "$project_dir/deploy/templates" + + mkdir -p "$project_dir/scripts" + dograh_download_bundle_file_for_ref "$project_dir/scripts/lib/setup_common.sh" "scripts/lib/setup_common.sh" "$ref" + dograh_download_bundle_file_for_ref "$project_dir/scripts/run_dograh_init.sh" "scripts/run_dograh_init.sh" "$ref" + chmod +x "$project_dir/scripts/run_dograh_init.sh" + dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/nginx.remote.conf.template" "deploy/templates/nginx.remote.conf.template" "$ref" + dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/turnserver.remote.conf.template" "deploy/templates/turnserver.remote.conf.template" "$ref" +} + +dograh_download_remote_support_bundle() { + local project_dir=$1 + local ref=${2:-main} + + dograh_download_bundle_file_for_ref "$project_dir/remote_up.sh" "remote_up.sh" "$ref" + chmod +x "$project_dir/remote_up.sh" + dograh_download_init_support_bundle "$project_dir" "$ref" +} diff --git a/scripts/run_dograh_init.sh b/scripts/run_dograh_init.sh new file mode 100755 index 0000000..3637f86 --- /dev/null +++ b/scripts/run_dograh_init.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_DIR="${DOGRAH_INIT_WORKSPACE_DIR:-/workspace}" +OUTPUT_ROOT="${DOGRAH_INIT_OUTPUT_ROOT:-/generated}" +NGINX_OUTPUT_DIR="$OUTPUT_ROOT/nginx" +COTURN_OUTPUT_DIR="$OUTPUT_ROOT/coturn" +CERTS_DIR="${DOGRAH_INIT_CERTS_DIR:-/certs}" + +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/setup_common.sh" + +DOGRAH_DEPLOY_PROJECT_DIR="$WORKSPACE_DIR" + +mkdir -p "$NGINX_OUTPUT_DIR" "$COTURN_OUTPUT_DIR" + +if [[ "${ENVIRONMENT:-local}" == "production" ]]; then + dograh_validate_remote_runtime_env + [[ -f "$CERTS_DIR/local.crt" ]] || dograh_fail "certs/local.crt not found" + [[ -f "$CERTS_DIR/local.key" ]] || dograh_fail "certs/local.key not found" + + export TURN_EXTERNAL_IP="$SERVER_IP" + dograh_render_remote_nginx_conf "$WORKSPACE_DIR" "$NGINX_OUTPUT_DIR/default.conf" + dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf" + dograh_success "✓ dograh-init rendered remote nginx and coturn config" + exit 0 +fi + +if [[ -n "${TURN_SECRET:-}" && -n "${TURN_HOST:-}" ]]; then + export TURN_EXTERNAL_IP="$TURN_HOST" + dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf" + dograh_success "✓ dograh-init rendered local TURN config" + exit 0 +fi + +dograh_success "✓ dograh-init no-op for current profile" diff --git a/scripts/setup_custom_domain.sh b/scripts/setup_custom_domain.sh index fec11be..d3d3c78 100755 --- a/scripts/setup_custom_domain.sh +++ b/scripts/setup_custom_domain.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Custom Domain Setup ║" @@ -15,13 +35,10 @@ echo "║ Automated Let's Encrypt SSL certificate setup ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# Check if running as root or with sudo if [[ $EUID -ne 0 ]]; then - echo -e "${RED}Error: This script must be run as root or with sudo${NC}" - exit 1 + dograh_fail "This script must be run as root or with sudo" fi -# Check if dograh directory exists if [[ ! -d "dograh" ]]; then echo -e "${RED}Error: 'dograh' directory not found.${NC}" echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}" @@ -30,29 +47,17 @@ if [[ ! -d "dograh" ]]; then exit 1 fi -# Get the domain name echo -e "${YELLOW}Enter your domain name (e.g., voice.yourcompany.com):${NC}" read -p "> " DOMAIN_NAME +[[ -n "$DOMAIN_NAME" ]] || dograh_fail "Domain name cannot be empty" -if [[ -z "$DOMAIN_NAME" ]]; then - echo -e "${RED}Error: Domain name cannot be empty${NC}" - exit 1 -fi - -# Basic domain validation if ! [[ "$DOMAIN_NAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$ ]]; then - echo -e "${RED}Error: Invalid domain name format${NC}" - exit 1 + dograh_fail "Invalid domain name format" fi -# Get email for Let's Encrypt notifications echo -e "${YELLOW}Enter your email address for SSL certificate notifications:${NC}" read -p "> " EMAIL_ADDRESS - -if [[ -z "$EMAIL_ADDRESS" ]]; then - echo -e "${RED}Error: Email address cannot be empty (required by Let's Encrypt)${NC}" - exit 1 -fi +[[ -n "$EMAIL_ADDRESS" ]] || dograh_fail "Email address cannot be empty (required by Let's Encrypt)" echo "" echo -e "${GREEN}Configuration:${NC}" @@ -60,13 +65,12 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}" echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}" echo "" -# Verify DNS is pointing to this server echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}" -SERVER_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") -RESOLVED_IP=$(dig +short "$DOMAIN_NAME" | tail -1) +SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")" +RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)" if [[ -z "$SERVER_IP" ]]; then - echo -e "${YELLOW}Warning: Could not detect server's public IP${NC}" + dograh_warn "Warning: Could not detect server's public IP" elif [[ "$RESOLVED_IP" != "$SERVER_IP" ]]; then echo -e "${YELLOW}Warning: Domain '$DOMAIN_NAME' resolves to '$RESOLVED_IP' but this server's IP is '$SERVER_IP'${NC}" echo -e "${YELLOW}Make sure your DNS A record points to this server before proceeding.${NC}" @@ -80,7 +84,6 @@ else echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}" fi -# Detect package manager and install certbot echo -e "${BLUE}[2/7] Installing Certbot...${NC}" if command -v apt-get &> /dev/null; then apt-get update -qq @@ -90,14 +93,20 @@ elif command -v yum &> /dev/null; then elif command -v dnf &> /dev/null; then dnf install -y -q certbot else - echo -e "${RED}Error: Could not detect package manager. Please install certbot manually.${NC}" - exit 1 + dograh_fail "Could not detect package manager. Please install certbot manually." fi echo -e "${GREEN}✓ Certbot installed${NC}" -# Stop Dograh services to free port 80 echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}" cd dograh +DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)" + +if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then + dograh_download_remote_support_bundle "$(pwd)" "main" +fi + +dograh_require_init_compose_layout "$(pwd)" + if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then docker compose --profile remote down echo -e "${GREEN}✓ Dograh services stopped${NC}" @@ -105,7 +114,6 @@ else echo -e "${YELLOW}⚠ No running services found${NC}" fi -# Generate SSL certificate echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}" CERTBOT_OUTPUT=$(certbot certonly --standalone \ --non-interactive \ @@ -115,7 +123,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ echo -e "${RED}✗ Certificate generation failed${NC}" echo "" - # Check for common errors and provide helpful hints if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}" @@ -123,14 +130,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ echo "" echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership." echo "" - echo -e "${BLUE}If using AWS EC2:${NC}" - echo " 1. Go to AWS Console → EC2 → Security Groups" - echo " 2. Find the security group for your instance" - echo " 3. Add inbound rule: HTTP | TCP | Port 80 | 0.0.0.0/0" - echo "" - echo -e "${BLUE}If using another cloud provider:${NC}" - echo " • Ensure port 80 (TCP) is open for inbound traffic from all sources" - echo "" elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}" @@ -160,161 +159,60 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ } echo -e "${GREEN}✓ SSL certificate generated${NC}" -# Verify and display certificate location CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME" echo "" echo -e "${BLUE}Certificate location:${NC}" echo -e " ${CERT_PATH}/" -if [[ -f "$CERT_PATH/fullchain.pem" ]]; then - echo -e " ${GREEN}✓${NC} fullchain.pem exists" -else - echo -e " ${RED}✗${NC} fullchain.pem NOT FOUND" -fi -if [[ -f "$CERT_PATH/privkey.pem" ]]; then - echo -e " ${GREEN}✓${NC} privkey.pem exists" -else - echo -e " ${RED}✗${NC} privkey.pem NOT FOUND" -fi +[[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}✓${NC} fullchain.pem exists" || echo -e " ${RED}✗${NC} fullchain.pem NOT FOUND" +[[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}✓${NC} privkey.pem exists" || echo -e " ${RED}✗${NC} privkey.pem NOT FOUND" echo "" -# Copy certificates to dograh/certs directory -cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem certs/local.crt -cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem certs/local.key +mkdir -p certs +cp "$CERT_PATH/fullchain.pem" certs/local.crt +cp "$CERT_PATH/privkey.pem" certs/local.key chmod 644 certs/local.crt certs/local.key echo -e "${GREEN}✓${NC} Certificates copied to certs/ directory" echo "" -# Update nginx.conf -echo -e "${BLUE}[5/7] Updating nginx configuration...${NC}" -cat > nginx.conf << NGINX_EOF -server { - listen 80; - server_name $DOMAIN_NAME; +echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}" +dograh_load_env_file .env - # Redirect all HTTP to HTTPS - return 301 https://\$host\$request_uri; -} - -server { - listen 443 ssl; - server_name $DOMAIN_NAME; - - ssl_certificate /etc/nginx/certs/local.crt; - ssl_certificate_key /etc/nginx/certs/local.key; - - # TLS settings - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; - - # Backend API and WebSockets — bypass the UI, go straight to api:8000 - location /api/v1/ { - proxy_pass http://api:8000; - proxy_http_version 1.1; - - proxy_set_header Upgrade \$http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Long-lived WebSockets (audio streaming, signaling) - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - - # Don't buffer streamed responses - proxy_buffering off; - client_max_body_size 100M; - } - - location / { - proxy_pass http://ui:3010; - proxy_http_version 1.1; - - # Important for WebSockets / hot reload etc. - proxy_set_header Upgrade \$http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Rewrite localhost MinIO URLs in API responses to use current domain - sub_filter 'http://localhost:9000/voice-audio/' 'https://\$host/voice-audio/'; - sub_filter_once off; - sub_filter_types application/json text/html; - } - - location /voice-audio/ { - proxy_pass http://minio:9000/voice-audio/; - - proxy_http_version 1.1; - - # Headers for file downloads from MinIO - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Allow large file downloads - proxy_buffering off; - client_max_body_size 100M; - } -} -NGINX_EOF -echo -e "${GREEN}✓ nginx.conf updated${NC}" - -# Update .env file with domain name -echo -e "${BLUE}[6/8] Updating environment variables...${NC}" -if [[ -f ".env" ]]; then - # Update BACKEND_API_ENDPOINT to use domain (public URL the backend advertises) - sed -i.bak "s|^BACKEND_API_ENDPOINT=.*|BACKEND_API_ENDPOINT=https://$DOMAIN_NAME|" .env - # Drop any stale BACKEND_URL override — the ui container should use the - # internal Docker URL (http://api:8000) from docker-compose defaults. - sed -i.bak "/^BACKEND_URL=/d" .env - sed -i.bak "/^# Backend URL for UI$/d" .env - # Update TURN_HOST to use domain - sed -i.bak "s|^TURN_HOST=.*|TURN_HOST=$DOMAIN_NAME|" .env - # Update MINIO_PUBLIC_ENDPOINT to use domain (browsers fetch /voice-audio/* here) - if grep -q "^MINIO_PUBLIC_ENDPOINT=" .env; then - sed -i.bak "s|^MINIO_PUBLIC_ENDPOINT=.*|MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME|" .env - else - echo "MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME" >> .env - fi - rm -f .env.bak - echo -e "${GREEN}✓ .env updated with domain name${NC}" -else - echo -e "${YELLOW}⚠ .env file not found - skipping environment update${NC}" +if [[ -z "${SERVER_IP:-}" ]]; then + SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)" fi -# Setup auto-renewal -echo -e "${BLUE}[7/8] Setting up automatic certificate renewal...${NC}" -DOGRAH_PATH=$(pwd) +[[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install" + +dograh_set_env_key .env SERVER_IP "$SERVER_IP" +dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME" +dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME" +dograh_delete_env_key .env BACKEND_URL +dograh_prepare_remote_install "$(pwd)" +echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}" + +echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}" +DOGRAH_PATH="$(pwd)" -# Create renewal hook script that copies new certificates and restarts nginx cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF #!/bin/bash -# Copy renewed certificates to dograh certs directory -cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem $DOGRAH_PATH/certs/local.crt -cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem $DOGRAH_PATH/certs/local.key +cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt +cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key -# Restart nginx to load new certificates cd $DOGRAH_PATH docker compose --profile remote restart nginx 2>/dev/null || true HOOK_EOF chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh -# Test renewal -certbot renew --dry-run --quiet && echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" || echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" +if certbot renew --dry-run --quiet; then + echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" +else + echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" +fi -# Start Dograh services echo "" -echo -e "${BLUE}[8/8] Starting Dograh services...${NC}" -docker compose --profile remote up -d --pull always +echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}" +./remote_up.sh echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" @@ -331,8 +229,7 @@ echo -e " Private Key: $DOGRAH_PATH/certs/local.key" echo -e " Auto-renewal: Enabled (certificates renew automatically)" echo "" echo -e "${YELLOW}Files modified:${NC}" -echo " - dograh/nginx.conf (updated with domain name)" -echo " - dograh/.env (BACKEND_API_ENDPOINT and TURN_HOST updated)" +echo " - dograh/.env (canonical public host/base URL updated)" echo " - dograh/certs/local.crt (SSL certificate)" echo " - dograh/certs/local.key (SSL private key)" echo " - /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh (renewal hook)" diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 86a0b71..be31384 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Local Setup ║" @@ -16,7 +36,7 @@ echo "╚═══════════════════════ echo -e "${NC}" # Ask whether to enable coturn (skip prompt if ENABLE_COTURN is already set) -if [[ -z "$ENABLE_COTURN" ]]; then +if [[ -z "${ENABLE_COTURN:-}" ]]; then echo -e "${YELLOW}Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:${NC}" read -p "> " ENABLE_COTURN_INPUT if [[ "$ENABLE_COTURN_INPUT" =~ ^[Yy] ]]; then @@ -26,7 +46,7 @@ if [[ -z "$ENABLE_COTURN" ]]; then fi fi -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then # Pick a TURN_HOST that's reachable from BOTH the browser (running on the # host) and the API container (running in docker). 127.0.0.1 is tempting # but doesn't work for the api container — its own loopback isn't where @@ -54,7 +74,7 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then DEFAULT_TURN_HOST="${DEFAULT_TURN_HOST:-127.0.0.1}" # Get the host browsers/peers will use to reach the TURN server - if [[ -z "$TURN_HOST" ]]; then + if [[ -z "${TURN_HOST:-}" ]]; then echo -e "${YELLOW}Enter the host browsers AND the API container will use to reach TURN${NC}" echo -e "${YELLOW}(press Enter for ${DEFAULT_TURN_HOST}):${NC}" read -p "> " TURN_HOST @@ -68,13 +88,13 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then fi # Get the TURN secret (skip prompt if TURN_SECRET is already set) - if [[ -z "$TURN_SECRET" ]]; then + if [[ -z "${TURN_SECRET:-}" ]]; then echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" read -sp "> " TURN_SECRET echo "" fi - if [[ -z "$TURN_SECRET" ]]; then + if [[ -z "${TURN_SECRET:-}" ]]; then TURN_SECRET=$(openssl rand -hex 32) echo -e "${BLUE}Generated random TURN secret${NC}" fi @@ -88,8 +108,8 @@ REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}" echo "" echo -e "${GREEN}Configuration:${NC}" -echo -e " Coturn: ${BLUE}$ENABLE_COTURN${NC}" -if [[ "$ENABLE_COTURN" == "true" ]]; then +echo -e " Coturn: ${BLUE}${ENABLE_COTURN:-false}${NC}" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then echo -e " TURN Host: ${BLUE}$TURN_HOST${NC}" echo -e " TURN Secret: ${BLUE}********${NC}" fi @@ -99,52 +119,26 @@ echo "" # Download compose file (skip when DOGRAH_SKIP_DOWNLOAD=1 — e.g. local repo testing). TOTAL_STEPS=2 -if [[ "$ENABLE_COTURN" == "true" ]]; then - TOTAL_STEPS=3 -fi -if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then - echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}" +if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then + if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml and TURN helper bundle...${NC}" + else + echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}" + fi curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml - echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" + if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + dograh_download_init_support_bundle "$(pwd)" "main" + fi + echo -e "${GREEN}✓ Deployment files downloaded${NC}" else echo -e "${BLUE}[1/$TOTAL_STEPS] Using docker-compose.yaml in current directory${NC}" fi -# Generate turnserver.conf if coturn is enabled -if [[ "$ENABLE_COTURN" == "true" ]]; then - echo -e "${BLUE}[2/$TOTAL_STEPS] Creating TURN server configuration...${NC}" - cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration (local) -# Auto-generated by setup_local.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$TURN_HOST - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF - echo -e "${GREEN}✓ turnserver.conf created${NC}" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + [[ -f scripts/run_dograh_init.sh ]] || dograh_fail "scripts/run_dograh_init.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." + [[ -f scripts/lib/setup_common.sh ]] || dograh_fail "scripts/lib/setup_common.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." + [[ -f deploy/templates/turnserver.remote.conf.template ]] || dograh_fail "deploy/templates/turnserver.remote.conf.template not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." fi # Generate .env @@ -163,7 +157,7 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET ENABLE_TELEMETRY=$ENABLE_TELEMETRY ENV_EOF -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then cat >> .env << ENV_EOF # TURN Server Configuration (time-limited credentials via TURN REST API) @@ -181,11 +175,13 @@ echo "" echo -e "Files created in ${BLUE}$(pwd)${NC}:" echo " - docker-compose.yaml" echo " - .env" -if [[ "$ENABLE_COTURN" == "true" ]]; then - echo " - turnserver.conf" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + echo " - scripts/run_dograh_init.sh" + echo " - scripts/lib/setup_common.sh" + echo " - deploy/templates/" fi echo "" -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then echo -e "${YELLOW}To start Dograh with TURN, run:${NC}" echo "" echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}" diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index 334a516..cd01f7f 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Remote Setup ║" @@ -16,24 +36,21 @@ echo "╚═══════════════════════ echo -e "${NC}" # Get the public IP address (skip prompt if SERVER_IP is already set) -if [[ -z "$SERVER_IP" ]]; then +if [[ -z "${SERVER_IP:-}" ]]; then echo -e "${YELLOW}Enter your server's public IP address:${NC}" read -p "> " SERVER_IP fi if [[ -z "$SERVER_IP" ]]; then - echo -e "${RED}Error: IP address cannot be empty${NC}" - exit 1 + dograh_fail "IP address cannot be empty" fi -# Validate IP address format (basic validation) -if ! [[ "$SERVER_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${RED}Error: Invalid IP address format${NC}" - exit 1 +if ! dograh_is_ipv4 "$SERVER_IP"; then + dograh_fail "Invalid IP address format" fi # Get the TURN secret (skip prompt if TURN_SECRET is already set) -if [[ -z "$TURN_SECRET" ]]; then +if [[ -z "${TURN_SECRET:-}" ]]; then echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" read -sp "> " TURN_SECRET echo "" @@ -45,10 +62,8 @@ if [[ -z "$TURN_SECRET" ]]; then fi # Deployment mode. Skip prompt if DEPLOY_MODE is already set. Non-interactive -# callers (cloud-init, CI, terraform) without a TTY default to "prebuilt" so -# existing automation keeps working without changes - explicitly set -# DEPLOY_MODE=build to opt into source builds from a non-interactive context. -if [[ -z "$DEPLOY_MODE" ]]; then +# callers without a TTY default to "prebuilt" to keep automation stable. +if [[ -z "${DEPLOY_MODE:-}" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}Deployment mode:${NC}" @@ -58,19 +73,16 @@ if [[ -z "$DEPLOY_MODE" ]]; then mode_choice="${mode_choice:-1}" case "$mode_choice" in 1|prebuilt) DEPLOY_MODE="prebuilt" ;; - 2|build) DEPLOY_MODE="build" ;; - *) echo -e "${RED}Error: invalid choice '$mode_choice'${NC}"; exit 1 ;; + 2|build) DEPLOY_MODE="build" ;; + *) dograh_fail "invalid choice '$mode_choice'" ;; esac else DEPLOY_MODE="prebuilt" fi fi -# Build mode needs source code - either use existing repo or clone fresh. -# Same TTY rule: prompt interactively, otherwise pick sensible defaults so -# automation that sets DEPLOY_MODE=build doesn't need to spell everything out. if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ -z "$REPO_SOURCE" ]]; then + if [[ -z "${REPO_SOURCE:-}" ]]; then if [[ -d ".git" ]] && [[ -f "docker-compose.yaml" ]]; then if [[ -t 0 ]]; then echo "" @@ -91,7 +103,7 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then fi if [[ "$REPO_SOURCE" == "clone" ]]; then - if [[ -z "$FORK_REPO" ]]; then + if [[ -z "${FORK_REPO:-}" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}GitHub repo to clone (format: owner/name):${NC}" @@ -101,7 +113,8 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then FORK_REPO="dograh-hq/dograh" fi fi - if [[ -z "$BRANCH" ]]; then + + if [[ -z "${BRANCH:-}" ]]; then if [[ -t 0 ]]; then echo -e "${YELLOW}Branch:${NC}" read -p "[main]: " BRANCH @@ -113,13 +126,9 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then fi fi -# Telemetry opt-out (default: true) ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" +FASTAPI_WORKERS="${FASTAPI_WORKERS:-}" -# Number of uvicorn worker processes. Each runs as its own process on a -# distinct port (8000, 8001, ...) and nginx balances across them with -# least_conn. Better than uvicorn --workers for long-lived WebSocket -# connections, which would otherwise stick to whichever worker accepted them. if [[ -z "$FASTAPI_WORKERS" ]]; then if [[ -t 0 ]]; then echo "" @@ -131,26 +140,15 @@ if [[ -z "$FASTAPI_WORKERS" ]]; then fi fi -if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then - echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}" - exit 1 -fi +[[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)" -# Where setup artifacts (.env, certs, nginx.conf, etc.) will land. Build mode -# with an existing repo writes them next to docker-compose.yaml in cwd; -# everything else writes into a fresh dograh/ subdirectory. -if [[ "$DEPLOY_MODE" == "build" && "$REPO_SOURCE" == "existing" ]]; then +if [[ "$DEPLOY_MODE" == "build" && "${REPO_SOURCE:-}" == "existing" ]]; then TARGET_DIR="." else TARGET_DIR="dograh" fi -# Refuse to overwrite an existing install - re-running this script would -# regenerate OSS_JWT_SECRET (invalidating logged-in sessions), reset the -# TURN secret (breaking WebRTC auth), and overwrite nginx.conf customizations. -# Set DOGRAH_FORCE_OVERWRITE=1 to bypass; DOGRAH_SKIP_DOWNLOAD=1 (used by e2e) -# also bypasses since those flows manage state themselves. -if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then +if [[ "${DOGRAH_FORCE_OVERWRITE:-}" != "1" && "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then if [[ -f "$TARGET_DIR/.env" ]]; then if [[ "$TARGET_DIR" == "." ]]; then existing_path="$(pwd)/.env" @@ -164,7 +162,7 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the echo -e "${RED}Refusing to continue - re-running setup would:${NC}" echo -e "${RED} - overwrite .env (invalidates sessions, breaks TURN auth)${NC}" echo -e "${RED} - regenerate SSL certificates${NC}" - echo -e "${RED} - reset nginx.conf and turnserver.conf customizations${NC}" + echo -e "${RED} - replace the validated remote deployment bundle${NC}" echo "" echo -e "${BLUE}To upgrade an existing install, follow:${NC}" echo -e " ${BLUE}https://docs.dograh.com/deployment/update${NC}" @@ -176,11 +174,10 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the fi fi -# Total step count depends on mode (build adds the override-file step) if [[ "$DEPLOY_MODE" == "build" ]]; then - TOTAL=7 -else TOTAL=6 +else + TOTAL=5 fi echo "" @@ -190,24 +187,20 @@ echo -e " TURN Secret: ${BLUE}********${NC}" echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ "$REPO_SOURCE" == "clone" ]]; then - echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}" + if [[ "${REPO_SOURCE:-}" == "clone" ]]; then + echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}" else - echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}" + echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}" fi fi echo "" -# Step 1: get the source - either the standalone compose file (prebuilt mode) -# or the full repo (build mode). Skip the download/clone when -# DOGRAH_SKIP_DOWNLOAD=1 (e.g. e2e tests that already have everything in place). if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ "$DOGRAH_SKIP_DOWNLOAD" == "1" ]]; then + if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" == "1" ]]; then echo -e "${BLUE}[1/$TOTAL] Using existing repo in current directory${NC}" - elif [[ "$REPO_SOURCE" == "clone" ]]; then + elif [[ "${REPO_SOURCE:-}" == "clone" ]]; then if [[ -e "dograh" ]]; then - echo -e "${RED}Error: 'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it.${NC}" - exit 1 + dograh_fail "'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it." fi echo -e "${BLUE}[1/$TOTAL] Cloning $FORK_REPO (branch: $BRANCH)...${NC}" git clone --branch "$BRANCH" --recurse-submodules "https://github.com/$FORK_REPO.git" dograh @@ -217,123 +210,26 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then echo -e "${BLUE}[1/$TOTAL] Using existing repo at $(pwd)${NC}" fi else - if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then + if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then mkdir -p dograh 2>/dev/null || true cd dograh - echo -e "${BLUE}[1/$TOTAL] Downloading docker-compose.yaml...${NC}" - curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml - echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" + echo -e "${BLUE}[1/$TOTAL] Downloading deployment bundle...${NC}" + curl -fsSL -o docker-compose.yaml "https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml" + dograh_download_remote_support_bundle "$(pwd)" "main" + echo -e "${GREEN}✓ Deployment bundle downloaded${NC}" else - echo -e "${BLUE}[1/$TOTAL] Using docker-compose.yaml in current directory${NC}" + echo -e "${BLUE}[1/$TOTAL] Using deployment files in current directory${NC}" fi fi -echo -e "${BLUE}[2/$TOTAL] Creating nginx.conf...${NC}" -# Build the upstream block first (needs shell interpolation for the server -# lines), then append the static server blocks via a quoted heredoc. The -# SERVER_IP_PLACEHOLDER gets replaced by sed below. -{ - echo "# Backend API workers — one uvicorn process per port, balanced by least_conn." - echo "# Generated by setup_remote.sh; regenerate to change worker count." - echo "upstream dograh_api {" - echo " least_conn;" - for ((i=0; i nginx.conf - -# Replace placeholder with actual IP -sed -i.bak "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.bak -echo -e "${GREEN}✓ nginx.conf created${NC}" - -echo -e "${BLUE}[3/$TOTAL] Creating SSL certificate generation script...${NC}" +echo -e "${BLUE}[2/$TOTAL] Creating SSL certificate generation script...${NC}" cat > generate_certificate.sh << CERT_EOF #!/bin/bash mkdir -p certs @@ -346,50 +242,22 @@ CERT_EOF chmod +x generate_certificate.sh echo -e "${GREEN}✓ generate_certificate.sh created${NC}" -echo -e "${BLUE}[4/$TOTAL] Generating SSL certificates...${NC}" +echo -e "${BLUE}[3/$TOTAL] Generating SSL certificates...${NC}" ./generate_certificate.sh echo -e "${GREEN}✓ SSL certificates generated${NC}" -echo -e "${BLUE}[5/$TOTAL] Creating TURN server configuration...${NC}" -cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration -# Auto-generated by setup_remote.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$SERVER_IP - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF -echo -e "${GREEN}✓ turnserver.conf created${NC}" - -echo -e "${BLUE}[6/$TOTAL] Creating environment file...${NC}" +echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) cat > .env << ENV_EOF # Change environment from local to production so that coturn filters local IPs ENVIRONMENT=production +# Canonical public host/base URL for this install. +SERVER_IP=$SERVER_IP +PUBLIC_HOST=$SERVER_IP +PUBLIC_BASE_URL=https://$SERVER_IP + # Backend API endpoint (public URL the backend uses to build webhook/embed links) BACKEND_API_ENDPOINT=https://$SERVER_IP @@ -407,18 +275,16 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET ENABLE_TELEMETRY=$ENABLE_TELEMETRY # Number of uvicorn worker processes; nginx load-balances across them -# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn. -# Must match the upstream block in nginx.conf — re-run setup_remote.sh -# (with DOGRAH_FORCE_OVERWRITE=1) to change. FASTAPI_WORKERS=$FASTAPI_WORKERS ENV_EOF echo -e "${GREEN}✓ .env file created${NC}" -# In build mode, write the override file that swaps prebuilt images for -# local builds. Compose auto-loads docker-compose.override.yaml, so no -f flag -# is needed at runtime. +echo -e "${BLUE}[5/$TOTAL] Validating remote init configuration...${NC}" +dograh_prepare_remote_install "$(pwd)" +echo -e "${GREEN}✓ Remote init configuration validated${NC}" + if [[ "$DEPLOY_MODE" == "build" ]]; then - echo -e "${BLUE}[7/$TOTAL] Creating docker-compose.override.yaml...${NC}" + echo -e "${BLUE}[6/$TOTAL] Creating docker-compose.override.yaml...${NC}" cat > docker-compose.override.yaml << 'OVERRIDE_EOF' # Auto-generated by setup_remote.sh (build mode). # Overrides docker-compose.yaml to build api and ui images from local source @@ -452,8 +318,9 @@ echo " - docker-compose.yaml" if [[ "$DEPLOY_MODE" == "build" ]]; then echo " - docker-compose.override.yaml (build directives)" fi -echo " - nginx.conf" -echo " - turnserver.conf" +echo " - remote_up.sh" +echo " - scripts/run_dograh_init.sh" +echo " - deploy/templates/" echo " - generate_certificate.sh" echo " - certs/local.crt" echo " - certs/local.key" @@ -461,28 +328,17 @@ echo " - .env" echo "" echo -e "${YELLOW}To start Dograh, run:${NC}" echo "" -# The script's own cd into dograh/ doesn't persist to the user's shell, so -# remind them to cd themselves — except when they're already there (build mode -# with REPO_SOURCE=existing, which writes into cwd). -if [[ "$DEPLOY_MODE" != "build" || "$REPO_SOURCE" != "existing" ]]; then +if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then echo -e " ${BLUE}cd $(pwd)${NC}" fi if [[ "$DEPLOY_MODE" == "build" ]]; then - echo -e " ${BLUE}sudo docker compose --profile remote up -d --build${NC}" + echo -e " ${BLUE}./remote_up.sh --build${NC}" echo "" echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}" echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}" echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}" - echo "" - echo -e "${YELLOW}The first build can take several minutes${NC}" - echo -e "${YELLOW}(downloading base images, installing dependencies).${NC}" - echo -e "${YELLOW}If you know how to speed this up, we would love a pull request.${NC}" - echo "" - echo -e "${YELLOW}To rebuild after editing api/ or ui/ code:${NC}" - echo "" - echo -e " ${BLUE}sudo docker compose --profile remote build && sudo docker compose --profile remote up -d${NC}" else - echo -e " ${BLUE}sudo docker compose --profile remote up --pull always${NC}" + echo -e " ${BLUE}./remote_up.sh${NC}" fi echo "" echo -e "${YELLOW}Your application will be available at:${NC}" diff --git a/scripts/update_remote.sh b/scripts/update_remote.sh index 45c2802..119439b 100755 --- a/scripts/update_remote.sh +++ b/scripts/update_remote.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,34 +8,39 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + REPO="dograh-hq/dograh" TIMESTAMP=$(date +%Y%m%d-%H%M%S) echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Remote Update ║" -echo "║ Refresh host-side configs and pin api/ui image versions ║" +echo "║ Refresh deployment files and validate runtime config ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# Refuse outside an install — nothing to update if these aren't here. -if [[ ! -f docker-compose.yaml ]]; then - echo -e "${RED}Error: docker-compose.yaml not found in $(pwd)${NC}" - echo -e "${RED}Run this script from your Dograh install directory${NC}" - echo -e "${RED}(the 'dograh/' folder created by setup_remote.sh).${NC}" - exit 1 -fi +[[ -f docker-compose.yaml ]] || dograh_fail "docker-compose.yaml not found in $(pwd)" +[[ -f .env ]] || dograh_fail ".env not found in $(pwd)" -if [[ ! -f .env ]]; then - echo -e "${RED}Error: .env not found in $(pwd)${NC}" - echo -e "${RED}This script updates an existing install — there is nothing here to update.${NC}" - echo -e "${RED}For a fresh install, see https://docs.dograh.com/deployment/docker${NC}" - exit 1 -fi - -# Build-mode installs update via git, not via this script. The presence of an -# override file is the definitive marker (created by setup_remote.sh in build -# mode and not in prebuilt mode). if [[ -f docker-compose.override.yaml ]]; then echo -e "${YELLOW}Build-mode install detected (docker-compose.override.yaml present).${NC}" echo "" @@ -44,61 +49,47 @@ if [[ -f docker-compose.override.yaml ]]; then echo -e " ${BLUE}git fetch${NC}" echo -e " ${BLUE}git checkout # or: git pull${NC}" echo -e " ${BLUE}git submodule update --init --recursive${NC}" - echo -e " ${BLUE}sudo docker compose --profile remote build${NC}" - echo -e " ${BLUE}sudo docker compose --profile remote up -d${NC}" + echo -e " ${BLUE}./remote_up.sh --build${NC}" echo "" echo -e "${YELLOW}See https://docs.dograh.com/deployment/update#updating-a-source-build${NC}" exit 1 fi -############################################################################### -### Discover existing config from .env -############################################################################### +_caller_FASTAPI_WORKERS="${FASTAPI_WORKERS:-}" +_caller_TARGET_VERSION="${TARGET_VERSION:-}" -# Save anything the caller exported before we overwrite from .env. -_caller_FASTAPI_WORKERS="$FASTAPI_WORKERS" -_caller_TARGET_VERSION="$TARGET_VERSION" +DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)" +dograh_load_env_file .env -set -a -# shellcheck disable=SC1091 -. ./.env -set +a +[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET not found in .env" -# SERVER_IP isn't a literal key in .env — derive it from BACKEND_API_ENDPOINT. -if [[ -z "$SERVER_IP" ]]; then - if [[ -n "$BACKEND_API_ENDPOINT" ]]; then - SERVER_IP="${BACKEND_API_ENDPOINT#https://}" - SERVER_IP="${SERVER_IP#http://}" +if [[ -n "$_caller_FASTAPI_WORKERS" ]]; then + FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS" +fi + +if [[ -z "${FASTAPI_WORKERS:-}" ]]; then + if [[ -t 0 ]]; then + echo "" + echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}" + read -p "[4]: " FASTAPI_WORKERS + FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" + else + FASTAPI_WORKERS="4" fi fi -if [[ -z "$SERVER_IP" ]]; then - echo -e "${RED}Error: could not determine SERVER_IP from .env${NC}" - echo -e "${RED}Expected BACKEND_API_ENDPOINT=https:// in .env${NC}" - exit 1 -fi +[[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)" -if [[ -z "$TURN_SECRET" ]]; then - echo -e "${RED}Error: TURN_SECRET not found in .env${NC}" - exit 1 -fi - -# Reapply caller overrides on top of sourced .env so e.g. FASTAPI_WORKERS=8 ./update_remote.sh works. -[[ -n "$_caller_FASTAPI_WORKERS" ]] && FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS" -[[ -n "$_caller_TARGET_VERSION" ]] && TARGET_VERSION="$_caller_TARGET_VERSION" - -############################################################################### -### Determine target version -############################################################################### +TARGET_VERSION="${_caller_TARGET_VERSION:-${TARGET_VERSION:-}}" if [[ -z "$TARGET_VERSION" ]]; then - echo -e "${BLUE}Fetching latest release tag from GitHub...${NC}" + dograh_info "Fetching latest release tag from GitHub..." LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ | grep -E '"tag_name":' | head -1 \ | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) if [[ -z "$LATEST_TAG" ]]; then - echo -e "${YELLOW}Could not auto-discover latest tag — defaulting to 'main'.${NC}" + dograh_warn "Could not auto-discover latest tag - defaulting to 'main'." LATEST_TAG="main" fi @@ -113,28 +104,19 @@ if [[ -z "$TARGET_VERSION" ]]; then fi fi -# "latest" isn't a real ref on GitHub — treat it as "latest release". if [[ "$TARGET_VERSION" == "latest" ]]; then TARGET_VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ | grep -E '"tag_name":' | head -1 \ | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) - if [[ -z "$TARGET_VERSION" ]]; then - echo -e "${RED}Error: could not resolve 'latest' to a release tag${NC}" - exit 1 - fi + [[ -n "$TARGET_VERSION" ]] || dograh_fail "could not resolve 'latest' to a release tag" fi - -# GitHub release tags use a 'dograh-v' prefix (e.g. dograh-v1.28.0); Docker -# image tags on Docker Hub drop both the prefix and the 'v' (e.g. ':1.28.0'). -# Users commonly type shortcuts like '1.28.0' or 'v1.28.0' — try all reasonable -# variants so the script accepts any of those forms. TRY_TAGS=("$TARGET_VERSION") case "$TARGET_VERSION" in main|HEAD) - ;; # branch refs — leave as-is + ;; dograh-*) - ;; # already in the full tag form + ;; v*) TRY_TAGS+=("dograh-$TARGET_VERSION") ;; @@ -143,7 +125,7 @@ case "$TARGET_VERSION" in ;; esac -echo -e "${BLUE}Validating target version: $TARGET_VERSION...${NC}" +dograh_info "Validating target version: $TARGET_VERSION..." RESOLVED_TAG="" for tag in "${TRY_TAGS[@]}"; do if curl -fsI "https://raw.githubusercontent.com/$REPO/$tag/docker-compose.yaml" >/dev/null 2>&1; then @@ -152,81 +134,49 @@ for tag in "${TRY_TAGS[@]}"; do fi done -if [[ -z "$RESOLVED_TAG" ]]; then - echo -e "${RED}Error: could not find a git tag matching '$TARGET_VERSION'${NC}" - echo -e "${RED}Tried: ${TRY_TAGS[*]}${NC}" - echo -e "${RED}See available releases at: https://github.com/$REPO/releases${NC}" - exit 1 -fi +[[ -n "$RESOLVED_TAG" ]] || dograh_fail "could not find a git tag matching '$TARGET_VERSION'" if [[ "$RESOLVED_TAG" != "$TARGET_VERSION" ]]; then - echo -e "${GREEN}✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'${NC}" + dograh_success "✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'" fi + TARGET_VERSION="$RESOLVED_TAG" RAW_BASE="https://raw.githubusercontent.com/$REPO/$TARGET_VERSION" - -# Derive the Docker image tag from the git tag. Tags on Docker Hub use bare -# semver — strip the 'dograh-' prefix and the leading 'v'. IMAGE_TAG="" + case "$TARGET_VERSION" in dograh-v*) IMAGE_TAG="${TARGET_VERSION#dograh-v}" ;; - v*) IMAGE_TAG="${TARGET_VERSION#v}" ;; + v*) IMAGE_TAG="${TARGET_VERSION#v}" ;; main|HEAD) IMAGE_TAG="" ;; - *) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;; + *) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;; esac -# Verify the image tag actually exists on Docker Hub. If not (e.g. CI hasn't -# published yet), fall back to ':latest' rather than pinning to a missing tag. if [[ -n "$IMAGE_TAG" ]]; then if curl -fsI "https://hub.docker.com/v2/repositories/dograhai/dograh-api/tags/$IMAGE_TAG/" >/dev/null 2>&1; then - echo -e "${GREEN}✓ Image tag :$IMAGE_TAG found on Docker Hub${NC}" + dograh_success "✓ Image tag :$IMAGE_TAG found on Docker Hub" else - echo -e "${YELLOW}Warning: image tag :$IMAGE_TAG not found on Docker Hub — leaving images at :latest${NC}" + dograh_warn "Warning: image tag :$IMAGE_TAG not found on Docker Hub - leaving images at :latest" IMAGE_TAG="" fi fi -############################################################################### -### Reconcile required keys that may be missing on older installs -############################################################################### - -if [[ -z "$FASTAPI_WORKERS" ]]; then - if [[ -t 0 ]]; then - echo "" - echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}" - read -p "[4]: " FASTAPI_WORKERS - FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" - else - FASTAPI_WORKERS="4" - fi -fi - -if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then - echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}" - exit 1 -fi - -############################################################################### -### Summary + confirmation -############################################################################### - echo "" echo -e "${GREEN}Update plan:${NC}" -echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" +echo -e " Server IP: ${BLUE}$(dograh_infer_server_ip "$(pwd)" || echo "unknown")${NC}" echo -e " Target version: ${BLUE}$TARGET_VERSION${NC}" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" echo "" echo -e "${YELLOW}Files that will be replaced (backups saved with suffix .bak.$TIMESTAMP):${NC}" echo " - docker-compose.yaml (pulled from GitHub at $TARGET_VERSION)" -echo " - nginx.conf (regenerated from this script's template)" -echo " - turnserver.conf (regenerated from this script's template)" -echo " - .env (existing values preserved; missing keys appended)" -echo "" -echo -e "${YELLOW}Any local customizations to these files will be overwritten — check the backup${NC}" -echo -e "${YELLOW}files if you need to re-apply edits afterwards.${NC}" +echo " - remote_up.sh (startup wrapper / preflight)" +echo " - scripts/run_dograh_init.sh" +echo " - scripts/lib/setup_common.sh" +echo " - deploy/templates/*.template" +echo " - .env (canonical remote keys synchronized)" +echo " - legacy nginx.conf / turnserver.conf backups will be kept if those files still exist" echo "" -if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then +if [[ -t 0 && "${DOGRAH_UPDATE_YES:-}" != "1" ]]; then read -p "Proceed? [y/N]: " confirm if ! [[ "$confirm" =~ ^[Yy] ]]; then echo -e "${RED}Aborted.${NC}" @@ -234,198 +184,44 @@ if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then fi fi -############################################################################### -### Step 1 — backups -############################################################################### - echo "" -echo -e "${BLUE}[1/5] Backing up existing files...${NC}" -for f in docker-compose.yaml nginx.conf turnserver.conf .env; do +echo -e "${BLUE}[1/3] Backing up existing files...${NC}" +for f in \ + docker-compose.yaml \ + nginx.conf \ + turnserver.conf \ + .env \ + remote_up.sh \ + scripts/run_dograh_init.sh \ + scripts/lib/setup_common.sh \ + deploy/templates/nginx.remote.conf.template \ + deploy/templates/turnserver.remote.conf.template +do if [[ -f "$f" ]]; then + mkdir -p "$(dirname "$f")" cp -p "$f" "$f.bak.$TIMESTAMP" echo -e " ${GREEN}✓ $f → $f.bak.$TIMESTAMP${NC}" fi done -############################################################################### -### Step 2 — docker-compose.yaml (download + pin image tags) -############################################################################### - -echo -e "${BLUE}[2/5] Downloading docker-compose.yaml at $TARGET_VERSION...${NC}" +echo -e "${BLUE}[2/3] Downloading deployment bundle at $TARGET_VERSION...${NC}" curl -fsSL -o docker-compose.yaml "$RAW_BASE/docker-compose.yaml" +dograh_download_remote_support_bundle "$(pwd)" "$TARGET_VERSION" +rm -f nginx.conf turnserver.conf -# Pin api/ui image tags when we resolved one. For branch refs (main) IMAGE_TAG -# is empty, so the images stay at ':latest' and `up --pull always` grabs the -# newest build of that branch. if [[ -n "$IMAGE_TAG" ]]; then sed -i.tmp -E "s#(dograh-(api|ui)):latest#\1:$IMAGE_TAG#g" docker-compose.yaml rm -f docker-compose.yaml.tmp - echo -e "${GREEN}✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG${NC}" + dograh_success "✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG" else - echo -e "${GREEN}✓ docker-compose.yaml updated (image tags left at :latest)${NC}" + dograh_success "✓ docker-compose.yaml updated (image tags left at :latest)" fi -############################################################################### -### Step 3 — nginx.conf (regenerate from embedded template) -############################################################################### - -echo -e "${BLUE}[3/5] Regenerating nginx.conf...${NC}" -{ - echo "# Backend API workers — one uvicorn process per port, balanced by least_conn." - echo "# Generated by update_remote.sh; regenerate to change worker count." - echo "upstream dograh_api {" - echo " least_conn;" - for ((i=0; i nginx.conf - -sed -i.tmp "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.tmp -echo -e "${GREEN}✓ nginx.conf regenerated${NC}" - -############################################################################### -### Step 4 — turnserver.conf (regenerate from embedded template) -############################################################################### - -echo -e "${BLUE}[4/5] Regenerating turnserver.conf...${NC}" -cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration -# Auto-generated by update_remote.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$SERVER_IP - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF -echo -e "${GREEN}✓ turnserver.conf regenerated${NC}" - -############################################################################### -### Step 5 — reconcile .env (append missing keys; never overwrite existing) -############################################################################### - -echo -e "${BLUE}[5/5] Reconciling .env...${NC}" -if ! grep -q "^FASTAPI_WORKERS=" .env; then - { - echo "" - echo "# Number of uvicorn worker processes; nginx load-balances across them" - echo "# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn." - echo "FASTAPI_WORKERS=$FASTAPI_WORKERS" - } >> .env - echo -e "${GREEN}✓ Added FASTAPI_WORKERS=$FASTAPI_WORKERS to .env${NC}" -else - echo -e "${GREEN}✓ .env already has FASTAPI_WORKERS — left unchanged${NC}" -fi - -############################################################################### -### Done — print restart + rollback instructions -############################################################################### +echo -e "${BLUE}[3/3] Synchronizing environment and validating init-based remote config...${NC}" +dograh_set_env_key .env FASTAPI_WORKERS "$FASTAPI_WORKERS" +dograh_prepare_remote_install "$(pwd)" +docker compose config -q +dograh_success "✓ Remote init configuration validated" echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" @@ -434,15 +230,14 @@ echo -e "${GREEN}╚════════════════════ echo "" echo -e "Backups: ${BLUE}*.bak.$TIMESTAMP${NC}" echo "" -echo -e "${YELLOW}To apply, recreate the stack:${NC}" +echo -e "${YELLOW}To apply, restart through the validated wrapper:${NC}" echo "" -echo -e " ${BLUE}sudo docker compose --profile remote down${NC}" -echo -e " ${BLUE}sudo docker compose --profile remote up -d --pull always${NC}" +echo -e " ${BLUE}./remote_up.sh${NC}" echo "" -echo -e "${YELLOW}To roll back, restore the backups and recreate:${NC}" +echo -e "${YELLOW}To roll back, restore the backups and re-run the wrapper:${NC}" echo "" -echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env; do${NC}" -echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}" +echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do${NC}" +echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}" echo -e " ${BLUE}done${NC}" -echo -e " ${BLUE}sudo docker compose --profile remote down && sudo docker compose --profile remote up -d${NC}" +echo -e " ${BLUE}./remote_up.sh${NC}" echo "" diff --git a/ui/src/components/layout/AppLayout.tsx b/ui/src/components/layout/AppLayout.tsx index d91547d..30e1e20 100644 --- a/ui/src/components/layout/AppLayout.tsx +++ b/ui/src/components/layout/AppLayout.tsx @@ -62,15 +62,13 @@ const AppLayout: React.FC = ({ // Hide sidebar for root (/), /handler routes (Stack Auth routes), and /auth routes const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler") && !pathname.startsWith("/auth"); - // Check if we're in workflow editor mode or superadmin runs - collapse sidebar by default // Only match the exact editor page /workflow/, not sub-routes like /workflow//runs const isWorkflowEditor = /^\/workflow\/\d+$/.test(pathname); - const isSuperadmin = pathname.startsWith("/superadmin"); // Always render SidebarProvider to keep the component tree shape consistent // across route changes (avoids React hooks ordering violations during navigation). return ( - + {shouldShowSidebar ? (
diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index dac3139..a6df4a2 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -14,6 +14,7 @@ import { Home, Key, LogOut, + type LucideIcon, Megaphone, Phone, Settings, @@ -49,12 +50,7 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppConfig } from "@/context/AppConfigContext"; import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext"; import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion"; @@ -62,6 +58,94 @@ import type { LocalUser } from "@/lib/auth"; import { useAuth } from "@/lib/auth"; import { cn } from "@/lib/utils"; +type SidebarNavItem = { + title: string; + url: string; + icon: LucideIcon; + showsTelephonyWarning?: boolean; +}; + +type SidebarNavSection = { + label?: string; + items: SidebarNavItem[]; +}; + +const TELEPHONY_WARNING_DEADLINE = "15 May 2026"; +const TELEPHONY_WARNING_COPY = `Action required before ${TELEPHONY_WARNING_DEADLINE}`; + +const NAV_SECTIONS: SidebarNavSection[] = [ + { + items: [ + { + title: "Overview", + url: "/overview", + icon: Home, + }, + ], + }, + { + label: "BUILD", + items: [ + { + title: "Voice Agents", + url: "/workflow", + icon: Workflow, + }, + { + title: "Campaigns", + url: "/campaigns", + icon: Megaphone, + }, + { + title: "Models", + url: "/model-configurations", + icon: Brain, + }, + { + title: "Telephony", + url: "/telephony-configurations", + icon: Phone, + showsTelephonyWarning: true, + }, + { + title: "Tools", + url: "/tools", + icon: Wrench, + }, + { + title: "Files", + url: "/files", + icon: Database, + }, + { + title: "Recordings", + url: "/recordings", + icon: AudioLines, + }, + { + title: "Developers", + url: "/api-keys", + icon: Key, + }, + ], + }, + { + label: "OBSERVE", + items: [ + { + title: "Agent Runs", + url: "/usage", + icon: TrendingUp, + }, + { + title: "Reports", + url: "/reports", + icon: FileText, + }, + ], + }, +]; + // Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context const StackTeamSwitcher = React.lazy(() => import("@stackframe/stack").then((mod) => ({ @@ -77,10 +161,7 @@ export function AppSidebar() { const { config } = useAppConfig(); const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings(); const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0; - - // On mobile the sidebar renders as a full-width sheet overlay, so treat it - // as always "expanded" regardless of the desktop collapsed/expanded state. - const effectiveState = isMobile ? "expanded" : state; + const isCollapsed = !isMobile && state === "collapsed"; // Get selected team for Stack auth (cast to Team type from Stack) // Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes, @@ -101,90 +182,7 @@ export function AppSidebar() { { enabled: config?.deploymentMode === "oss" }, ); - const isActive = (path: string) => { - return pathname.startsWith(path); - }; - - - // Organize navigation into sections - const overviewSection = [ - { - title: "Overview", - url: "/overview", - icon: Home, - }, - ]; - - const buildSection = [ - { - title: "Voice Agents", - url: "/workflow", - icon: Workflow, - }, - { - title: "Campaigns", - url: "/campaigns", - icon: Megaphone, - }, - // { - // title: "Automation", - // url: "/automation", - // icon: Zap, - // }, - { - title: "Models", - url: "/model-configurations", - icon: Brain, - }, - { - title: "Telephony", - url: "/telephony-configurations", - icon: Phone, - }, - { - title: "Tools", - url: "/tools", - icon: Wrench, - }, - { - title: "Files", - url: "/files", - icon: Database, - }, - { - title: "Recordings", - url: "/recordings", - icon: AudioLines, - }, - // { - // title: "Integrations", - // url: "/integrations", - // icon: Plug, - // }, - { - title: "Developers", - url: "/api-keys", - icon: Key, - }, - ]; - - const observeSection = [ - { - title: "Agent Runs", - url: "/usage", - icon: TrendingUp, - }, - { - title: "Reports", - url: "/reports", - icon: FileText, - }, - // { - // title: "LoopTalk", - // url: "/looptalk", - // icon: MessageSquare, - // }, - ]; + const isActive = (path: string) => pathname.startsWith(path); const handleMobileNavClick = () => { if (isMobile) { @@ -192,79 +190,65 @@ export function AppSidebar() { } }; - const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => { + const SidebarLink = ({ item }: { item: SidebarNavItem }) => { const isItemActive = isActive(item.url); const Icon = item.icon; - const showWarningDot = - item.url === "/telephony-configurations" && hasTelephonyWarning; - - if (effectiveState === "collapsed") { - return ( - - - - - - - {showWarningDot && ( - - )} - - {item.title} - {showWarningDot && " — action required before 15 May 2026"} - - - - - -

- {item.title} - {showWarningDot && ( - - Action required before 15 May 2026 - - )} -

-
-
-
- ); - } + const showWarningDot = item.showsTelephonyWarning && hasTelephonyWarning; + const tooltip = { + children: ( +
+

{item.title}

+ {showWarningDot && ( +

{TELEPHONY_WARNING_COPY}

+ )} +
+ ), + }; + const warningIndicator = ( + + ); return ( - - - {item.title} + + + + {item.title} + {showWarningDot && ( - + isCollapsed ? ( + warningIndicator + ) : ( - + {warningIndicator} -

Action required before 15 May 2026

+

{TELEPHONY_WARNING_COPY}

-
+ ) )}
@@ -273,77 +257,70 @@ export function AppSidebar() { return ( - +
- {/* Logo - only show when expanded */} - {effectiveState === "expanded" && ( -
- - Dograh - {versionInfo && ( - - v{versionInfo.ui} +
+ + Dograh + {versionInfo && ( + + v{versionInfo.ui} + + )} + + {isBehind && latestRelease && ( + + + + + Update + + + +

Latest: {latestRelease} — click to see the update guide

+
+
+ )} + {isLatest && ( + + + + Latest - )} - - {isBehind && latestRelease && ( - - - - - - Update - - - -

Latest: {latestRelease} — click to see the update guide

-
-
-
- )} - {isLatest && ( - - - - - Latest - - - -

You're running the latest release

-
-
-
- )} -
- )} - {/* Toggle button - center it when collapsed */} - - {effectiveState === "expanded" ? ( - - ) : ( + + +

You're running the latest release

+
+ + )} +
+ + + {isCollapsed ? ( + ) : ( + )}
- {/* Team Switcher for Stack Auth - at the top */} - {provider === "stack" && effectiveState === "expanded" && ( -
+ {provider === "stack" && ( +
+
} >
)} - - - {/* Overview Section */} - - - {overviewSection.map((item) => ( - - - - ))} - - - - {/* BUILD Section */} - {buildSection.length > 0 && ( - - {effectiveState === "expanded" && ( - - BUILD + + {NAV_SECTIONS.map((section, index) => ( + + {section.label && ( + + {section.label} )} - {buildSection.map((item) => ( + {section.items.map((item) => ( ))} - )} - - {/* OBSERVE Section */} - - {effectiveState === "expanded" && ( - - OBSERVE - - )} - - {observeSection.map((item) => ( - - - - ))} - - + ))} - - {/* Bottom Actions */} +
- {/* User Button - for local/OSS mode */} {provider !== "stack" && ( -
+
-
)} - {/* User Button - for Stack auth */} {provider === "stack" && ( -
+
-
)} - {/* Theme Toggle - at the very bottom */} -
- {effectiveState === "collapsed" ? ( - - - -
- -
-
- -

Toggle theme

-
-
-
+
+ {isCollapsed ? ( + + +
+ +
+
+ +

Toggle theme

+
+
) : ( - +
+ +
)}
-