refactor(scripts): lib de despliegue OOP (Env + colaboradores)
deploy-branch / deploy (push) Successful in 2m14s
deploy-branch / teardown (push) Skipped

- scripts/lib/env.sh: modelo OOP. Env (aggregate root) compone las entidades
  SystemdUnit, NginxVhost y TlsCert, más servicios estáticos (Slug, Host,
  Port, Artifacts, Deps, Logger). Instancias = arrays asociativos globales
  accedidos vía nameref (local -n); la composición vive en Env::new.
- deploy.sh / teardown.sh: pasan a ser entrypoints finos (~33 líneas) que
  sourcean lib/env.sh. Comparten una única fuente de verdad para "qué es un
  entorno", eliminando la duplicación de slugify y el regex de vhost.
- Comportamiento idéntico al procedural: heredocs de systemd/nginx son
  byte-idénticos; orquestación de deploy y teardown verificados end-to-end.

Requiere bash >= 4.3 (namerefs), ya cubierto en el host runner.
This commit is contained in:
2026-08-01 18:08:01 +02:00
parent 6323fe8f0e
commit 6cc326e8ac
3 changed files with 494 additions and 168 deletions
+22 -142
View File
@@ -1,153 +1,33 @@
#!/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.
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
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
# shellcheck source=lib/env.sh
. "$SCRIPT_DIR/lib/env.sh"
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/-+$//'
usage() {
echo "usage: $0 <branch>" >&2
exit 2
}
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"
main() {
local branch root
# --- 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 ---
# Reutiliza los node_modules ya compilados por el workflow (better-sqlite3 v12 exige
# C++20, GCC 12). Copiar + npm prune evita recompilar en cada despliegue.
# Fallback a npm ci --omit=dev si se ejecuta a mano sin build previo.
log "instalando dependencias (prod)"
if [ -d "$ROOT/node_modules" ]; then
mkdir -p "$DEST/node_modules"
rsync -a --delete "$ROOT/node_modules/" "$DEST/node_modules/"
( cd "$DEST" && npm prune --omit=dev --no-audit --no-fund )
else
( cd "$DEST" && npm ci --omit=dev --no-audit --no-fund )
fi
# --- 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."
branch="${1:-${GITHUB_REF_NAME:-}}"
if [ -z "$branch" ]; then
usage
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
root="$(cd "$SCRIPT_DIR/.." && pwd)"
PROTO=https; [ ! -d "/etc/letsencrypt/live/$HOST" ] && PROTO=http
log "LISTO: $PROTO://$HOST (rama=$BRANCH unit=$UNIT puerto=$PORT)"
Logger::set_tag deploy
Env::new env "$branch" "$root"
Logger::info "$(Env::banner env)"
Env::deploy env
Logger::info "LISTO: $(Env::url env) ($(Env::summary env))"
}
main "$@"