Files
localesp/scripts/lib/env.sh
edgar.friendly d1fec59dcb
deploy-branch / deploy (push) Successful in 2m15s
deploy-branch / teardown (push) Skipped
ci: despliegue automático por rama (workflow + scripts OOP)
Añade la automatización de despliegue a esta rama (no la tenía):
- .gitea/workflows/deploy.yml: build + deploy on push, teardown on delete.
- scripts/lib/env.sh + deploy.sh + teardown.sh: lib OOP (Env + SystemdUnit/
  NginxVhost/TlsCert) y entrypoints finos.

El código de la app NO se modifica. Traído path-scoped desde master (6cc326e).
Cada push a prototipo-yb01 publicará la app en prototipo-yb01.localesp.es.
2026-08-01 18:30:00 +02:00

451 lines
13 KiB
Bash

#!/usr/bin/env bash
# lib/env.sh — Modelo OOP de entornos de previsualización por rama.
#
# Estrategia (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í.
#
# Representación OOP en bash:
# - Instancia = array asociativo global, referenciado por NOMBRE.
# - Constructor = Class::new <nombre> <args> (declara el array y establece invariantes).
# - Método = Class::method <nombre> <args>; accede al estado vía `local -n self="$1"`.
# - Composición = Env posee los nombres de sus colaboradores (<env>__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 <<EOF
[Unit]
Description=LocaleSP env rama '${u[branch]}' (${u[host]})
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=${u[dest]}
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=5s
Environment=NODE_ENV=production
Environment=PORT=${u[port]}
[Install]
WantedBy=multi-user.target
EOF
}
SystemdUnit::write() {
local self="$1"
local -n u="$self"
Logger::info "escribiendo unit ${u[name]} (PORT=${u[port]})"
SystemdUnit::render "$self" > "${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 <<EOF
server {
listen 80;
listen [::]:80;
server_name ${v[host]};
location / {
proxy_pass http://localhost:${v[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
}
NginxVhost::create() {
local self="$1"
local -n v="$self"
local path="/etc/nginx/conf.d/${v[slug]}.conf"
Logger::info "creando vhost HTTP $path"
NginxVhost::render "$self" > "$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:-<sin resolver>}', 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]}"
}