chore(deploy): add production caddy setup
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.git/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
htmlcov/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
mongo-data/
|
||||||
|
redis-data/
|
||||||
|
rabbitmq-data/
|
||||||
|
|
||||||
24
.env.production.example
Normal file
24
.env.production.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
APP_ENV=production
|
||||||
|
MONGO_DB_NAME=gapido_auth
|
||||||
|
JWT_SECRET_KEY=replace-with-a-long-random-production-secret
|
||||||
|
JWT_ISSUER=gapido-auth
|
||||||
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
|
REFRESH_TOKEN_TTL_SECONDS=604800
|
||||||
|
OTP_TTL_SECONDS=120
|
||||||
|
OTP_MAX_ATTEMPTS=5
|
||||||
|
OTP_REQUEST_LIMIT=3
|
||||||
|
OTP_REQUEST_WINDOW_SECONDS=300
|
||||||
|
|
||||||
|
# Choose exactly one real provider in production: kavenegar or sms_ir.
|
||||||
|
SMS_PROVIDER=sms_ir
|
||||||
|
KAVENEGAR_API_KEY=
|
||||||
|
KAVENEGAR_LOGIN_TEMPLATE=login-otp
|
||||||
|
SMS_IR_API_KEY=replace-with-real-sms-ir-key
|
||||||
|
SMS_IR_VERIFY_TEMPLATE_ID=570574
|
||||||
|
|
||||||
|
ADMIN_MOBILE=989120000000
|
||||||
|
DEMO_ENABLE_DEBUG_OTP=false
|
||||||
|
DEMO_DEBUG_SMS_TTL_SECONDS=300
|
||||||
|
GRPC_HOST=0.0.0.0
|
||||||
|
GRPC_PORT=50051
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,6 +24,7 @@ htmlcov/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.production.example
|
||||||
|
|
||||||
# IDE and OS
|
# IDE and OS
|
||||||
.idea/
|
.idea/
|
||||||
@@ -37,4 +38,3 @@ Thumbs.db
|
|||||||
mongo-data/
|
mongo-data/
|
||||||
redis-data/
|
redis-data/
|
||||||
rabbitmq-data/
|
rabbitmq-data/
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends curl \
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& groupadd --system app \
|
||||||
|
&& useradd --system --gid app --home-dir /app app \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY proto ./proto
|
COPY proto ./proto
|
||||||
|
|
||||||
RUN pip install --no-cache-dir ".[dev]" \
|
RUN pip install --no-cache-dir "."
|
||||||
&& python -m gapido_auth.tools.generate_proto
|
|
||||||
|
USER app
|
||||||
|
|
||||||
CMD ["python", "-m", "gapido_auth.transport.grpc.server"]
|
CMD ["python", "-m", "gapido_auth.transport.grpc.server"]
|
||||||
|
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -13,8 +13,10 @@ typecheck:
|
|||||||
mypy
|
mypy
|
||||||
|
|
||||||
compose-up:
|
compose-up:
|
||||||
docker compose up --build
|
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
|
||||||
|
|
||||||
compose-down:
|
compose-down:
|
||||||
docker compose down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
|
|
||||||
|
compose-prod-config:
|
||||||
|
docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml config
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -14,11 +14,29 @@ Python gRPC OTP authentication service with MongoDB, Redis, RabbitMQ, and select
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up --build
|
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The gRPC service listens on `localhost:50051`. The demo UI is available at `http://localhost:8080`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`.
|
The gRPC service listens on `localhost:50051`. The demo UI is available at `http://localhost:8080`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
Production uses Caddy as the only public entrypoint for `https://gapido.amiirkhl.ir`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.production.example .env.production
|
||||||
|
docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Before running production, set real values in `.env.production`:
|
||||||
|
|
||||||
|
- `JWT_SECRET_KEY`
|
||||||
|
- `ADMIN_MOBILE`
|
||||||
|
- `SMS_PROVIDER`
|
||||||
|
- the selected SMS provider credentials
|
||||||
|
|
||||||
|
Only ports `80` and `443` are published in the production Compose overlay. MongoDB, Redis, RabbitMQ, gRPC, and the FastAPI demo service stay private on the Docker network.
|
||||||
|
|
||||||
## SMS Provider
|
## SMS Provider
|
||||||
|
|
||||||
The SMS integration uses the Strategy pattern behind the `SmsClient` port. Select the provider with:
|
The SMS integration uses the Strategy pattern behind the `SmsClient` port. Select the provider with:
|
||||||
@@ -53,6 +71,8 @@ python -m gapido_auth.tools.generate_proto
|
|||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The production Docker image installs runtime dependencies only. Development tools are installed locally through `.[dev]`.
|
||||||
|
|
||||||
## gRPC Methods
|
## gRPC Methods
|
||||||
|
|
||||||
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.
|
- `RequestOtp`: public; creates a short-lived OTP and publishes an SMS job.
|
||||||
|
|||||||
15
deploy/caddy/Caddyfile
Normal file
15
deploy/caddy/Caddyfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
gapido.amiirkhl.ir {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy demo-app:8080
|
||||||
|
}
|
||||||
|
|
||||||
24
docker-compose.local.yml
Normal file
24
docker-compose.local.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
auth-service:
|
||||||
|
ports:
|
||||||
|
- "50051:50051"
|
||||||
|
|
||||||
|
demo-app:
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
DEMO_ENABLE_DEBUG_OTP: "true"
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
|
||||||
44
docker-compose.prod.yml
Normal file
44
docker-compose.prod.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
auth-service:
|
||||||
|
environment:
|
||||||
|
SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env}
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?set JWT_SECRET_KEY in production env}
|
||||||
|
ADMIN_MOBILE: ${ADMIN_MOBILE:?set ADMIN_MOBILE in production env}
|
||||||
|
|
||||||
|
sms-worker:
|
||||||
|
environment:
|
||||||
|
SMS_PROVIDER: ${SMS_PROVIDER:?set SMS_PROVIDER in production env}
|
||||||
|
KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-}
|
||||||
|
SMS_IR_API_KEY: ${SMS_IR_API_KEY:-}
|
||||||
|
|
||||||
|
demo-app:
|
||||||
|
environment:
|
||||||
|
DEMO_ENABLE_DEBUG_OTP: "false"
|
||||||
|
depends_on:
|
||||||
|
auth-service:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2.8-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
depends_on:
|
||||||
|
demo-app:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
|
||||||
@@ -21,8 +21,18 @@ services:
|
|||||||
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
|
ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000}
|
||||||
GRPC_HOST: 0.0.0.0
|
GRPC_HOST: 0.0.0.0
|
||||||
GRPC_PORT: 50051
|
GRPC_PORT: 50051
|
||||||
ports:
|
healthcheck:
|
||||||
- "50051:50051"
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"import grpc; from grpc_health.v1 import health_pb2, health_pb2_grpc; channel=grpc.insecure_channel('localhost:50051'); stub=health_pb2_grpc.HealthStub(channel); stub.Check(health_pb2.HealthCheckRequest(), timeout=3)",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
depends_on:
|
depends_on:
|
||||||
mongo:
|
mongo:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -51,32 +61,31 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AUTH_GRPC_TARGET: auth-service:50051
|
AUTH_GRPC_TARGET: auth-service:50051
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
DEMO_ENABLE_DEBUG_OTP: "true"
|
DEMO_ENABLE_DEBUG_OTP: ${DEMO_ENABLE_DEBUG_OTP:-true}
|
||||||
ports:
|
healthcheck:
|
||||||
- "8080:8080"
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz', timeout=3).read()"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
depends_on:
|
depends_on:
|
||||||
auth-service:
|
auth-service:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
volumes:
|
||||||
- mongo-data:/data/db
|
- mongo-data:/data/db
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management-alpine
|
image: rabbitmq:3.13-management-alpine
|
||||||
ports:
|
volumes:
|
||||||
- "5672:5672"
|
- rabbitmq-data:/var/lib/rabbitmq
|
||||||
- "15672:15672"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
|
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -85,3 +94,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
|
rabbitmq-data:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ dependencies = [
|
|||||||
"grpcio==1.68.1",
|
"grpcio==1.68.1",
|
||||||
"grpcio-health-checking==1.68.1",
|
"grpcio-health-checking==1.68.1",
|
||||||
"grpcio-reflection==1.68.1",
|
"grpcio-reflection==1.68.1",
|
||||||
"grpcio-tools==1.68.1",
|
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"motor==3.6.0",
|
"motor==3.6.0",
|
||||||
"protobuf==5.29.2",
|
"protobuf==5.29.2",
|
||||||
@@ -22,6 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"grpcio-tools==1.68.1",
|
||||||
"mypy==1.14.1",
|
"mypy==1.14.1",
|
||||||
"pytest==8.3.4",
|
"pytest==8.3.4",
|
||||||
"pytest-asyncio==0.25.2",
|
"pytest-asyncio==0.25.2",
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ async def index() -> FileResponse:
|
|||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/request-otp")
|
@app.post("/api/auth/request-otp")
|
||||||
async def request_otp(
|
async def request_otp(
|
||||||
body: RequestOtpBody,
|
body: RequestOtpBody,
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ def test_demo_auth_flow_forwards_to_grpc_client() -> None:
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz() -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
try:
|
||||||
|
response = client.get("/healthz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def test_debug_otp_endpoint_reads_redis_when_enabled() -> None:
|
def test_debug_otp_endpoint_reads_redis_when_enabled() -> None:
|
||||||
app.dependency_overrides[get_app_settings] = lambda: Settings(demo_enable_debug_otp=True)
|
app.dependency_overrides[get_app_settings] = lambda: Settings(demo_enable_debug_otp=True)
|
||||||
app.dependency_overrides[get_debug_redis] = lambda: FakeDebugRedis()
|
app.dependency_overrides[get_debug_redis] = lambda: FakeDebugRedis()
|
||||||
|
|||||||
Reference in New Issue
Block a user