diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 179f470..66336f0 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,153 +1,33 @@ #!/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. +# 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 " >&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 " >&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" < "$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." + branch="${1:-${GITHUB_REF_NAME:-}}" + if [ -z "$branch" ]; then + usage 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 + 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 "$@" diff --git a/scripts/lib/env.sh b/scripts/lib/env.sh new file mode 100644 index 0000000..d0e26eb --- /dev/null +++ b/scripts/lib/env.sh @@ -0,0 +1,450 @@ +#!/usr/bin/env bash +# lib/env.sh — Modelo OOP de entornos de previsualización por rama. +# +# Estrategia (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í. +# +# Representación OOP en bash: +# - Instancia = array asociativo global, referenciado por NOMBRE. +# - Constructor = Class::new (declara el array y establece invariantes). +# - Método = Class::method ; accede al estado vía `local -n self="$1"`. +# - Composición = Env posee los nombres de sus colaboradores (__unit, __vhost, __cert). +# - Servicios sin estado (Slug, Host, Port, Artifacts, Deps, Logger) = métodos estáticos. +# +# Requiere bash >= 4.3 (namerefs). Sin efectos al sourcear: sólo define funciones/constantes. +# +# CONTRATO (importante): el NOMBRE de instancia pasado a Env::new (p.ej. "env") es el +# nombre de un array asociativo global. NUNCA declares en el caller una `local` con ese +# mismo nombre: bash usa ámbito dinámico y la local sombrearía al array global, rompiendo +# el nameref `local -n self=env`. Pasa el nombre como literal: Env::new env ... + +DOMAIN="localesp.es" +PORT_BASE=8082 # 8080/8081 son entornos legacy +LOGGER_TAG="env" + +# --------------------------------------------------------------------------- +# Logger — servicio singleton (tag configurable según entrypoint). +# --------------------------------------------------------------------------- +Logger::set_tag() { + LOGGER_TAG="$1" +} + +Logger::info() { + printf '\033[1;34m[%s]\033[0m %s\n' "$LOGGER_TAG" "$*" +} + +Logger::error() { + printf '\033[1;31m[%s:ERROR]\033[0m %s\n' "$LOGGER_TAG" "$*" >&2 +} + +# --------------------------------------------------------------------------- +# Slug — value object estático: normaliza un nombre de rama a slug. +# --------------------------------------------------------------------------- +Slug::from() { + local branch="$1" + printf '%s' "$branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' +} + +# --------------------------------------------------------------------------- +# Host — servicio estático: IP pública y resolución DNS. +# --------------------------------------------------------------------------- +Host::public_ip() { + curl -s4 --max-time 5 ifconfig.me || true +} + +Host::resolved_ip() { + local host="$1" + getent hosts "$host" | awk '{print $1}' | head -1 || true +} + +# --------------------------------------------------------------------------- +# Port — servicio estático: asigna/reutiliza puerto para una unit+dest. +# --------------------------------------------------------------------------- +Port::acquire() { + local unit="$1" + local dest="$2" + local unit_file="/etc/systemd/system/$unit.service" + local used + local port + + # Adopta entorno existente: reutiliza el puerto de la unit ya instalada. + if [ -f "$unit_file" ]; then + port="$(grep -oE 'PORT=[0-9]+' "$unit_file" | head -1 | cut -d= -f2 || true)" + if [ -n "$port" ]; then + echo "$port" + return + fi + fi + if [ -f "$dest/.port" ]; then + cat "$dest/.port" + return + fi + + 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)" + port="$PORT_BASE" + while printf '%s\n' "$used" | grep -qx "$port"; do + port=$((port+1)) + done + echo "$port" +} + +# --------------------------------------------------------------------------- +# Artifacts — servicio estático: artefactos buildados. +# --------------------------------------------------------------------------- +Artifacts::require_built() { + local root="$1" + + if [ ! -d "$root/dist" ]; then + Logger::error "falta dist/ — ¿se ejecutó el build?" + exit 1 + fi +} + +Artifacts::sync() { + local root="$1" + local dest="$2" + + 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í. +} + +# --------------------------------------------------------------------------- +# Deps — servicio estático: dependencias de producción. +# --------------------------------------------------------------------------- +Deps::install() { + local root="$1" + local dest="$2" + + # 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. + 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 +} + +# --------------------------------------------------------------------------- +# SystemdUnit — entidad: fichero .service de un entorno. +# --------------------------------------------------------------------------- +SystemdUnit::new() { + local self="$1" + local name="$2" + local dest="$3" + local branch="$4" + local host="$5" + local port="$6" + + declare -gA "$self" + local -n u="$self" + u[name]="$name" + u[dest]="$dest" + u[branch]="$branch" + u[host]="$host" + u[port]="$port" + u[file]="/etc/systemd/system/${name}.service" +} + +SystemdUnit::render() { + local self="$1" + local -n u="$self" + + cat < "${u[file]}" + echo "${u[port]}" > "${u[dest]}/.port" + systemctl daemon-reload + systemctl enable "${u[name]}" >/dev/null 2>&1 || true + systemctl restart "${u[name]}" + Logger::info "${u[name]} arrancada" +} + +SystemdUnit::remove() { + local self="$1" + local -n u="$self" + + systemctl disable --now "${u[name]}" 2>/dev/null || true + rm -f "${u[file]}" + systemctl daemon-reload +} + +# --------------------------------------------------------------------------- +# NginxVhost — entidad: vhost nginx de un entorno. +# --------------------------------------------------------------------------- +NginxVhost::new() { + local self="$1" + local slug="$2" + local host="$3" + local port="$4" + + declare -gA "$self" + local -n v="$self" + v[slug]="$slug" + v[host]="$host" + v[port]="$port" +} + +NginxVhost::find_path() { + local self="$1" + local -n v="$self" + local host_re + host_re="$(printf '%s' "${v[host]}" | sed 's/\./\\./g')" + grep -rlE "server_name[[:space:]]+$host_re[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true +} + +NginxVhost::render() { + local self="$1" + local -n v="$self" + + cat < "$path" +} + +NginxVhost::repoint() { + local self="$1" + local -n v="$self" + local path="$2" + + Logger::info "vhost existente: $path — forzando proxy_pass -> :${v[port]}" + sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:${v[port]};|g" "$path" +} + +NginxVhost::configure() { + local self="$1" + local existing + + existing="$(NginxVhost::find_path "$self")" + if [ -z "$existing" ]; then + NginxVhost::create "$self" + else + NginxVhost::repoint "$self" "$existing" + fi + nginx -t 2>&1 | tail -2 + systemctl reload nginx + Logger::info "nginx recargado" +} + +NginxVhost::remove() { + local self="$1" + local path + + path="$(NginxVhost::find_path "$self")" + if [ -n "$path" ]; then + Logger::info "borrando vhost $path" + rm -f "$path" + nginx -t && systemctl reload nginx + fi +} + +# --------------------------------------------------------------------------- +# TlsCert — entidad: certificado Let's Encrypt de un entorno. +# --------------------------------------------------------------------------- +TlsCert::new() { + local self="$1" + local host="$2" + + declare -gA "$self" + local -n c="$self" + c[host]="$host" +} + +TlsCert::exists() { + local self="$1" + local -n c="$self" + [ -d "/etc/letsencrypt/live/${c[host]}" ] +} + +TlsCert::ensure() { + local self="$1" + local -n c="$self" + local host="${c[host]}" + local pub_ip resolved + + if TlsCert::exists "$self"; then + Logger::info "cert TLS ya presente para $host" + return + fi + + Logger::info "comprobando DNS de $host" + pub_ip="$(Host::public_ip)" + resolved="$(Host::resolved_ip "$host")" + + if [ -n "$pub_ip" ] && [ "$resolved" = "$pub_ip" ]; then + Logger::info "emitiendo cert con certbot --nginx" + if certbot --nginx -d "$host" -n --redirect --keep-until-expiring; then + Logger::info "cert emitido ✓" + else + Logger::error "certbot falló; $host sigue en HTTP. Revisa y vuelve a lanzar el workflow." + fi + else + Logger::error "DNS de $host -> '${resolved:-}', esperado $pub_ip." + Logger::error "Crea el registro A $host -> $pub_ip y, tras propagar, re-lanza el workflow" + Logger::error "(o ejecuta: certbot --nginx -d $host). La app ya vive en http://$host" + fi +} + +TlsCert::delete() { + local self="$1" + local -n c="$self" + local host="${c[host]}" + + if TlsCert::exists "$self"; then + Logger::info "borrando cert $host" + certbot delete --cert-name "$host" -n 2>/dev/null || true + fi +} + +TlsCert::protocol() { + local self="$1" + + if TlsCert::exists "$self"; then + echo "https" + else + echo "http" + fi +} + +# --------------------------------------------------------------------------- +# Env — aggregate root: un entorno de rama. Compone SystemdUnit, NginxVhost, TlsCert. +# --------------------------------------------------------------------------- +Env::new() { + local self="$1" + local branch="$2" + local root="$3" + + declare -gA "$self" + local -n s="$self" + + s[branch]="$branch" + s[root]="$root" + s[slug]="$(Slug::from "$branch")" + if [ -z "${s[slug]}" ]; then + Logger::error "slug vacío para la rama '$branch'" + exit 2 + fi + s[host]="${s[slug]}.$DOMAIN" + s[dest]="/opt/localesp/${s[slug]}" + s[unit_name]="localesp-${s[slug]}" + s[port]="$(Port::acquire "${s[unit_name]}" "${s[dest]}")" + + # Composición: crea los colaboradores y guarda sus nombres en el agregado. + SystemdUnit::new "${self}__unit" "${s[unit_name]}" "${s[dest]}" "$branch" "${s[host]}" "${s[port]}" + NginxVhost::new "${self}__vhost" "${s[slug]}" "${s[host]}" "${s[port]}" + TlsCert::new "${self}__cert" "${s[host]}" +} + +Env::deploy() { + local self="$1" + local -n s="$self" + + Artifacts::require_built "${s[root]}" + Logger::info "sincronizando artefactos -> ${s[dest]}" + Artifacts::sync "${s[root]}" "${s[dest]}" + + Logger::info "instalando dependencias (prod)" + Deps::install "${s[root]}" "${s[dest]}" + + SystemdUnit::write "${self}__unit" + + Logger::info "configurando nginx para ${s[host]}" + NginxVhost::configure "${self}__vhost" + + TlsCert::ensure "${self}__cert" +} + +Env::teardown() { + local self="$1" + local -n s="$self" + + SystemdUnit::remove "${self}__unit" + TlsCert::delete "${self}__cert" + NginxVhost::remove "${self}__vhost" + rm -rf "${s[dest]}" +} + +Env::url() { + local self="$1" + local -n s="$self" + local proto + + proto="$(TlsCert::protocol "${self}__cert")" + echo "$proto://${s[host]}" +} + +Env::banner() { + local self="$1" + local -n s="$self" + + echo "rama=${s[branch]} slug=${s[slug]} host=${s[host]} dest=${s[dest]} puerto=${s[port]}" +} + +Env::identity() { + local self="$1" + local -n s="$self" + + echo "${s[host]} (${s[dest]}, ${s[unit_name]})" +} + +Env::summary() { + local self="$1" + local -n s="$self" + + echo "rama=${s[branch]} unit=${s[unit_name]} puerto=${s[port]}" +} diff --git a/scripts/teardown.sh b/scripts/teardown.sh index 345102a..b06e14b 100755 --- a/scripts/teardown.sh +++ b/scripts/teardown.sh @@ -1,37 +1,33 @@ #!/usr/bin/env bash # teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano). -# usage: teardown.sh +# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint. set -euo pipefail -BRANCH="${1:?usage: $0 }" -DOMAIN="localesp.es" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/env.sh +. "$SCRIPT_DIR/lib/env.sh" -log() { printf '\033[1;34m[teardown]\033[0m %s\n' "$*"; } +usage() { + echo "usage: $0 " >&2 + exit 2 +} -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" +main() { + local branch root -log "$HOST ($DEST, $UNIT)" + branch="${1:-}" + if [ -z "$branch" ]; then + usage + fi + root="$(cd "$SCRIPT_DIR/.." && pwd)" -systemctl disable --now "$UNIT" 2>/dev/null || true -rm -f "/etc/systemd/system/$UNIT.service" -systemctl daemon-reload + Logger::set_tag teardown + Env::new env "$branch" "$root" + Logger::info "$(Env::identity env)" -if [ -d "/etc/letsencrypt/live/$HOST" ]; then - log "borrando cert $HOST" - certbot delete --cert-name "$HOST" -n 2>/dev/null || true -fi + Env::teardown env -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 + Logger::info "entorno eliminado ✓" +} -rm -rf "$DEST" -log "entorno eliminado ✓" +main "$@"