diff --git a/.gitignore b/.gitignore index 5e8407c..ab327ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ output/ +docker/dev-secrets/ +docker/.env *.iso model-staging/ __pycache__/ diff --git a/config/includes.chroot/usr/lib/neuraldrive/api/neuraldrive_api/main.py b/config/includes.chroot/usr/lib/neuraldrive/api/neuraldrive_api/main.py index 1796e9a..74dd1a9 100644 --- a/config/includes.chroot/usr/lib/neuraldrive/api/neuraldrive_api/main.py +++ b/config/includes.chroot/usr/lib/neuraldrive/api/neuraldrive_api/main.py @@ -28,6 +28,10 @@ def _read_version() -> str: MODELS_DIR = "/var/lib/neuraldrive/models" DATA_DIR = "/var/lib/neuraldrive" +# When running inside Docker (NEURALDRIVE_CONTAINER=1) there is no systemd, +# journalctl, or hostnamectl. Operations that require them return stub responses. +CONTAINER_MODE = os.environ.get("NEURALDRIVE_CONTAINER") == "1" + ALLOWED_SERVICES = [ "neuraldrive-ollama", "neuraldrive-webui", @@ -98,6 +102,8 @@ def get_system_status(): @app.get("/system/services", dependencies=[Depends(verify_token)]) def list_services(): + if CONTAINER_MODE: + return {"services": [{"name": s, "status": "container", "since": ""} for s in ALLOWED_SERVICES]} results = [] for svc in ALLOWED_SERVICES: status = _systemctl("is-active", svc) @@ -119,6 +125,8 @@ def restart_service(name: str): raise HTTPException( status_code=403, detail=f"Service '{name}' not in allowlist" ) + if CONTAINER_MODE: + return {"message": f"Restarted {name} (container mode: use docker compose restart)"} subprocess.run(["systemctl", "restart", name], check=True) return {"message": f"Restarted {name}"} @@ -131,6 +139,8 @@ def service_action(name: str, action: str): ) if action not in ("start", "stop"): raise HTTPException(status_code=400, detail="Action must be 'start' or 'stop'") + if CONTAINER_MODE: + return {"message": f"{action.capitalize()}ed {name} (container mode: use docker compose {action})"} subprocess.run(["systemctl", action, name], check=True) return {"message": f"{action.capitalize()}ed {name}"} @@ -141,6 +151,8 @@ def get_logs(service: str = "ollama", lines: int = 50, level: str = ""): service = f"neuraldrive-{service}" if service not in ALLOWED_SERVICES: raise HTTPException(status_code=403, detail="Service not in allowlist") + if CONTAINER_MODE: + return {"service": service, "lines": ["(container mode: use `docker compose logs` to view service logs)"]} capped_lines = min(lines, 500) cmd = ["journalctl", "-u", service, "-n", str(capped_lines), "--no-pager"] if level: @@ -184,6 +196,8 @@ def get_network(): def set_hostname(hostname: str): if not hostname or len(hostname) > 63: raise HTTPException(status_code=400, detail="Hostname must be 1-63 characters") + if CONTAINER_MODE: + return {"message": f"Hostname set to {hostname} (container mode: not persisted)"} subprocess.run(["hostnamectl", "set-hostname", hostname], check=True) return {"message": f"Hostname set to {hostname}"} @@ -229,6 +243,8 @@ def manage_ssh(action: str): raise HTTPException( status_code=400, detail="Action must be 'enable' or 'disable'" ) + if CONTAINER_MODE: + return {"message": f"SSH {action}d (container mode: not applicable)"} cmd = "start" if action == "enable" else "stop" subprocess.run(["systemctl", cmd, "ssh"], check=True) if action == "enable": @@ -240,7 +256,7 @@ def manage_ssh(action: str): @app.get("/system/security", dependencies=[Depends(verify_token)]) def get_security(): - ssh_active = _systemctl("is-active", "ssh") == "active" + ssh_active = False if CONTAINER_MODE else _systemctl("is-active", "ssh") == "active" cert_exists = os.path.exists(f"{CERT_DIR}/server.crt") cert_expiry = "" if cert_exists: @@ -276,7 +292,8 @@ def rotate_api_key(): Path(API_KEY_PATH).write_text(new_key) caddy_env_path = Path("/etc/neuraldrive/caddy.env") caddy_env_path.write_text(f"NEURALDRIVE_API_KEY={new_key}\n") - subprocess.run(["systemctl", "reload", "neuraldrive-caddy"], check=False) + if not CONTAINER_MODE: + subprocess.run(["systemctl", "reload", "neuraldrive-caddy"], check=False) return {"message": "API key rotated", "new_key": new_key} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..0026562 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,81 @@ +services: + ollama: + image: ollama/ollama:latest + volumes: + - ollama_data:/root/.ollama + networks: + - neuraldrive + + ollama-gpu: + image: ollama/ollama:latest + volumes: + - ollama_data:/root/.ollama + networks: + - neuraldrive + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + profiles: + - gpu + + webui: + image: ghcr.io/open-webui/open-webui:main + environment: + - OLLAMA_BASE_URL=http://ollama:11434 + - WEBUI_NAME=NeuralDrive + - ENABLE_SIGNUP=false + - DEFAULT_USER_ROLE=user + - WEBUI_AUTH=true + - ENABLE_EASTER_EGGS=false + volumes: + - webui_data:/app/backend/data + depends_on: + - ollama + networks: + - neuraldrive + + system-api: + build: + context: . + dockerfile: docker/system-api/Dockerfile + environment: + - NEURALDRIVE_CONTAINER=1 + env_file: + - docker/.env + volumes: + - ./docker/dev-secrets/api.key:/etc/neuraldrive/api.key:ro + - ./docker/dev-secrets/gpu.conf:/run/neuraldrive/gpu.conf:ro + - ./docker/dev-secrets/tls:/etc/neuraldrive/tls:ro + networks: + - neuraldrive + + caddy: + image: caddy:latest + environment: + - NEURALDRIVE_API_KEY=${NEURALDRIVE_API_KEY} + volumes: + - ./docker/Caddyfile.dev:/etc/caddy/Caddyfile:ro + - ./docker/dev-secrets/tls:/etc/neuraldrive/tls:ro + - caddy_data:/data + ports: + - "443:443" + - "8443:8443" + - "11434:11434" + depends_on: + - ollama + - webui + - system-api + networks: + - neuraldrive + +networks: + neuraldrive: + +volumes: + ollama_data: + webui_data: + caddy_data: diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..3189750 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1 @@ +NEURALDRIVE_API_KEY=change-me diff --git a/docker/Caddyfile.dev b/docker/Caddyfile.dev new file mode 100644 index 0000000..7dd076e --- /dev/null +++ b/docker/Caddyfile.dev @@ -0,0 +1,41 @@ +{ + admin off + auto_https disable_redirects +} + +:443 { + tls /etc/neuraldrive/tls/server.crt /etc/neuraldrive/tls/server.key + + handle /* { + reverse_proxy webui:8080 + } +} + +:8443 { + tls /etc/neuraldrive/tls/server.crt /etc/neuraldrive/tls/server.key + + @api_routes { + path /v1/* /api/* + } + @api_authenticated { + path /v1/* /api/* + header Authorization "Bearer {env.NEURALDRIVE_API_KEY}" + } + + handle @api_authenticated { + reverse_proxy ollama:11434 + } + + handle @api_routes { + respond "Unauthorized — provide Authorization: Bearer " 401 + } + + handle /system/* { + uri strip_prefix /system + reverse_proxy system-api:3001 + } + + handle /health { + respond "OK" 200 + } +} diff --git a/docker/system-api/Dockerfile b/docker/system-api/Dockerfile new file mode 100644 index 0000000..17c3966 --- /dev/null +++ b/docker/system-api/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim + +RUN pip install --no-cache-dir fastapi uvicorn psutil httpx + +COPY config/includes.chroot/usr/lib/neuraldrive/api/neuraldrive_api /app/neuraldrive_api + +WORKDIR /app + +ENV NEURALDRIVE_CONTAINER=1 + +CMD ["uvicorn", "neuraldrive_api.main:app", "--host", "0.0.0.0", "--port", "3001"] diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 0000000..2ce35af --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SECRETS_DIR="$(cd "$(dirname "$0")/.." && pwd)/docker/dev-secrets" + +echo "Setting up NeuralDrive dev environment..." + +mkdir -p "$SECRETS_DIR/tls" + +# API key +if [[ ! -f "$SECRETS_DIR/api.key" ]]; then + printf "nd-%s" "$(openssl rand -hex 16)" > "$SECRETS_DIR/api.key" + echo " Generated API key: $SECRETS_DIR/api.key" +else + echo " API key already exists, skipping" +fi + +# TLS cert +if [[ ! -f "$SECRETS_DIR/tls/server.crt" ]]; then + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$SECRETS_DIR/tls/server.key" \ + -out "$SECRETS_DIR/tls/server.crt" \ + -days 365 \ + -subj "/CN=neuraldrive.local" \ + -addext "subjectAltName=DNS:localhost,DNS:neuraldrive.local,IP:127.0.0.1" \ + 2>/dev/null + echo " Generated TLS cert: $SECRETS_DIR/tls/" +else + echo " TLS cert already exists, skipping" +fi + +# GPU stub (CPU-only mode) +if [[ ! -f "$SECRETS_DIR/gpu.conf" ]]; then + printf "GPU_VENDOR=none\nGPU_COUNT=0\n" > "$SECRETS_DIR/gpu.conf" + echo " Created GPU stub (CPU mode): $SECRETS_DIR/gpu.conf" +else + echo " gpu.conf already exists, skipping" +fi + +# docker/.env +ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/docker/.env" +if [[ ! -f "$ENV_FILE" ]]; then + API_KEY=$(cat "$SECRETS_DIR/api.key") + printf "NEURALDRIVE_API_KEY=%s\n" "$API_KEY" > "$ENV_FILE" + echo " Created docker/.env" +else + echo " docker/.env already exists, skipping" +fi + +API_KEY=$(cat "$SECRETS_DIR/api.key") + +echo "" +echo "Dev environment ready. Start the stack with:" +echo "" +echo " docker compose -f docker-compose.dev.yml up" +echo "" +echo "Endpoints:" +echo " Web UI: https://localhost/" +echo " Health: https://localhost:8443/health" +echo " System API: https://localhost:8443/system/status" +echo " Ollama: http://localhost:11434/api/tags" +echo "" +echo "API key: $API_KEY" +echo "(also in docker/dev-secrets/api.key)" +echo "" +echo "To enable GPU passthrough (NVIDIA + Container Toolkit required):" +echo " docker compose -f docker-compose.dev.yml --profile gpu up"