diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..34e7f89 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,121 @@ +name: build-and-promote + +# Build Once → Promote → Deploy (modelo da stack — ver Documentos-Unicos/deploy): +# • push na main → job BUILD: builda 1x e publica por COMMIT (: + :latest). NÃO deploya. +# • tag vX.Y.Z → job PROMOTE: re-tagueia a imagem do commit como :vX.Y.Z (SEM rebuild), +# valida digest e deploya na VPS1 (pull + up --no-build). +# A versão semântica NÃO entra no build: nasce da tag e é injetada em runtime (docker-compose.prod.yml). +# O React (Vite) é buildado como step ANTES do docker build (o Dockerfile atual espera o dist/ pronto). +on: + push: + branches: [main] + tags: ['v*'] + +jobs: + # ───────────────────────── BUILD (só em push de branch) ───────────────────────── + build: + if: startsWith(github.ref, 'refs/heads/') + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Vars (commit + data) + id: vars + run: | + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "build_time=$(date -u +'%Y-%m-%d %H:%M UTC')" >> "$GITHUB_OUTPUT" + + - name: Login no registry + run: | + if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin + else + echo "REGISTRY_TOKEN ausente — usando a credencial já presente no host." + fi + + - name: Build do React (Vite) — gera dental-client/dist/ antes do docker build + run: | + set -e + cp dental-server/version.txt dental-server/dental-client/public/version.txt + cd dental-server/dental-client + NODE_ENV=development npm install --include=dev --prefer-offline + npm run build + ls dist/index.html >/dev/null && echo "OK: dist/ pronto" + + - name: Build da imagem (inclui dental-client/dist/) + env: + COMPOSE_PROJECT_NAME: rxscoreodontocom + run: docker compose -f docker-compose.yml build app + + - name: Publicar por COMMIT (: + :latest), com retry p/ 499 + run: | + set -e + REG=git.clube67.com/ruicesar + C="${{ steps.vars.outputs.commit }}" + SRC="rxscoreodontocom-app:latest" + DST="${REG}/rx-scoreodonto" + docker tag "$SRC" "$DST:$C" + docker tag "$SRC" "$DST:latest" + for tag in "$C" "latest"; do + n=0 + until docker push "$DST:$tag"; do + n=$((n+1)); [ $n -ge 4 ] && { echo "push falhou: $DST:$tag"; exit 1; } + echo "retry $n (499 transitório) em $DST:$tag"; sleep 3 + done + done + echo "Publicado: rx-scoreodonto:$C (+ :latest). Push NÃO deploya." + + # ─────────────────────── PROMOTE (só em tag vX.Y.Z) ─────────────────────── + promote: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: self-hosted + steps: + - name: Checkout (na tag) + uses: actions/checkout@v4 + + - name: Vars (tag + commit) + id: vars + run: | + echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Login no registry + run: | + if [ -n "${{ secrets.REGISTRY_TOKEN }}" ]; then + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.clube67.com -u ruicesar --password-stdin + fi + + - name: Integridade — a imagem do commit existe? (nunca buildar na tag) + run: | + set -e + REG=git.clube67.com/ruicesar + C="${{ steps.vars.outputs.commit }}" + if ! docker buildx imagetools inspect "${REG}/rx-scoreodonto:$C" >/dev/null 2>&1; then + echo "ERRO: ${REG}/rx-scoreodonto:$C não existe no registry." + echo " Faça 'git push origin main' (que builda o commit $C) ANTES de criar a tag." + exit 1 + fi + echo "OK: rx-scoreodonto:$C presente no registry." + + - name: Promover — re-tag commit → versão (SEM rebuild) + conferir digest + run: | + set -e + REG=git.clube67.com/ruicesar + C="${{ steps.vars.outputs.commit }}"; V="${{ steps.vars.outputs.version }}" + DST="${REG}/rx-scoreodonto" + docker buildx imagetools create -t "$DST:$V" "$DST:$C" + dC=$(docker buildx imagetools inspect "$DST:$C" --format '{{.Manifest.Digest}}') + dV=$(docker buildx imagetools inspect "$DST:$V" --format '{{.Manifest.Digest}}') + [ "$dC" = "$dV" ] || { echo "ERRO: digest divergiu ($V=$dV != $C=$dC)"; exit 1; } + echo "Promovido sem rebuild: :$V e :$C apontam para o mesmo artefato (digest $dV)." + + - name: Deploy na VPS1 (pull :versao + up --no-build; versao/commit injetados em runtime) + run: | + set -e + V="${{ steps.vars.outputs.version }}"; C="${{ steps.vars.outputs.commit }}" + scp -o StrictHostKeyChecking=accept-new \ + docker-compose.yml docker-compose.prod.yml deploy-prod.sh \ + deploy@10.99.0.1:/home/deploy/stack/rx.scoreodonto.com/ + ssh -o StrictHostKeyChecking=accept-new deploy@10.99.0.1 \ + "/home/deploy/stack/rx.scoreodonto.com/deploy-prod.sh $V $C" diff --git a/dental-server/dental-client/public/version.txt b/dental-server/dental-client/public/version.txt index 8b0d8fd..fc866d1 100644 --- a/dental-server/dental-client/public/version.txt +++ b/dental-server/dental-client/public/version.txt @@ -1 +1 @@ -2.1.76 +2.1.77 diff --git a/dental-server/dental-client/src/App.jsx b/dental-server/dental-client/src/App.jsx index 43c8e9d..0fa63c5 100644 --- a/dental-server/dental-client/src/App.jsx +++ b/dental-server/dental-client/src/App.jsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; import { SocketProvider } from './contexts/SocketContext'; @@ -23,8 +24,24 @@ import UsersPage from './pages/UsersPage'; import SyncPage from './pages/SyncPage'; import ApiDocsPage from './pages/ApiDocsPage'; +// Versão fixa no canto inferior direito (padrão da stack — lê /api/health; não bloqueia cliques). +function VersionBadge() { + const [ver, setVer] = useState(''); + useEffect(() => { + fetch('/api/health').then(r => (r.ok ? r.json() : null)) + .then(v => { if (v && v.version) setVer(v.version); }).catch(() => {}); + }, []); + if (!ver) return null; + return ( +
+ v{ver} +
+ ); +} + export default function App() { return ( + <> @@ -145,5 +162,7 @@ export default function App() { + + ); } diff --git a/dental-server/dental-client/vite.config.js b/dental-server/dental-client/vite.config.js index 5535fc7..ee1082f 100644 --- a/dental-server/dental-client/vite.config.js +++ b/dental-server/dental-client/vite.config.js @@ -4,9 +4,13 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - // Em dev, faz proxy da API para o backend Express + // Em dev, o vite serve o frontend e faz proxy da API para o backend (container rx_dev_api). server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, cors: true, + allowedHosts: ['dev.rx.scoreodonto.com'], hmr: { host: 'dev.rx.scoreodonto.com', clientPort: 443, @@ -15,7 +19,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: 'http://127.0.0.1:3000', + target: 'http://rx_dev_api:3000', configure: (proxy) => { proxy.on('proxyRes', (proxyRes) => { proxyRes.headers['access-control-allow-origin'] = '*'; @@ -23,15 +27,14 @@ export default defineConfig({ } }, '/socket.io': { - target: 'http://127.0.0.1:3000', + target: 'http://rx_dev_api:3000', ws: true }, - '/uploads': 'http://127.0.0.1:3000', - '/updates': 'http://127.0.0.1:3000' + '/uploads': 'http://rx_dev_api:3000', + '/updates': 'http://rx_dev_api:3000' } }, build: { - // O output vai para o diretório que o Express vai servir outDir: 'dist', emptyOutDir: true } diff --git a/dental-server/server.js b/dental-server/server.js index c4076df..bdecedc 100644 --- a/dental-server/server.js +++ b/dental-server/server.js @@ -122,6 +122,40 @@ app.get('/api/redis/ping', async (req, res) => { } }); +// Health check padronizado (status do backend + banco + cache). Padrão da stack: +// ver git.clube67.com/ruicesar/Documentos-Unicos → deploy/versionamento.md +app.get('/api/health', async (req, res) => { + const checks = {}; + try { + const t = Date.now(); + await db.get('SELECT 1'); + checks.database = { status: 'ok', latency_ms: Date.now() - t }; + } catch { + checks.database = { status: 'down' }; + } + try { + const client = redis.getClient(); + if (client) { const t = Date.now(); await client.ping(); checks.cache = { status: 'ok', latency_ms: Date.now() - t }; } + else checks.cache = { status: 'disabled' }; + } catch { + checks.cache = { status: 'down' }; + } + // Em PROD a versão vem da TAG (APP_VERSION, injetada em runtime pelo deploy); + // em DEV cai para version.txt (arquivo da imagem). Padrão "build once, promote". + let version = (process.env.APP_VERSION && process.env.APP_VERSION !== 'dev') ? process.env.APP_VERSION : 'dev'; + if (version === 'dev') { try { version = (await fs.readFile(path.join(__dirname, 'version.txt'), 'utf8')).trim(); } catch {} } + const dbOk = checks.database.status === 'ok'; + const status = !dbOk ? 'down' : (checks.cache.status === 'down' ? 'degraded' : 'ok'); + res.status(dbOk ? 200 : 503).json({ + status, + version, + commit: process.env.GIT_COMMIT || 'dev', + environment: (process.env.APP_ENV || 'DEV').toUpperCase(), + uptime_s: Math.round(process.uptime()), + checks, + }); +}); + // Verificar se o nome do computador/cliente já existe app.get('/api/clients/check-name', async (req, res) => { try { diff --git a/dental-server/version.txt b/dental-server/version.txt index 8b0d8fd..fc866d1 100644 --- a/dental-server/version.txt +++ b/dental-server/version.txt @@ -1 +1 @@ -2.1.76 +2.1.77 diff --git a/deploy-prod-legado-webhook.sh b/deploy-prod-legado-webhook.sh new file mode 100755 index 0000000..e1a1e34 --- /dev/null +++ b/deploy-prod-legado-webhook.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# ─── deploy-prod.sh (rx.scoreodonto.com) ────────────────────────────────────── +# GitOps: VPS4 (Builder) → VPS1 (Produção) +# Modelo A: build na VPS4 (inclui build React + Docker) → imagem pronta → VPS1 +# ────────────────────────────────────────────────────────────────────────────── +set -e + +PROD_VPS="10.99.0.1" +PROD_USER="deploy" +PROD_PATH="/home/deploy/stack/rx.scoreodonto.com" +LOG_FILE="/home/deploy/stack/webhook-listener/deploy.log" +LOG_TAG="[rx.scoreodonto.com]" +DOCKER_HOST_REMOTE="unix:///run/user/1000/docker.sock" + +LOCK_FILE="/tmp/deploy-rx-scoreodonto.lock" +if [ -f "$LOCK_FILE" ]; then + echo "🚨 Deploy já em andamento!" | tee -a "$LOG_FILE" + exit 1 +fi +touch "$LOCK_FILE" +trap 'rm -f "$LOCK_FILE"' EXIT + +log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ${LOG_TAG} $1" | tee -a "$LOG_FILE"; } + +log "🚀 Iniciando deploy..." + +# 1. GitOps: fonte de verdade é o Gitea (VPS3) +cd "$PROD_PATH" +log "📦 git fetch + reset --hard origin/main" +git fetch origin main +git reset --hard origin/main + +# 2. Bump de versão (sincroniza version.txt) +log "🔢 Incrementando versão..." +node update-version.js + +# 3. Build do frontend React (Vite) — gera dental-server/dental-client/dist/ +# DEVE ocorrer ANTES do docker compose build para que o dist/ seja copiado na imagem +log "⚛️ Build do React (Vite)..." +cd "$PROD_PATH/dental-server/dental-client" +cp "$PROD_PATH/dental-server/version.txt" public/version.txt +# --include=dev: o listener roda com NODE_ENV=production, que por padrão pula +# devDependencies — mas o Vite é devDependency e é necessário para o build. +NODE_ENV=development npm install --include=dev --prefer-offline +npm run build +cd "$PROD_PATH" +log "✅ Build React concluído — dist/ pronto" + +# 4. Build da imagem Docker na VPS4 (inclui dental-client/dist/) +log "🐳 docker compose build na VPS4..." +docker compose -f "$PROD_PATH/docker-compose.yml" build app + +# 5. Exportar imagem para arquivo +log "💾 Exportando imagem para arquivo..." +IMAGE_NAME="rxscoreodontocom-app:latest" +IMAGE_TAR="/tmp/rxscoreodontocom-app.tar.gz" +docker save "$IMAGE_NAME" | gzip > "$IMAGE_TAR" + +# 6. Enviar docker-compose.yml + imagem + versão para VPS1 +log "🚚 rsync docker-compose + imagem + versão → VPS1..." +rsync -avz \ + "$PROD_PATH/docker-compose.yml" \ + "$PROD_PATH/dental-server/version.txt" \ + "$IMAGE_TAR" \ + "${PROD_USER}@${PROD_VPS}:${PROD_PATH}/" + +# 7. Carregar imagem e subir container na VPS1 +log "🚀 Load imagem + docker compose up na VPS1..." +ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${PROD_USER}@${PROD_VPS}" " + set -e + cd ${PROD_PATH} + export DOCKER_HOST=${DOCKER_HOST_REMOTE} + docker load < rxscoreodontocom-app.tar.gz + docker compose up -d --no-deps app + rm -f rxscoreodontocom-app.tar.gz +" + +# 8. Commit do bump de versão de volta ao Gitea (sem loop — listener ignora) +# package.json NÃO é incluído: preserva cache do npm install no Docker +log "💾 git commit + push do bump de versão..." +git add dental-server/version.txt +git commit -m "chore(deploy): bump version" || true +git push origin main || true + +log "✅ Deploy concluído sem downtime!" diff --git a/deploy-prod.sh b/deploy-prod.sh index e1a1e34..3f08903 100755 --- a/deploy-prod.sh +++ b/deploy-prod.sh @@ -1,85 +1,49 @@ -#!/bin/bash -# ─── deploy-prod.sh (rx.scoreodonto.com) ────────────────────────────────────── -# GitOps: VPS4 (Builder) → VPS1 (Produção) -# Modelo A: build na VPS4 (inclui build React + Docker) → imagem pronta → VPS1 -# ────────────────────────────────────────────────────────────────────────────── -set -e +#!/usr/bin/env bash +# ============================================================================= +# Deploy por TAG na PRODUÇÃO (VPS1) — rx.scoreodonto.com (modelo build-once/promote) +# Puxa a imagem versionada do registry do Gitea e sobe, SEM build. +# +# Uso (NA VPS1): ./deploy-prod.sh v2.2.0 +# Rollback: ./deploy-prod.sh +# +# Pré-requisitos (uma vez): docker login git.clube67.com (token read:package). +# Substitui o modelo antigo (webhook 9003 → build na VPS4 → save/rsync/load). +# ============================================================================= +set -euo pipefail -PROD_VPS="10.99.0.1" -PROD_USER="deploy" -PROD_PATH="/home/deploy/stack/rx.scoreodonto.com" -LOG_FILE="/home/deploy/stack/webhook-listener/deploy.log" -LOG_TAG="[rx.scoreodonto.com]" -DOCKER_HOST_REMOTE="unix:///run/user/1000/docker.sock" - -LOCK_FILE="/tmp/deploy-rx-scoreodonto.lock" -if [ -f "$LOCK_FILE" ]; then - echo "🚨 Deploy já em andamento!" | tee -a "$LOG_FILE" +TAG="${1:-}" +COMMIT="${2:-dev}" +if [ -z "$TAG" ]; then + echo "uso: ./deploy-prod.sh [commit] (ex.: ./deploy-prod.sh v2.2.0 ab12cd3)" + echo "tags disponíveis: git.clube67.com → Packages → rx-scoreodonto" exit 1 fi -touch "$LOCK_FILE" -trap 'rm -f "$LOCK_FILE"' EXIT -log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ${LOG_TAG} $1" | tee -a "$LOG_FILE"; } +export DOCKER_HOST=unix:///run/user/1000/docker.sock +export RX_TAG="$TAG" +export RX_COMMIT="$COMMIT" +cd "$(dirname "$0")" +FILES="-f docker-compose.yml -f docker-compose.prod.yml" -log "🚀 Iniciando deploy..." +echo "──────────────────────────────────────────────────────────────" +echo " Deploy rx.scoreodonto.com → PRODUÇÃO tag: $TAG commit: $COMMIT" +echo " Versão exibida (/api/health → badge) injetada em runtime = $TAG" +echo " Volumes preservados: uploads/ processed/ data/ dental-server/updates/ (auto-update dos clients)" +echo "──────────────────────────────────────────────────────────────" -# 1. GitOps: fonte de verdade é o Gitea (VPS3) -cd "$PROD_PATH" -log "📦 git fetch + reset --hard origin/main" -git fetch origin main -git reset --hard origin/main +echo "[1/3] pull da imagem $TAG ..." +docker compose $FILES pull app -# 2. Bump de versão (sincroniza version.txt) -log "🔢 Incrementando versão..." -node update-version.js +echo "[2/3] subindo (sem build) ..." +docker compose $FILES up -d --no-build app -# 3. Build do frontend React (Vite) — gera dental-server/dental-client/dist/ -# DEVE ocorrer ANTES do docker compose build para que o dist/ seja copiado na imagem -log "⚛️ Build do React (Vite)..." -cd "$PROD_PATH/dental-server/dental-client" -cp "$PROD_PATH/dental-server/version.txt" public/version.txt -# --include=dev: o listener roda com NODE_ENV=production, que por padrão pula -# devDependencies — mas o Vite é devDependency e é necessário para o build. -NODE_ENV=development npm install --include=dev --prefer-offline -npm run build -cd "$PROD_PATH" -log "✅ Build React concluído — dist/ pronto" +echo "[3/3] verificação (aguardando boot) ..." +sleep 12 +echo -n " health no ar: " +curl -s --max-time 10 https://rx.scoreodonto.com/api/health || echo "(ainda subindo — confira em alguns segundos)" +echo +docker logs rx_scoreodonto_app --tail 5 2>&1 | grep -iE 'VERSION|listen|porta|ready' || true -# 4. Build da imagem Docker na VPS4 (inclui dental-client/dist/) -log "🐳 docker compose build na VPS4..." -docker compose -f "$PROD_PATH/docker-compose.yml" build app - -# 5. Exportar imagem para arquivo -log "💾 Exportando imagem para arquivo..." -IMAGE_NAME="rxscoreodontocom-app:latest" -IMAGE_TAR="/tmp/rxscoreodontocom-app.tar.gz" -docker save "$IMAGE_NAME" | gzip > "$IMAGE_TAR" - -# 6. Enviar docker-compose.yml + imagem + versão para VPS1 -log "🚚 rsync docker-compose + imagem + versão → VPS1..." -rsync -avz \ - "$PROD_PATH/docker-compose.yml" \ - "$PROD_PATH/dental-server/version.txt" \ - "$IMAGE_TAR" \ - "${PROD_USER}@${PROD_VPS}:${PROD_PATH}/" - -# 7. Carregar imagem e subir container na VPS1 -log "🚀 Load imagem + docker compose up na VPS1..." -ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${PROD_USER}@${PROD_VPS}" " - set -e - cd ${PROD_PATH} - export DOCKER_HOST=${DOCKER_HOST_REMOTE} - docker load < rxscoreodontocom-app.tar.gz - docker compose up -d --no-deps app - rm -f rxscoreodontocom-app.tar.gz -" - -# 8. Commit do bump de versão de volta ao Gitea (sem loop — listener ignora) -# package.json NÃO é incluído: preserva cache do npm install no Docker -log "💾 git commit + push do bump de versão..." -git add dental-server/version.txt -git commit -m "chore(deploy): bump version" || true -git push origin main || true - -log "✅ Deploy concluído sem downtime!" +echo +echo "✅ Deploy concluído (tag $TAG)." +echo " Rollback: ./deploy-prod.sh " diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 7a086a5..652c6e2 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,10 +1,11 @@ version: '3.8' +# DEV do rx (VPS4). Antes usava network_mode:host, que no Docker rootless não expõe na +# rede real → Traefik (VPS1) não alcançava o vite → 502. Agora: bridge + porta publicada. services: dev-api: image: node:20 container_name: rx_dev_api - network_mode: "host" working_dir: /app volumes: - ./dental-server:/app @@ -13,16 +14,25 @@ services: sh -c "npm install && npm run dev" env_file: - ./dental-server/.env + networks: + - rxdev restart: unless-stopped dev-client: image: node:20 container_name: rx_dev_client - network_mode: "host" working_dir: /app volumes: - ./dental-server/dental-client:/app - /app/node_modules command: > sh -c "npm install && npx vite --host 0.0.0.0 --port 5173" + ports: + - "5173:5173" # publicado na VPS4 → alvo do Traefik (dev.rx → 10.99.0.4:5173) + networks: + - rxdev # mesma rede do api → o proxy do vite alcança rx_dev_api:3000 restart: unless-stopped + +networks: + rxdev: + driver: bridge diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6e5bc2e --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,19 @@ +# Override de PRODUÇÃO (VPS1): roda a IMAGEM VERSIONADA do registry em vez de buildar. +# A produção não builda — só consome a imagem pronta publicada pela CI (build once). +# +# Uso (na VPS1), via deploy-prod.sh , que exporta RX_TAG/RX_COMMIT e faz: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml pull app +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build app +# O --no-build garante que o build: do compose base seja ignorado (usa a imagem puxada). +# +# VERSÃO EM RUNTIME: a versão semântica exibida (badge → /api/health) vem da TAG promovida, +# injetada via `environment` abaixo — NÃO do build. Assim a MESMA imagem pode ser promovida +# sob qualquer tag sem rebuild (pré-requisito do "build once, promote the same artifact"). +# O `commit` também é injetado em runtime (vem do CI que buildou a imagem do commit). +services: + app: + image: git.clube67.com/ruicesar/rx-scoreodonto:${RX_TAG:-latest} + environment: + - APP_VERSION=${RX_TAG:-dev} + - APP_ENV=PROD + - GIT_COMMIT=${RX_COMMIT:-dev}