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
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
splinker/
6 changes: 5 additions & 1 deletion eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export default defineConfig([
'mysql/',
'public/',
'coverage/',
'node_modules/'
'node_modules/',
'.pnp.cjs',
'.pnp.loader.mjs',
'.yarn/',
'splinker/'
]
},
{
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"cors": "2.8.5",
"date-fns": "4.1.0",
"dotenv": "17.2.3",
"ejs": "^2.6.1",
"ejs": "5.0.2",
"express": "5.1.0",
"express-rate-limit": "8.2.1",
"express-validator": "7.3.0",
Expand All @@ -47,6 +47,7 @@
"multer": "2.0.2",
"mysql2": "3.15.3",
"nodemailer": "7.0.10",
"p-limit": "^7.3.0",
"pg": "^8.16.3",
"puppeteer": "24.28.0",
"q": "1.5.1",
Expand All @@ -58,7 +59,8 @@
"swagger-ui-express": "5.0.1",
"throttled-queue": "3.0.0",
"tsx": "4.20.6",
"uuid": "13.0.0"
"uuid": "13.0.0",
"wkx": "^0.5.0"
},
"devDependencies": {
"@babel/cli": "7.28.3",
Expand Down Expand Up @@ -89,5 +91,6 @@
"typescript": "5.9.3",
"typescript-eslint": "8.46.3",
"vitest": "4.0.7"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
16 changes: 16 additions & 0 deletions script/database-backup/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
DATABASE_HOST=10.0.10.80
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=masterkey
DATABASE_NAME=herbario_prod

# Google Drive (Dados reais que você gerou)
GDRIVE_TOKEN={"access_token":"SEU_ACCESS_TOKEN_AQUI","token_type":"Bearer","refresh_token":"SEU_REFRESH_TOKEN_AQUI","expiry":"2026-01-01T00:00:00Z"}
# ID extraído da URL da pasta do Google Drive destino
GDRIVE_FOLDER_ID=COLE_O_ID_DA_PASTA_DO_DRIVE_AQUI

# Configurações do serviço
RETENTION_DAILY=5
RETENTION_WEEKLY=4
CRON_SCHEDULE="0 2 * * *"
TZ=America/Sao_Paulo
1 change: 1 addition & 0 deletions script/database-backup/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
36 changes: 36 additions & 0 deletions script/database-backup/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
FROM debian:12-slim

RUN \
apt-get update && \
apt-get install -y \
cron \
tzdata \
gnupg \
curl \
ca-certificates \
rclone \
gzip && \
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg && \
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y \
postgresql-client-18 && \
apt-get remove -y \
curl \
ca-certificates \
gnupg && \
rm -rf /var/lib/apt/lists/*

ENV TZ=America/Sao_Paulo
ENV CRON_SCHEDULE="0 2 * * *"

RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

COPY backup.sh entrypoint.sh /

RUN sed -i 's/\r$//' /backup.sh /entrypoint.sh && \
chmod +x /backup.sh /entrypoint.sh

ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]

CMD ["cron", "-f"]
90 changes: 90 additions & 0 deletions script/database-backup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# database-backup

Serviço standalone de backup automático do PostgreSQL para o Google Drive.

Arquitetura:

- Container Debian slim com `postgresql-client-18`, `rclone`, `cron` e `gzip`
- Deploy como app separado no CapRover
- Container stateless (arquivo temporário em `/tmp`)

Fluxo:

`cron -> backup.sh -> pg_dump -Fp -> gzip -> rclone copy -> retenção`

## Formato do backup

Arquivos seguem o padrão:

```text
{DATABASE_NAME}_{unix_timestamp}.sql.gz
```

Exemplo: `herbario_prod_1748469600.sql.gz`

## Variáveis de ambiente

Veja `.env.example` para o contrato completo.

- `DATABASE_HOST`
- `DATABASE_PORT`
- `DATABASE_USER`
- `DATABASE_PASSWORD`
- `DATABASE_NAME`
- `GDRIVE_TOKEN`
- `GDRIVE_FOLDER_ID`
- `RETENTION_DAILY` (default: `5`)
- `RETENTION_WEEKLY` (default: `4`)
- `CRON_SCHEDULE` (ex.: `0 2 * * *`)
- `TZ` (ex.: `America/Sao_Paulo`)

## Política de retenção

Após upload bem-sucedido:

- mantém os `RETENTION_DAILY` backups mais recentes
- entre os restantes, mantém até `RETENTION_WEEKLY` semanas ISO distintas
- exclui o restante com `rclone deletefile`

Com valores padrão: máximo de 9 arquivos.

## Teste local

No diretório `script/database-backup`:

```bash
docker compose build
docker compose run --rm database-backup /backup.sh
```

Para validar remoto configurado no container:

```bash
docker compose run --rm database-backup rclone lsf gdrive:
```

## Restore de backup

O dump é SQL plain comprimido (`.sql.gz`), então restore é via `psql`:

```bash
gunzip -c herbario_prod_1748469600.sql.gz | \
PGPASSWORD="SENHA" psql \
-h 10.0.10.80 \
-p 5432 \
-U postgres \
-d herbario_restore
```

Fluxo recomendado:

1. Baixar `.sql.gz` do Google Drive.
2. Criar banco temporário de restore.
3. Restaurar no banco temporário.
4. Validar integridade e tabelas.
5. Remover banco temporário.

## Segurança

- O `access_token` expira; o `refresh_token` mantém o acesso de longo prazo.
- Se houver exposição do token, revogue no Google e gere um novo.
85 changes: 85 additions & 0 deletions script/database-backup/backup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/bin/bash

set -euo pipefail

TIMESTAMP=$(date +%s)
FILE_NAME="${DATABASE_NAME}_${TIMESTAMP}.sql.gz"
TMP_FILE="/tmp/${FILE_NAME}"

echo "Iniciando processo de backup: ${FILE_NAME}"

echo "Dumping e comprimindo database..."
PGPASSWORD="${DATABASE_PASSWORD}" pg_dump \
-h "${DATABASE_HOST}" \
-p "${DATABASE_PORT}" \
-U "${DATABASE_USER}" \
-d "${DATABASE_NAME}" \
-Fp | gzip > "${TMP_FILE}"

echo "Enviando para o Google Drive..."
rclone copy "${TMP_FILE}" gdrive:

echo "Limpando arquivo local..."
rm -f "${TMP_FILE}"

echo "Aplicando política de retenção..."
FILES=()
INDEX_FILE=$(mktemp)

while IFS= read -r file; do
file="${file%$'\r'}"

ts="${file##*_}"
ts="${ts%.sql.gz}"
ts="${ts%$'\r'}"

if [[ "$ts" =~ ^[0-9]{10,}$ ]]; then
printf '%s\t%s\n' "$ts" "$file" >> "$INDEX_FILE"
fi
done < <(rclone lsf gdrive: --files-only)

if [ -s "$INDEX_FILE" ]; then
mapfile -t FILES < <(sort -rn "$INDEX_FILE" | cut -f2-)
fi

echo " -> Arquivos elegiveis para retenção: ${#FILES[@]}"

rm -f "$INDEX_FILE"

if [ "${#FILES[@]}" -eq 0 ]; then
echo " -> Nenhum arquivo elegivel para retenção foi encontrado em gdrive:."
fi

DAILY_KEPT=0
WEEKLY_KEPT=0
WEEKLY_SEEN_WEEKS=""
MAX_DAILY=${RETENTION_DAILY:-5}
MAX_WEEKLY=${RETENTION_WEEKLY:-4}

for FILE in "${FILES[@]}"; do
FILE_TS="${FILE##*_}"
FILE_TS="${FILE_TS%.sql.gz}"

if [[ ! "$FILE_TS" =~ ^[0-9]{10,}$ ]]; then
continue
fi

ISO_WEEK=$(date -d "@$FILE_TS" +%G-W%V)

if [ "$DAILY_KEPT" -lt "$MAX_DAILY" ]; then
echo " -> [DIÁRIO] Mantendo backup recente: $FILE (Semana: $ISO_WEEK)"
DAILY_KEPT=$((DAILY_KEPT + 1))

else
if [[ ! "$WEEKLY_SEEN_WEEKS" =~ " $ISO_WEEK " ]] && [ "$WEEKLY_KEPT" -lt "$MAX_WEEKLY" ]; then
echo " -> [SEMANAL] Mantendo primeiro da semana: $FILE (Semana: $ISO_WEEK)"
WEEKLY_KEPT=$((WEEKLY_KEPT + 1))
WEEKLY_SEEN_WEEKS="$WEEKLY_SEEN_WEEKS $ISO_WEEK "
else
echo " -> Deletando backup antigo: $FILE"
rclone deletefile gdrive:"$FILE"
fi
fi
done

echo "Processo de backup concluído com sucesso: ${FILE_NAME}"
4 changes: 4 additions & 0 deletions script/database-backup/captain-definition.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
22 changes: 22 additions & 0 deletions script/database-backup/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
services:
database-backup:
image: database-backup:local
container_name: database-backup
build: .
environment:
# PostgreSQL
DATABASE_HOST: "${DATABASE_HOST}"
DATABASE_PORT: "${DATABASE_PORT}"
DATABASE_USER: "${DATABASE_USER}"
DATABASE_PASSWORD: "${DATABASE_PASSWORD}"
DATABASE_NAME: "${DATABASE_NAME}"

# Google Drive
GDRIVE_TOKEN: "${GDRIVE_TOKEN}"
GDRIVE_FOLDER_ID: "${GDRIVE_FOLDER_ID}"

RETENTION_DAILY: "${RETENTION_DAILY}"
RETENTION_WEEKLY: "${RETENTION_WEEKLY}"

CRON_SCHEDULE: "${CRON_SCHEDULE}"
TZ: "${TZ}"
26 changes: 26 additions & 0 deletions script/database-backup/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/sh

echo "Configurando rclone..."
mkdir -p /root/.config/rclone
cat <<EOF > /root/.config/rclone/rclone.conf
[gdrive]
type = drive
scope = drive.file
token = ${GDRIVE_TOKEN}
root_folder_id = ${GDRIVE_FOLDER_ID}
EOF

echo "Configuring cron job with schedule: $CRON_SCHEDULE ($TZ)"

printenv | grep -E "^(DATABASE_|GDRIVE_|RETENTION_|CRON_SCHEDULE|TZ)" >> /etc/environment

echo "$CRON_SCHEDULE /backup.sh 1> /proc/1/fd/1 2> /proc/1/fd/2" | crontab -

if ! crontab -l >/dev/null 2>&1; then
echo "Failed to configure cron job"
exit 1
fi

crontab -l

exec "$@"
24 changes: 20 additions & 4 deletions splinker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
FROM eclipse-temurin:21-jre

ARG SPLINKER_VERSION=7.2.7
ARG NODE_VERSION=22.20.0

WORKDIR /app

RUN \
apt-get update && \
# Install minimal packages, download splinker.jar and Node.js binary
RUN apt-get update && \
apt-get install -y \
cron \
tzdata \
curl && \
curl \
xz-utils \
ca-certificates && \
curl -fsSL https://github.com/cria/splinker-javafx/releases/download/v${SPLINKER_VERSION}/splinker.jar \
-o /app/splinker.jar && \
apt-get remove -y curl && \
curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz -o /tmp/node.tar.xz && \
tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 && \
rm -f /tmp/node.tar.xz && \
corepack enable && \
apt-get remove -y curl xz-utils && \
apt autoremove -y && \
rm -rf /var/lib/apt/lists/*

# Copy package.json and yarn.lock from splinker/ and install dependencies
COPY package.json yarn.lock /app/
RUN yarn install --production && \
yarn cache clean --force

ENV \
TZ=America/Sao_Paulo \
CRON_SCHEDULE="0 0 * * *"

RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

COPY save_logs.mjs run_splinker.sh /app/
RUN chmod +x /app/run_splinker.sh

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

Expand Down
2 changes: 2 additions & 0 deletions splinker/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ services:
container_name: splinker
build: .
env_file: .env
extra_hosts:
- "host.docker.internal:host-gateway"
Loading
Loading