# DevOps Guide - SaveInMed Guia de operações e infraestrutura do SaveInMed. --- ## Scaleway Container Registry ### 1. Login no Registry ```bash # Login no registry Scaleway (usar secret do CI/CD) echo "${SCW_SECRET_KEY}" | docker login rg.fr-par.scw.cloud -u nologin --password-stdin ``` > [!CAUTION] > **NUNCA** commitar credenciais no código. Use variáveis de ambiente ou secrets do CI/CD. ### 2. Build da Imagem ```bash # Build com platform específico (importante para deploy em servidores AMD64) docker build --platform linux/amd64 --no-cache -t saveinmed-frontend:latest . # Com BuildKit para cache otimizado DOCKER_BUILDKIT=1 docker build \ --platform linux/amd64 \ --cache-from rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest \ -t saveinmed-frontend:latest . ``` ### 3. Tag e Push ```bash # Tag com versão e latest docker tag saveinmed-frontend:latest \ rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION} docker tag saveinmed-frontend:latest \ rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest # Push ambas as tags docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION} docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest ``` ### 4. Variáveis de Ambiente (CI/CD) ```bash # Secrets do Forgejo/GitHub Actions SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # IAM → API Keys SCW_REGISTRY_NAMESPACE=funcscwinfrastructure... # Container Registry → Namespace ``` --- ## Melhores Práticas de Versionamento ### Semantic Versioning (SemVer) ``` MAJOR.MINOR.PATCH Exemplo: 1.2.3 - MAJOR (1): Breaking changes - MINOR (2): Novas features (backwards compatible) - PATCH (3): Bug fixes ``` ### Estratégias de Tag | Tag | Uso | Quando | |-----|-----|--------| | `latest` | Desenvolvimento | Apenas em dev/staging | | `v1.2.3` | Produção | Release oficial | | `v1.2.3-rc.1` | Release Candidate | Pré-release para testes | | `sha-abc1234` | CI/CD | Cada commit (rastreabilidade) | | `main-20241229` | Branch + Data | Builds automáticos | ### Exemplo de Workflow CI/CD ```yaml # .forgejo/workflows/deploy.yaml env: REGISTRY: rg.fr-par.scw.cloud NAMESPACE: ${{ secrets.SCW_REGISTRY_NAMESPACE }} jobs: build: steps: - name: Generate version run: | # Usar git tag se existir, senão usar SHA VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD) echo "VERSION=$VERSION" >> $GITHUB_ENV - name: Login to Scaleway Registry run: | echo "${{ secrets.SCW_SECRET_KEY }}" | \ docker login $REGISTRY -u nologin --password-stdin - name: Build and Push run: | docker build --platform linux/amd64 \ -t $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION \ -t $REGISTRY/$NAMESPACE/saveinmed-frontend:latest . docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:latest ``` ### ✅ Boas Práticas - **Sempre usar tags específicas** em produção (nunca `latest`) - **Imagens imutáveis**: uma vez publicada, nunca sobrescrever - **Multi-stage builds** para imagens menores - **Scan de vulnerabilidades** antes do deploy - **Cleanup automático** de imagens antigas no registry --- ## Deploy no Servidor (apolo) ### Pré-requisitos - Servidor: `apolo.rede5.com.br` - SO: AlmaLinux com Podman - Rede: `web_proxy` (para comunicação com Traefik) ### Estrutura de Diretórios ``` /mnt/data/saveinmed/ ├── backend/.env # Variáveis de ambiente backend (Go) ├── backoffice/.env # Variáveis de ambiente backoffice (NestJS) └── marketplace/.env # Variáveis de ambiente marketplace (Vite/React) /etc/containers/systemd/ ├── saveinmed-backend.container ├── saveinmed-backoffice.container └── saveinmed-marketplace.container ``` > [!NOTE] > Como as imagens vêm do Scaleway Registry, **não é necessário** clonar o repo no servidor. > O código está empacotado na imagem Docker. --- ### Passo 1: Criar Diretórios e Arquivos .env ```bash # Conectar no servidor ssh root@apolo # Criar estrutura de diretórios mkdir -p /mnt/data/saveinmed/{backend,backoffice,marketplace} ``` #### Backend .env (`/mnt/data/saveinmed/backend/.env`) ```bash # Geral ENV=production PORT=8080 TZ=America/Sao_Paulo # Database DATABASE_URL=postgresql://USER:PASS@postgres-main:5432/saveinmed?sslmode=disable # Redis REDIS_URL=redis://:PASSWORD@redis-saveinmed:6379/0 # JWT JWT_SECRET=GERAR_SECRET_32_CHARS JWT_EXPIRATION=7d # CORS CORS_ORIGINS=https://saveinmed.com.br,https://api.saveinmed.com.br # Cookies COOKIE_SECRET=GERAR_SECRET COOKIE_DOMAIN=saveinmed.com.br # Stripe STRIPE_SECRET_KEY=sk_live_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx # Scaleway Storage SCW_ACCESS_KEY=SCWxxx SCW_SECRET_KEY=xxx SCW_REGION=fr-par SCW_BUCKET_NAME=saveinmed-uploads SCW_ENDPOINT=https://s3.fr-par.scw.cloud ``` #### Marketplace .env (`/mnt/data/saveinmed/marketplace/.env`) ```bash # Vite/React SPA - variáveis são injetadas no build # Para produção, configure no Dockerfile ou CI/CD VITE_API_URL=https://api.saveinmed.com.br ``` > [!NOTE] > O marketplace é uma SPA (Single Page Application) servida pelo nginx. > As variáveis `VITE_*` são injetadas em tempo de build, não de runtime. --- ### Passo 2: Criar Arquivos Quadlet > [!IMPORTANT] > Quadlet é o sistema nativo do Podman para gerenciar containers como serviços systemd. #### Backend (`/etc/containers/systemd/saveinmed-backend.container`) ```ini [Unit] Description=SaveInMed Backend After=network-online.target postgres-main.service [Container] Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest ContainerName=saveinmed-backend # Variáveis de ambiente EnvironmentFile=/mnt/data/saveinmed/backend/.env # Rede interna (Traefik) Network=web_proxy PublishPort=8080:8080 # Labels Traefik Label=traefik.enable=true Label=traefik.http.routers.saveinmed-backend.rule=Host(`api.saveinmed.com.br`) Label=traefik.http.routers.saveinmed-backend.entrypoints=websecure Label=traefik.http.routers.saveinmed-backend.tls.certresolver=myresolver [Install] WantedBy=multi-user.target ``` #### Marketplace (`/etc/containers/systemd/saveinmed-marketplace.container`) ```ini [Unit] Description=SaveInMed Marketplace (Vite/React SPA) After=network-online.target saveinmed-backend.service [Container] Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-marketplace:latest ContainerName=saveinmed-marketplace Pull=never # serve roda na porta 3000 Network=web_proxy PublishPort=3010:3000 # Labels Traefik Label=traefik.enable=true Label=traefik.http.routers.saveinmed-marketplace.rule=Host(`saveinmed.com.br`) Label=traefik.http.routers.saveinmed-marketplace.entrypoints=websecure Label=traefik.http.routers.saveinmed-marketplace.tls.certresolver=myresolver Label=traefik.http.services.saveinmed-marketplace.loadbalancer.server.port=3000 [Install] WantedBy=multi-user.target ``` #### Backoffice (`/etc/containers/systemd/saveinmed-backoffice.container`) ```ini [Unit] Description=SaveInMed Backoffice After=network-online.target saveinmed-backend.service [Container] Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backoffice:latest ContainerName=saveinmed-backoffice Pull=never EnvironmentFile=/mnt/data/saveinmed/backoffice/.env Network=web_proxy PublishPort=3011:3000 Label=traefik.enable=true Label=traefik.http.routers.saveinmed-backoffice.rule=Host(`backoffice.saveinmed.com.br`) Label=traefik.http.routers.saveinmed-backoffice.entrypoints=websecure Label=traefik.http.routers.saveinmed-backoffice.tls.certresolver=myresolver Label=traefik.http.services.saveinmed-backoffice.loadbalancer.server.port=3000 [Install] WantedBy=multi-user.target ``` --- ### Passo 3: Ativar e Iniciar Serviços ```bash # Recarregar configurações do systemd systemctl daemon-reload # Iniciar serviços systemctl start saveinmed-backend systemctl start saveinmed-backoffice systemctl start saveinmed-marketplace # Habilitar para iniciar no boot systemctl enable saveinmed-backend systemctl enable saveinmed-backoffice systemctl enable saveinmed-marketplace # Verificar status systemctl status saveinmed-backend systemctl status saveinmed-marketplace ``` --- ### Passo 4: Atualizar Imagem (sem rebuild) Como você não vai fazer build da imagem localmente, o processo de atualização é: ```bash # 1. Fazer pull da nova imagem podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest # 2. Reiniciar o serviço (vai usar a nova imagem) systemctl restart saveinmed-backend ``` Ou para todos: ```bash # Script de atualização for svc in backend backoffice marketplace; do podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-${svc}:latest systemctl restart saveinmed-${svc} done ``` --- ### Configuração do Traefik O Traefik já está configurado em `/etc/containers/systemd/traefik.container`: ```ini [Container] Image=docker.io/library/traefik:v3.0 ContainerName=traefik Network=web_proxy PublishPort=80:80 PublishPort=443:443 Volume=/run/podman/podman.sock:/run/podman/podman.sock:ro Volume=/opt/traefik/letsencrypt:/letsecrypt Exec=--providers.docker=true \ --providers.docker.endpoint=unix:///run/podman/podman.sock \ --providers.docker.exposedByDefault=false \ --entryPoints.web.address=:80 \ --entryPoints.websecure.address=:443 \ --certificatesResolvers.myresolver.acme.tlschallenge=true \ --certificatesResolvers.myresolver.acme.email=seu-email@dominio.com \ --certificatesResolvers.myresolver.acme.storage=/letsecrypt/acme.json ``` > [!TIP] > Os certificados SSL são gerados automaticamente pelo Let's Encrypt via TLS challenge. --- ### Comandos Úteis ```bash # Ver containers rodando podman ps # Ver logs de um serviço journalctl -u saveinmed-backend -f # Reiniciar todos os serviços SaveInMed systemctl restart saveinmed-* # Verificar healthcheck curl -s https://api.saveinmed.com.br/health | jq # Login no registry antes de pull echo "${SCW_SECRET_KEY}" | podman login rg.fr-par.scw.cloud -u nologin --password-stdin ``` --- ## Como Subir Imagem no Scaleway ### Passo a Passo Rápido 1. **Obter credenciais** no console Scaleway → IAM → API Keys 2. **Configurar variáveis de ambiente** (ver abaixo) 3. **Usar o endpoint de upload** da API ### Endpoint de Upload ```bash # Upload de imagem de produto curl -X POST https://api.saveinmed.com.br/v1/products/{productId}/images \ -H "Authorization: Bearer {token}" \ -F "image=@/path/to/image.jpg" # Upload de documento KYC curl -X POST https://api.saveinmed.com.br/v1/companies/documents \ -H "Authorization: Bearer {token}" \ -F "document=@/path/to/license.pdf" \ -F "type=license" ``` ### Response ```json { "url": "https://s3.fr-par.scw.cloud/saveinmed-uploads/products/abc123/main.webp" } ``` --- ## Scaleway Object Storage O Scaleway Object Storage é um serviço de armazenamento de objetos compatível com a API S3 da AWS. Usamos este serviço para: - Imagens de produtos - Documentos KYC (licenças, CNPJs) - Fotos de perfil - Comprovantes de entrega --- ## Configuração ### 1. Variáveis de Ambiente ```bash # .env SCW_ACCESS_KEY=SCWxxxxxxxxxxxxxxxxx SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx SCW_REGION=fr-par # ou nl-ams, pl-waw SCW_BUCKET_NAME=saveinmed-uploads SCW_ENDPOINT=https://s3.fr-par.scw.cloud ``` ### 2. Estrutura de Buckets ``` saveinmed-uploads/ ├── products/ # Imagens de produtos │ └── {product_id}/ │ ├── main.webp │ └── thumb.webp ├── documents/ # Documentos KYC │ └── {company_id}/ │ ├── cnpj.pdf │ └── license.pdf ├── profiles/ # Fotos de perfil │ └── {user_id}.webp └── shipments/ # Comprovantes de entrega └── {shipment_id}/ └── proof.webp ``` --- ## Implementação Backend (Go) ### 1. Dependências ```bash go get github.com/aws/aws-sdk-go-v2/config go get github.com/aws/aws-sdk-go-v2/service/s3 go get github.com/aws/aws-sdk-go-v2/credentials ``` ### 2. Cliente S3 (internal/storage/s3.go) ```go package storage import ( "context" "fmt" "io" "os" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" ) type S3Client struct { client *s3.Client bucketName string endpoint string } func NewS3Client() (*S3Client, error) { region := os.Getenv("SCW_REGION") endpoint := os.Getenv("SCW_ENDPOINT") accessKey := os.Getenv("SCW_ACCESS_KEY") secretKey := os.Getenv("SCW_SECRET_KEY") bucketName := os.Getenv("SCW_BUCKET_NAME") resolver := aws.EndpointResolverWithOptionsFunc( func(service, region string, options ...interface{}) (aws.Endpoint, error) { return aws.Endpoint{URL: endpoint}, nil }, ) cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region), config.WithEndpointResolverWithOptions(resolver), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")), ) if err != nil { return nil, fmt.Errorf("failed to load config: %w", err) } client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true // Required for Scaleway }) return &S3Client{ client: client, bucketName: bucketName, endpoint: endpoint, }, nil } // Upload envia um arquivo para o bucket func (c *S3Client) Upload(ctx context.Context, key string, reader io.Reader, contentType string) (string, error) { _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(c.bucketName), Key: aws.String(key), Body: reader, ContentType: aws.String(contentType), ACL: "public-read", // ou "private" para documentos sensíveis }) if err != nil { return "", fmt.Errorf("failed to upload: %w", err) } url := fmt.Sprintf("%s/%s/%s", c.endpoint, c.bucketName, key) return url, nil } // GetPresignedURL gera URL temporária para download func (c *S3Client) GetPresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) { presigner := s3.NewPresignClient(c.client) req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(c.bucketName), Key: aws.String(key), }, s3.WithPresignExpires(expiration)) if err != nil { return "", err } return req.URL, nil } // Delete remove um arquivo do bucket func (c *S3Client) Delete(ctx context.Context, key string) error { _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(c.bucketName), Key: aws.String(key), }) return err } ``` ### 3. Handler de Upload (internal/http/handler/upload_handler.go) ```go package handler import ( "fmt" "net/http" "path/filepath" "strings" "github.com/google/uuid" ) const maxUploadSize = 10 << 20 // 10MB var allowedMimeTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "application/pdf": true, } func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request) { // Limitar tamanho r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { http.Error(w, "Arquivo muito grande (max 10MB)", http.StatusBadRequest) return } file, header, err := r.FormFile("image") if err != nil { http.Error(w, "Arquivo não encontrado", http.StatusBadRequest) return } defer file.Close() // Validar MIME type contentType := header.Header.Get("Content-Type") if !allowedMimeTypes[contentType] { http.Error(w, "Tipo de arquivo não permitido", http.StatusBadRequest) return } // Gerar key única productID := r.PathValue("productId") ext := filepath.Ext(header.Filename) key := fmt.Sprintf("products/%s/%s%s", productID, uuid.New().String(), ext) // Upload url, err := h.storage.Upload(r.Context(), key, file, contentType) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"url": url}) } ``` --- ## Implementação Frontend (TypeScript) ### 1. Serviço de Upload (src/services/storageService.ts) ```typescript import { apiClient } from './apiClient' interface UploadResponse { url: string } export const storageService = { // Upload de imagem de produto uploadProductImage: async (productId: string, file: File): Promise => { const formData = new FormData() formData.append('image', file) const response = await apiClient.post( `/v1/products/${productId}/images`, formData ) return response.url }, // Upload de documento KYC uploadDocument: async (file: File, type: 'cnpj' | 'license' | 'other'): Promise => { const formData = new FormData() formData.append('document', file) formData.append('type', type) const response = await apiClient.post( '/v1/companies/documents', formData ) return response.url }, // Upload com preview e progresso uploadWithProgress: async ( endpoint: string, file: File, onProgress: (percent: number) => void ): Promise => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() const formData = new FormData() formData.append('file', file) xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { onProgress(Math.round((e.loaded / e.total) * 100)) } }) xhr.addEventListener('load', () => { if (xhr.status === 200) { const response = JSON.parse(xhr.responseText) resolve(response.url) } else { reject(new Error('Upload failed')) } }) xhr.addEventListener('error', () => reject(new Error('Upload failed'))) xhr.open('POST', `${import.meta.env.VITE_API_URL}${endpoint}`) xhr.setRequestHeader('Authorization', `Bearer ${localStorage.getItem('token')}`) xhr.send(formData) }) } } ``` ### 2. Componente de Upload (src/components/ImageUpload.tsx) ```tsx import { useState, useRef } from 'react' import { storageService } from '../services/storageService' interface ImageUploadProps { productId: string onUploadComplete: (url: string) => void } export function ImageUpload({ productId, onUploadComplete }: ImageUploadProps) { const [uploading, setUploading] = useState(false) const [progress, setProgress] = useState(0) const [preview, setPreview] = useState(null) const inputRef = useRef(null) const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return // Validações if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { alert('Apenas JPEG, PNG ou WebP são permitidos') return } if (file.size > 10 * 1024 * 1024) { alert('Máximo 10MB') return } // Preview local setPreview(URL.createObjectURL(file)) setUploading(true) try { const url = await storageService.uploadWithProgress( `/v1/products/${productId}/images`, file, setProgress ) onUploadComplete(url) } catch (err) { console.error('Upload failed:', err) alert('Falha no upload') } finally { setUploading(false) setProgress(0) } } return (
{preview && ( Preview )}
) } ``` --- ## Melhores Práticas ### 1. Segurança - ✅ **Validar MIME type** no backend (não confiar no frontend) - ✅ **Limitar tamanho** máximo de upload (10MB recomendado) - ✅ **Usar URLs presignadas** para documentos sensíveis - ✅ **Verificar autenticação** antes de permitir upload - ✅ **Sanitizar nomes** de arquivos (usar UUID) ### 2. Performance - ✅ **Compressão de imagens** antes do upload (client-side) - ✅ **Gerar thumbnails** automaticamente - ✅ **Usar WebP** como formato preferencial - ✅ **CDN** em produção para distribuição global ### 3. Organização ```bash # Estrutura de keys recomendada {bucket}/{tipo}/{entity_id}/{arquivo} # Exemplos: products/abc123/main.webp documents/company456/license.pdf profiles/user789.webp ``` ### 4. Custo - ✅ **Lifecycle rules** para limpar arquivos antigos - ✅ **Storage class** adequado (Standard vs Glacier-like) - ✅ **Monitorar uso** para evitar surpresas --- ## Configuração do Bucket (Console Scaleway) 1. Acesse **Object Storage** no console Scaleway 2. Clique em **Create a bucket** 3. Nome: `saveinmed-uploads` 4. Região: `fr-par` (ou mais próxima) 5. Visibility: **Private** (acesso via API) 6. Configure **CORS**: ```json [ { "AllowedOrigins": ["https://saveinmed.com.br", "http://localhost:5173"], "AllowedMethods": ["GET", "PUT", "POST", "DELETE"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3600 } ] ``` --- ## Troubleshooting ### Erro: "Access Denied" - Verifique as credenciais `SCW_ACCESS_KEY` e `SCW_SECRET_KEY` - Confirme que as permissões do bucket estão corretas ### Erro: "CORS Policy" - Configure CORS no bucket (ver acima) - Adicione o domínio do frontend na lista de origens permitidas ### Upload lento - Use upload multipart para arquivos > 5MB - Considere compressão client-side para imagens --- ## Referências - [Scaleway Object Storage Documentation](https://www.scaleway.com/en/docs/object-storage-feature/) - [AWS SDK for Go v2](https://aws.github.io/aws-sdk-go-v2/docs/) - [S3-Compatible API Reference](https://www.scaleway.com/en/docs/object-storage-s3-api/)