Skip to content

Commit 34499f8

Browse files
committed
[CICD] add workflows, add CORS configuration at server
1 parent 35f3382 commit 34499f8

14 files changed

Lines changed: 302 additions & 8 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Backend Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "backend/**"
8+
- "src/fall_in/core/**"
9+
- "src/fall_in/net/**"
10+
pull_request:
11+
branches: [main]
12+
paths:
13+
- "backend/**"
14+
- "src/fall_in/core/**"
15+
- "src/fall_in/net/**"
16+
17+
defaults:
18+
run:
19+
shell: bash
20+
working-directory: backend
21+
22+
jobs:
23+
test:
24+
name: Lint & Test
25+
runs-on: ubuntu-latest
26+
27+
steps:
28+
- uses: actions/checkout@v6
29+
30+
- name: Set up Python 3.12
31+
uses: actions/setup-python@v6
32+
with:
33+
python-version: "3.12"
34+
35+
- name: Install uv
36+
uses: astral-sh/setup-uv@v7
37+
38+
- name: Install dependencies
39+
run: uv sync --extra dev
40+
41+
- name: Lint (ruff)
42+
run: uv run ruff check app/ tests/
43+
44+
- name: Test (pytest)
45+
run: uv run pytest --cov=app --cov-report=term-missing
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: Deploy Backend
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches: [main]
7+
paths:
8+
- "backend/**"
9+
- "src/fall_in/core/**"
10+
- "src/fall_in/ai/**"
11+
- "src/fall_in/net/**"
12+
- "src/fall_in/multiplayer/models.py"
13+
14+
# Only one deployment at a time.
15+
concurrency:
16+
group: deploy-backend
17+
cancel-in-progress: true
18+
19+
jobs:
20+
test:
21+
name: Pre-deploy Test
22+
runs-on: ubuntu-latest
23+
defaults:
24+
run:
25+
shell: bash
26+
working-directory: backend
27+
28+
steps:
29+
- uses: actions/checkout@v6
30+
31+
- name: Set up Python 3.12
32+
uses: actions/setup-python@v6
33+
with:
34+
python-version: "3.12"
35+
36+
- name: Install uv
37+
uses: astral-sh/setup-uv@v7
38+
39+
- name: Install dependencies
40+
run: uv sync --extra dev
41+
42+
- name: Lint
43+
run: uv run ruff check app/ tests/
44+
45+
- name: Test
46+
run: uv run pytest -x -q
47+
48+
deploy:
49+
name: Deploy to OCI
50+
needs: test
51+
runs-on: ubuntu-latest
52+
53+
steps:
54+
- uses: actions/checkout@v6
55+
56+
- name: Set up SSH
57+
run: |
58+
mkdir -p ~/.ssh
59+
echo "${{ secrets.OCI_SSH_KEY }}" > ~/.ssh/id_ed25519
60+
chmod 600 ~/.ssh/id_ed25519
61+
ssh-keyscan -H "${{ secrets.OCI_HOST }}" >> ~/.ssh/known_hosts
62+
63+
- name: Sync source to server
64+
run: |
65+
rsync -azP --delete \
66+
--include='backend/***' \
67+
--include='src/fall_in/core/***' \
68+
--include='src/fall_in/ai/***' \
69+
--include='src/fall_in/net/***' \
70+
--include='src/fall_in/multiplayer/models.py' \
71+
--include='src/fall_in/__init__.py' \
72+
--include='src/fall_in/multiplayer/__init__.py' \
73+
--include='src/' \
74+
--include='src/fall_in/' \
75+
--include='src/fall_in/multiplayer/' \
76+
--include='pyproject.toml' \
77+
--include='uv.lock' \
78+
--include='data/***' \
79+
--exclude='*' \
80+
./ "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}:~/fall-in/"
81+
82+
- name: Build & restart on server
83+
run: |
84+
ssh "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}" << 'DEPLOY_SCRIPT'
85+
set -e
86+
cd ~/fall-in
87+
88+
# Build Docker image (ARM-native on Ampere A1).
89+
docker build -t fall-in-backend -f backend/Dockerfile .
90+
91+
# Stop existing container (if any) and start fresh.
92+
docker stop fall-in-backend 2>/dev/null || true
93+
docker rm fall-in-backend 2>/dev/null || true
94+
95+
docker run -d \
96+
--name fall-in-backend \
97+
--restart unless-stopped \
98+
--env-file ~/fall-in/backend/.env \
99+
--network host \
100+
fall-in-backend
101+
102+
# Wait for health check.
103+
echo "Waiting for health check..."
104+
for i in $(seq 1 15); do
105+
if curl -sf http://localhost:8000/healthz > /dev/null 2>&1; then
106+
echo "Health check passed."
107+
exit 0
108+
fi
109+
sleep 2
110+
done
111+
echo "Health check failed after 30s"
112+
docker logs fall-in-backend --tail 30
113+
exit 1
114+
DEPLOY_SCRIPT
115+
116+
- name: Clean up old Docker images
117+
if: success()
118+
run: |
119+
ssh "${{ secrets.OCI_USER }}@${{ secrets.OCI_HOST }}" \
120+
'docker image prune -f'

backend/.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
__pycache__
2+
*.pyc
3+
.venv
4+
.env
5+
*.db
6+
.ruff_cache
7+
.pytest_cache
8+
tests/

backend/Dockerfile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# --- Fall-In Backend ---
2+
# Multi-stage build optimised for ARM64 (Oracle Cloud Ampere A1).
3+
# Build (from repo root):
4+
# docker build -t fall-in-backend -f backend/Dockerfile .
5+
# Run:
6+
# docker run -p 8000:8000 --env-file backend/.env fall-in-backend
7+
8+
FROM python:3.12-slim AS base
9+
10+
# Prevent Python from writing .pyc files and enable unbuffered output.
11+
ENV PYTHONDONTWRITEBYTECODE=1 \
12+
PYTHONUNBUFFERED=1
13+
14+
WORKDIR /app
15+
16+
# --- Dependencies stage ---
17+
FROM base AS deps
18+
19+
# Install uv for fast, reproducible dependency resolution.
20+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
21+
22+
# Copy only dependency metadata first (cache-friendly layer).
23+
COPY backend/pyproject.toml backend/uv.lock ./backend/
24+
COPY pyproject.toml uv.lock ./
25+
26+
# Install backend deps (prod extras include redis).
27+
# The client package (fall_in.core, fall_in.ai, etc.) is needed at runtime.
28+
RUN cd backend && uv sync --no-dev --extra prod --no-install-project
29+
30+
# --- Runtime stage ---
31+
FROM base AS runtime
32+
33+
# Copy the virtual environment from deps stage.
34+
COPY --from=deps /app/backend/.venv /app/backend/.venv
35+
36+
# Copy source code.
37+
COPY backend/app ./backend/app
38+
COPY backend/migrations ./backend/migrations
39+
COPY backend/alembic.ini ./backend/
40+
COPY src/fall_in ./src/fall_in
41+
COPY data ./data
42+
43+
# Make fall_in package importable (backend pythonpath includes ../src).
44+
ENV PYTHONPATH="/app/src:/app/backend"
45+
ENV PATH="/app/backend/.venv/bin:$PATH"
46+
47+
WORKDIR /app/backend
48+
49+
EXPOSE 8000
50+
51+
# Run migrations then start uvicorn.
52+
# --host 0.0.0.0 binds to all interfaces inside the container.
53+
# --workers 1 is correct for the in-memory singleton architecture.
54+
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

backend/app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ class Settings(BaseSettings):
7777
LOG_LEVEL: str = "INFO"
7878
ADMIN_TOKEN: str = ""
7979

80+
# CORS — comma-separated origins, or ["*"] for wide-open dev.
81+
# Production example: "https://fallin.example.com,https://web.fallin.example.com"
82+
CORS_ORIGINS: list[str] = ["*"]
83+
8084
model_config = SettingsConfigDict(
8185
env_file=".env",
8286
env_file_encoding="utf-8",

backend/app/database.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,16 @@
1919

2020

2121
def _make_engine(url: str):
22-
kwargs = {}
22+
kwargs: dict = {}
2323
if url.startswith("sqlite"):
2424
kwargs["connect_args"] = {"check_same_thread": False}
25+
else:
26+
# Production DB (PostgreSQL): detect stale connections after DB restart
27+
# and set a reasonable connection timeout.
28+
kwargs["pool_pre_ping"] = True
29+
kwargs["pool_size"] = 5
30+
kwargs["max_overflow"] = 10
31+
kwargs["pool_timeout"] = 10
2532
return create_engine(url, **kwargs)
2633

2734

backend/app/main.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
from contextlib import asynccontextmanager
1414

1515
from fastapi import FastAPI
16+
from fastapi.middleware.cors import CORSMiddleware
17+
from sqlalchemy import text
1618

1719
from app.api import admin as admin_router
1820
from app.api import auth as auth_router
1921
from app.api import me as me_router
2022
from app.api import report as report_router
2123
from app.config import settings
22-
from app.database import Base, engine
24+
from app.database import Base, SessionLocal, engine
2325
from app.logging_config import configure_logging
2426
from app.ws import endpoint as ws_router
2527

@@ -43,6 +45,15 @@ async def lifespan(app: FastAPI):
4345
lifespan=lifespan,
4446
)
4547

48+
# CORS — allow game clients (desktop, web) and local dev.
49+
app.add_middleware(
50+
CORSMiddleware,
51+
allow_origins=settings.CORS_ORIGINS,
52+
allow_credentials=True,
53+
allow_methods=["*"],
54+
allow_headers=["*"],
55+
)
56+
4657
app.include_router(auth_router.router)
4758
app.include_router(me_router.router)
4859
app.include_router(report_router.router)
@@ -52,6 +63,13 @@ async def lifespan(app: FastAPI):
5263

5364
@app.get("/healthz", tags=["ops"])
5465
def health_check():
66+
"""Liveness probe. Verifies the DB connection pool is healthy."""
67+
try:
68+
db = SessionLocal()
69+
db.execute(text("SELECT 1"))
70+
db.close()
71+
except Exception:
72+
return {"status": "degraded", "db": "unreachable"}
5573
return {"status": "ok"}
5674

5775

backend/app/services/emote_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ def remaining_cooldown(self, conn_id: str) -> float:
161161
remaining = settings.EMOTE_COOLDOWN_SECONDS - (time.time() - last)
162162
return max(0.0, remaining)
163163

164+
# ------------------------------------------------------------------
165+
# Connection cleanup
166+
# ------------------------------------------------------------------
167+
168+
def cleanup_connection(self, conn_id: str) -> None:
169+
"""Remove all rate-limit state for a disconnected connection."""
170+
self._last_sent.pop(conn_id, None)
171+
self._window_history.pop(conn_id, None)
172+
self._same_emote.pop(conn_id, None)
173+
self._last_deny_reason.pop(conn_id, None)
174+
164175
# ------------------------------------------------------------------
165176
# Lifecycle
166177
# ------------------------------------------------------------------

backend/app/ws/endpoint.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from app.models.room import SeatControllerType
3333
from app.repositories.match_repo import InMemoryMatchRepo
3434
from app.repositories.room_repo import InMemoryRoomRepo
35+
from app.services.emote_service import emote_service
3536
from app.services.match_service import MatchService
3637
from app.services.matchmaking_service import MatchmakingService
3738
from app.services.matchmaking_service import matchmaking_service as _default_matchmaking_service
@@ -228,6 +229,7 @@ async def _on_turn_ready(rc: str, m) -> None:
228229
},
229230
)
230231

232+
emote_service.cleanup_connection(conn_id)
231233
manager.disconnect(conn_id, session.room_code)
232234
logger.info(
233235
"ws_disconnect",

0 commit comments

Comments
 (0)