chart 0.3.0: AddonClaim FUERA del chart Helm (ADR-066 del IDP)
Some checks failed
__APP_NAME__ — build & deploy / Calidad + build + push (push) Failing after 14s
__APP_NAME__ — build & deploy / Helm upgrade --install (push) Has been skipped

Bug en chart 0.2.x: AddonClaim era Helm hook pre-install,pre-upgrade
weight -10 SIN hook-delete-policy. El default Helm 3 es
before-hook-creation → borra el claim en cada upgrade → finalizer
cleanup-logical rotaba credenciales + atoraba release en pending-upgrade.

Cambios:
- helm/APP_NAME/templates/addonclaim.yaml eliminado.
- manifests/addonclaim.yaml creado con placeholders sed.
- scripts/pre-helm-detach.sh idempotente para migracion v1.0.0 → 1.1.0.
- .gitea/workflows/APP_NAME-build.yaml: 2 nuevos steps detach + apply
  AddonClaim entre kubeconfig y helm upgrade.
- Chart.yaml bump 0.2.0 → 0.3.0.
- README reescrito (3 pasos workflow + tabla hooks sin AddonClaim +
  seccion Por que FUERA del chart + opt-out actualizado).
- Comentarios stale del AddonClaim como hook -10 limpiados en
  networkpolicy.yaml, role-db-secret-reader.yaml, job-alembic-upgrade.yaml.

ADR-066 del IDP documenta la decision arquitectural completa.
This commit is contained in:
2026-05-25 14:28:17 -05:00
parent bba5720975
commit 13b9fae61c
9 changed files with 214 additions and 71 deletions

View File

@@ -13,6 +13,8 @@ on:
paths:
- 'code/**'
- 'helm/**'
- 'manifests/**'
- 'scripts/**'
- '.gitea/workflows/__APP_NAME__-build.yaml'
workflow_dispatch: {}
@@ -125,6 +127,36 @@ 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: Helm upgrade --install
env:
TENANT_ID: ${{ vars.TENANT_ID }}
@@ -144,8 +176,8 @@ jobs:
--wait --timeout 7m
# 7m absorbe el peor caso del reconcile de addon-provisioner
# (RECONCILE_INTERVAL 300s + initial_delay 60s) + StatefulSet MariaDB
# ready (~60s) + Job DDL (~10s) + InitContainer wait-for-secret (~5s)
# + Alembic upgrade. Ver helm/<APP_NAME>/templates/addonclaim.yaml.
# ready (~60s) + Job DDL (~10s) + InitContainer wait-for-secret + Alembic.
# AddonClaim ya fue aplicado en el step previo (ADR-066).
- name: Verificar rollout
run: |

View File

@@ -26,7 +26,11 @@ tenant, los placeholders en mayúsculas se sustituyen automáticamente.
│ ├── pyproject.toml
│ ├── requirements.txt
│ └── Dockerfile
├── helm/__APP_NAME__/ ← Helm chart con AddonClaim + hook Alembic
├── helm/__APP_NAME__/ ← Helm chart (Deployment, Job Alembic, NP, RBAC)
├── manifests/
│ └── addonclaim.yaml ← AddonClaim fuera del chart (ADR-066)
├── scripts/
│ └── pre-helm-detach.sh ← migración v1.0.0 → v1.1.0 (idempotente)
└── .gitea/workflows/ ← CI: tests + build + deploy
```
@@ -48,30 +52,57 @@ tenant, los placeholders en mayúsculas se sustituyen automáticamente.
---
## Orden de hooks Helm
## Orden de aplicación — AddonClaim + hooks Helm
El workflow del tenant sigue este orden en cada `git push`:
| Paso | Acción | Motivo |
|------|--------|--------|
| 1 | `bash scripts/pre-helm-detach.sh` | Migración chart 1.0.0 → 1.1.0: si el AddonClaim tiene labels/annotations de un release Helm previo, las quita. Idempotente: skip si no aplica. Sunset tras ~2 semanas. |
| 2 | `kubectl apply -f manifests/addonclaim.yaml` (con `sed` para placeholders) | Crea o actualiza el `AddonClaim`. Idempotente. `addon-provisioner` empieza a reconciliar en paralelo. |
| 3 | `helm upgrade --install <release> ./helm/<APP_NAME> --wait --timeout 7m` | Aplica el chart Helm. Los hooks `pre-install,pre-upgrade` corren en orden de `hook-weight` (ver tabla abajo). El Job Alembic espera al Secret. |
El chart usa hooks `pre-install,pre-upgrade` con `hook-weight` para garantizar
el orden de creación. El `helm upgrade --install` funciona **sin `--no-hooks`**:
el orden de creación de SUS recursos (sin AddonClaim — ese vive fuera).
`helm upgrade --install` funciona **sin `--no-hooks`**:
| Recurso | Template | `hook-weight` | Motivo |
|---------|----------|---------------|--------|
| `AddonClaim` | `addonclaim.yaml` | `-10` | Declara la intención de la app de tener una BD — el primero en aplicarse para que `addon-provisioner` empiece a reconciliar lo antes posible |
| `NetworkPolicy` | `networkpolicy.yaml` | `-8` | Bajo el `default-deny` del namespace `apps`, esta NP permite DNS + apiserver + addons:3306. Debe existir antes que el InitContainer del Job Alembic intente conectarse al apiserver. |
| `Role` + `RoleBinding` | `role-db-secret-reader.yaml` | `-7` | Permite al SA leer el Secret `<APP_NAME>-db-creds` (lo consume el InitContainer `wait-for-secret` del Job Alembic) |
| `ServiceAccount` | `serviceaccount.yaml` | `-5` | Lo referencia el pod del Job Alembic — debe existir antes |
| `Job` Alembic | `job-alembic-upgrade.yaml` | `0` | InitContainer `wait-for-secret` espera al Secret publicado por `addon-provisioner` (hasta 240s), luego corre `alembic upgrade head` |
El `AddonClaim` (hook weight `-10`) NO declara `hook-delete-policy` intencionalmente:
así sobrevive entre `helm upgrade` (re-apply idempotente) y se evita disparar el
finalizer `cleanup-logical` del controller en cada upgrade (que dropearía el
user de la BD y regeneraría el password, causando ventana de downtime).
Trade-off: `helm uninstall` deja el AddonClaim huérfano y MariaDB sigue
aprovisionada. Es coherente con `deletionPolicy: Retain` — borrar BD productiva
debe ser decisión explícita, no side-effect del uninstall. Cleanup explícito:
### Por qué el `AddonClaim` vive FUERA del chart (ADR-066)
En chart 0.2.x el `AddonClaim` era hook `pre-install,pre-upgrade` weight `-10`.
El default de Helm 3 (`hook-delete-policy: before-hook-creation`) borraba el
claim previo antes de aplicar el nuevo en cada `helm upgrade --install`. El
finalizer `addon-provisioner.idp.coralware.cloud/cleanup-logical` ejecutaba
entonces `REVOKE` + `DROP USER` + delete del Secret en cada upgrade → rotación
involuntaria de credenciales + release atorado en `pending-upgrade`.
Convertir el `AddonClaim` a manifest principal del chart (no hook) también
falla: los hooks `pre-install,pre-upgrade` corren ANTES del manifest principal,
así que el Job Alembic intentaría leer el Secret antes de que el AddonClaim
exista — chicken-and-egg fatal.
La solución es desacoplar el lifecycle del CRD del lifecycle del release Helm:
el `AddonClaim` lo aplica el workflow con `kubectl apply` (idempotente, sin
ownership Helm), y Helm gobierna sólo la app + sus hooks Alembic/NP/RBAC.
`helm uninstall` no toca el AddonClaim — desacoplamiento garantizado.
Borrar la BD lógica es decisión explícita:
```bash
kubectl delete addonclaim <release>-db -n apps
```
(Con `deletionPolicy: Retain` — default — preserva la BD lógica; el operador
emite Event `OrphanLogicalDatabase`. Cambiar a `Delete` previo para `DROP DATABASE`.)
### Por qué el ServiceAccount es hook
El `ServiceAccount` se materializa como hook (no en la fase principal de Helm)
porque el Job Alembic, que también es hook, declara `serviceAccountName`. Si el
SA viviera en la fase principal se crearía **después** del Job → el pod del Job
@@ -108,28 +139,33 @@ claves `DATABASE_URL` / `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_NAME` /
`DATABASE_USER` / `DATABASE_PASSWORD` (y opcionalmente `MIGRATION_DATABASE_URL`).
El Secret lo materializa automáticamente la plataforma vía el flujo
`AddonClaim``addon-provisioner` (ADR-052 + ADR-064):
`AddonClaim``addon-provisioner` (ADR-052 + ADR-064 + ADR-066):
1. El chart aplica `helm/__APP_NAME__/templates/addonclaim.yaml` como hook
`pre-install,pre-upgrade` weight `-10` — declara `addonType: mariadb`,
`databaseName` derivado del Release name y el `secretName` esperado.
1. El workflow del tenant aplica `manifests/addonclaim.yaml` con
`kubectl apply -f -` (placeholders sustituidos con `sed`). El claim declara
`addonType: mariadb`, `databaseName` derivado del nombre del release y el
`secretName` esperado.
2. `addon-provisioner` (corriendo en el plano de control IDP) reconcilia el
`AddonClaim`: crea una BD lógica + user + grant en la instancia MariaDB
compartida del tenant (ns `addons` del cluster tenant) vía Job DDL efímero.
3. Publica el Secret `__APP_NAME__-db-creds` en el ns `apps`.
4. El InitContainer `wait-for-secret` del Job Alembic espera al Secret (hasta
240s), Alembic ejecuta `upgrade head`, y el Deployment rola.
4. El workflow corre `helm upgrade --install`. El InitContainer
`wait-for-secret` del Job Alembic (hook `pre-install,pre-upgrade`) espera al
Secret (hasta 240s), Alembic ejecuta `upgrade head`, y el Deployment rola.
### Opt-out — apps stateless
Si tu app no necesita BD, en `values.yaml`:
Si tu app no necesita BD:
```yaml
database:
enabled: false # no se aplica AddonClaim ni Role/RoleBinding
alembic:
1. En `values.yaml`:
```yaml
database:
enabled: false # no se aplica Role/RoleBinding al SA
alembic:
enabled: false # no corre el Job Alembic
```
```
2. Elimina el step "Aplicar AddonClaim" del workflow `.gitea/workflows/__APP_NAME__-build.yaml`
(junto con `manifests/addonclaim.yaml` y el step `pre-helm-detach`).
Con `database.enabled=false` la app se despliega sin BD ni migraciones.

View File

@@ -1,6 +1,14 @@
apiVersion: v2
name: __APP_NAME__
# Helm chart del Golden Path web-backend/python.
# 0.3.0 (2026-05-25): ADR-066 — AddonClaim sale del chart y vive en
# manifests/addonclaim.yaml. El workflow del tenant lo aplica con
# kubectl apply ANTES de helm upgrade --install. Razon: en chart 0.2.x el
# AddonClaim era hook pre-install,pre-upgrade weight -10 → el default Helm
# `before-hook-creation` borraba el claim en cada upgrade → el finalizer
# cleanup-logical rotaba credenciales y atoraba el release en
# pending-upgrade. Desacoplar el lifecycle del CRD del lifecycle del
# release Helm resuelve el bug de raiz.
# 0.2.0 (2026-05-19): cambio Ingress controller Traefik (IngressRoute CRD)
# → rke2-ingress-nginx (networking.k8s.io/v1 Ingress). Razón:
# TENANT-INGRESS-CONTROLLER-MISMATCH (APERTURA COVE Sesión 3). El cluster
@@ -8,7 +16,7 @@ name: __APP_NAME__
# adicional. tlsSecretName por default = apps-wildcard-tls (Gap 3).
description: Helm chart del Golden Path web-backend/python
type: application
version: 0.2.0
version: 0.3.0
appVersion: "0.1.0"
keywords:
- fastapi

View File

@@ -1,39 +0,0 @@
{{- if .Values.database.enabled }}
# AddonClaim — declara la intencion de la app de tener una BD (default MariaDB).
# Lo materializa addon-provisioner (CRD idp.coralware.cloud/v1alpha1):
# - reconcilia este claim cada RECONCILE_INTERVAL (default 300s) por TenantProfile.
# - crea BD logica + user + grant via Job DDL efimero en el cluster tenant.
# - publica el Secret `{{ .Values.database.secretName }}` en este ns (`apps`)
# con DATABASE_URL / DATABASE_HOST / DATABASE_PORT / DATABASE_USER /
# DATABASE_PASSWORD / DATABASE_NAME (contrato §2.3 del OpenSpec).
#
# Ordering con los hooks del chart (clave para que Alembic encuentre el Secret):
# weight -10 (este AddonClaim)
# -> weight -5 (ServiceAccount)
# -> weight 0 (Job Alembic — espera al Secret via InitContainer wait-for-secret)
#
# delete-policy: NO se declara intencionalmente.
# - Asi el AddonClaim sobrevive entre `helm upgrade` (re-apply idempotente).
# - Evita disparar el finalizer `cleanup-logical` en cada upgrade (que dropearia
# el user y regeneraria el password, causando ventana de downtime).
# - Trade-off documentado: `helm uninstall` deja el AddonClaim orfano y MariaDB
# sigue aprovisionada. Es coherente con `deletionPolicy: Retain` (default):
# borrar BD productiva debe ser decision explicita, no side-effect del uninstall.
# Cleanup explicito: `kubectl delete addonclaim {{ .Release.Name }}-db -n apps`.
apiVersion: idp.coralware.cloud/v1alpha1
kind: AddonClaim
metadata:
name: {{ .Release.Name }}-db
labels:
{{- include "app.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
spec:
addonType: {{ .Values.database.addonType | default "mariadb" | quote }}
databaseName: {{ .Values.database.databaseName | default (printf "%s_db" (.Release.Name | replace "-" "_")) | quote }}
user:
permissions: {{ .Values.database.user.permissions | default "rw" | quote }}
secretName: {{ .Values.database.secretName | quote }}
deletionPolicy: {{ .Values.database.deletionPolicy | default "Retain" | quote }}
{{- end }}

View File

@@ -5,9 +5,11 @@
# visible para `kubectl logs job/...` y diagnostico.
#
# InitContainer `wait-for-secret`:
# - El AddonClaim (hook weight -10) ya fue aplicado, pero addon-provisioner es
# timer-based (RECONCILE_INTERVAL 300s, initial_delay 60s) y materializa el
# Secret `{{ .Values.database.secretName }}` de forma asincrona.
# - El AddonClaim ya fue aplicado por el workflow del tenant ANTES del
# `helm upgrade --install` (ADR-066, vive FUERA del chart en
# manifests/addonclaim.yaml). addon-provisioner es timer-based
# (RECONCILE_INTERVAL 300s, initial_delay 60s) y materializa el Secret
# `{{ .Values.database.secretName }}` de forma asincrona.
# - El InitContainer hace busy-wait hasta que el Secret existe — absorbe la
# latencia del reconcile dentro del pod, sin tocar el operator ni
# bajar RECONCILE_INTERVAL global.

View File

@@ -12,7 +12,10 @@
# NetworkPolicy nunca se aplica en la fase principal.
#
# Weight ordering (mas chico = antes):
# -10 AddonClaim -> -8 NetworkPolicy -> -7 Role/RoleBinding -> -5 SA -> 0 Job Alembic
# -8 NetworkPolicy -> -7 Role/RoleBinding -> -5 SA -> 0 Job Alembic
# (El AddonClaim vive FUERA del chart desde 0.3.0 — ADR-066. Lo aplica el
# workflow del tenant ANTES de `helm upgrade --install`, asi que en la fase
# de hooks ya existe y addon-provisioner ya esta reconciliando.)
#
# delete-policy: before-hook-creation (NO hook-succeeded): la fase principal del
# Deployment necesita la NetworkPolicy viva en runtime; solo se borra/recrea al

View File

@@ -4,11 +4,13 @@
# (hook pre-install/pre-upgrade weight 0) que hace busy-wait hasta que
# addon-provisioner publica el Secret tras reconciliar el AddonClaim.
#
# Hook weight "-7" — entre AddonClaim (-10) y SA (-5):
# Hook weight "-7" — entre NetworkPolicy (-8) y SA (-5):
# - Garantiza que RBAC exista antes de que el SA del hook -5 se materialice
# y antes de que el Job -0 intente leer el Secret.
# - delete-policy: before-hook-creation para que sea idempotente entre upgrades
# sin dejar Roles huerfanos.
# (El AddonClaim ya no es hook del chart desde 0.3.0 — ADR-066. Lo aplica el
# workflow del tenant ANTES del `helm upgrade --install`.)
#
# Scope minimo: solo lectura (get/watch) del Secret `<release>-db-creds`, no
# secrets cluster-wide ni del ns `addons`. El root Secret de MariaDB vive en

34
manifests/addonclaim.yaml Normal file
View File

@@ -0,0 +1,34 @@
# AddonClaim — vive FUERA del chart Helm (ADR-066).
#
# El workflow del tenant sustituye los placeholders con `sed` y aplica con
# `kubectl apply -f -` ANTES de `helm upgrade --install`. helm nunca lo
# trackea — al `helm uninstall` no lo borra; al `helm upgrade --install`
# repetido no lo toca. Idempotente.
#
# Lifecycle gobernado por addon-provisioner (CRD idp.coralware.cloud/v1alpha1):
# - reconcilia cada RECONCILE_INTERVAL (default 300s) por TenantProfile.
# - crea BD logica + user + GRANT via Job DDL efimero en cluster tenant.
# - publica Secret `${RELEASE_NAME}-db-creds` en ns `apps`.
# - agrega finalizer `addon-provisioner.idp.coralware.cloud/cleanup-logical`.
#
# Cleanup explicito post `helm uninstall`:
# kubectl delete addonclaim ${RELEASE_NAME}-db -n apps
# (con deletionPolicy: Retain — default — preserva la BD; Event
# OrphanLogicalDatabase. Cambiar a Delete previo para DROP DATABASE.)
apiVersion: idp.coralware.cloud/v1alpha1
kind: AddonClaim
metadata:
name: ${RELEASE_NAME}-db
namespace: apps
labels:
app.kubernetes.io/name: ${RELEASE_NAME}
app.kubernetes.io/part-of: ${RELEASE_NAME}
idp.coralware.cloud/golden-path: web-backend
idp.coralware.cloud/golden-path-lang: python
spec:
addonType: mariadb
databaseName: ${DATABASE_NAME}
user:
permissions: rw
secretName: ${RELEASE_NAME}-db-creds
deletionPolicy: Retain

65
scripts/pre-helm-detach.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Script de migracion v1.0.0 -> v1.1.0 del chart web-backend-python (ADR-066).
#
# Las apps desplegadas con chart 1.0.0 tienen el AddonClaim creado como Helm
# hook con annotations `meta.helm.sh/release-name`, `app.kubernetes.io/managed-by=Helm`
# y `helm.sh/hook`. El chart 1.1.0 mueve el AddonClaim FUERA del chart; el
# workflow del tenant lo aplica con `kubectl apply -f` despues de este script.
#
# Sin de-adopcion, el siguiente `helm upgrade --install` veria el AddonClaim
# en el state previo (release v_n) pero no en el manifest nuevo (chart 1.1.0)
# → helm intentaria delete del recurso → el finalizer dropearia user/Secret.
#
# Este script:
# - skip silencioso si el AddonClaim no existe (primera instalacion).
# - skip silencioso si el AddonClaim NO tiene annotations Helm (ya de-adoptado).
# - de-adopta si encuentra annotations Helm — quita las labels/annotations
# que hacen que Helm lo reconozca como recurso del release.
#
# Idempotente. Sunset del script tras una rotacion completa de releases
# desplegadas con chart 1.0.0 (~2 semanas).
#
# Variables de entorno requeridas:
# - RELEASE_NAME: nombre del release Helm (== nombre de la app del tenant)
# - KUBECONFIG: apuntando al cluster tenant
#
# Uso (desde el workflow del tenant):
# RELEASE_NAME=__APP_NAME__ bash scripts/pre-helm-detach.sh
set -euo pipefail
: "${RELEASE_NAME:?ERROR: RELEASE_NAME no esta seteado}"
NAMESPACE="${NAMESPACE:-apps}"
CLAIM_NAME="${RELEASE_NAME}-db"
# Comprobar existencia. kubectl get devuelve exit 1 si NotFound — capturamos
# con `|| true` para no romper bajo `set -e`.
if ! kubectl get addonclaim "${CLAIM_NAME}" -n "${NAMESPACE}" >/dev/null 2>&1; then
echo "[pre-helm-detach] AddonClaim ${CLAIM_NAME} no existe en ns ${NAMESPACE} — skip (primera instalacion)."
exit 0
fi
# Detectar si tiene annotations Helm — si no, ya esta de-adoptado.
RELEASE_ANNOTATION=$(kubectl get addonclaim "${CLAIM_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || echo "")
if [ -z "${RELEASE_ANNOTATION}" ]; then
echo "[pre-helm-detach] AddonClaim ${CLAIM_NAME} sin annotations Helm — skip (ya de-adoptado)."
exit 0
fi
echo "[pre-helm-detach] AddonClaim ${CLAIM_NAME} con release=${RELEASE_ANNOTATION} — de-adoptando…"
# Quitar annotations Helm — `-` sufijo significa "remove".
kubectl annotate addonclaim "${CLAIM_NAME}" -n "${NAMESPACE}" \
meta.helm.sh/release-name- \
meta.helm.sh/release-namespace- \
--overwrite
# Quitar label de managed-by — el hook tambien deja la annotation `helm.sh/hook`
# en algunos casos; la dejamos viva porque no afecta el ownership tracking.
kubectl label addonclaim "${CLAIM_NAME}" -n "${NAMESPACE}" \
app.kubernetes.io/managed-by- \
--overwrite
echo "[pre-helm-detach] De-adopcion completa. El siguiente helm upgrade --install no tocara este AddonClaim."