From 71a6c4987511caf2b2561c8828567d59bf6494de Mon Sep 17 00:00:00 2001 From: aagusfernandez02 Date: Mon, 6 Jul 2026 11:44:56 -0300 Subject: [PATCH] feat: new client, store and docs --- README.md | 158 +++++++++++++++++- data/jenkins/jenkins.yaml | 84 ++++++++-- data/jenkins/pipelines/Jenkinsfile.lab | 55 ++++++ data/jenkins/pipelines/Jenkinsfile.ralph | 12 ++ data/jenkins/scripts/create-client.sh | 203 +++++++++++++++++++++++ data/jenkins/scripts/create-node.sh | 100 +++++++++++ 6 files changed, 587 insertions(+), 25 deletions(-) create mode 100644 data/jenkins/pipelines/Jenkinsfile.lab create mode 100644 data/jenkins/scripts/create-client.sh create mode 100644 data/jenkins/scripts/create-node.sh diff --git a/README.md b/README.md index 14b0f16..764545a 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,49 @@ Jenkins centralizado para el despliegue de software en terminales SCO (Self-Chec ├── deploy/ │ └── install.sh # Script desplegado en cada SCO └── pipelines/ - └── Jenkinsfile.ralph # Pipeline del cliente Ralph + ├── Jenkinsfile.ralph # Pipeline del cliente Ralph + └── Jenkinsfile.lab # Pipeline del entorno de laboratorio ``` ## Arquitectura -- **Jenkins master**: corre en Docker en un servidor (`172.19.13.204`). -- **Agentes (SCOs)**: cada terminal SCO conecta al master vía JNLP (puerto 50000). El proceso del agente corre como `root`. +- **Jenkins master**: corre en Docker en un servidor (`172.19.13.204`). No expone puertos al host; solo es alcanzable dentro de la red interna Docker `proxy`. +- **Nginx Proxy Manager (NPM)**: reverse proxy interno que resuelve el hostname `jenkins.laoficina1782.com` y hace forward hacia `jenkins-server:8080`. Corre en el mismo servidor, red `proxy`. +- **Cloudflare Tunnel (`cloudflared`)**: es el único punto de entrada desde internet. El contenedor `cloudflared` abre una conexión saliente hacia el edge de Cloudflare (autenticada con `TUNNEL_TOKEN`), por lo que **no hace falta abrir puertos entrantes en el firewall/router** del servidor. El tunnel está configurado del lado de Cloudflare para enrutar el hostname público hacia `http://npm:80` dentro de la red `proxy`. +- **Agentes (SCOs)**: cada terminal SCO conecta al master vía WebSocket (no JNLP), outbound hacia `wss://jenkins.laoficina1782.com/`. El proceso del agente corre como `root`. - **Storage**: Minio almacena el software de cada cliente en buckets separados (ej: bucket `ralph`). +### Flujo de tráfico + +``` +Internet + │ + ▼ +Cloudflare Edge (DNS + proxy) + │ túnel saliente autenticado (TUNNEL_TOKEN) + ▼ +cloudflared (contenedor, red "proxy", sin puertos publicados) + │ http://npm:80 + ▼ +NPM (nginx-proxy-manager) — vhost jenkins.laoficina1782.com + │ http://jenkins-server:8080 + ▼ +jenkins-server (sin puertos publicados al host) +``` + +Jenkins **no** expone puertos al host: NPM y `cloudflared` se comunican con él por nombre de contenedor dentro de la red Docker `proxy`. Como no hay puertos entrantes abiertos en el servidor, toda la superficie pública depende de Cloudflare (WAF, DNS, TLS terminado en el edge). + +> ⚠️ NPM publica además `80`, `443` y `81` directamente al host (ver `docker-compose.yml`). Si el firewall del servidor permite esas conexiones, Jenkins/NPM también serían alcanzables sin pasar por Cloudflare. Para que el tunnel sea realmente el único punto de entrada, esos puertos deberían quedar bloqueados a nivel de firewall/red salvo acceso local. + +### Variables de entorno + +El tunnel necesita el token generado en el dashboard de Cloudflare (Zero Trust → Networks → Tunnels): + +```sh +cp .env.example .env +# editar .env y setear CLOUDFLARE_TUNNEL_TOKEN= +``` + ### Labels de agentes Formato: `sco ` @@ -39,7 +73,28 @@ Formato: `sco ` docker compose up -d --build ``` -Jenkins queda disponible en `http://:8080` (admin / admin123). +Jenkins queda disponible en `https://jenkins.laoficina1782.com` una vez configurados el proxy y el tunnel. + +### Nginx Proxy Manager + +Panel de administración: `http://172.19.13.204:81` (o localmente en el servidor). + +Configuración del proxy host para Jenkins: + +| Campo | Valor | +|-------|-------| +| Domain Names | `jenkins.laoficina1782.com` | +| Scheme | `http` | +| Forward Hostname | `jenkins-server` | +| Forward Port | `8080` | +| Websockets Support | ✅ activado | + +### Cloudflare Tunnel + +1. En el dashboard de Cloudflare (Zero Trust → Networks → Tunnels), crear un tunnel y copiar su token. +2. Configurar la ruta pública (Public Hostname) del tunnel: `jenkins.laoficina1782.com` → `http://npm:80`. +3. Setear el token en `.env` como `CLOUDFLARE_TUNNEL_TOKEN` (ver arriba). +4. Levantar el stack: el contenedor `cloudflared` se conecta solo, sin necesidad de abrir puertos en el servidor. ## Conectar un agente SCO @@ -61,7 +116,8 @@ Jenkins queda disponible en `http://:8080` (admin / admin123). [Service] User=root ExecStart=/usr/bin/java -jar /opt/jenkins-agent/agent.jar \ - -url http://:8080/ \ + -url https://jenkins.laoficina1782.com/ \ + -webSocket \ -secret \ -name "" \ -workDir "/opt/jenkins-agent" @@ -80,10 +136,31 @@ Jenkins queda disponible en `http://:8080` (admin / admin123). ## Ejecutar un deploy -1. En Jenkins, abrir el job `deploy-ralph`. +1. En Jenkins, abrir el job `deploy-ralph` (o `deploy-lab` para el entorno de laboratorio). 2. Click en **Build with Parameters**. -3. Seleccionar la **tienda** destino en el dropdown (yabucoa / san-lorenzo / rio-grande). -4. El pipeline despliega `install.sh ralph` en paralelo en todos los SCOs online de esa tienda. +3. Seleccionar la **tienda** destino en el dropdown (yabucoa / san-lorenzo / rio-grande para `deploy-ralph`; oficina para `deploy-lab`). +4. Opcionalmente, completar **NODO** con el nombre exacto de un SCO (ej. `ralph-yabucoa-sco05`) para deployar solo en ese nodo. Si se deja vacío, despliega en paralelo en todos los SCOs online de la tienda seleccionada. + +También se puede disparar desde consola con el Jenkins CLI o `curl`, ver [Ejecutar un job desde consola](#ejecutar-un-job-desde-consola). + +### Ejecutar un job desde consola + +```sh +curl -O https://jenkins.laoficina1782.com/jnlpJars/jenkins-cli.jar + +java -jar jenkins-cli.jar -s https://jenkins.laoficina1782.com/ \ + -auth TU_USUARIO:TU_API_TOKEN \ + build deploy-lab -p TIENDA=oficina -s -v + +# Deploy a un solo nodo de la tienda +java -jar jenkins-cli.jar -s https://jenkins.laoficina1782.com/ \ + -auth TU_USUARIO:TU_API_TOKEN \ + build deploy-lab -p TIENDA=oficina -p NODO=lab-oficina-sco-77 -s -v +``` + +- `-s` espera a que termine el build; `-v` muestra el log en consola. +- El API Token se genera en el usuario de Jenkins → Configure → API Token. +- Alternativa sin el CLI: `curl -X POST https://jenkins.laoficina1782.com/job/deploy-lab/buildWithParameters --user TU_USUARIO:TU_API_TOKEN --data-urlencode "TIENDA=oficina"`. ## Qué hace `install.sh` @@ -100,8 +177,73 @@ Recibe el nombre del cliente como argumento (`$1`): 9. Inicializa carpetas y archivos de base de datos del cpi-server. 10. Genera `engine.properties` para SymmetricDS (solo en primera instalación). +## Scripts de alta (create-node.sh / create-client.sh) + +`data/jenkins/scripts/` tiene dos scripts para no tener que editar `jenkins.yaml`, los Jenkinsfiles y `docker-compose.yml` a mano. + +**Prerequisito**: [`yq`](https://github.com/mikefarah/yq) (la versión de Go, **no** la de Python — son incompatibles, ver más abajo) y que los scripts tengan permiso de ejecución: +```sh +chmod +x data/jenkins/scripts/*.sh +``` + +Ambos **solo editan archivos** — no hacen `docker compose up -d --build` ni commits. Después de correrlos, revisá con `git diff` y aplicá el rebuild vos mismo. + +### create-node.sh — agregar un nodo a un cliente ya existente + +```sh +data/jenkins/scripts/create-node.sh --cliente --tienda --numero +``` + +- Arma el nombre (`--sco`, `numero` normalizado a 3 dígitos) y el label (`sco `) siguiendo la convención existente, y agrega el nodo al principio de `.jenkins.nodes` en `jenkins.yaml`. +- `cliente` y `tienda` deben ser minúsculas, números y guiones (`^[a-z0-9-]+$`) — nada de mayúsculas ni espacios. +- Valida que el cliente ya esté dado de alta (que exista `Jenkinsfile.`) y que la tienda esté en su dropdown de `TIENDA`. Si falla cualquiera de las dos, corta sin tocar `jenkins.yaml`. +- Detecta nodos duplicados (mismo nombre final) y corta sin duplicar. +- `--skip-checks` salta las validaciones de cliente/tienda existentes — lo usa `create-client.sh` internamente al dar de alta un cliente nuevo (en ese momento el Jenkinsfile todavía no existe). + +Ejemplo: +```sh +data/jenkins/scripts/create-node.sh --cliente ralph --tienda yabucoa --numero 8 +# -> nodo "ralph-yabucoa-sco008", label "sco ralph yabucoa" +``` + +### create-client.sh — dar de alta un cliente nuevo completo + +```sh +data/jenkins/scripts/create-client.sh --cliente \ + --tienda [--tienda ...] \ + --nodo : [--nodo : ...] +``` + +- `--tienda` es repetible: declara cada tienda que va a aparecer en el dropdown del Jenkinsfile (se deduplica automáticamente si se repite). +- `--nodo` es repetible: uno por cada SCO a crear, en formato `:`. Cada `tienda` referenciada en un `--nodo` tiene que haber sido declarada con algún `--tienda`. +- Corta antes de tocar nada si el cliente ya existe (ya hay `Jenkinsfile.` o ya hay un job `deploy-` en `jenkins.yaml`). +- Si falla la creación de algún nodo a mitad del loop (ej. `--nodo` repetido), revierte automáticamente los nodos ya creados en esa misma corrida antes de cortar — no deja nodos huérfanos. +- Al terminar, genera `data/jenkins/pipelines/Jenkinsfile.` (mismo esqueleto que `Jenkinsfile.ralph`, con el parámetro `NODO` opcional), agrega el job `deploy-` a `jenkins.yaml` y el volumen correspondiente a `docker-compose.yml`. + +Ejemplo: +```sh +data/jenkins/scripts/create-client.sh --cliente econo-mbs \ + --tienda yabucoa --tienda san-lorenzo \ + --nodo yabucoa:1 --nodo yabucoa:2 --nodo yabucoa:3 \ + --nodo san-lorenzo:3 --nodo san-lorenzo:4 +``` + +### Troubleshooting + +- **`usage: yq [-h] [--yaml-output] ...` / `yq: error: argument files: can't open '.jenkins...'`**: tenés instalado el `yq` de Python (kislyuk/yq), que usa sintaxis de `jq` y es incompatible. Desinstalalo (`pip uninstall yq`) e instalá el de Go: + ```sh + wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/local/bin/yq + chmod +x /usr/local/bin/yq + hash -r # si ya habías corrido "yq" antes en esa misma terminal, bash puede tener cacheada la ruta vieja + yq --version # debe decir "yq (https://github.com/mikefarah/yq/) version v4..." + ``` +- **`Permission denied` al correr un script**: falta el bit de ejecución — `chmod +x data/jenkins/scripts/*.sh`. +- **`printf: NNN: invalid octal number`**: no debería pasar (ya está resuelto), pero si aparece es porque un `--numero` con cero a la izquierda se está interpretando como octal en vez de decimal. + ## Agregar un nuevo cliente +Con los scripts (recomendado, ver arriba), o a mano siguiendo estos pasos: + 1. Agregar los nodos del cliente en `data/jenkins/jenkins.yaml` con el label `sco `. 2. Crear `data/jenkins/pipelines/Jenkinsfile.` con el dropdown de tiendas. 3. Agregar el job en la sección `jobs` de `jenkins.yaml`. diff --git a/data/jenkins/jenkins.yaml b/data/jenkins/jenkins.yaml index 524c8a7..1c99627 100644 --- a/data/jenkins/jenkins.yaml +++ b/data/jenkins/jenkins.yaml @@ -1,5 +1,16 @@ jenkins: numExecutors: 2 + nodeMonitors: + - "architecture" + - "clock" + - diskSpace: + freeSpaceThreshold: "0" + freeSpaceWarningThreshold: "0" + - "swapSpace" + - tmpSpace: + freeSpaceThreshold: "0" + freeSpaceWarningThreshold: "0" + - "responseTime" authorizationStrategy: loggedInUsersCanDoAnything: allowAnonymousRead: false @@ -10,7 +21,7 @@ jenkins: - id: admin password: H0l4mund0! - # Agentes permanentes (inbound/JNLP): cada SCO conecta outbound a Jenkins. + # Agentes permanentes (WebSocket): cada SCO conecta outbound a Jenkins via wss://. # Nombre = identificador en Jenkins. Label "sco" agrupa todos los SCOs, # luego cliente (ralph) y tienda (yabucoa / san-lorenzo / rio-grande). nodes: @@ -21,7 +32,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-yabucoa-sco03" labelString: "sco ralph yabucoa" @@ -29,7 +41,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-yabucoa-sco04" labelString: "sco ralph yabucoa" @@ -37,7 +50,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-yabucoa-sco05" labelString: "sco ralph yabucoa" @@ -45,7 +59,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-yabucoa-sco06" labelString: "sco ralph yabucoa" @@ -53,7 +68,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-yabucoa-sco07" labelString: "sco ralph yabucoa" @@ -61,7 +77,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-san-lorenzo-sco03" labelString: "sco ralph san-lorenzo" @@ -69,7 +86,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-san-lorenzo-sco04" labelString: "sco ralph san-lorenzo" @@ -77,7 +95,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-san-lorenzo-sco05" labelString: "sco ralph san-lorenzo" @@ -85,7 +104,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-san-lorenzo-sco06" labelString: "sco ralph san-lorenzo" @@ -93,7 +113,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco01" labelString: "sco ralph rio-grande" @@ -101,7 +122,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco02" labelString: "sco ralph rio-grande" @@ -109,7 +131,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco03" labelString: "sco ralph rio-grande" @@ -117,7 +140,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco04" labelString: "sco ralph rio-grande" @@ -125,7 +149,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco05" labelString: "sco ralph rio-grande" @@ -133,7 +158,8 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true - permanent: name: "ralph-rio-grande-sco06" labelString: "sco ralph rio-grande" @@ -141,7 +167,21 @@ jenkins: numExecutors: 1 retentionStrategy: "always" launcher: - inbound: {} + inbound: + webSocket: true + - permanent: + name: "lab-oficina-sco-77" + labelString: "sco lab oficina" + remoteFS: "/opt/jenkins-agent" + numExecutors: 1 + retentionStrategy: "always" + launcher: + inbound: + webSocket: true + +unclassified: + location: + url: https://jenkins.laoficina1782.com/ security: globalJobDslSecurityConfiguration: @@ -158,3 +198,13 @@ jobs: } } } + - script: | + pipelineJob('deploy-lab') { + description('Deploy de install.sh a los SCOs de Deploy Lab en la tienda seleccionada.') + definition { + cps { + script(new File('/opt/jenkins/Jenkinsfile.lab').text) + sandbox(true) + } + } + } diff --git a/data/jenkins/pipelines/Jenkinsfile.lab b/data/jenkins/pipelines/Jenkinsfile.lab new file mode 100644 index 0000000..343b517 --- /dev/null +++ b/data/jenkins/pipelines/Jenkinsfile.lab @@ -0,0 +1,55 @@ +pipeline { + agent none + + parameters { + choice( + name: 'TIENDA', + choices: ['oficina'], + description: 'Tienda destino' + ) + string( + name: 'NODO', + defaultValue: '', + description: 'Nombre exacto del nodo a deployar (vacío = toda la tienda)' + ) + } + + stages { + stage('Preparar scripts') { + agent { label 'built-in' } + steps { + sh 'cp /opt/deploy/install.sh .' + stash name: 'scripts', includes: 'install.sh' + } + } + + stage('Deploy') { + agent { label 'built-in' } + steps { + script { + def labelExpr = "lab && ${params.TIENDA}" + def nodes = nodesByLabel(label: labelExpr, offline: false) + if (params.NODO?.trim()) { + def target = params.NODO.trim() + if (!nodes.contains(target)) { + error "El nodo '${target}' no está online o no pertenece al label: ${labelExpr}" + } + nodes = [target] + } + if (nodes.isEmpty()) { + error "No hay nodos online con el label: ${labelExpr}" + } + def deployStages = nodes.collectEntries { nodeName -> + ["Deploy ${nodeName}": { + node(nodeName) { + unstash 'scripts' + sh "bash install.sh 'lab'" + } + }] + } + parallel deployStages + } + } + } + } +} diff --git a/data/jenkins/pipelines/Jenkinsfile.ralph b/data/jenkins/pipelines/Jenkinsfile.ralph index 85f17b5..bd3dfbe 100644 --- a/data/jenkins/pipelines/Jenkinsfile.ralph +++ b/data/jenkins/pipelines/Jenkinsfile.ralph @@ -7,6 +7,11 @@ pipeline { choices: ['yabucoa', 'san-lorenzo', 'rio-grande'], description: 'Tienda destino' ) + string( + name: 'NODO', + defaultValue: '', + description: 'Nombre exacto del nodo a deployar (vacío = toda la tienda)' + ) } stages { @@ -24,6 +29,13 @@ pipeline { script { def labelExpr = "ralph && ${params.TIENDA}" def nodes = nodesByLabel(label: labelExpr, offline: false) + if (params.NODO?.trim()) { + def target = params.NODO.trim() + if (!nodes.contains(target)) { + error "El nodo '${target}' no está online o no pertenece al label: ${labelExpr}" + } + nodes = [target] + } if (nodes.isEmpty()) { error "No hay nodos online con el label: ${labelExpr}" } diff --git a/data/jenkins/scripts/create-client.sh b/data/jenkins/scripts/create-client.sh new file mode 100644 index 0000000..af5d6b8 --- /dev/null +++ b/data/jenkins/scripts/create-client.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# Da de alta un cliente nuevo: crea sus nodos, su Jenkinsfile, el job en +# jenkins.yaml y el volumen en docker-compose.yml. +# +# Uso: +# create-client.sh --cliente \ +# --tienda [--tienda ...] \ +# --nodo : [--nodo : ...] +# +# Ejemplo: +# create-client.sh --cliente econo-mbs \ +# --tienda yabucoa --tienda san-lorenzo \ +# --nodo yabucoa:1 --nodo yabucoa:2 --nodo yabucoa:3 \ +# --nodo san-lorenzo:3 --nodo san-lorenzo:4 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JENKINS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$JENKINS_DIR/../.." && pwd)" +JENKINS_YAML="$JENKINS_DIR/jenkins.yaml" +PIPELINES_DIR="$JENKINS_DIR/pipelines" +DOCKER_COMPOSE="$REPO_ROOT/docker-compose.yml" + +cliente="" +tiendas=() +nodos=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --cliente) cliente="$2"; shift 2 ;; + --tienda) tiendas+=("$2"); shift 2 ;; + --nodo) nodos+=("$2"); shift 2 ;; + *) echo "Argumento desconocido: $1" >&2; exit 1 ;; + esac +done + +command -v yq >/dev/null 2>&1 || { + echo "yq no está instalado. Instalalo (ej. 'apt install yq' o ver https://github.com/mikefarah/yq#install)." >&2 + exit 1 +} + +[[ -n "$cliente" ]] || { echo "Falta --cliente" >&2; exit 1; } +[[ "$cliente" =~ ^[a-z0-9-]+$ ]] || { echo "cliente inválido: '$cliente' (solo minúsculas, números y guiones)" >&2; exit 1; } +[[ ${#tiendas[@]} -gt 0 ]] || { echo "Falta al menos un --tienda" >&2; exit 1; } +[[ ${#nodos[@]} -gt 0 ]] || { echo "Falta al menos un --nodo" >&2; exit 1; } + +for tienda in "${tiendas[@]}"; do + [[ "$tienda" =~ ^[a-z0-9-]+$ ]] || { echo "tienda inválida: '$tienda' (solo minúsculas, números y guiones)" >&2; exit 1; } +done + +declare -A tiendas_vistas=() +tiendas_dedup=() +for tienda in "${tiendas[@]}"; do + [[ -n "${tiendas_vistas[$tienda]+x}" ]] && continue + tiendas_vistas[$tienda]=1 + tiendas_dedup+=("$tienda") +done +tiendas=("${tiendas_dedup[@]}") + +jenkinsfile="$PIPELINES_DIR/Jenkinsfile.${cliente}" + +[[ -f "$jenkinsfile" ]] && { echo "Ya existe $jenkinsfile" >&2; exit 1; } +if yq eval '.jobs[].script' "$JENKINS_YAML" | grep -q "deploy-${cliente}"; then + echo "Ya existe una entrada de job para '${cliente}' en $JENKINS_YAML" >&2 + exit 1 +fi + +# --nodo debe referenciar una tienda ya declarada con --tienda +for nodo in "${nodos[@]}"; do + nodo_tienda="${nodo%%:*}" + nodo_numero="${nodo#*:}" + [[ "$nodo_tienda" != "$nodo" && -n "$nodo_numero" ]] || { + echo "Formato de --nodo inválido: '$nodo' (esperado :)" >&2 + exit 1 + } + match=false + for tienda in "${tiendas[@]}"; do + [[ "$tienda" == "$nodo_tienda" ]] && { match=true; break; } + done + [[ "$match" == true ]] || { + echo "El nodo '$nodo' referencia la tienda '$nodo_tienda', que no fue declarada con --tienda" >&2 + exit 1 + } +done + +rollback_nodos() { + local nombre + for nombre in "${creados[@]}"; do + echo " - revirtiendo nodo: $nombre" >&2 + NODE_TO_DELETE="$nombre" yq eval -i 'del(.jenkins.nodes[] | select(.permanent.name == strenv(NODE_TO_DELETE)))' "$JENKINS_YAML" + done +} + +echo "Creando nodos..." +creados=() +for nodo in "${nodos[@]}"; do + nodo_tienda="${nodo%%:*}" + nodo_numero="${nodo#*:}" + if ! "$SCRIPT_DIR/create-node.sh" --cliente "$cliente" --tienda "$nodo_tienda" --numero "$nodo_numero" --skip-checks; then + echo "" >&2 + echo "Falló la creación de '$nodo'. Revirtiendo los ${#creados[@]} nodo(s) ya creado(s) para '$cliente'..." >&2 + rollback_nodos + exit 1 + fi + nodo_numero_padded=$(printf '%03d' "$((10#$nodo_numero))") + creados+=("${cliente}-${nodo_tienda}-sco${nodo_numero_padded}") +done + +echo "" +echo "Generado $jenkinsfile..." +choices="" +for tienda in "${tiendas[@]}"; do + choices+="'${tienda}', " +done +choices="${choices%, }" + +cat > "$jenkinsfile" < + ["Deploy \${nodeName}": { + node(nodeName) { + unstash 'scripts' + sh "bash install.sh '${cliente}'" + } + }] + } + parallel deployStages + } + } + } + } +} +EOF + +echo "" +echo "Agregado job 'deploy-${cliente}' a $JENKINS_YAML..." +JOB_SCRIPT="pipelineJob('deploy-${cliente}') { + description('Deploy de install.sh a los SCOs de ${cliente} en la tienda seleccionada.') + definition { + cps { + script(new File('/opt/jenkins/Jenkinsfile.${cliente}').text) + sandbox(true) + } + } +} +" +JOB_SCRIPT="$JOB_SCRIPT" yq eval -i '.jobs += [{"script": strenv(JOB_SCRIPT)}]' "$JENKINS_YAML" +yq eval -i '(.jobs[-1].script) style="literal"' "$JENKINS_YAML" + +echo "" +echo "Agregado volumen a $DOCKER_COMPOSE..." +VOLUME_LINE="./data/jenkins/pipelines/Jenkinsfile.${cliente}:/opt/jenkins/Jenkinsfile.${cliente}:ro" +VOLUME_LINE="$VOLUME_LINE" yq eval -i '.services.jenkins.volumes += [strenv(VOLUME_LINE)]' "$DOCKER_COMPOSE" + +echo "" +echo "Cliente '${cliente}' creado:" +echo " - Nodos: ${nodos[*]}" +echo " - Jenkinsfile: $jenkinsfile" +echo " - Job: deploy-${cliente}" +echo " - Volumen agregado en docker-compose.yml" +echo "" +echo "Aplicá con: docker compose up -d --build" diff --git a/data/jenkins/scripts/create-node.sh b/data/jenkins/scripts/create-node.sh new file mode 100644 index 0000000..6fec0b8 --- /dev/null +++ b/data/jenkins/scripts/create-node.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Agrega un nodo permanente (SCO) a data/jenkins/jenkins.yaml. +# +# Uso: +# create-node.sh --cliente --tienda --numero +# +# Ejemplo: +# create-node.sh --cliente ralph --tienda yabucoa --numero 8 +# -> nodo "ralph-yabucoa-sco008", label "sco ralph yabucoa" +# +# Por default valida que el cliente ya esté dado de alta (que exista +# Jenkinsfile.) y que la tienda esté en su dropdown de TIENDA. +# --skip-checks salta ambas validaciones (lo usa create-client.sh al dar +# de alta un cliente nuevo, cuando el Jenkinsfile todavía no existe). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +JENKINS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +JENKINS_YAML="$JENKINS_DIR/jenkins.yaml" +PIPELINES_DIR="$JENKINS_DIR/pipelines" + +cliente="" +tienda="" +numero="" +skip_checks=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --cliente) cliente="$2"; shift 2 ;; + --tienda) tienda="$2"; shift 2 ;; + --numero) numero="$2"; shift 2 ;; + --skip-checks) skip_checks=true; shift ;; + *) echo "Argumento desconocido: $1" >&2; exit 1 ;; + esac +done + +command -v yq >/dev/null 2>&1 || { + echo "yq no está instalado. Instalalo (ej. 'apt install yq' o ver https://github.com/mikefarah/yq#install)." >&2 + exit 1 +} + +[[ -n "$cliente" && -n "$tienda" && -n "$numero" ]] || { + echo "Uso: $0 --cliente --tienda --numero " >&2 + exit 1 +} + +[[ "$cliente" =~ ^[a-z0-9-]+$ ]] || { echo "cliente inválido: '$cliente' (solo minúsculas, números y guiones)" >&2; exit 1; } +[[ "$tienda" =~ ^[a-z0-9-]+$ ]] || { echo "tienda inválida: '$tienda' (solo minúsculas, números y guiones)" >&2; exit 1; } +[[ "$numero" =~ ^[0-9]+$ ]] || { echo "numero inválido: '$numero' (solo dígitos)" >&2; exit 1; } + +numero_padded=$(printf '%03d' "$((10#$numero))") +node_name="${cliente}-${tienda}-sco${numero_padded}" +node_label="sco ${cliente} ${tienda}" + +[[ -f "$JENKINS_YAML" ]] || { echo "No se encontró $JENKINS_YAML" >&2; exit 1; } + +if [[ "$skip_checks" == false ]]; then + jenkinsfile="$PIPELINES_DIR/Jenkinsfile.${cliente}" + [[ -f "$jenkinsfile" ]] || { + echo "El cliente '$cliente' no está dado de alta (no existe $jenkinsfile)." >&2 + echo "Usá create-client.sh para darlo de alta primero." >&2 + exit 1 + } + + choices_line=$(grep -m1 "choices:" "$jenkinsfile" || true) + if [[ -n "$choices_line" ]] && ! grep -oE "'[a-z0-9-]+'" <<< "$choices_line" | tr -d "'" | grep -qx "$tienda"; then + echo "La tienda '$tienda' no está en el dropdown de $jenkinsfile" >&2 + exit 1 + fi +fi + +if yq eval ".jenkins.nodes[].permanent.name" "$JENKINS_YAML" | grep -qx "$node_name"; then + echo "El nodo '$node_name' ya existe en $JENKINS_YAML" >&2 + exit 1 +fi + +NODE_NAME="$node_name" NODE_LABEL="$node_label" yq eval -i ' + .jenkins.nodes += [{ + "permanent": { + "name": strenv(NODE_NAME), + "labelString": strenv(NODE_LABEL), + "remoteFS": "/opt/jenkins-agent", + "numExecutors": 1, + "retentionStrategy": "always", + "launcher": { + "inbound": { + "webSocket": true + } + } + } + }] +' "$JENKINS_YAML" + +echo "Nodo creado: $node_name (label: $node_label)" +if [[ "$skip_checks" == false ]]; then + echo "" + echo "Revisá en jenkins.yaml > jenkins > nodes" + echo "Aplicá con: docker compose up -d --build" +fi