Master #2

Closed
Yosko597 wants to merge 7 commits from master into prototipo-yb01
6 changed files with 344 additions and 0 deletions
Showing only changes of commit 27166eb3f4 - Show all commits
+50
View File
@@ -0,0 +1,50 @@
name: deploy-branch
# CI/CD: cada push a cualquier rama se despliega en <rama>.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/<rama>
BRANCH: ${{ github.event.ref }}
run: bash scripts/teardown.sh "${BRANCH#refs/heads/}"
+4
View File
@@ -6,3 +6,7 @@ dist
.idea
.env
.env.local
# artefactos de despliegue manual / datos locales
*.tgz
localesp.db
+1
View File
@@ -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 `<rama>.localesp.es`.
## Licencia
+108
View File
@@ -0,0 +1,108 @@
# CI/CD — despliegue por rama
Cada rama se despliega automáticamente en `https://<rama>.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 <rama>
/opt/localesp-<slug>/ código + dist/ + node_modules (prod)
systemd localesp-<slug>.service node server/index.js (PORT=80xx)
nginx <slug>.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-<slug>.service`, se
reutiliza su puerto (adopta el entorno existente).
## Precondición manual: el registro DNS
El registro A de `<rama>.localesp.es` **se crea a mano** (no está automatizado).
1. En el proveedor DNS de `localesp.es`, añade un registro A:
`<rama>.localesp.es A <IP-pública-del-VPS>`
2. Espera a que propague (`dig +short <rama>.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-<slug>`). 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*.
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# deploy.sh — Despliega la rama actual a <slug>.localesp.es
#
# Estrategia A (entorno por rama):
# /opt/localesp-<slug> + systemd unit localesp-<slug> + 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 <slug>.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 <branch>" >&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" <<EOF
[Unit]
Description=LocaleSP env rama '$BRANCH' ($HOST)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$DEST
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=5s
Environment=NODE_ENV=production
Environment=PORT=$PORT
[Install]
WantedBy=multi-user.target
EOF
echo "$PORT" > "$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" <<EOF
server {
listen 80;
listen [::]:80;
server_name $HOST;
location / {
proxy_pass http://localhost:$PORT;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
else
log "vhost existente: $VHOST — forzando proxy_pass -> :$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:-<sin resolver>}', 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)"
+37
View File
@@ -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 <branch>
set -euo pipefail
BRANCH="${1:?usage: $0 <branch>}"
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 ✓"