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/**' - 'manifests/**' - 'scripts/**' - '.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). El commit de render dispara este workflow on:push en segundos; # si los secrets aún no están, el job build se SKIPEA (no falla, no rojo) y # tenant-manager dispara 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 # Los tests corren sobre SQLite efímero (ver code/tests/conftest.py) — # el runner no tiene Docker daemon. El deploy real usa MariaDB. 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-` (ADR-019 D-03). # Credenciales: robot scoped push/pull SOLO a ese proyecto, sembrado # por tenant-manager en el repo del tenant (paso 3 del reconcile). 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: | # build-gate ya garantizó que HARBOR_ROBOT_USER está presente (si no, # este job se habría skipeado) — GOLDEN-PATH-BUILD-DEPLOY-ORDER-RACE. 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 con 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: Instalar helm run: | 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 mkdir -p "${HOME}/.local/bin" curl -fsSL "https://get.helm.sh/helm-${HELM_VERSION}-linux-${ARCH}.tar.gz" \ | tar -xz -C /tmp "linux-${ARCH}/helm" mv "/tmp/linux-${ARCH}/helm" "${HOME}/.local/bin/helm" chmod +x "${HOME}/.local/bin/helm" echo "${HOME}/.local/bin" >> "$GITHUB_PATH" "${HOME}/.local/bin/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" # ── 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: | STATUS="$(helm status __APP_NAME__ -n apps 2>/dev/null | sed -n 's/^STATUS: //p')" 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 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: 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