From 41cd03360c274d9fea0dba01194c2ad97c922daa Mon Sep 17 00:00:00 2001 From: idp-template-mirror Date: Sat, 20 Jun 2026 01:02:32 +0000 Subject: [PATCH] chore(mirror): sync desde templates/web-backend-python (ADR-095) --- .gitea/workflows/APP_NAME-build.yaml | 145 ++-------------------- scripts/tenant-first-deploy.sh | 179 +++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 132 deletions(-) create mode 100755 scripts/tenant-first-deploy.sh diff --git a/.gitea/workflows/APP_NAME-build.yaml b/.gitea/workflows/APP_NAME-build.yaml index 10f4178..1b37a16 100644 --- a/.gitea/workflows/APP_NAME-build.yaml +++ b/.gitea/workflows/APP_NAME-build.yaml @@ -205,139 +205,20 @@ jobs: echo "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config" chmod 600 "${HOME}/.kube/config" - # ── 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. - - - name: De-adoptar AddonClaim legacy (migracion chart 1.0.0 -> 1.1.0) - # Idempotente: skip silencioso si AddonClaim no existe (primera - # instalacion) o si ya esta de-adoptado (segundo run post-migracion). - # Si tiene annotations Helm de un release v1.0.0, las quita para que - # el siguiente helm upgrade no intente borrarlo (no esta en el chart). - # Sunset del step tras una rotacion completa (~2 semanas). - run: | - RELEASE_NAME=__APP_NAME__ bash scripts/pre-helm-detach.sh - - - name: Aplicar AddonClaim - # Sustitucion de placeholders con sed (evita dependencia de envsubst, - # que no esta 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. - env: - RELEASE_NAME: __APP_NAME__ - run: | - # databaseName = release name con guiones a underscores + sufijo _db. - # Coincide con el default del chart 1.0.0 (.Release.Name | replace - # "-" "_" | printf "%s_db"). - DATABASE_NAME="$(echo "${RELEASE_NAME}" | tr '-' '_')_db" - sed -e "s|\${RELEASE_NAME}|${RELEASE_NAME}|g" \ - -e "s|\${DATABASE_NAME}|${DATABASE_NAME}|g" \ - manifests/addonclaim.yaml | kubectl apply -f - - - - name: Esperar rancher-webhook ready (RANCHER-WEBHOOK-RACE-RECOVERY) - # Race condition durante el bootstrap del cluster tenant: el - # `rancher-webhook` Deployment en cattle-system aún no tiene endpoints - # cuando el primer `helm upgrade --install` intenta crear 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`. - # El re-dispatch ~5-10min después recupera natural, pero degrada el smoke - # E2E. Espera explícita evita el flake. - run: | - # `kubectl wait` falla con NotFound si el Deployment aún no existe: - # durante el bootstrap del cluster tenant, `rancher-webhook` puede no - # haberse creado todavía cuando corre este step. Primero esperamos a - # que el objeto APAREZCA (poll), luego a que esté Available. - 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 - - - name: 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 anterior a este fix), el release queda en un estado `pending-*` - # y el siguiente `helm upgrade --install` aborta. Lo detectamos y recuperamos - # antes de desplegar. - # Fail-safe: SOLO actúa sobre estados `pending-*`/`uninstalling`; un release - # `deployed`, `failed` o inexistente NO se toca. Si el parseo no encuentra - # STATUS (formato inesperado) cae al branch no-op → comportamiento idéntico - # al actual, nunca peor. - run: | - # `helm status` de un release inexistente (first-deploy frío) sale con - # exit≠0; bajo el shell del runner (`bash -e -o pipefail`) eso abortaría - # el step ANTES del case. `|| true` lo neutraliza → STATUS="" → branch no-op. - STATUS="$(helm status __APP_NAME__ -n apps 2>/dev/null | sed -n 's/^STATUS: //p')" || true - echo "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 (equivale al - # destrabado manual del 2026-06-10) sin riesgo de datos: el primer deploy - # aún no levantó la app. - echo "Release en '${STATUS}' (sin revisión estable) → helm uninstall para limpiar." - helm uninstall __APP_NAME__ -n apps --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). - echo "Release en '${STATUS}' → helm rollback a la última revisión estable." - helm rollback __APP_NAME__ 0 -n apps --wait --timeout 2m || true - ;; - *) - echo "Release en estado estable o inexistente → sin recuperación necesaria." - ;; - esac - - - name: Helm upgrade --install + # ── Deploy del tenant — lógica factorizada a script reusable (ADR-095 §B2-D2) ─ + # La secuencia completa del deploy (de-adopt → AddonClaim → wait-webhook → + # recovery-release → helm upgrade --install → rollout → smoke) vive en + # scripts/tenant-first-deploy.sh. CS-7: el gate efímero B2 invoca ese MISMO + # shell byte-idéntico (con GATE_MODE=1) → prueba en runtime el shell donde + # estuvo el bug canónico de la enfermedad B (el `helm status` bajo `set -e`). + # El AddonClaim (ADR-066) lo aplica el script solo si existe + # manifests/addonclaim.yaml (este template lo tiene). helm y el kubeconfig ya + # quedaron listos en los steps previos (setup de entorno, no lógica de deploy). + - name: Deploy del tenant (tenant-first-deploy.sh) env: + APP_NAME: __APP_NAME__ + IMAGE_TAG: ${{ needs.build.outputs.image_tag }} TENANT_ID: ${{ vars.TENANT_ID }} TENANT_ID_NODASHES: ${{ vars.TENANT_ID_NODASHES }} TENANT_TIER: ${{ vars.TENANT_TIER }} - IMAGE_TAG: ${{ needs.build.outputs.image_tag }} - # 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. - run: | - helm upgrade --install __APP_NAME__ ./helm/__APP_NAME__ \ - --namespace apps \ - --create-namespace \ - --set image.tag=${IMAGE_TAG} \ - --set tenantId=${TENANT_ID} \ - --set tenantIdNodashes=${TENANT_ID_NODASHES} \ - --set tier=${TENANT_TIER} \ - --wait --timeout 10m - # 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: - # con RECONCILE_INTERVAL=300s y wait 240s el init container moria - # antes (delay observado 577s, ticket - # ADDON-PROVISIONER-DB-SECRET-NOT-PUBLISHED). AddonClaim ya fue - # aplicado en el step previo (ADR-066). - - - name: Verificar rollout - run: | - kubectl -n apps rollout status deploy/__APP_NAME__ --timeout=3m - - - name: Smoke check post-deploy - # 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. - env: - INGRESS_HOST: __APP_NAME__.${{ vars.TENANT_ID }}.apps.coralware.cloud - run: | - for i in $(seq 1 12); do - if wget -q -O - --timeout=10 "https://${INGRESS_HOST}/health" | grep -q '"status":"ok"'; then - echo "Smoke OK: /health responde 200 con db:up" - 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 + run: bash scripts/tenant-first-deploy.sh diff --git a/scripts/tenant-first-deploy.sh b/scripts/tenant-first-deploy.sh new file mode 100755 index 0000000..b9eb755 --- /dev/null +++ b/scripts/tenant-first-deploy.sh @@ -0,0 +1,179 @@ +#!/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