#!/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]}" }