# Spec — Golden Path `web-backend/python` **Versión del shape:** 1.2.0 **Status:** Activo (PoC NexoBMS como primer consumidor) **ADR de referencia:** ADR-053 (anatomía y decisiones), ADR-061 (workflow CI self-contained — sin `uses:` cross-repo; tests sobre SQLite efímero), ADR-066 (lifecycle del `AddonClaim` FUERA del chart Helm — aplicado por el workflow del tenant antes de `helm upgrade --install`), ADR-067/ADR-082 (FQDN subdominio DOT + wildcard TLS per-tenant) > **1.2.0** (2026-06-10): nueva ruta raíz `/` = página de bienvenida por defecto > (instrucciones de uso para el tenant; el primer `commit`+`push` la reemplaza con > su desarrollo). Endpoint opcional → bump minor (§7). Además, §2.2 corrige el FQDN > de la app a la forma subdominio DOT canónica (ADR-082). **Visibilidad:** interna de Coralware — este spec NO se sincroniza al repo Gitea `idp-templates/template-web-backend-python` ni al repo del cliente. --- ## 1. Propósito Definir el comportamiento operativo del shape `web-backend/python` — scaffold de app FastAPI con persistencia MariaDB y migraciones Alembic que se materializa en un repo Gitea por tenant via `repo-provisioner`. Este spec es la fuente de verdad del **flujo CI/CD del shape**. Cambios al flowchart son cambios de contrato — exigen subir `shapeVersion` (semver) y actualizar `repo-provisioner` y `tenant-manager` si los placeholders o secrets esperados cambian. --- ## 2. Contrato del shape ### 2.1 Entradas | Entrada | Origen | Descripción | |---------|--------|-------------| | `__APP_NAME__` | placeholder | Nombre del repo/app, sustituido server-side por `repo-provisioner` | | `__TENANT_ID__` | placeholder | UUID del tenant (con guiones) | | `__TENANT_ID_NODASHES__` | placeholder | UUID sin guiones, para construir DNS y namespaces | | `__TIER__` | placeholder | Tier del tenant: `startup` | `business` | `enterprise` | | Secret `-db-creds` | runbook MariaDB / AddonClaim (Fase 5.5+) | Claves `DATABASE_URL`, `MIGRATION_DATABASE_URL` | | `KUBECONFIG_TENANT_B64` | secret de proyecto Gitea | Provisto por `tenant-manager` al crear el repo | | `vars.TENANT_ID`, `vars.TENANT_ID_NODASHES`, `vars.TENANT_TIER` | vars de proyecto Gitea | Provisto por `tenant-manager` | ### 2.2 Salidas observables | Salida | Forma | |--------|-------| | Imagen Docker | `registry.coralsafety.com/t-/:` — proyecto Harbor dedicado del tenant (ADR-019 D-03) | | Helm release | `` en namespace `apps` del cluster tenant (ADR-054) | | App reachable | `https://..apps.coralware.cloud/health` retorna 200 (FQDN subdominio DOT, ADR-067/ADR-082) | | Página de bienvenida | `/` sirve HTML de bienvenida por defecto; el primer `commit`+`push` del tenant la reemplaza con su desarrollo | | Ingress controller | **Traefik Proxy + Gateway API** en `traefik-system` (bundle Fleet `traefik-bootstrap`) — ADR-060 Fase 1 (`GATEWAY-API-MIGRATION`); reemplaza `rke2-ingress-nginx` (EOL marzo 2026). El shape emite un `HTTPRoute` (gateway.networking.k8s.io/v1) adjunto al `Gateway` `tenant-gateway` (ns `apps`, creado por stack-deployer §3c) | | TLS | Wildcard per-tenant `*..apps.coralware.cloud` vía Secret `tenant-wildcard-tls` (cert-manager local del cluster, ADR-067) | | Endpoints obligatorios | `/`, `/health`, `/docs`, `/openapi.json`, `/demo`, `/metrics` | | Métricas Prometheus | scrape en `/metrics`; etiquetas con `golden-path=web-backend` | ### 2.3 Invariantes - La app **nunca** crashea por DB caída — `/health` retorna 503 con `db:down`. - Migraciones Alembic corren **antes** del rollout del Deployment (Helm hook). - Falla de Alembic ⇒ Helm aborta upgrade ⇒ Deployment anterior intacto. - `helm upgrade --install` del chart funciona **sin `--no-hooks`**. El `ServiceAccount` que referencia el pod del Job Alembic se materializa como hook `pre-install,pre-upgrade` con `hook-weight: -5` (menor que el `0` del Job) — así existe antes de que corra el Job. El SA-hook usa `hook-delete-policy: before-hook-creation` (no `hook-succeeded`) porque el Deployment de la fase principal lo necesita vivo en runtime. El `imagePullSecret` y el Secret de BD los provee la plataforma, no el chart. - SecurityContext: `runAsNonRoot=true`, `readOnlyRootFilesystem=true`, `drop ALL caps`. Si el código rompe esto, el spec lo prohíbe. - Sin IPs hardcoded; sin credenciales en código (env vars o Secret montado). ### 2.4 Lifecycle del `AddonClaim` (ADR-066) El `AddonClaim` vive **FUERA del chart Helm** — en `manifests/addonclaim.yaml` del scaffold del shape, con placeholders `${RELEASE_NAME}` / `${DATABASE_NAME}` sustituidos por `envsubst` en el workflow del tenant. El workflow lo aplica con `kubectl apply -f -` ANTES de `helm upgrade --install`. Helm nunca lo trackea. Justificación y reglas operativas: - **Por qué no en `templates/`:** un Helm hook `pre-install,pre-upgrade` cae en el default `hook-delete-policy: before-hook-creation`, que borra el claim previo antes de cada upgrade. El finalizer `addon-provisioner.idp.coralware.cloud/cleanup-logical` ejecuta entonces `REVOKE` + `DROP USER` + delete Secret en cada upgrade, rota credenciales involuntariamente y atora el release v_n+1 en `pending-upgrade`. El intento "sin delete-policy explícita" del chart 1.0.0 era incorrecto: el default Helm sí borra. - **Por qué no como manifest principal del chart (Opción B descartada):** los hooks `pre-install/pre-upgrade` corren ANTES del manifest principal. El Job Alembic (hook weight 0, con InitContainer `wait-for-secret`) intentaría conectarse al Secret antes de que el AddonClaim del manifest principal haya sido aplicado por Helm — chicken-and-egg fatal: InitContainer busy-wait 360s sobre un Secret que nunca aparece en la primera instalación. - **Por qué FUERA del chart (Opción A elegida):** desacopla totalmente el lifecycle de la app (chart Helm) del lifecycle de la BD (CRD del IDP). El workflow del tenant aplica el AddonClaim primero — el controller arranca el reconcile en paralelo — luego corre `helm upgrade --install` cuyos hooks encuentran el AddonClaim ya en flight. El InitContainer `wait-for-secret` absorbe la latencia del reconcile como hasta ahora. - **`kubectl apply -f` es idempotente:** sin annotations Helm, el AddonClaim no compite con ningún field manager. Helm en upgrade no lo trackea — no intenta borrarlo en uninstall ni modificarlo en upgrade. - **`helm uninstall` no toca el AddonClaim:** desacoplamiento garantizado. Cleanup explícito post-uninstall: `kubectl delete addonclaim -db -n apps` dispara el finalizer; con `Retain` (default) preserva la BD; cambiar a `Delete` previo si se quiere `DROP DATABASE`. - **Migración v1 → v2** (apps desplegadas con chart 1.0.0, donde el AddonClaim fue creado como Helm hook): el workflow aplica el script `pre-helm-detach.sh` ANTES de `helm upgrade --install`. Idempotente: si el AddonClaim NO tiene annotations Helm, no hace nada; si las tiene, ejecuta `kubectl annotate ... meta.helm.sh/release-name- meta.helm.sh/release-namespace-` y `kubectl label ... app.kubernetes.io/managed-by-` para que Helm deje de reconocerlo como recurso del release. El siguiente `helm upgrade --install` con chart 1.1.0 (sin AddonClaim en `templates/`) no lo verá en el diff y no intentará borrarlo. Sunset del script tras una rotación de releases. - **InitContainer `wait-for-secret`** absorbe la latencia del primer reconcile del controller. Window dimensionado a `3 × RECONCILE_INTERVAL` del addon-provisioner: con default 60s + initial_delay 60s, el chart espera 72 × 5s = 360s. Subir el default del controller obliga a subir este window en paralelo (ver `code/addon-provisioner/openspec/specs/main.md` §3.1). Lección 2026-05-28: con RECONCILE_INTERVAL=300s y wait=240s el init container moria antes de que el Secret se publicara — delay observado 577s. Ticket ADDON-PROVISIONER-DB-SECRET-NOT-PUBLISHED. --- ## 3. Flujo principal ```mermaid flowchart TD dev_push([Dev hace git push\nal repo Gitea del tenant]) --> quality_gate[quality_gate\nruff + black + bandit + pytest --cov 80] quality_gate -- fail --> pipeline_failed([pipeline_failed\nHelm no se invoca]) quality_gate -- ok --> image_build[image_build\nbuildx push a Harbor t-short] image_build -- fail --> pipeline_failed image_build -- ok --> pre_helm_detach[pre_helm_detach\nde-adoptar AddonClaim legacy\nidempotente migracion v1.0→1.1] pre_helm_detach --> apply_addonclaim[apply_addonclaim\nkubectl apply manifests/addonclaim.yaml] apply_addonclaim --> helm_deploy[helm_deploy\nhelm upgrade --install --wait] helm_deploy --> alembic_hook[alembic_hook\nJob pre-upgrade alembic upgrade head] alembic_hook -- fail --> app_intact_old_version([app_intact_old_version\nDeployment anterior corriendo\npipeline_failed]) alembic_hook -- ok --> rollout_apply[rollout_apply\nDeployment con nuevo image tag] rollout_apply --> rollout_status_check[rollout_status_check\nkubectl rollout status timeout 3m] rollout_status_check -- fail --> helm_rollback_manual([helm_rollback_manual\ndev decide rollback\npipeline_failed]) rollout_status_check -- ok --> smoke_health_check[smoke_health_check\nwget /health 200 + db:up] smoke_health_check -- fail --> investigate([investigate\nlogs + events\npipeline_failed]) smoke_health_check -- ok --> app_running([app_running\ncriterios PoC 1-9 cumplidos]) ``` **Node IDs como labels Prometheus.** Cada nodo es un valor del label `phase` en métricas como `idp_shape_pipeline_phase_total{shape="web-backend-python", phase="alembic_hook", outcome="success|fail"}`. --- ## 4. Estados terminales | Estado | Significado | Acción del dev | |--------|-------------|----------------| | `app_running` | Todo OK — app desplegada, /health 200, db:up | Ninguna | | `pipeline_failed` | Cualquier etapa antes de rollout falló | Inspeccionar logs del CI, corregir, push otra vez | | `app_intact_old_version` | Alembic falló — versión anterior sigue corriendo | Inspeccionar logs del Job alembic; corregir migración; push | | `helm_rollback_manual` | Rollout no estabilizó en 3 min | Ejecutar `helm rollback` manual; investigar pod events | | `investigate` | Smoke falla aunque rollout terminó | Logs del Deployment, eventos del cluster, /health interno | --- ## 5. Comportamiento fail-safe - **DB caída en runtime:** `/health` 503 + `db:down`. App sigue sirviendo endpoints que no requieren DB. Nunca exit 1, nunca CrashLoopBackOff por DB intermitente. (Pod muere solo si DATABASE_URL falta al startup.) - **Imagen no disponible en Harbor:** K8s reporta `ImagePullBackOff` visible en Rancher; rollout no avanza; rollback automático no aplica (dev decide). - **Migration Alembic inválida:** transacción revertida; `alembic_version` sin cambio; Helm aborta upgrade; Deployment con versión vieja sigue. - **`/health` reporta `db:up` con DB caída:** prohibido — handler hace `SELECT 1` por request, sin cache (validado en tests). --- ## 6. Pruebas negativas obligatorias (N1-N5) Definidas en `docs/runbook-mariadb-poc.md` §6 y `poc-nexobms.md` §4. Cualquier release del shape (cambio de `shapeVersion`) las re-ejecuta: | ID | Prueba | Estado terminal esperado | |----|--------|--------------------------| | N1 | Test fallando | `pipeline_failed` en `quality_gate` | | N2 | Migración Alembic inválida | `app_intact_old_version` | | N3 | Credencial DB inválida | App corriendo, `/health` 503 con `db:down` | | N4 | `DATABASE_URL` ausente al startup | `CrashLoopBackOff` visible en Rancher | | N5 | Imagen tag inexistente | `ImagePullBackOff` visible en Rancher | --- ## 7. Versionado del shape `coralware-shape.yaml` declara `shapeVersion` (semver): | Cambio | Bump | |--------|------| | Añadir endpoint nuevo opcional | minor | | Renombrar endpoint obligatorio | major | | Cambiar nombre de Secret esperado | major | | Cambiar threshold de coverage | minor | | Añadir nueva variable de entorno opcional | minor | | Añadir nodo nuevo al flowchart | minor | | Cambiar terminal state | major | Tenants existentes congelan su versión hasta migrar explícitamente. `coralware.cloud/v1alpha1/Shape` permite marcar versiones deprecated; `cove-api` puede surfacear avisos en el portal del tenant. --- ## 8. Sincronización a Gitea `templates/web-backend-python/` (este repo IDP) → `idp-templates/template-web-backend-python` (Gitea). Exclusiones del sync (no llegan al repo del cliente): - `openspec/` — gobernanza interna del golden path. - `.ruff_cache/`, `__pycache__/`, `.pytest_cache/`. - `.venv/`, `venv/`. Mecanismo: `rsync --exclude='openspec/' --exclude-from=.gitignore` desde el scaffold local hacia el clone temporal del repo Gitea, commit, push. --- ## 9. Implementación pendiente (referencia) Ver ADR-053 §11. Resumen: - [ ] Sync inicial del scaffold a Gitea (esta sesión). - [ ] Render server-side de placeholders en `repo-provisioner`. - [ ] Provisión de `KUBECONFIG_TENANT_B64` en Gitea Actions secrets por `tenant-manager`. - [ ] Smoke E2E del shape (extiende ADR-051) con stage `app_deployed_and_healthy`. - [ ] Migración del runbook manual MariaDB al `addon-provisioner` (ADR-052) en Fase 5.5+. - [ ] Sunset del step `pre_helm_detach` tras una rotación completa de releases desplegadas con chart 1.0.0 (~2 semanas — la migración v1→v2 será de un solo uso por release y luego idempotente skip).