diff --git a/script/database-backup/.env.example b/script/database-backup/.env.example new file mode 100644 index 00000000..72b61682 --- /dev/null +++ b/script/database-backup/.env.example @@ -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 diff --git a/script/database-backup/.gitignore b/script/database-backup/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/script/database-backup/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/script/database-backup/Dockerfile b/script/database-backup/Dockerfile new file mode 100644 index 00000000..d36c3d9e --- /dev/null +++ b/script/database-backup/Dockerfile @@ -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"] diff --git a/script/database-backup/README.md b/script/database-backup/README.md new file mode 100644 index 00000000..6794a989 --- /dev/null +++ b/script/database-backup/README.md @@ -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. diff --git a/script/database-backup/backup.sh b/script/database-backup/backup.sh new file mode 100644 index 00000000..7d87ec01 --- /dev/null +++ b/script/database-backup/backup.sh @@ -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}" diff --git a/script/database-backup/captain-definition.json b/script/database-backup/captain-definition.json new file mode 100644 index 00000000..0e14f823 --- /dev/null +++ b/script/database-backup/captain-definition.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} diff --git a/script/database-backup/docker-compose.yml b/script/database-backup/docker-compose.yml new file mode 100644 index 00000000..a04f1643 --- /dev/null +++ b/script/database-backup/docker-compose.yml @@ -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}" diff --git a/script/database-backup/entrypoint.sh b/script/database-backup/entrypoint.sh new file mode 100644 index 00000000..ad6176c8 --- /dev/null +++ b/script/database-backup/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +echo "Configurando rclone..." +mkdir -p /root/.config/rclone +cat < /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 "$@"