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

892 lines
22 KiB
Markdown

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