From 1ac6a17f6f47891ba5cad16a9ee40c84ac4e507e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 02:14:14 +0300 Subject: [PATCH] feat: Docker deployment + Gitea CI/CD workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multi-stage Dockerfile: Node frontend build → Python wheel build → slim runtime - Backend serves SvelteKit static output via FastAPI StaticFiles mount - docker-compose.yml with named volume for /data persistence - Gitea Actions workflow: build/push Docker image + create release on v* tags - Add NOTIFY_BRIDGE_STATIC_DIR config for frontend path - Fix run() to use configurable host/port --- .dockerignore | 24 +++++++ .gitea/workflows/release.yml | 64 ++++++++++++++++++ Dockerfile | 65 +++++++++++++++++++ docker-compose.yml | 23 +++++++ .../server/src/notify_bridge_server/config.py | 3 + .../server/src/notify_bridge_server/main.py | 10 ++- 6 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/release.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9527eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.gitignore +.claude/ +.vscode/ +.idea/ +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +venv/ +.venv/ +env/ +node_modules/ +frontend/build/ +frontend/.svelte-kit/ +test-data/ +*.log +scripts/ +.pytest_cache/ +htmlcov/ +.coverage +server.log +Thumbs.db +.DS_Store diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..3f08ee2 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: git.dolgolyov-family.by + IMAGE_NAME: alexei.dolgolyov/notify-bridge + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git tag --sort=-v:refname | head -2 | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "${{ github.ref_name }}" ]; then + CHANGELOG=$(git log --oneline --no-decorate HEAD~20..HEAD) + else + CHANGELOG=$(git log --oneline --no-decorate ${PREV_TAG}..HEAD) + fi + echo "$CHANGELOG" > /tmp/changelog.txt + + - name: Create Gitea Release + run: | + BODY=$(cat /tmp/changelog.txt | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + curl -s -X POST \ + "https://${{ env.REGISTRY }}/api/v1/repos/${{ env.IMAGE_NAME }}/releases" \ + -H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${{ github.ref_name }}\",\"name\":\"Notify Bridge ${{ steps.version.outputs.VERSION }}\",\"body\":${BODY}}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1903bc5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# ============================================================================= +# Stage 1: Build frontend (SvelteKit static output) +# ============================================================================= +FROM node:22-alpine AS frontend-build + +WORKDIR /build + +# Cache npm install layer +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +# Build static site +COPY frontend/ ./ +RUN npm run build + +# ============================================================================= +# Stage 2: Build Python wheels +# ============================================================================= +FROM python:3.12-slim AS python-build + +WORKDIR /build + +RUN pip install --no-cache-dir build + +# Build core package wheel +COPY packages/core/ packages/core/ +RUN python -m build packages/core/ --wheel --outdir /wheels + +# Build server package wheel +COPY packages/server/ packages/server/ +RUN python -m build packages/server/ --wheel --outdir /wheels + +# ============================================================================= +# Stage 3: Runtime +# ============================================================================= +FROM python:3.12-slim + +WORKDIR /app + +# Install wheels +COPY --from=python-build /wheels/ /tmp/wheels/ +RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels + +# Copy frontend build +COPY --from=frontend-build /build/build/ /app/static/ + +# Create non-root user and data directory +RUN useradd --create-home --shell /bin/bash appuser \ + && mkdir -p /data \ + && chown appuser:appuser /data + +# Environment defaults +ENV NOTIFY_BRIDGE_DATA_DIR=/data \ + NOTIFY_BRIDGE_STATIC_DIR=/app/static \ + NOTIFY_BRIDGE_DEBUG=false + +VOLUME /data +EXPOSE 8420 + +USER appuser + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')" + +CMD ["notify-bridge"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ad37114 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + notify-bridge: + image: git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge:latest + # For local builds instead of pulling from registry: + # build: . + container_name: notify-bridge + restart: unless-stopped + ports: + - "8420:8420" + volumes: + - notify-bridge-data:/data + environment: + - NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)} + - NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + notify-bridge-data: diff --git a/packages/server/src/notify_bridge_server/config.py b/packages/server/src/notify_bridge_server/config.py index ed7aaf2..f2dc373 100644 --- a/packages/server/src/notify_bridge_server/config.py +++ b/packages/server/src/notify_bridge_server/config.py @@ -36,6 +36,9 @@ class Settings(BaseSettings): cors_allowed_origins: str = "*" """Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com'). Use '*' for dev.""" + static_dir: str = "" + """Path to frontend static files. Set to serve SvelteKit build via FastAPI (e.g. /app/static in Docker).""" + model_config = {"env_prefix": "NOTIFY_BRIDGE_"} @property diff --git a/packages/server/src/notify_bridge_server/main.py b/packages/server/src/notify_bridge_server/main.py index b71d535..e33c150 100644 --- a/packages/server/src/notify_bridge_server/main.py +++ b/packages/server/src/notify_bridge_server/main.py @@ -118,6 +118,14 @@ async def health(): return {"status": "ok"} +# --- Serve frontend static files (production) --- +# Must come AFTER all API routes so /api/* takes priority +from pathlib import Path +if _cfg.static_dir and Path(_cfg.static_dir).is_dir(): + from fastapi.staticfiles import StaticFiles + app.mount("/", StaticFiles(directory=_cfg.static_dir, html=True), name="frontend") + + def run(): import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8420) + uvicorn.run(app, host=_cfg.host, port=_cfg.port)