From 5a215ed2933a42ab5200e104c993c00a6f59abb3 Mon Sep 17 00:00:00 2001 From: ilopez Date: Mon, 25 May 2026 07:56:42 -0500 Subject: [PATCH] feat: AddonClaim + InitContainer wait-for-secret en chart helm Cierra GOLDEN-PATH-ADDONCLAIM-MISSING (P0 hands-off pipeline). Cambios: - helm/APP_NAME/templates/addonclaim.yaml (NEW): hook pre-install/pre-upgrade weight -10, SIN delete-policy (sobrevive upgrades, evita disparar finalizer cleanup-logical). Gated por .Values.database.enabled (default true). - helm/APP_NAME/templates/role-db-secret-reader.yaml (NEW): Role + RoleBinding weight -7 que permite al SA leer el Secret -db-creds. Lo consume el InitContainer wait-for-secret del Job Alembic. - helm/APP_NAME/templates/job-alembic-upgrade.yaml: gated por database.enabled, anade InitContainer wait-for-secret (48x5s=240s) que espera a que addon-provisioner publique el Secret tras reconciliar el AddonClaim. Absorbe la latencia del @kopf.timer (RECONCILE_INTERVAL 300s + initial_delay 60s) sin tocar el operator. - helm/APP_NAME/values.yaml: bloque database completo (enabled, addonType, databaseName, user.permissions, deletionPolicy Retain). Opt-out con database.enabled=false + alembic.enabled=false. - .gitea/workflows/APP_NAME-build.yaml: helm timeout 5m -> 7m (margen para reconcile addon-provisioner + StatefulSet ready + DDL Job + Alembic). - claim-mariadb.yaml (DELETED): obsoleto - estaba en la raiz del template y nunca se renderizaba por helm. Reemplazado por addonclaim.yaml dentro del chart helm. - README.md: documenta nueva tabla de hook weights, opt-out database, y trade-off del orphan AddonClaim post-uninstall (cleanup explicito). Decision arquitectural: Opcion D modificada (Opus 4.6 consult). Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/APP_NAME-build.yaml | 6 +- README.md | 59 +++++++++++++++---- claim-mariadb.yaml | 25 -------- helm/APP_NAME/templates/addonclaim.yaml | 39 ++++++++++++ .../templates/job-alembic-upgrade.yaml | 55 +++++++++++++++-- .../templates/role-db-secret-reader.yaml | 50 ++++++++++++++++ helm/APP_NAME/values.yaml | 17 ++++++ 7 files changed, 208 insertions(+), 43 deletions(-) delete mode 100644 claim-mariadb.yaml create mode 100644 helm/APP_NAME/templates/addonclaim.yaml create mode 100644 helm/APP_NAME/templates/role-db-secret-reader.yaml diff --git a/.gitea/workflows/APP_NAME-build.yaml b/.gitea/workflows/APP_NAME-build.yaml index 86638eb..a21be44 100644 --- a/.gitea/workflows/APP_NAME-build.yaml +++ b/.gitea/workflows/APP_NAME-build.yaml @@ -141,7 +141,11 @@ jobs: --set tenantId=${TENANT_ID} \ --set tenantIdNodashes=${TENANT_ID_NODASHES} \ --set tier=${TENANT_TIER} \ - --wait --timeout 5m + --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//templates/addonclaim.yaml. - name: Verificar rollout run: | diff --git a/README.md b/README.md index 0d46113..595de8c 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,7 @@ tenant, los placeholders en mayúsculas se sustituyen automáticamente. │ ├── pyproject.toml │ ├── requirements.txt │ └── Dockerfile -├── helm/__APP_NAME__/ ← Helm chart con hook Alembic pre-upgrade -├── claim-mariadb.yaml ← AddonClaim de la BD (ver §Base de datos) +├── helm/__APP_NAME__/ ← Helm chart con AddonClaim + hook Alembic └── .gitea/workflows/ ← CI: tests + build + deploy ``` @@ -56,8 +55,22 @@ el orden de creación. El `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 | +| `Role` + `RoleBinding` | `role-db-secret-reader.yaml` | `-7` | Permite al SA leer el Secret `-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` | Corre `alembic upgrade head` antes de rolar el Deployment | +| `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: + +```bash +kubectl delete addonclaim -db -n apps +``` 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 @@ -68,8 +81,9 @@ El SA-hook usa `hook-delete-policy: before-hook-creation` (no `hook-succeeded`): el Deployment lo necesita vivo en runtime, así que solo se borra/recrea al inicio del siguiente hook, manteniéndolo idempotente entre upgrades. -El `imagePullSecret` y el Secret `-db-creds` que referencia el Job los -provee la plataforma (stack-deployer / addon), no el chart. +El `imagePullSecret` lo provee la plataforma (stack-deployer), no el chart. +El Secret `-db-creds` lo publica `addon-provisioner` tras reconciliar +el `AddonClaim` (ver §Base de datos). --- @@ -90,14 +104,34 @@ provee la plataforma (stack-deployer / addon), no el chart. ## Base de datos Tu app espera un Secret `__APP_NAME__-db-creds` en su namespace con las -claves `DATABASE_URL` y `MIGRATION_DATABASE_URL`. El Secret lo provisiona la -plataforma: +claves `DATABASE_URL` / `DATABASE_HOST` / `DATABASE_PORT` / `DATABASE_NAME` / +`DATABASE_USER` / `DATABASE_PASSWORD` (y opcionalmente `MIGRATION_DATABASE_URL`). -- **PoC / MVP** — Coralware lo crea manualmente siguiendo el runbook - `docs/runbook-mariadb-poc.md` en el repo IDP. Tú no haces nada. -- **Producción (Fase 5.5+)** — `claim-mariadb.yaml` lo materializa - automáticamente cuando esté disponible el controller `addon-provisioner` - (ver ADR-052). +El Secret lo materializa automáticamente la plataforma vía el flujo +`AddonClaim` → `addon-provisioner` (ADR-052 + ADR-064): + +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. +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. + +### Opt-out — apps stateless + +Si tu app no necesita BD, en `values.yaml`: + +```yaml +database: + enabled: false # no se aplica AddonClaim ni Role/RoleBinding +alembic: + enabled: false # no corre el Job Alembic +``` + +Con `database.enabled=false` la app se despliega sin BD ni migraciones. --- @@ -165,4 +199,5 @@ Sin lock-in técnico. - `docs/adr-053-shape-web-backend-python.md` — anatomía del shape - `docs/adr-052-tenant-addon-claim-model.md` — modelo de add-ons (BD) +- `docs/adr-064-free-trial-allows-mariadb.md` — free-trial permite MariaDB - `docs/adr-038-ci-estandar-reusable-workflow.md` — pipeline Python diff --git a/claim-mariadb.yaml b/claim-mariadb.yaml deleted file mode 100644 index 9125f15..0000000 --- a/claim-mariadb.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# AddonClaim — referencia futura para cuando ADR-052 esté implementado. -# -# DURANTE LA POC NEXOBMS Y HASTA QUE addon-provisioner ESTÉ DISPONIBLE: -# este archivo NO se aplica. Coralware materializa el Secret -# __APP_NAME__-db-creds manualmente siguiendo docs/runbook-mariadb-poc.md. -# -# CUANDO ADR-052 ESTÉ EN FASE 5.5+: -# - el dev/CI hace `kubectl apply -f claim-mariadb.yaml` al ns de la app -# - addon-provisioner crea BD lógica + user + Secret automáticamente -# - el campo .spec.secretName debe coincidir con .Values.database.secretName -# del Helm chart (por convención: "-db-creds") -apiVersion: idp.coralware.cloud/v1alpha1 -kind: AddonClaim -metadata: - name: __APP_NAME__-db - labels: - idp.coralware.cloud/tenant-id: __TENANT_ID__ - idp.coralware.cloud/app: __APP_NAME__ -spec: - addonType: mariadb - databaseName: __APP_NAME___db # nombre de la BD lógica - user: - permissions: rw # rw | ro — default rw - secretName: __APP_NAME__-db-creds - deletionPolicy: Retain # Retain (default) | Delete diff --git a/helm/APP_NAME/templates/addonclaim.yaml b/helm/APP_NAME/templates/addonclaim.yaml new file mode 100644 index 0000000..d17876c --- /dev/null +++ b/helm/APP_NAME/templates/addonclaim.yaml @@ -0,0 +1,39 @@ +{{- 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 }} diff --git a/helm/APP_NAME/templates/job-alembic-upgrade.yaml b/helm/APP_NAME/templates/job-alembic-upgrade.yaml index 351789c..1ceefdf 100644 --- a/helm/APP_NAME/templates/job-alembic-upgrade.yaml +++ b/helm/APP_NAME/templates/job-alembic-upgrade.yaml @@ -1,8 +1,17 @@ -{{- if .Values.alembic.enabled }} +{{- if and .Values.alembic.enabled .Values.database.enabled }} # Job pre-upgrade que corre Alembic antes de rolar el Deployment. -# - backoffLimit: 0 → si falla, NO reintenta; Helm aborta el upgrade. -# - hook-delete-policy hook-succeeded → solo se borra al éxito; en fallo queda -# visible para `kubectl logs job/...` y diagnóstico. +# - backoffLimit: 0 -> si falla, NO reintenta; Helm aborta el upgrade. +# - hook-delete-policy hook-succeeded -> solo se borra al exito; en fallo queda +# 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 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. +# - Loop: 48 iteraciones x 5s = 240s — encaja dentro del helm timeout 7m. apiVersion: batch/v1 kind: Job metadata: @@ -28,6 +37,42 @@ spec: securityContext: runAsNonRoot: true runAsUser: 1001 + initContainers: + - name: wait-for-secret + image: bitnami/kubectl:1.30 + command: + - sh + - -c + - | + set -eu + # Espera hasta que addon-provisioner publique el Secret tras + # reconciliar el AddonClaim (hook weight -10). 48 x 5s = 240s. + SECRET="{{ .Values.database.secretName }}" + i=1 + while [ "$i" -le 48 ]; do + if kubectl get secret "$SECRET" -n "$NAMESPACE" >/dev/null 2>&1; then + echo "Secret $SECRET encontrado en intento $i" + exit 0 + fi + echo "[$i/48] Secret $SECRET aun no publicado, esperando 5s..." + sleep 5 + i=$((i + 1)) + done + echo "ERROR: Secret $SECRET no aparecio tras 240s — addon-provisioner stuck?" + exit 1 + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + resources: + requests: {cpu: 10m, memory: 32Mi} + limits: {cpu: 100m, memory: 64Mi} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] containers: - name: alembic image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -37,7 +82,7 @@ spec: name: {{ .Values.database.secretName }} env: # Si el Secret expone MIGRATION_DATABASE_URL, lo usa; si no, env.py - # cae a DATABASE_URL automáticamente (ver settings.migration_url). + # cae a DATABASE_URL automaticamente (ver settings.migration_url). - name: DATABASE_URL valueFrom: secretKeyRef: diff --git a/helm/APP_NAME/templates/role-db-secret-reader.yaml b/helm/APP_NAME/templates/role-db-secret-reader.yaml new file mode 100644 index 0000000..ddabd7c --- /dev/null +++ b/helm/APP_NAME/templates/role-db-secret-reader.yaml @@ -0,0 +1,50 @@ +{{- if .Values.database.enabled }} +# Role + RoleBinding que permiten al SA de la app leer el Secret `-db-creds` +# en el ns `apps`. Lo consume el InitContainer `wait-for-secret` del Job Alembic +# (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): +# - 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. +# +# Scope minimo: solo lectura (get/watch) del Secret `-db-creds`, no +# secrets cluster-wide ni del ns `addons`. El root Secret de MariaDB vive en +# `addons` y nunca debe ser accesible desde `apps` (ADR-052 §6.1). +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-db-secret-reader + labels: + {{- include "app.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-7" + "helm.sh/hook-delete-policy": before-hook-creation +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: [{{ .Values.database.secretName | quote }}] + verbs: ["get", "watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-db-secret-reader + labels: + {{- include "app.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-7" + "helm.sh/hook-delete-policy": before-hook-creation +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Release.Name }}-db-secret-reader +{{- end }} diff --git a/helm/APP_NAME/values.yaml b/helm/APP_NAME/values.yaml index cc4537e..b76077d 100644 --- a/helm/APP_NAME/values.yaml +++ b/helm/APP_NAME/values.yaml @@ -18,7 +18,24 @@ goldenPath: web-backend goldenPathLang: python database: + # Si enabled=false: no se aplica AddonClaim, no se crea Role/RoleBinding, + # ni corre el Job Alembic. El shape web-backend/python es BD-first por + # diseño, asi que el default es true. Para una app stateless, el dev + # opt-out poniendo database.enabled=false (y alembic.enabled=false). + enabled: true + # Tipo de add-on solicitado al addon-provisioner. R1 solo soporta mariadb. + addonType: mariadb + # Nombre del Secret a publicar en el ns `apps` con el connection string. + # El Job Alembic y el Deployment lo montan via envFrom. secretName: __APP_NAME__-db-creds + # Nombre de la BD logica. Vacio -> addon-provisioner deriva del Release.Name + # (snake_case + "_db"). Ver template addonclaim.yaml. + databaseName: "" + user: + permissions: rw # rw | ro + # Retain (default) | Delete — ver ADR-052 §6.2. Retain preserva la BD + # logica si el claim se borra (recomendado para PoC/free-trial). + deletionPolicy: Retain config: logLevel: INFO