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:
parent
11f2a3c2b3
commit
a8add8e96b
5 changed files with 143 additions and 13 deletions
42
compose.cloud.yml
Normal file
42
compose.cloud.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
34
docker/web/nginx.cloud.conf
Normal file
34
docker/web/nginx.cloud.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
51
manage.sh
51
manage.sh
|
|
@ -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 "Dev:"
|
||||||
echo " start Build (if needed) and start all services"
|
echo " start Build (if needed) and start all services"
|
||||||
echo " stop Stop and remove containers"
|
echo " stop Stop and remove containers"
|
||||||
echo " restart Stop then start"
|
echo " restart Stop then start"
|
||||||
echo " status Show running containers"
|
echo " status Show running containers"
|
||||||
echo " logs Follow logs (logs api | logs web | logs — defaults to all)"
|
echo " logs [svc] Follow logs (api | web — defaults to all)"
|
||||||
echo " open Open web UI in browser"
|
echo " open Open web UI in browser"
|
||||||
echo " build Rebuild Docker images without cache"
|
echo " build Rebuild Docker images without cache"
|
||||||
echo " update Pull latest images and rebuild"
|
echo " update Pull latest images and rebuild"
|
||||||
echo " test Run pytest test suite in the api container"
|
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
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue