feat: Docker deployment + Gitea CI/CD workflow
- 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
This commit is contained in:
@@ -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
|
||||||
@@ -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}}"
|
||||||
+65
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -36,6 +36,9 @@ class Settings(BaseSettings):
|
|||||||
cors_allowed_origins: str = "*"
|
cors_allowed_origins: str = "*"
|
||||||
"""Comma-separated allowed origins for CORS (e.g. 'http://localhost:5173,https://myapp.com'). Use '*' for dev."""
|
"""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_"}
|
model_config = {"env_prefix": "NOTIFY_BRIDGE_"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ async def health():
|
|||||||
return {"status": "ok"}
|
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():
|
def run():
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8420)
|
uvicorn.run(app, host=_cfg.host, port=_cfg.port)
|
||||||
|
|||||||
Reference in New Issue
Block a user