feat(snipe): cloud deployment under menagerie.circuitforge.tech/snipe

- compose.cloud.yml: snipe-cloud project, proper Docker bridge network
  (api is internal-only, no host port), port 8514 for nginx
- docker/web/Dockerfile: VITE_BASE_URL + VITE_API_BASE build args so
  Vite bakes the /snipe path prefix into the bundle at cloud build time
- docker/web/nginx.cloud.conf: upstream api:8510 via Docker network
  (vs 172.17.0.1:8510 in dev which uses host networking)
- manage.sh: cloud-start/stop/restart/status/logs/build commands
- stores/search.ts: VITE_API_BASE prefix on all /api fetch calls

Gate: Caddy basicauth (username: cf) — temporary gate while proper
Heimdall license validation UI is built. Password stored at
/devl/snipe-cloud-data/.beta-password (host-only, not in repo).

Note: Caddyfile updated separately (caddy-proxy volume, not this repo).
This commit is contained in:
pyr0ball 2026-03-26 08:14:01 -07:00
parent 11f2a3c2b3
commit a8add8e96b
5 changed files with 143 additions and 13 deletions

42
compose.cloud.yml Normal file
View file

@ -0,0 +1,42 @@
# Snipe — cloud managed instance
# Project: snipe-cloud (docker compose -f compose.cloud.yml -p snipe-cloud ...)
# Web: http://127.0.0.1:8514 → menagerie.circuitforge.tech/snipe (via Caddy basicauth)
# API: internal only on snipe-cloud-net (no host port — only reachable via nginx)
#
# Usage: ./manage.sh cloud-start | cloud-stop | cloud-restart | cloud-status | cloud-logs | cloud-build
services:
api:
build:
context: ..
dockerfile: snipe/Dockerfile
restart: unless-stopped
# No network_mode: host — isolated on snipe-cloud-net; nginx reaches it via 'api:8510'
volumes:
- /devl/snipe-cloud-data:/app/snipe/data
networks:
- snipe-cloud-net
web:
build:
context: .
dockerfile: docker/web/Dockerfile
args:
# Vite bakes these at image build time — changing them requires cloud-build.
# VITE_BASE_URL: app served under /snipe → asset URLs become /snipe/assets/...
# VITE_API_BASE: prepended to all /api/* fetch calls → /snipe/api/search
VITE_BASE_URL: /snipe
VITE_API_BASE: /snipe
restart: unless-stopped
ports:
- "8514:80" # Caddy (caddy-proxy container) reaches via host.docker.internal:8514
volumes:
- ./docker/web/nginx.cloud.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- snipe-cloud-net
depends_on:
- api
networks:
snipe-cloud-net:
driver: bridge

View file

@ -4,6 +4,15 @@ WORKDIR /app
COPY web/package*.json ./ COPY web/package*.json ./
RUN npm ci --prefer-offline RUN npm ci --prefer-offline
COPY web/ ./ COPY web/ ./
# Build-time env vars — Vite bakes these as static strings into the bundle.
# VITE_BASE_URL: URL prefix the app is served under (/ for dev, /snipe for cloud)
# VITE_API_BASE: prefix for all /api/* fetch calls (empty for dev, /snipe for cloud)
ARG VITE_BASE_URL=/
ARG VITE_API_BASE=
ENV VITE_BASE_URL=$VITE_BASE_URL
ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build RUN npm run build
# Stage 2: serve # Stage 2: serve

View file

@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API requests to the FastAPI container via Docker bridge network.
# In cloud, 'api' resolves to the api service container — no host networking needed.
location /api/ {
proxy_pass http://api:8510;
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 $http_x_forwarded_proto;
}
# index.html — never cache; ensures clients always get the latest entry point
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
# SPA fallback for all other routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively — content hash in filename guarantees freshness
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -2,22 +2,35 @@
set -euo pipefail set -euo pipefail
SERVICE=snipe SERVICE=snipe
PORT=8509 # Vue web UI (nginx) PORT=8509 # Vue web UI (nginx) — dev
API_PORT=8510 # FastAPI API_PORT=8510 # FastAPI — dev
CLOUD_PORT=8514 # Vue web UI (nginx) — cloud (menagerie.circuitforge.tech/snipe)
COMPOSE_FILE="compose.yml" COMPOSE_FILE="compose.yml"
CLOUD_COMPOSE_FILE="compose.cloud.yml"
CLOUD_PROJECT="snipe-cloud"
usage() { usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|update|test}" echo "Usage: $0 {start|stop|restart|status|logs|open|build|update|test"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
echo "" echo ""
echo " start Build (if needed) and start all services" echo "Dev:"
echo " stop Stop and remove containers" echo " start Build (if needed) and start all services"
echo " restart Stop then start" echo " stop Stop and remove containers"
echo " status Show running containers" echo " restart Stop then start"
echo " logs Follow logs (logs api | logs web | logs — defaults to all)" echo " status Show running containers"
echo " open Open web UI in browser" echo " logs [svc] Follow logs (api | web — defaults to all)"
echo " build Rebuild Docker images without cache" echo " open Open web UI in browser"
echo " update Pull latest images and rebuild" echo " build Rebuild Docker images without cache"
echo " test Run pytest test suite in the api container" echo " update Pull latest images and rebuild"
echo " test Run pytest test suite in the api container"
echo ""
echo "Cloud (menagerie.circuitforge.tech/snipe):"
echo " cloud-start Build cloud images and start snipe-cloud project"
echo " cloud-stop Stop cloud instance"
echo " cloud-restart Stop then start cloud instance"
echo " cloud-status Show cloud containers"
echo " cloud-logs Follow cloud logs [api|web — defaults to all]"
echo " cloud-build Rebuild cloud images without cache (required after code changes)"
exit 1 exit 1
} }
@ -67,6 +80,36 @@ case "$cmd" in
docker compose -f "$COMPOSE_FILE" exec api \ docker compose -f "$COMPOSE_FILE" exec api \
conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}" conda run -n job-seeker python -m pytest /app/snipe/tests/ -v "${@}"
;; ;;
# ── Cloud commands ────────────────────────────────────────────────────────
cloud-start)
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build
echo "$SERVICE cloud started — https://menagerie.circuitforge.tech/snipe"
;;
cloud-stop)
docker compose -p "$CLOUD_PROJECT" down --remove-orphans
;;
cloud-restart)
docker compose -p "$CLOUD_PROJECT" down --remove-orphans
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build
echo "$SERVICE cloud restarted — https://menagerie.circuitforge.tech/snipe"
;;
cloud-status)
docker compose -p "$CLOUD_PROJECT" ps
;;
cloud-logs)
target="${1:-}"
if [[ -n "$target" ]]; then
docker compose -p "$CLOUD_PROJECT" logs -f "$target"
else
docker compose -p "$CLOUD_PROJECT" logs -f
fi
;;
cloud-build)
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" build --no-cache
echo "Cloud build complete. Run './manage.sh cloud-restart' to deploy."
;;
*) *)
usage usage
;; ;;

View file

@ -84,13 +84,15 @@ export const useSearchStore = defineStore('search', () => {
try { try {
// TODO: POST /api/search with { query: q, filters } // TODO: POST /api/search with { query: q, filters }
// API does not exist yet — stub returns empty results // API does not exist yet — stub returns empty results
// VITE_API_BASE is '' in dev; '/snipe' under menagerie (baked at build time by Vite)
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
const params = new URLSearchParams({ q }) const params = new URLSearchParams({ q })
if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice)) if (filters.maxPrice != null) params.set('max_price', String(filters.maxPrice))
if (filters.minPrice != null) params.set('min_price', String(filters.minPrice)) if (filters.minPrice != null) params.set('min_price', String(filters.minPrice))
if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages)) if (filters.pages != null && filters.pages > 1) params.set('pages', String(filters.pages))
if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim()) if (filters.mustInclude?.trim()) params.set('must_include', filters.mustInclude.trim())
if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim()) if (filters.mustExclude?.trim()) params.set('must_exclude', filters.mustExclude.trim())
const res = await fetch(`/api/search?${params}`) const res = await fetch(`${apiBase}/api/search?${params}`)
if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`) if (!res.ok) throw new Error(`Search failed: ${res.status} ${res.statusText}`)
const data = await res.json() as { const data = await res.json() as {