#!/usr/bin/env bash # tenant-first-deploy.sh — Deploy del tenant al cluster (ADR-095 Fase B2, CS-7). # # Factoriza la LÓGICA DE DEPLOY del job `helm-deploy` del workflow del template # (antes inline en `.gitea/workflows/__APP_NAME__-build.yaml`) a un script reusable. # Lo invocan DOS consumidores con el MISMO shell byte-idéntico: # 1. El workflow del tenant, contra el cluster real (producción). # 2. El gate efímero B2 (`infrastructure/scripts/templates-deploy-gate.sh`), # contra un cluster k3s descartable, con GATE_MODE=1. # # Por qué un script y no lógica duplicada en el gate (ADR-095 §B2-D2): el bug # canónico de la "enfermedad B" (el `helm status` de un release inexistente que # bajo `set -e -o pipefail` abortaba el first-deploy frío, 06-14) es SEMÁNTICA DE # SHELL — solo ejecutar ese mismo shell lo caza. Reimplementarlo en el gate # reintroduce la divergencia productor↔consumidor (clase ADR-083). # # El script es AGNÓSTICO DEL CLUSTER: asume que `helm`/`kubectl` ya apuntan al # cluster destino (el caller decodifica el kubeconfig y asegura helm en el PATH — # eso es setup de entorno, específico del secret del tenant / de la VM efímera, no # lógica de deploy). El script es IDÉNTICO en ambos templates (web-backend + # rest-api): la divergencia se resuelve por DATOS, no por código — # - AddonClaim: se aplica solo si existe `manifests/addonclaim.yaml` # (web-backend lo tiene; rest-api no) → un único script sirve a ambos. # - Timeout de helm: env var HELM_TIMEOUT (web-backend 10m por defecto; el # workflow rest-api pasa 5m para preservar su comportamiento previo). # # Variables de entorno: # APP_NAME (OBLIGATORIO) nombre de la app == nombre del release Helm. # IMAGE_TAG (OBLIGATORIO) tag de la imagen ya publicada en Harbor. # TENANT_ID (opcional) uuid del tenant — usado en --set y en el smoke host. # TENANT_ID_NODASHES (opcional) uuid sin guiones — usado en --set. # TENANT_TIER (opcional) tier del tenant — usado en --set. # NAMESPACE (default `apps`) namespace funcional del cluster tenant (ADR-054). # HELM_TIMEOUT (default `10m`) timeout de `helm upgrade --install --wait`. # GATE_MODE (default `0`) si `1`, salta el smoke HTTPS final (DNS+cert+ # Gateway reales) — esa cobertura es del canary, no del efímero; # el script termina en `rollout status` (ADR-095 §B2-D2). # # Uso (desde el workflow del tenant, cwd = raíz del repo del tenant): # APP_NAME=__APP_NAME__ IMAGE_TAG=$SHA TENANT_ID=... \ # bash scripts/tenant-first-deploy.sh set -euo pipefail : "${APP_NAME:?ERROR: APP_NAME no está seteado}" : "${IMAGE_TAG:?ERROR: IMAGE_TAG no está seteado}" NAMESPACE="${NAMESPACE:-apps}" HELM_TIMEOUT="${HELM_TIMEOUT:-10m}" GATE_MODE="${GATE_MODE:-0}" TENANT_ID="${TENANT_ID:-}" TENANT_ID_NODASHES="${TENANT_ID_NODASHES:-}" TENANT_TIER="${TENANT_TIER:-}" log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [tenant-first-deploy] $*"; } log "Iniciando deploy app=${APP_NAME} ns=${NAMESPACE} tag=${IMAGE_TAG} gate_mode=${GATE_MODE}" # ── ADR-066: AddonClaim vive FUERA del chart Helm ─────────────────────────── # El claim lo gestiona addon-provisioner via finalizer cleanup-logical; no debe # acoplarse al lifecycle del release Helm para no rotar credenciales en cada # upgrade. Solo aplica a templates con base de datos (web-backend); rest-api no # tiene `manifests/addonclaim.yaml` → se salta el bloque entero. if [ -f manifests/addonclaim.yaml ]; then # De-adoptar AddonClaim legacy (migración chart 1.0.0 -> 1.1.0). # Idempotente: skip silencioso si AddonClaim no existe (primera instalación) # o si ya está de-adoptado (segundo run post-migración). Sunset del helper tras # una rotación completa (~2 semanas). if [ -f scripts/pre-helm-detach.sh ]; then log "De-adoptando AddonClaim legacy (pre-helm-detach.sh)…" RELEASE_NAME="${APP_NAME}" NAMESPACE="${NAMESPACE}" bash scripts/pre-helm-detach.sh fi # Aplicar AddonClaim. Sustitución de placeholders con sed (evita dependencia de # envsubst, no garantizado en la imagen del runner). kubectl apply es # idempotente: primer push lo crea, push subsiguientes son no-op si el spec no # cambia. helm upgrade --install nunca lo trackea ni lo borra. # databaseName = release name con guiones a underscores + sufijo _db (coincide # con el default del chart 1.0.0: .Release.Name | replace "-" "_" | printf "%s_db"). log "Aplicando AddonClaim…" DATABASE_NAME="$(echo "${APP_NAME}" | tr '-' '_')_db" sed -e "s|\${RELEASE_NAME}|${APP_NAME}|g" \ -e "s|\${DATABASE_NAME}|${DATABASE_NAME}|g" \ manifests/addonclaim.yaml | kubectl apply -f - fi # ── Esperar rancher-webhook ready (RANCHER-WEBHOOK-RACE-RECOVERY) ──────────── # Race durante el bootstrap del cluster tenant: el `rancher-webhook` Deployment en # cattle-system aún no tiene endpoints cuando el primer `helm upgrade --install` # crea el namespace `apps`, lo que dispara el webhook # `rancher.cattle.io.namespaces.create-non-kubesystem` y aborta el release con # `no endpoints available for service rancher-webhook`. Espera explícita evita el # flake. `kubectl wait` falla con NotFound si el Deployment aún no existe → primero # esperamos a que APAREZCA (poll), luego a que esté Available. log "Esperando rancher-webhook en cattle-system…" until kubectl get deployment rancher-webhook -n cattle-system >/dev/null 2>&1; do echo "Esperando a que el Deployment rancher-webhook exista en cattle-system..." sleep 5 done kubectl wait deployment rancher-webhook \ -n cattle-system \ --for=condition=Available \ --timeout=600s # ── Recuperar release Helm atascado (TENANT-BUILD-NO-CONCURRENCY-GUARD) ────── # Red de seguridad ADEMÁS del guard `concurrency`: si un run previo quedó a mitad # de una operación Helm (cancelado, runner reiniciado, o carrera histórica), el # release queda en `pending-*` y el siguiente `helm upgrade --install` aborta. Lo # detectamos y recuperamos antes de desplegar. # Fail-safe: SOLO actúa sobre `pending-*`/`uninstalling`; un release `deployed`, # `failed` o inexistente NO se toca. # `helm status` de un release inexistente (first-deploy frío) sale con exit≠0; bajo # `set -e -o pipefail` eso abortaría ANTES del case. `|| true` lo neutraliza → # STATUS="" → branch no-op. (ESTE es el bug canónico de la enfermedad B; ADR-095.) STATUS="$(helm status "${APP_NAME}" -n "${NAMESPACE}" 2>/dev/null | sed -n 's/^STATUS: //p')" || true log "Estado actual del release ${APP_NAME}: ${STATUS:-}" case "$STATUS" in pending-install|uninstalling) # Nunca hubo una revisión desplegada con éxito → no se puede rollback. # `helm uninstall` limpia el secret del release trabado sin riesgo de datos: # el primer deploy aún no levantó la app. log "Release en '${STATUS}' (sin revisión estable) → helm uninstall para limpiar." helm uninstall "${APP_NAME}" -n "${NAMESPACE}" --wait --timeout 2m || true ;; pending-upgrade|pending-rollback) # Hubo una revisión estable previa → rollback a la última desplegada (preserva # la app viva; el upgrade siguiente reintenta la nueva imagen). log "Release en '${STATUS}' → helm rollback a la última revisión estable." helm rollback "${APP_NAME}" 0 -n "${NAMESPACE}" --wait --timeout 2m || true ;; *) log "Release en estado estable o inexistente → sin recuperación necesaria." ;; esac # ── Helm upgrade --install ────────────────────────────────────────────────── # ADR-054: en cluster tenant los namespaces son funcionales (apps/addons), sin # tenant-id en el nombre — el cluster ya es el sandbox del tenant. # HELM_TIMEOUT (default 10m) absorbe el peor caso del reconcile de addon-provisioner # (RECONCILE_INTERVAL 60s + initial_delay 60s + 3 ticks ≈ 240s) + StatefulSet # MariaDB ready (~60s) + Job DDL (~10s) + InitContainer wait-for-secret 360s + # Alembic. Lección 2026-05-28 (ADDON-PROVISIONER-DB-SECRET-NOT-PUBLISHED). log "helm upgrade --install ${APP_NAME} (timeout ${HELM_TIMEOUT})…" helm upgrade --install "${APP_NAME}" "./helm/${APP_NAME}" \ --namespace "${NAMESPACE}" \ --create-namespace \ --set image.tag="${IMAGE_TAG}" \ --set tenantId="${TENANT_ID}" \ --set tenantIdNodashes="${TENANT_ID_NODASHES}" \ --set tier="${TENANT_TIER}" \ --wait --timeout "${HELM_TIMEOUT}" # ── Verificar rollout ─────────────────────────────────────────────────────── log "Verificando rollout deploy/${APP_NAME}…" kubectl -n "${NAMESPACE}" rollout status "deploy/${APP_NAME}" --timeout=3m # ── Smoke check post-deploy ───────────────────────────────────────────────── # En GATE_MODE (gate efímero B2) se salta: el smoke exige DNS+cert+Gateway reales # (`..apps.coralware.cloud`), cobertura del canary, no del efímero. El # script termina exitosamente en el rollout (ADR-095 §B2-D2). if [ "${GATE_MODE}" = "1" ]; then log "GATE_MODE=1 → se salta el smoke HTTPS (cobertura del canary). Deploy OK hasta rollout." exit 0 fi # FQDN en formato subdominio (ADR-067 / ADR-082): ..apps... # Coincide con el host del Ingress del cluster tenant (helm/APP_NAME/values.yaml) y # con el wildcard A record *.{tenant-uuid}.apps.coralware.cloud que crea tenant-manager. INGRESS_HOST="${APP_NAME}.${TENANT_ID}.apps.coralware.cloud" log "Smoke check https://${INGRESS_HOST}/health …" for i in $(seq 1 12); do if wget -q -O - --timeout=10 "https://${INGRESS_HOST}/health" | grep -q '"status":"ok"'; then log "Smoke OK: /health responde 200" exit 0 fi echo "Intento ${i}/12 — /health aún no responde, esperando 5s…" sleep 5 done echo "ERROR: /health no respondió 200 dentro del timeout" exit 1