Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions .github/workflows/ci-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ jobs:
set -euo pipefail
./scripts/test_seed_sqlite.sh
./scripts/test_alpine_image_static.sh
./scripts/smoke_compose.sh
./scripts/check_production_domain.sh
./scripts/test_coolify_deploy.sh
./scripts/test_health_lifecycle.sh

Expand Down Expand Up @@ -135,7 +133,7 @@ jobs:
- name: Ambiente isolado do smoke A/B
run: |
cat > "$RUNNER_TEMP/tabuamare-ci.env" <<'ENV'
POSTGRESQL_CONN_STR=postgresql://postgres:postgres@postgres:5432/tabuamare_ci
POSTGRESQL_CONN_STR=postgresql://postgres:postgres@127.0.0.1:5432/tabuamare_ci
GOOGLE_CLIENT_ID=ci-client
GOOGLE_CLIENT_SECRET=ci-secret
GOOGLE_REDIRECT_URI=https://tabuamare.api.br/auth/google/callback
Expand All @@ -146,15 +144,27 @@ jobs:
ENV

- name: Smoke da imagem exata em A/B
env:
COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml
TABUAMARE_IMAGE: ${{ env.LOCAL_IMAGE }}
TABUAMARE_ENV_FILE: ${{ runner.temp }}/tabuamare-ci.env
COMPOSE_TEST_ENV_FILE: ${{ runner.temp }}/tabuamare-ci.env
COMPOSE_TEST_ALLOW_DB_MUTATIONS: "yes"
COMPOSE_RUNTIME: "1"
COMPOSE_BUILD: "0"
run: ./scripts/smoke_compose.sh
run: |
set -euo pipefail
env_file="$RUNNER_TEMP/tabuamare-ci.env"
a_name="tabuamare-ci-a-${GITHUB_RUN_ID}"
b_name="tabuamare-ci-b-${GITHUB_RUN_ID}"
a_volume="tabuamare-ci-a-${GITHUB_RUN_ID}"
b_volume="tabuamare-ci-b-${GITHUB_RUN_ID}"
cleanup() { docker rm -f "$a_name" "$b_name" >/dev/null 2>&1 || true; docker volume rm "$a_volume" "$b_volume" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker volume create "$a_volume" >/dev/null
docker volume create "$b_volume" >/dev/null
wait_http() { local url="$1" expected="$2" code=000; for _ in $(seq 1 180); do code="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"; [[ "$code" == "$expected" ]] && return 0; sleep 1; done; echo "$url: recebido $code, esperado $expected" >&2; docker logs "$a_name" >&2 || true; docker logs "$b_name" >&2 || true; return 1; }
docker run -d --name "$a_name" --network host --env-file "$env_file" -e PORT=3330 -v "$a_volume:/app/data" "$LOCAL_IMAGE" 3330
wait_http http://127.0.0.1:3330/health/ready 204
docker run -d --name "$b_name" --network host --env-file "$env_file" -e PORT=3340 -v "$b_volume:/app/data" "$LOCAL_IMAGE" 3340
wait_http http://127.0.0.1:3340/health/ready 204
wait_http http://127.0.0.1:3330/api/v2/states 200
wait_http http://127.0.0.1:3340/api/v2/states 200
[[ "$(docker inspect --format '{{.State.Status}}' "$a_name")" == running ]]
[[ "$(docker inspect --format '{{.State.Status}}' "$b_name")" == running ]]
echo 'PASS: smoke direto A/B, readiness e API v2'

- name: Publicar tag imutavel
if: github.event_name == 'push'
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ bin/
/.qodo
/.trae
/.superpowers/

# SSH operacional local (nunca versionar)
/run_ssh.sh
__pycache__/
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ v test tests/find_nearested_harbor_test.v
# Docker production build (Alpine, uma app por container)
docker build --platform linux/amd64 -t tabua-mare-api:local .

# Docker Compose local A/B
docker compose up -d --build
# Validar imagem Alpine
docker build --platform linux/amd64 -t tabua-mare-api:local .
```

## Environment Setup
Expand Down Expand Up @@ -163,8 +163,7 @@ tests/ — integration tests (_test.v files, require DB)
### Production Deployment

- **Root `Dockerfile`** — Alpine 3.22 multi-stage, uma instância V por container na porta `3330`, UID 10001 e volume `/app/data`.
- **`docker-compose.yml`** — somente validação local A/B; volumes `sqlite-a` e `sqlite-b` separados.
- **Produção** — duas aplicações regulares Coolify usando `ghcr.io/ddiidev/tabua-mare-api:sha-<commit>`, balanceadas pelo Traefik.
- **Produção** — duas aplicações regulares Coolify usando `ghcr.io/ddiidev/tabua-mare-api:sha-<commit>`, balanceadas pelo Traefik. O repositório não usa Compose para produção nem CI.
- **Fluxo público** — Cloudflare proxy → Traefik/Coolify → A ou B. Sem nginx, Cloudflare Tunnel, Swarm ou Compose de produção.
- **Operação** — scripts e runbook em `ops/`; deploy manual sequencial em `.github/workflows/deploy-production.yml`.

Expand Down
13 changes: 0 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,6 @@ docker run --rm -p 3330:3330 \
- O seed SQLite é validado e atualizado atomicamente em `/app/data/taubinha.sqlite`.
- Health checks: `/health/live` e `/health/ready`.

### Validação local A/B

1. Copie o arquivo `.env.template` para `.env` e ajuste variáveis conforme necessário.
2. Construa e suba os serviços:

```
docker compose up -d --build
```

- O Compose local sobe somente `tabuamare-a` e `tabuamare-b`.
- Cada instância usa volume SQLite exclusivo.
- Portas locais: `3330` e `3340`.

### Produção Coolify

Produção usa duas aplicações regulares Coolify baseadas na mesma imagem GHCR imutável, atrás de Cloudflare e Traefik. Não usa nginx, Cloudflare Tunnel, Swarm ou Compose de produção.
Expand Down
2 changes: 1 addition & 1 deletion api.v
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,4 @@ pub fn (mut api APIController) get_nearest_harbor_by_state(mut ctx web_ctx.WsCtx
@['/nearest-harbor-independent-state/:lat_lng']
pub fn (mut api APIController) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_lng string) veb.Result {
return gone_v1(mut ctx)
}
}
90 changes: 44 additions & 46 deletions api_v2.v
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@ module main

import veb
import pool
import db.pg
import shareds.types
import shareds.web_ctx
import shareds.conf_env
import shareds.rate_limit
import repository.habor_mare as repo_habor_mare
import repository.tabua_mare as repo_tabua_mare
import repository.auth as repo_auth
import repository.auth.dto
import repository.rate_limit as rl
import shareds.infradb_pg

// APIController Controller da API endpoint base: /api/v2
pub struct APIControllerV2 {
veb.Middleware[web_ctx.WsCtx]
env conf_env.EnvConfig
mut:
pool_conn &pool.ConnectionPool
pg_holder &infradb_pg.PgHolder
}

// init_cors inicializa o middleware CORS para o APIController
Expand All @@ -39,9 +38,10 @@ fn (mut api APIControllerV2) init_cors() {

// init_rate_limit aplica o middleware de rate-limit (por IP/api_key, minuto + mes)
// usando a conexao PostgreSQL (contadores e creditos persistidos).
fn (mut api APIControllerV2) init_rate_limit(env conf_env.EnvConfig) {
fn (mut api APIControllerV2) init_rate_limit(env conf_env.EnvConfig, pg_holder &infradb_pg.PgHolder) {
api.use(rate_limit.rate_limit_middleware(rate_limit.RateLimitOpts{
env: env
env: env
pg_holder: pg_holder
}))
}

Expand Down Expand Up @@ -87,8 +87,8 @@ pub fn (mut api APIControllerV2) get_harbors_by_ids(mut ctx web_ctx.WsCtx, harbo
// get_tabua_mare Retorna o tábua (tabela) da mare de um porto específico para um mês e dias específicos.
@['/tabua-mare/:harbor/:month/:days']
pub fn (mut api APIControllerV2) get_tabua_mare(mut ctx web_ctx.WsCtx, harbor_id string, month int, days string) veb.Result {
result := repo_tabua_mare.get_tabua_mare_by_month_days(mut api.pool_conn, harbor_id,
month, types.IntRangeArr(days).ints()) or {
result := repo_tabua_mare.get_tabua_mare_by_month_days(mut api.pool_conn, harbor_id, month,
types.IntRangeArr(days).ints()) or {
ctx.res.set_status(.bad_request)
return ctx.json(types.failure[string](400, 'error: ${err}'))
}
Expand All @@ -107,7 +107,8 @@ pub fn (mut api APIControllerV2) get_nearested_tabua_mare(mut ctx web_ctx.WsCtx,
nearest_harbor := repo_habor_mare.find_nearest_harbor_within_same_state_v2(mut api.pool_conn,
lat, lng, state) or {
ctx.res.set_status(.not_found)
return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.'))
return ctx.json(types.failure[string](404,
'Nenhum porto encontrado perto das coordenadas fornecidas.'))
}

// TODO: CORRIGIR
Expand All @@ -130,7 +131,8 @@ pub fn (mut api APIControllerV2) get_nearest_harbor_by_state(mut ctx web_ctx.WsC
nearest_harbor := repo_habor_mare.find_nearest_harbor_within_same_state_v2(mut api.pool_conn,
lat, lng, state) or {
ctx.res.set_status(.not_found)
return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.'))
return ctx.json(types.failure[string](404,
'Nenhum porto encontrado perto das coordenadas fornecidas.'))
}

return ctx.json(types.success([nearest_harbor]))
Expand All @@ -145,7 +147,8 @@ pub fn (mut api APIControllerV2) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_l

nearest_harbor := repo_habor_mare.find_nearest_harbor_v2(mut api.pool_conn, lat, lng) or {
ctx.res.set_status(.not_found)
return ctx.json(types.failure[string](404, 'Nenhum porto encontrado perto das coordenadas fornecidas.'))
return ctx.json(types.failure[string](404,
'Nenhum porto encontrado perto das coordenadas fornecidas.'))
}

return ctx.json(types.success([nearest_harbor]))
Expand All @@ -155,54 +158,49 @@ pub fn (mut api APIControllerV2) get_nearest_harbor(mut ctx web_ctx.WsCtx, lat_l
// Autenticacao via header Authorization: Bearer <key> ou X-Api-Key.
@['/usage'; get]
pub fn (mut api APIControllerV2) usage(mut ctx web_ctx.WsCtx) veb.Result {
connstr := api.env.postgresql_conn_str
if connstr == '' {
ctx.res.set_status(.internal_server_error)
return ctx.json(types.failure[string](500, 'banco indisponivel'))
}

api_key := rate_limit.extract_api_key(mut ctx)
if api_key == '' {
ctx.res.set_status(.unauthorized)
return ctx.json(types.failure[string](401, 'api_key ausente'))
}

mut db := pg.connect_with_conninfo(connstr) or {
ctx.res.set_status(.internal_server_error)
return ctx.json(types.failure[string](500, 'banco indisponivel: ${err}'))
}
defer {
db.close() or {}
if !api.pg_holder.available() {
ctx.res.set_status(.service_unavailable)
return ctx.json(types.failure[string](503, 'banco indisponivel'))
}

mut key_found := true
key := repo_auth.find_by_key(mut db, api_key) or { key_found = false; dto.ApiKey{} }
if !key_found || key.revoked {
mut db := api.pg_holder.db()

identity := rate_limit.resolve_api_key_identity(mut db, api_key) or {
ctx.res.set_status(.service_unavailable)
return ctx.json(types.failure[string](503, 'banco indisponivel'))
}
if !identity.found {
ctx.res.set_status(.unauthorized)
return ctx.json(types.failure[string](401, 'api_key invalida ou revogada'))
}

user_plan := repo_auth.find_plan_by_id(mut db, key.user_id) or { 'free' }
mut effective_plan := key.plan
if !rate_limit.is_plan_allowed(key.plan, user_plan) {
effective_plan = user_plan
}

bucket := 'key:${key.key_value}'
effective_plan := identity.plan
bucket := identity.bucket
limit_rpm, limit_monthly := rate_limit.plan_limits(api.env, effective_plan)

used_rpm := rl.get_count(mut db, bucket, 'minute', rl.window_key_minute()) or { 0 }
monthly := rl.get_current_month_usage(mut db, bucket) or {
rl.CreditCheck{used: 0, remaining: limit_monthly, lim: limit_monthly}
}

return ctx.json(types.success([{
'plan': effective_plan
'limit_rpm': limit_rpm.str()
'used_rpm': used_rpm.str()
'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() }
'limit_monthly': limit_monthly.str()
'used_monthly': monthly.used.str()
'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() }
}]))
monthly := rl.get_current_month_usage(mut db, bucket, limit_monthly) or {
rl.CreditCheck{
used: 0
remaining: limit_monthly
lim: limit_monthly
}
}

return ctx.json(types.success([
{
'plan': effective_plan
'limit_rpm': limit_rpm.str()
'used_rpm': used_rpm.str()
'remaining_rpm': if limit_rpm == 0 { '-1' } else { (limit_rpm - used_rpm).str() }
'limit_monthly': limit_monthly.str()
'used_monthly': monthly.used.str()
'remaining_monthly': if limit_monthly == 0 { '-1' } else { monthly.remaining.str() }
},
]))
}
Loading
Loading