diff --git a/.forgejo/workflows/README.md b/.forgejo/workflows/README.md new file mode 100644 index 0000000..d48e3b1 --- /dev/null +++ b/.forgejo/workflows/README.md @@ -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 +``` diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..d358c12 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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" diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..f0f5144 --- /dev/null +++ b/backend/Dockerfile.prod @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 41cf2db..20f7246 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -66,6 +66,15 @@ def create_app(config_name: str | None = None) -> Flask: app.register_blueprint(agents_public_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 def track_page_view(): from flask import request as req diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..7d3e48b --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..1929c4a --- /dev/null +++ b/frontend/Dockerfile.prod @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d5b7095 --- /dev/null +++ b/frontend/nginx.conf @@ -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; +}