ci: add forgejo actions pipeline with traefik labels and https health checks
This commit is contained in:
parent
e6cb06255b
commit
dcd18a07e6
7 changed files with 407 additions and 0 deletions
63
.forgejo/workflows/README.md
Normal file
63
.forgejo/workflows/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# CI/CD Pipeline — SaaS Imobiliária
|
||||||
|
|
||||||
|
## Fluxo
|
||||||
|
|
||||||
|
```
|
||||||
|
push main → Build images → Push registry → Deploy Portainer → Health checks HTTPS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configurar no Forgejo (Settings → Secrets & Variables)
|
||||||
|
|
||||||
|
### Secrets (valores sensíveis)
|
||||||
|
| Secret | Descrição |
|
||||||
|
|--------|-----------|
|
||||||
|
| `REGISTRY_USER` | Usuário do registry (ex: `gitadmin`) |
|
||||||
|
| `REGISTRY_PASSWORD` | Senha ou token do registry |
|
||||||
|
| `PORTAINER_WEBHOOK_URL` | URL do webhook do stack no Portainer |
|
||||||
|
| `POSTGRES_DB` | Nome do banco de dados |
|
||||||
|
| `POSTGRES_USER` | Usuário do PostgreSQL |
|
||||||
|
| `POSTGRES_PASSWORD` | Senha do PostgreSQL |
|
||||||
|
| `SECRET_KEY` | Flask SECRET_KEY |
|
||||||
|
| `JWT_SECRET_KEY` | Chave JWT (mín. 32 chars) |
|
||||||
|
|
||||||
|
### Variables (valores não-sensíveis)
|
||||||
|
| Variable | Exemplo |
|
||||||
|
|----------|---------|
|
||||||
|
| `REGISTRY` | `git.matheussouza.com.br/gitadmin` |
|
||||||
|
| `DOMAIN` | `imobiliaria.matheussouza.com.br` |
|
||||||
|
|
||||||
|
## Configurar no Portainer
|
||||||
|
|
||||||
|
1. Crie um **Stack** com o arquivo `docker-compose.prod.yml`
|
||||||
|
2. Ative o **webhook** do stack (Stack → Webhooks → Enable)
|
||||||
|
3. Copie a URL do webhook → cole em `PORTAINER_WEBHOOK_URL`
|
||||||
|
4. Certifique-se que a rede `traefik-public` existe:
|
||||||
|
```
|
||||||
|
docker network create traefik-public
|
||||||
|
```
|
||||||
|
|
||||||
|
## Traefik — pré-requisitos
|
||||||
|
|
||||||
|
O Traefik deve estar rodando com:
|
||||||
|
- Entrypoint `web` (porta 80)
|
||||||
|
- Entrypoint `websecure` (porta 443)
|
||||||
|
- CertResolver `letsencrypt` configurado
|
||||||
|
- Conectado à rede `traefik-public`
|
||||||
|
|
||||||
|
Exemplo mínimo de configuração do Traefik:
|
||||||
|
```yaml
|
||||||
|
# traefik.yml
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
|
||||||
|
certificatesResolvers:
|
||||||
|
letsencrypt:
|
||||||
|
acme:
|
||||||
|
email: seu@email.com
|
||||||
|
storage: /letsencrypt/acme.json
|
||||||
|
httpChallenge:
|
||||||
|
entryPoint: web
|
||||||
|
```
|
||||||
160
.forgejo/workflows/deploy.yml
Normal file
160
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
name: CI/CD → Portainer
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }} # ex: git.matheussouza.com.br/gitadmin
|
||||||
|
BACKEND_IMAGE: saas-imobiliaria-backend
|
||||||
|
FRONTEND_IMAGE: saas-imobiliaria-frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. BUILD & PUSH IMAGES
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
build:
|
||||||
|
name: Build & Push Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set image tag
|
||||||
|
id: tag
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Log in to Forgejo Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
# ── Backend ──────────────────────────────────────────────────────────────
|
||||||
|
- name: Build & push backend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
file: ./backend/Dockerfile.prod
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ steps.tag.outputs.IMAGE_TAG }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:cache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:cache,mode=max
|
||||||
|
|
||||||
|
# ── Frontend ─────────────────────────────────────────────────────────────
|
||||||
|
- name: Build & push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
file: ./frontend/Dockerfile.prod
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
VITE_API_URL=https://${{ vars.DOMAIN }}/api
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ steps.tag.outputs.IMAGE_TAG }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:latest
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:cache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:cache,mode=max
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
image_tag: ${{ steps.tag.outputs.IMAGE_TAG }}
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. DEPLOY VIA PORTAINER WEBHOOK
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Portainer
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Trigger Portainer stack deploy
|
||||||
|
run: |
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--fail \
|
||||||
|
-X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"env": [
|
||||||
|
{"name": "IMAGE_TAG", "value": "${{ needs.build.outputs.image_tag }}"},
|
||||||
|
{"name": "REGISTRY", "value": "${{ vars.REGISTRY }}"},
|
||||||
|
{"name": "DOMAIN", "value": "${{ vars.DOMAIN }}"},
|
||||||
|
{"name": "POSTGRES_DB", "value": "${{ secrets.POSTGRES_DB }}"},
|
||||||
|
{"name": "POSTGRES_USER", "value": "${{ secrets.POSTGRES_USER }}"},
|
||||||
|
{"name": "POSTGRES_PASSWORD", "value": "${{ secrets.POSTGRES_PASSWORD }}"},
|
||||||
|
{"name": "SECRET_KEY", "value": "${{ secrets.SECRET_KEY }}"},
|
||||||
|
{"name": "JWT_SECRET_KEY", "value": "${{ secrets.JWT_SECRET_KEY }}"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. HEALTH CHECK (valida HTTPS + endpoints críticos)
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
healthcheck:
|
||||||
|
name: Validate HTTPS & Endpoints
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: deploy
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Wait for containers to stabilize
|
||||||
|
run: sleep 30
|
||||||
|
|
||||||
|
- name: Check frontend HTTPS
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--max-time 15 \
|
||||||
|
"https://${{ vars.DOMAIN }}")
|
||||||
|
echo "Frontend status: $STATUS"
|
||||||
|
[ "$STATUS" = "200" ] || (echo "Frontend HTTPS failed ($STATUS)" && exit 1)
|
||||||
|
|
||||||
|
- name: Check HTTP → HTTPS redirect
|
||||||
|
run: |
|
||||||
|
LOCATION=$(curl -s -o /dev/null -w "%{redirect_url}" \
|
||||||
|
--max-time 10 \
|
||||||
|
"http://${{ vars.DOMAIN }}")
|
||||||
|
echo "Redirect to: $LOCATION"
|
||||||
|
echo "$LOCATION" | grep -q "https://" || (echo "HTTP redirect missing" && exit 1)
|
||||||
|
|
||||||
|
- name: Check backend /health
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--max-time 15 \
|
||||||
|
"https://${{ vars.DOMAIN }}/api/health")
|
||||||
|
echo "Backend health status: $STATUS"
|
||||||
|
[ "$STATUS" = "200" ] || (echo "Backend health check failed ($STATUS)" && exit 1)
|
||||||
|
|
||||||
|
- name: Check TLS certificate validity
|
||||||
|
run: |
|
||||||
|
EXPIRY=$(echo | openssl s_client -connect ${{ vars.DOMAIN }}:443 -servername ${{ vars.DOMAIN }} 2>/dev/null \
|
||||||
|
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
|
echo "Certificate expires: $EXPIRY"
|
||||||
|
# Fail if cert expires in less than 7 days
|
||||||
|
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s)
|
||||||
|
NOW_EPOCH=$(date +%s)
|
||||||
|
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
|
||||||
|
echo "Days until expiry: $DAYS_LEFT"
|
||||||
|
[ "$DAYS_LEFT" -gt 7 ] || (echo "Certificate expires in $DAYS_LEFT days!" && exit 1)
|
||||||
|
|
||||||
|
- name: Check API properties endpoint
|
||||||
|
run: |
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
--max-time 15 \
|
||||||
|
"https://${{ vars.DOMAIN }}/api/properties?limit=1")
|
||||||
|
echo "Properties API status: $STATUS"
|
||||||
|
[ "$STATUS" = "200" ] || (echo "Properties API failed ($STATUS)" && exit 1)
|
||||||
|
|
||||||
|
- name: Deployment validated successfully
|
||||||
|
run: |
|
||||||
|
echo "✅ All checks passed!"
|
||||||
|
echo " → https://${{ vars.DOMAIN }} is live and healthy"
|
||||||
34
backend/Dockerfile.prod
Normal file
34
backend/Dockerfile.prod
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --no-dev --frozen
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# ── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libpq-dev curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:5000/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
@ -66,6 +66,15 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||||
app.register_blueprint(agents_public_bp)
|
app.register_blueprint(agents_public_bp)
|
||||||
app.register_blueprint(agents_admin_bp)
|
app.register_blueprint(agents_admin_bp)
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
from flask import jsonify
|
||||||
|
try:
|
||||||
|
db.session.execute(db.text("SELECT 1"))
|
||||||
|
return jsonify({"status": "ok", "db": "ok"}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"status": "error", "db": str(e)}), 503
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def track_page_view():
|
def track_page_view():
|
||||||
from flask import request as req
|
from flask import request as req
|
||||||
|
|
|
||||||
81
docker-compose.prod.yml
Normal file
81
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: ${REGISTRY}/saas-imobiliaria-backend:${IMAGE_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
|
||||||
|
FLASK_ENV: production
|
||||||
|
FLASK_APP: app
|
||||||
|
CORS_ORIGINS: https://${DOMAIN}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-public"
|
||||||
|
# Router
|
||||||
|
- "traefik.http.routers.imob-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.imob-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.imob-api.tls=true"
|
||||||
|
- "traefik.http.routers.imob-api.tls.certresolver=letsencrypt"
|
||||||
|
# Service
|
||||||
|
- "traefik.http.services.imob-api.loadbalancer.server.port=5000"
|
||||||
|
# Strip /api prefix before forwarding to Flask
|
||||||
|
- "traefik.http.middlewares.imob-api-strip.stripprefix.prefixes=/api"
|
||||||
|
- "traefik.http.routers.imob-api.middlewares=imob-api-strip"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: ${REGISTRY}/saas-imobiliaria-frontend:${IMAGE_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
- traefik-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-public"
|
||||||
|
# Router
|
||||||
|
- "traefik.http.routers.imob-frontend.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.imob-frontend.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.imob-frontend.tls=true"
|
||||||
|
- "traefik.http.routers.imob-frontend.tls.certresolver=letsencrypt"
|
||||||
|
# Redirect HTTP → HTTPS
|
||||||
|
- "traefik.http.routers.imob-frontend-http.rule=Host(`${DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.imob-frontend-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.imob-frontend-http.middlewares=redirect-to-https"
|
||||||
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true"
|
||||||
|
# Service
|
||||||
|
- "traefik.http.services.imob-frontend.loadbalancer.server.port=80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
28
frontend/Dockerfile.prod
Normal file
28
frontend/Dockerfile.prod
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# VITE_API_URL is injected at build time via --build-arg
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime (nginx) ───────────────────────────────────────────────────────────
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# SPA fallback: all routes → index.html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost/health || exit 1
|
||||||
32
frontend/nginx.conf
Normal file
32
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API calls to backend (handled by Traefik, but keep for direct access)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue