Files
template-rest-api-python/.gitea/workflows/APP_NAME-build.yaml
ilopez 5efd243972 fix(ci): helm collision-proof en helm-deploy (pre-horneado + fallback mktemp único)
Propaga desde coralware/IDP@b31c741 (CI-SHARED-HOME-RACE). El step usa el helm
pre-horneado de la imagen del runner si está presente; el fallback descarga a un
dir único por-run (mktemp), nunca a $HOME/.local/bin ni /tmp/linux-$ARCH
compartidos que con executor=host se pisan entre jobs concurrentes.
2026-06-14 21:17:09 -05:00

300 lines
14 KiB
YAML

name: __APP_NAME__ — build & deploy
# Workflow self-contained del golden path (ADR-061): calidad + build + push +
# helm-deploy inline, SIN `uses:` cross-repo al repo privado `coralware/IDP`.
# El repo del tenant es un repo cliente — su CI no comparte el ciclo de release
# del IDP ni el GitOps de Fleet; se construye leyendo solo su propio repo.
# Corre sobre los runners `[self-hosted, buildkit]` de ci-system (executor=host,
# buildkit remoto — sin Docker daemon local; ADR-043).
on:
push:
branches: [main]
paths:
- 'code/**'
- 'helm/**'
- '.gitea/workflows/__APP_NAME__-build.yaml'
workflow_dispatch: {}
# ── Serialización de deploys (TENANT-BUILD-NO-CONCURRENCY-GUARD) ──────────────
# Sin este guard, múltiples dispatches del mismo repo (scaffold `on:push` +
# tenant-manager fase 1/fase 2 + re-dispatch por rotación de kubeconfig + smoke)
# corren `helm upgrade --install` EN PARALELO sobre el MISMO release → Helm aborta
# con "another operation (install/upgrade/rollback) is in progress" y deja el
# release trabado en `pending-install` → el primer deploy del tenant nunca
# completa (vivido E2E 2026-06-10, tenant 9e6babb0; destrabado a mano borrando el
# secret del release). Los runs se ENCOLAN, NO se cancelan: cancelar un run a mitad
# de `helm install` es justo lo que CREA el estado `pending-install`.
concurrency:
group: deploy-${{ gitea.repository }}
cancel-in-progress: false
jobs:
# ── Gate de credenciales de build (GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE) ───
# Los secrets HARBOR_ROBOT_USER/TOKEN los siembra tenant-manager en su fase 1
# (build-ready) cuando RepoProvision.phase=ready. El commit de render de
# repo-provisioner dispara este workflow on:push en segundos; en un template
# mínimo la calidad (ruff/black/bandit/pytest) corre en ~15s, así que el build
# alcanzaría el step "Build y push a Harbor" ANTES de que la fase 1 siembre
# (~20s) → fallaría con secrets vacíos. Este gate decide ANTES de la calidad:
# si los secrets no están, el job build se SKIPEA (no falla, no rojo) y
# tenant-manager dispara automáticamente un run completo (workflow_dispatch)
# tras la fase 1+2. Simétrico con cluster-gate del helm-deploy.
build-gate:
name: ¿Credenciales de build listas?
runs-on: [self-hosted, buildkit]
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- name: Verificar secrets Harbor sembrados
id: check
env:
ROBOT_USER: ${{ secrets.HARBOR_ROBOT_USER }}
run: |
if [ -z "$ROBOT_USER" ]; then
echo "::notice::Credenciales Harbor aún no sembradas (tenant-manager fase 1 build-ready). Este run se omite; el deploy completo se dispara automáticamente cuando estén listas (run #2)."
echo "ready=false" >> "$GITHUB_OUTPUT"
else
echo "ready=true" >> "$GITHUB_OUTPUT"
fi
# ── Calidad + build + push de imagen a Harbor ────────────────────────────
build:
name: Calidad + build + push
needs: build-gate
if: needs.build-gate.outputs.ready == 'true'
runs-on: [self-hosted, buildkit]
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
defaults:
run:
working-directory: code
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crear venv e instalar dependencias
# --system-site-packages reutiliza ruff/black/bandit/pytest ya
# preinstalados en la imagen del runner; el venv añade las deps de la app.
run: |
python3 -m venv --system-site-packages /tmp/venv-__APP_NAME__
/tmp/venv-__APP_NAME__/bin/pip install --no-cache-dir -r requirements-dev.txt
- name: Lint (ruff)
run: ruff check app
- name: Formato (black --check)
run: black --check app
- name: Seguridad (bandit)
run: bandit -c pyproject.toml -r app --severity-level medium
- name: Tests con cobertura
run: |
/tmp/venv-__APP_NAME__/bin/python -m pytest tests/ -v \
--cov=app --cov-report=term-missing --cov-fail-under=80
- name: Metadata de imagen
id: meta
run: echo "tag=${{ gitea.sha }}" >> "$GITHUB_OUTPUT"
- name: Build y push a Harbor
# Proyecto Harbor del tenant: `t-<tenant-id-short>` (ADR-019 D-03).
# Credenciales: robot scoped push/pull SOLO a ese proyecto, sembrado por
# tenant-manager (fase 1 build-ready). El job build-gate ya garantizó que
# HARBOR_ROBOT_USER está presente (si no, este job se habría skipeado).
env:
REGISTRY: registry.coralsafety.com
ROBOT_USER: ${{ secrets.HARBOR_ROBOT_USER }}
ROBOT_TOKEN: ${{ secrets.HARBOR_ROBOT_TOKEN }}
IMAGE: t-__TENANT_ID_SHORT__/__APP_NAME__
TAG: ${{ steps.meta.outputs.tag }}
run: |
printf '%s' "$ROBOT_TOKEN" | docker login "$REGISTRY" -u "$ROBOT_USER" --password-stdin
# Builder buildx remoto — apunta al buildkit daemon de ci-system
# (el runner no tiene Docker daemon local).
if ! docker buildx inspect tenant-builder >/dev/null 2>&1; then
docker buildx create --name tenant-builder --driver remote --use \
"${BUILDKIT_HOST:-tcp://buildkitd.ci-system.svc.cluster.local:2375}"
else
docker buildx use tenant-builder
fi
docker buildx build --platform linux/amd64 \
-t "${REGISTRY}/${IMAGE}:${TAG}" \
--push .
# ── Gate de cluster listo (GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE) ──────────
# El kubeconfig (secret KUBECONFIG_TENANT_B64) lo siembra tenant-manager en su
# fase 2 (deploy-ready) cuando el cluster RKE2 del tenant está Ready (~8 min).
# En el primer run (disparado por el commit de bootstrap) el cluster aún se
# está aprovisionando: este gate marca `ready=false` → el job helm-deploy se
# SKIPEA (no falla, no rojo). La imagen ya quedó publicada en el job build.
# tenant-manager dispara automáticamente un segundo run (workflow_dispatch) al
# sembrar el kubeconfig → ahí helm-deploy corre y la app queda viva.
cluster-gate:
name: ¿Cluster del tenant listo?
needs: build
runs-on: [self-hosted, buildkit]
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- name: Verificar kubeconfig sembrado
id: check
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_TENANT_B64 }}
run: |
if [ -z "$KUBECONFIG_B64" ]; then
echo "::notice::Imagen publicada en Harbor ✅. El cluster del tenant se está aprovisionando (~8 min); el deploy se ejecutará automáticamente cuando esté listo (run #2)."
echo "ready=false" >> "$GITHUB_OUTPUT"
else
echo "ready=true" >> "$GITHUB_OUTPUT"
fi
# ── Deploy al cluster del tenant — Helm upgrade --install (sin hook Alembic) ─
# El runner corre executor=host: los steps se ejecutan directo sobre el pod
# runner, sin `container:`. helm se instala en runtime.
# Solo corre cuando el cluster está listo (cluster-gate). En caso contrario el
# job aparece como skipped (no failure) — separa "imagen publicada" de
# "app desplegada" como dos hitos.
helm-deploy:
name: Helm upgrade --install
needs: [build, cluster-gate]
if: needs.cluster-gate.outputs.ready == 'true'
runs-on: [self-hosted, buildkit]
steps:
- uses: actions/checkout@v4
- name: Asegurar helm disponible
# helm v3.14.4 viene pre-horneado en la imagen del runner
# (infrastructure/ci-system/Dockerfile, igual que kubectl/trivy) → este
# step es un no-op de verificación. Fallback para imágenes antiguas:
# descarga a un directorio ÚNICO por-run (`mktemp -d`) y lo añade al PATH;
# NUNCA escribe a rutas compartidas ($HOME/.local/bin ni /tmp/linux-$ARCH),
# que con executor=host se pisan entre jobs concurrentes del mismo runner
# (causa raíz del flake first-deploy: "helm: Text file busy" /
# "mv: can't rename", CI-SHARED-HOME-RACE, sesión CI 2026-06-16).
run: |
if command -v helm >/dev/null 2>&1; then
echo "helm pre-horneado en la imagen: $(helm version --short)"
exit 0
fi
HELM_VERSION=v3.14.4
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64) ARCH=arm64 ;;
*) echo "Arquitectura no soportada: $(uname -m)"; exit 1 ;;
esac
DEST="$(mktemp -d)"
curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \
| tar -xz -C "$DEST" "linux-${ARCH}/helm"
chmod +x "${DEST}/linux-${ARCH}/helm"
echo "${DEST}/linux-${ARCH}" >> "$GITHUB_PATH"
"${DEST}/linux-${ARCH}/helm" version
- name: Decodificar kubeconfig del tenant
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_TENANT_B64 }}
run: |
if [ -z "$KUBECONFIG_B64" ]; then
echo "ERROR: secret KUBECONFIG_TENANT_B64 no configurado en el proyecto Gitea."
echo "tenant-manager debe provisionarlo al crear el repo."
exit 1
fi
mkdir -p "${HOME}/.kube"
echo "$KUBECONFIG_B64" | base64 -d > "${HOME}/.kube/config"
chmod 600 "${HOME}/.kube/config"
- 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`.
# Paridad con el template web-backend (este paso faltaba en rest-api).
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: |
STATUS="$(helm status __APP_NAME__ -n apps 2>/dev/null | sed -n 's/^STATUS: //p')"
echo "Estado actual del release __APP_NAME__: ${STATUS:-<no existe>}"
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
env:
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: namespace funcional fijo en cluster tenant — el cluster ya es el sandbox.
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 5m
- 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): <app>.<tenant-uuid>.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"
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