diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7fe2ac2 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: deploy-branch + +# CI/CD: cada push a cualquier rama se despliega en .localesp.es +# Estrategia A — un entorno por rama. El registro DNS A es manual (precondición). +# Detalles: docs/07-cicd.md + +on: + push: + branches: + - "**" + delete: + workflow_dispatch: + inputs: + branch: + description: "Rama a desplegar (vacío = la actual)" + required: false + default: "" + +jobs: + deploy: + if: github.event_name != 'delete' + runs-on: self-hosted + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build (npm ci + vite build) + run: | + npm ci + npm run build + + - name: Deploy branch + env: + BRANCH: ${{ github.event.inputs.branch || github.ref_name }} + run: bash scripts/deploy.sh "$BRANCH" + + # Al borrar una rama, se limpia su entorno + teardown: + if: github.event_name == 'delete' + runs-on: self-hosted + timeout-minutes: 5 + steps: + - name: Checkout (default branch, trae scripts/) + uses: actions/checkout@v4 + - name: Teardown branch env + env: + # github.event.ref en un 'delete' es refs/heads/ + BRANCH: ${{ github.event.ref }} + run: bash scripts/teardown.sh "${BRANCH#refs/heads/}" diff --git a/.gitignore b/.gitignore index dc9ad98..b12846b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ dist .idea .env .env.local + +# artefactos de despliegue manual / datos locales +*.tgz +localesp.db diff --git a/README.md b/README.md index 5208a91..bd9718f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver - [`docs/04-governance.md`](docs/04-governance.md) – cómo se gobierna el proyecto. - [`docs/05-contributing.md`](docs/05-contributing.md) – cómo contribuir. - [`docs/06-github-setup.md`](docs/06-github-setup.md) – configuración inicial del repo. +- [`docs/07-cicd.md`](docs/07-cicd.md) – CI/CD: despliegue por rama en `.localesp.es`. ## Licencia diff --git a/docs/07-cicd.md b/docs/07-cicd.md new file mode 100644 index 0000000..d627f74 --- /dev/null +++ b/docs/07-cicd.md @@ -0,0 +1,108 @@ +# CI/CD — despliegue por rama + +Cada rama se despliega automáticamente en `https://.localesp.es`. + +## Cómo funciona + +``` +push a rama X + │ Gitea Actions (workflow: .gitea/workflows/deploy.yml) + ▼ +runner self-hosted (host executor, en el propio VPS) + │ npm ci → vite build → scripts/deploy.sh + ▼ +/opt/localesp-/ código + dist/ + node_modules (prod) +systemd localesp-.service node server/index.js (PORT=80xx) +nginx .localesp.es proxy_pass → localhost:80xx +certbot *.localesp.es Let's Encrypt (si el DNS ya apunta) +``` + +El **slug** se deriva del nombre de rama (`Feature/Foo` → `feature-foo`), +válido como hostname DNS. Un entorno = un directorio, un servicio, un vhost y +un puerto. La base de datos `localesp.db` de cada entorno **se conserva** entre +despliegues (no se sobreescribe). + +Puertos: `8080`/`8081` son entornos legacy (`localesp.service`, `localesp-master.service`); +los nuevos empiezan en `8082`. Si la rama ya tiene su `localesp-.service`, se +reutiliza su puerto (adopta el entorno existente). + +## Precondición manual: el registro DNS + +El registro A de `.localesp.es` **se crea a mano** (no está automatizado). + +1. En el proveedor DNS de `localesp.es`, añade un registro A: + `.localesp.es A ` +2. Espera a que propague (`dig +short .localesp.es`). +3. Lanza el workflow (push a la rama, o *Actions → Run workflow* en Gitea). + +Mientras el DNS no apunte al VPS, `deploy.sh` despliega la app igualmente y la +sirve por **HTTP**; emite el cert Let's Encrypt en cuanto detecta que el DNS ya +resuelve. No hay que tocar nada más. + +## Añadir una rama nueva + +```bash +git checkout -b mi-feature +# ... cambios ... +git push -u origin mi-feature +``` + +El workflow corre solo. Si quieres URL pública: crea el registro A (arriba). +Si no, el entorno existe pero no es alcanzable por hostname (útil para tests internos +si añades el host a `/etc/hosts`). + +## Borrar una rama + +Al borrar la rama en Gitea, el job `teardown` limpia el entorno (unit systemd, +vhost, cert y `/opt/localesp-`). También manual: + +```bash +ssh localesp.jumpingcrab.com 'bash /opt/localesp-master/scripts/teardown.sh mi-feature' +``` + +## Notas sobre los entornos existentes (migración) + +Al primer despliegue vía CI de cada rama legacy: + +- **`master`** → `/opt/localesp-master`, reutiliza el puerto **8081** y el vhost + existente (`master.localesp.es` y el apex `localesp.es`, que proxyan a 8081). + Transparente, no hay corte. +- **`prototipo-yb01`** → pasa a un entorno dedicado `/opt/localesp-prototipo-yb01` + en un puerto nuevo (8082) y el vhost `prototipo.conf` se repunta a ese puerto. + El antiguo `/opt/localesp` + `localesp.service` (8080) queda huérfano; puedes + retirarlo con `systemctl disable --now localesp.service` cuando confirmes que + el nuevo funciona. + +## Seguridad + +El runner (`gitea-runner`) se ejecuta como **root en el VPS de producción** y en +modo **host** (sin contenedor): cualquier workflow que se ejecute corresponde a +código del repo corriendo con privilegios de root en la caja. Esto es aceptable +mientras el repo sea propio / de baja colaboración. Si entran colaboradores +externos, conviene migrar a: + +- runner en contenedor + despliegue por SSH (clave como secret de Gitea), o +- *ephemeral runners* (`gitea-runner register --ephemeral`). + +## Evolución: estrategia B (wildcard) + +Para eliminar la precondición manual del DNS y el cert por rama, más adelante: + +1. Registro **wildcard `*.localesp.es`** (DNS). +2. **Wildcard cert** `*.localesp.es` vía challenge DNS-01 (una sola vez). +3. Un único vhost `*.localesp.es` que enruta por `Host` con un `map` rama→puerto. + +`deploy.sh` apenas cambia: se caen los pasos de creación de vhost y certbot. +Nada de lo hecho aquí se pierde. + +## Infra del runner (referencia) + +Instalado en el VPS (`localesp.jumpingcrab.com`): + +- Binario: `/usr/local/bin/gitea-runner` (v2.2.0), host executor. +- Config: `/etc/gitea-runner/config.yaml` — etiquetas `self-hosted:host`, `linux:host`. +- Registro: `/var/lib/gitea-runner/.runner` (instance-level, `https://git.localesp.es`). +- Servicio: `gitea-runner.service` (systemd, `User=root`, `WorkingDirectory=/var/lib/gitea-runner`). + +Comprobar estado: `systemctl status gitea-runner` · `journalctl -u gitea-runner -f`. +Ver runners en Gitea: *Site administration → Actions → Runners*. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..e4c71eb --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# deploy.sh — Despliega la rama actual a .localesp.es +# +# Estrategia A (entorno por rama): +# /opt/localesp- + systemd unit localesp- + vhost nginx + cert Let's Encrypt. +# Reutiliza el puerto de la unit existente (adopta entornos legacy) o asigna uno libre (>=8082). +# +# PRECONDICIÓN MANUAL: el registro A .localesp.es debe existir (se gestiona a mano). +# Si no resuelve aún, la app queda servida por HTTP y se emite el cert cuando el DNS apunte aquí. +# +# Ejecutar como root (host runner). Idempotente. +set -euo pipefail + +BRANCH="${1:-${GITHUB_REF_NAME:-}}" +[ -n "$BRANCH" ] || { echo "usage: $0 " >&2; exit 2; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DOMAIN="localesp.es" +PORT_BASE=8082 # 8080/8081 son entornos legacy + +log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[deploy:ERROR]\033[0m %s\n' "$*" >&2; } + +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' +} +SLUG="$(slugify "$BRANCH")" +[ -n "$SLUG" ] || { err "slug vacío para la rama '$BRANCH'"; exit 2; } + +DEST="/opt/localesp-$SLUG" +UNIT="localesp-$SLUG" +HOST="$SLUG.$DOMAIN" + +# --- asignación de puerto --- +allocate_port() { + local u="/etc/systemd/system/$UNIT.service" + if [ -f "$u" ]; then # adopta entorno existente + local p; p=$(grep -oE 'PORT=[0-9]+' "$u" | head -1 | cut -d= -f2 || true) + [ -n "$p" ] && { echo "$p"; return; } + fi + if [ -f "$DEST/.port" ]; then cat "$DEST/.port"; return; fi + local used; used=$( { grep -rhoE 'PORT=[0-9]+' /etc/systemd/system/localesp*.service 2>/dev/null | cut -d= -f2; + grep -rhoE 'localhost:[0-9]+' /etc/nginx/conf.d/*.conf 2>/dev/null | cut -d: -f2; } | sort -un) + local p=$PORT_BASE + while printf '%s\n' "$used" | grep -qx "$p"; do p=$((p+1)); done + echo "$p" +} +PORT="$(allocate_port)" +log "rama=$BRANCH slug=$SLUG host=$HOST dest=$DEST puerto=$PORT" + +# --- 1. build artifacts --- +[ -d "$ROOT/dist" ] || { err "falta dist/ — ¿se ejecutó el build?"; exit 1; } +log "sincronizando artefactos -> $DEST" +mkdir -p "$DEST/dist" "$DEST/server" +rsync -a --delete "$ROOT/dist/" "$DEST/dist/" +rsync -a --delete "$ROOT/server/" "$DEST/server/" +cp -a "$ROOT/package.json" "$ROOT/package-lock.json" "$DEST/" +# localesp.db se PRESERVA (datos de usuarios). No se toca aquí. + +# --- 2. dependencias de producción (express + better-sqlite3) --- +log "instalando dependencias (prod)" +( cd "$DEST" && npm ci --omit=dev --no-audit --no-fund ) + +# --- 3. systemd unit --- +log "escribiendo unit $UNIT (PORT=$PORT)" +cat > "/etc/systemd/system/$UNIT.service" < "$DEST/.port" +systemctl daemon-reload +systemctl enable "$UNIT" >/dev/null 2>&1 || true +systemctl restart "$UNIT" +log "$UNIT arrancada" + +# --- 4. vhost nginx (busca por server_name; crea o reajusta proxy_pass) --- +log "configurando nginx para $HOST" +HOST_RE="$(printf '%s' "$HOST" | sed 's/\./\\./g')" +VHOST="$(grep -rlE "server_name[[:space:]]+$HOST_RE[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true)" +if [ -z "$VHOST" ]; then + VHOST="/etc/nginx/conf.d/$SLUG.conf" + log "creando vhost HTTP $VHOST" + cat > "$VHOST" < :$PORT" + sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:$PORT;|g" "$VHOST" +fi +nginx -t 2>&1 | tail -2 +systemctl reload nginx +log "nginx recargado" + +# --- 5. TLS (sólo si no hay cert y el DNS ya apunta aquí) --- +if [ -d "/etc/letsencrypt/live/$HOST" ]; then + log "cert TLS ya presente para $HOST" +else + log "comprobando DNS de $HOST" + PUBIP="$(curl -s4 --max-time 5 ifconfig.me || true)" + RESOLVED="$(getent hosts "$HOST" | awk '{print $1}' | head -1 || true)" + if [ -n "$PUBIP" ] && [ "$RESOLVED" = "$PUBIP" ]; then + log "emitiendo cert con certbot --nginx" + if certbot --nginx -d "$HOST" -n --redirect --keep-until-expiring; then + log "cert emitido ✓" + else + err "certbot falló; $HOST sigue en HTTP. Revisa y vuelve a lanzar el workflow." + fi + else + err "DNS de $HOST -> '${RESOLVED:-}', esperado $PUBIP." + err "Crea el registro A $HOST -> $PUBIP y, tras propagar, re-lanza el workflow" + err "(o ejecuta: certbot --nginx -d $HOST). La app ya vive en http://$HOST" + fi +fi + +PROTO=https; [ ! -d "/etc/letsencrypt/live/$HOST" ] && PROTO=http +log "LISTO: $PROTO://$HOST (rama=$BRANCH unit=$UNIT puerto=$PORT)" diff --git a/scripts/teardown.sh b/scripts/teardown.sh new file mode 100755 index 0000000..8fe37ef --- /dev/null +++ b/scripts/teardown.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano). +# usage: teardown.sh +set -euo pipefail + +BRANCH="${1:?usage: $0 }" +DOMAIN="localesp.es" + +log() { printf '\033[1;34m[teardown]\033[0m %s\n' "$*"; } + +slugify() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'; } +SLUG="$(slugify "$BRANCH")" +UNIT="localesp-$SLUG" +HOST="$SLUG.$DOMAIN" +DEST="/opt/localesp-$SLUG" + +log "$HOST ($DEST, $UNIT)" + +systemctl disable --now "$UNIT" 2>/dev/null || true +rm -f "/etc/systemd/system/$UNIT.service" +systemctl daemon-reload + +if [ -d "/etc/letsencrypt/live/$HOST" ]; then + log "borrando cert $HOST" + certbot delete --cert-name "$HOST" -n 2>/dev/null || true +fi + +HOST_RE="$(printf '%s' "$HOST" | sed 's/\./\\./g')" +VHOST="$(grep -rlE "server_name[[:space:]]+$HOST_RE[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true)" +if [ -n "$VHOST" ]; then + log "borrando vhost $VHOST" + rm -f "$VHOST" + nginx -t && systemctl reload nginx +fi + +rm -rf "$DEST" +log "entorno eliminado ✓"