saveinmed/docs/DEVOPS.md
2026-01-08 14:28:34 -03:00

22 KiB

DevOps Guide - SaveInMed

Status (pronto x faltando)

Pronto

  • Conteúdo descrito neste documento.

Faltando

  • Confirmar no código o estado real das funcionalidades e atualizar esta seção conforme necessário.

Guia de operações e infraestrutura do SaveInMed.


Scaleway Container Registry

1. Login no Registry

# 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

# 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

# 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)

# 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

# .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

# 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)

# 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)

# 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)

[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)

[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)

[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

# 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 é:

# 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:

# 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:

[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

# 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

# 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

{
  "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

# .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

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)

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)

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)

import { apiClient } from './apiClient'

interface UploadResponse {
  url: string
}

export const storageService = {
  // Upload de imagem de produto
  uploadProductImage: async (productId: string, file: File): Promise<string> => {
    const formData = new FormData()
    formData.append('image', file)
    
    const response = await apiClient.post<UploadResponse>(
      `/v1/products/${productId}/images`,
      formData
    )
    return response.url
  },

  // Upload de documento KYC
  uploadDocument: async (file: File, type: 'cnpj' | 'license' | 'other'): Promise<string> => {
    const formData = new FormData()
    formData.append('document', file)
    formData.append('type', type)
    
    const response = await apiClient.post<UploadResponse>(
      '/v1/companies/documents',
      formData
    )
    return response.url
  },

  // Upload com preview e progresso
  uploadWithProgress: async (
    endpoint: string,
    file: File,
    onProgress: (percent: number) => void
  ): Promise<string> => {
    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)

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<string | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)

  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    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 (
    <div className="upload-container">
      <input
        ref={inputRef}
        type="file"
        accept="image/jpeg,image/png,image/webp"
        onChange={handleFileSelect}
        hidden
      />
      
      <button 
        onClick={() => inputRef.current?.click()}
        disabled={uploading}
      >
        {uploading ? `Enviando ${progress}%` : '📤 Enviar Imagem'}
      </button>

      {preview && (
        <img src={preview} alt="Preview" className="upload-preview" />
      )}
    </div>
  )
}

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

# 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:
[
  {
    "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