diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..2aee2dc --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: deploy-branch + +# CI/CD: cada push a cualquier rama se despliega en .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) + shell: bash + run: | + # better-sqlite3 (v12) requiere C++20; el GCC 8 del sistema (AlmaLinux 8) no lo soporta. + # gcc-toolset-12 (GCC 12) ya está instalado en /opt/rh. + source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado" + 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/ + BRANCH: ${{ github.event.ref }} + run: bash scripts/teardown.sh "${BRANCH#refs/heads/}" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..66336f0 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# deploy.sh — Despliega la rama actual a .localesp.es +# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/env.sh +. "$SCRIPT_DIR/lib/env.sh" + +usage() { + echo "usage: $0 " >&2 + exit 2 +} + +main() { + local branch root + + branch="${1:-${GITHUB_REF_NAME:-}}" + if [ -z "$branch" ]; then + usage + fi + root="$(cd "$SCRIPT_DIR/.." && pwd)" + + 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 new file mode 100755 index 0000000..b06e14b --- /dev/null +++ b/scripts/teardown.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano). +# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/env.sh +. "$SCRIPT_DIR/lib/env.sh" + +usage() { + echo "usage: $0 " >&2 + exit 2 +} + +main() { + local branch root + + branch="${1:-}" + if [ -z "$branch" ]; then + usage + fi + root="$(cd "$SCRIPT_DIR/.." && pwd)" + + Logger::set_tag teardown + Env::new env "$branch" "$root" + Logger::info "$(Env::identity env)" + + Env::teardown env + + Logger::info "entorno eliminado ✓" +} + +main "$@"