From 94b215c2b3f0ef47a8bb18f13a40a2d246708540 Mon Sep 17 00:00:00 2001 From: Fendy Date: Sun, 6 Sep 2026 19:59:27 +0800 Subject: [PATCH] feat(deploy): compose w/ nginx api-prefix ingress, scaled stateless api, e2e smoke --- .env.example | 4 +++ deploy/Dockerfile.api | 13 +++++++++ deploy/Dockerfile.web | 3 ++ deploy/nginx.conf | 18 ++++++++++++ deploy/web-dist/index.html | 1 + docker-compose.yml | 34 +++++++++++++++++++++++ scripts/smoke.sh | 56 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+) create mode 100644 .env.example create mode 100644 deploy/Dockerfile.api create mode 100644 deploy/Dockerfile.web create mode 100644 deploy/nginx.conf create mode 100644 deploy/web-dist/index.html create mode 100644 docker-compose.yml create mode 100755 scripts/smoke.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..95ccbac --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +JWT_SECRET=change-me-openssl-rand-hex-32 +ADMIN_USER=admin +ADMIN_PASSWORD=change-me-min-8 +SCAN_INTERVAL_SEC=60 diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api new file mode 100644 index 0000000..e71cb23 --- /dev/null +++ b/deploy/Dockerfile.api @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server + +FROM alpine:3.20 +RUN adduser -D -H app +COPY --from=build /server /server +USER app +EXPOSE 8080 +ENTRYPOINT ["/server"] diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web new file mode 100644 index 0000000..99b1847 --- /dev/null +++ b/deploy/Dockerfile.web @@ -0,0 +1,3 @@ +FROM nginx:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/web-dist /usr/share/nginx/html diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..8f0dccf --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + client_max_body_size 200m; + resolver 127.0.0.11 valid=10s; + + location /api/ { + set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本 + proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } +} diff --git a/deploy/web-dist/index.html b/deploy/web-dist/index.html new file mode 100644 index 0000000..011f16d --- /dev/null +++ b/deploy/web-dist/index.html @@ -0,0 +1 @@ +booklib

backend up — frontend lands in Plan 2.

diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d043ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + web: + build: { context: ., dockerfile: deploy/Dockerfile.web } + ports: ["8080:80"] + depends_on: [api] + api: + build: { context: ., dockerfile: deploy/Dockerfile.api } + environment: + DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET} + ADMIN_USER: ${ADMIN_USER} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + BOOKS_DIR: /data/books + CACHE_DIR: /data/cache + SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60} + volumes: + - ./library:/data/books + - cache:/data/cache + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_started } + postgres: + image: postgres:16-alpine + environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib } + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 } + redis: + image: redis:7-alpine + command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"] + # 故意无 volume:redis 里全是可再生数据(spec §6.2) +volumes: + pgdata: + cache: diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..e8c0121 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail +BASE=${BASE:-http://localhost:8080} +API=$BASE/api +J=(-H 'content-type: application/json') +[ -f .env ] && set -a && . ./.env && set +a + +say(){ echo "smoke: $1"; } +die(){ echo "SMOKE FAIL: $1"; exit 1; } +tokfor(){ curl -fsS "$API/auth/login" "${J[@]}" -d "{\"username\":\"$1\",\"password\":\"$2\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/'; } + +say "healthz" +curl -fsS "$API/healthz" >/dev/null || die "healthz down" + +say "login" +TOK=$(tokfor "$ADMIN_USER" "$ADMIN_PASSWORD") +[ -n "$TOK" ] || die "no token" +AUTH="authorization: Bearer $TOK" + +say "member user + role enforcement" +curl -fsS "$API/users" "${J[@]}" -H "$AUTH" -d '{"username":"smoke","password":"smokepw123","role":"member"}' >/dev/null || die "create member" +MTOK=$(tokfor smoke smokepw123) +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/users" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"username":"x","password":"xpw12345","role":"member"}') +[ "$code" = 403 ] || die "member write not blocked ($code)" + +say "library + bad-ext upload rejected + good upload + scan" +mkdir -p library/smoke-books +LID=$(curl -fsS "$API/libraries" "${J[@]}" -H "$AUTH" -d '{"name":"smoke","root_path":"/data/books/smoke-books"}' | sed -E 's/.*"id":([0-9]+).*/\1/') +printf 'x' > library_upload_note.txt +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=virus.exe" && die "bad ext upload must fail" || true +printf 'hello smoke book' > library_upload_note.txt +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=note.txt" || die "upload failed" +curl -fsS -o /dev/null -X POST "$API/libraries/$LID/scan" -H "$AUTH" || die "scan trigger" +found="" +for _ in $(seq 30); do + if curl -fsS "$API/books?library=$LID" -H "$AUTH" | grep -q '"path":"note.txt"'; then found=1; break; fi + sleep 1 +done +[ -n "$found" ] || die "book not indexed after 30s" + +say "read + progress roundtrip" +BID=$(curl -fsS "$API/books?library=$LID" -H "$AUTH" | sed -E 's/.*"id":([0-9]+).*/\1/') +curl -fsS "$API/books/$BID/file" -H "$AUTH" | grep -q "hello smoke book" || die "file body" +code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$API/books/$BID/progress" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"locator":{"scroll":0.5},"percent":0.5}') +[ "$code" = 204 ] || die "progress put $code" +curl -fsS "$API/progress" -H "authorization: Bearer $MTOK" | grep -q '"percent":0.5' || die "progress read" + +say "immutable cache header" +COVER=$(curl -fsS "$API/books/$BID" -H "$AUTH" | sed -E 's/.*"cover_url":"([^"]+)".*/\1/') +curl -fsS -o /dev/null -D - "$BASE$COVER" -H "$AUTH" | grep -qi 'cache-control:.*immutable' || die "cover not immutable" + +say "delete book → file gone from host dir" +curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete" +[ ! -f library/smoke-books/note.txt ] || die "file survived delete" + +say "ALL SMOKE TESTS PASSED"