El cutover del catálogo — Lakekeeper ⇒ Gravitino IRC
Approach abierto: 2026-08-13. Propone, no describe. Regla de la casa: cada hecho
lleva el comando que lo demuestra; lo que no lo lleva va marcado como pendiente (⏳).Decisión del owner: adoptar el IRC en lugar de Lakekeeper. Este documento mide
primero qué es Lakekeeper para nosotros —no lo que dice su folleto— y después qué
entra en su lugar, qué se gana, qué se pierde y qué hay que traducir.Encuadre:
index-gobernanza-mapa.md§5·octies · §8·1.
1 · La pieza que se sustituye — LAKEKEEPER, medido
npx dotenv -e .env.local -- npx tsx scripts/warehouse/f0-scan-planning.ts
/v1/config → 200 · endpoints declarados: 25 · ⛔ ninguno de plan
1·1 · Los cinco papeles que juega de verdad
| Papel | ¿Lo usamos? | |
|---|---|---|
| ⭐⭐⭐ | El registro: nombre → puntero de metadata | SÍ. Es el núcleo |
| ⭐⭐⭐ | El CAS del commit — donde vive la atomicidad del lakehouse | SÍ. Invariante nº4: «Index nunca serializa escrituras; el CAS es suyo» |
| ⭐⭐ | El vending de credenciales (…/tables/{t}/credentials) | SÍ, y es el camino de lectura de HOY: Spark pide con X-Iceberg-Access-Delegation: vended-credentials |
| ⭐⭐ | Warehouses por inquilino — 9 (1 compartido + 8), 7 de 8 ya se sirven del suyo | SÍ, y es el aislamiento de almacenamiento |
| OIDC con Keycloak · soft-delete 7 días (P·3) · protección de borrado | SÍ |
1·2 · Y lo que NO usamos, aunque esté en su folleto
OpenFGA inerte · Cedar (no existe en su OSS) · el OPA bridge (es de Trino, que salió)
· sts-enabled=false y remote-signing-enabled=false.
⇒ Hoy Lakekeeper es, para nosotros, un almacén de metadatos Iceberg con vending. Toda la gobernanza es nuestra — y por eso este cutover no toca la gobernanza.
2 · Lo que entra — EL IRC DE GRAVITINO, medido
kubectl run … curl http://gravitino-irc:9001/iceberg/v1/config (desde el cluster)
24 endpoints · defaults con R2 ya resuelto:
s3.path-style-access=true · client.region=auto · S3FileIO · el endpoint de R2
| Lakekeeper (25) | Gravitino IRC (24) | |
|---|---|---|
| Registro + CAS | ✅ | ✅ |
⭐ …/tables/{t}/credentials | ✅ | ✅ el riesgo que no era |
⭐⭐ POST …/tables/{t}/plan | ⛔ | ✅ lo que se gana |
POST …/namespaces/{ns}/register | ✅ | ✅ ⇒ migrar es re-registrar punteros, no mover bytes |
Vistas (CRUD) · rename · metrics | ✅ | ✅ |
⛔ POST /v1/{prefix}/transactions/commit | ✅ declarado | ⛔ NO — §3 |
| Latencia desde el cómputo | 222 ms (Railway) | ⭐ 5,2 ms (mismo nodo) |
3 · ⭐⭐ «No declara transacciones» — qué es y qué se pierde exactamente
POST /v1/{prefix}/transactions/commit es el endpoint de la spec Iceberg REST para el
commit ATÓMICO MULTI-TABLA: se mandan los cambios de N tablas en una sola petición y
o entran todas o no entra ninguna.
Qué perdemos, dicho con precisión:
| ✅ Hoy no se usa | sustrato-07 §7 lo lista como «el catálogo ya lo declara, sin estrenar». Ninguna ruta nuestra lo llama |
| ⛔ Pero es la pieza que cierra una deuda ya escrita | «⛔⛔ Una escritura toca UNA tabla (writeTarget?: string)» — la primera de la deuda de escritura |
| ⛔ Y su ausencia se nota justo donde vamos | un pipeline con dos salidas · un MERGE que reparte · la ingesta como sentencia si escribe más de una tabla · y el split de transformPaths (matched/unmatched), que ya escribe dos datasets y hoy lo hace en dos commits |
⇒ No perdemos una función: perdemos una OPCIÓN comprada. La diferencia importa
para decidir —no rompe nada hoy— pero registrarla como «no perdemos nada» sería
exactamente el error de comparar folletos al revés.
Y hay que medir antes de cerrar esto: ⏳ ¿lo declara Gravitino 1.3+? La versión
desplegada es 1.2.0 porque :latest estaba roto (Jackson). Si una versión posterior lo
trae, la pérdida es temporal.
4 · ⭐⭐⭐ El warehouse por inquilino — sí se puede madurar, y así
Medido. Pidiendo un prefijo inventado al IRC:
GET /iceberg/v1/w_aaaa/namespaces
→ 404 NoSuchCatalogException: "Couldn't find Iceberg configuration for catalog w_aaaa"
at IcebergCatalogWrapperManager.createCatalogWrapper
⇒ ⭐ El IRC SÍ tiene el concepto: el prefijo mapea a un CATÁLOGO CONFIGURADO. No es
que sirva uno solo — es que hoy sólo hay uno declarado
(GRAVITINO_WAREHOUSE=s3://lakehouse/_probe-gravitino, el de la sonda G·1).
Las dos formas de declararlos, y su diferencia operativa es lo que decide:
| Cómo | Provisionar un inquilino nuevo | |
|---|---|---|
| estático (IRC standalone) | un bloque de config por catálogo | ⚠️ cambio de manifiesto + redespliegue |
| por API (servidor Gravitino completo) | catálogos del metalake | ✅ una llamada, como hoy |
⚠️ Ésa es la regresión real del cutover, y no está en los endpoints: Lakekeeper crea
warehouses por API (/management/v1/warehouse). Con el IRC standalone, dar de alta
un inquilino pasa de una llamada HTTP a un despliegue.
⭐ Y sin embargo es asumible hoy, por un dato nuestro: /management/v1/warehouse
sólo lo llama la ruta de warehouses DEDICADOS, que hoy no usa ningún inquilino — el
aprovisionamiento de los 8 se hizo de forma controlada, no por autoservicio. ⇒ La
regresión es de frecuencia baja y conocida, y desaparece el día que entre el
servidor completo (que es el mismo día que se quieran gobernar tópicos y modelos,
§5·septies).
5 · Lo que este cutover NO cambia — y es lo importante
⭐⭐ La gobernanza no se mueve. La cara
/api/icebergsigue delante, autorizando
por inquilino y por tabla; Index sigue siendo la única retícula; la puerta sigue
preguntando «¿puede esta persona?». Lo que se sustituye es el almacén de
metadatos que hay detrás de la cara.
⇒ Y de ahí sale la simplificación más grande del plan: el IRC no necesita OIDC
propio. Vive detrás de la cara, en ClusterIP sin ruta externa, y el único que le
habla es la cara — que ya autentica al motor y usa su propia credencial aguas arriba.
El OIDC de Keycloak que Lakekeeper sí tenía no hay que rehacerlo: hay que no
necesitarlo.
⚠️ Con su condición, que es la de siempre: si algún motor puede apuntar al IRC
directamente, esto no aplica nada. ClusterIP sin Gateway es hoy lo que lo garantiza,
y tiene que seguir siéndolo.
6 · Los gates, en orden de lo que puede tumbar el cutover
| Qué | Gate | |
|---|---|---|
| 🏁 C·0·a | ⭐⭐⭐ El MECANISMO: el IRC sobre Postgres | VERDE 08-13 — §6·bis |
| 🏁 C·0·b | ⭐⭐ La BASE DURABLE en europe-west1 | VERDE 08-13 — §6·ter |
| 🏁 C·1 | Los catálogos por inquilino declarados — 8 + el compartido | VERDE 08-13 — 9/9 + control negativo (§6·quater) |
| 🟡 C·2 | ⭐ El vending, EJERCIDO (no sólo declarado) | Mecanismo VERDE · paridad ROJA (§6·quinquies · §6·sexies) |
| ⛔ C·2·b | ⭐⭐⭐ ¿Qué vende Lakekeeper? — la pregunta que decide si el cutover es equivalente | MEDIDO 08-13: vende TEMPORAL (~900 s) ⇒ BLOQUEA el cutover (§6·sexies) |
| ⛔ C·3 | El re-registro: ~82-84 tablas vía POST …/register | Las tablas listadas en el IRC = las de Lakekeeper, y loadTable devuelve el mismo puntero |
| ⚠️ C·4 | El CAS bajo concurrencia — dos escrituras a la vez sobre la misma tabla | Una gana, la otra reintenta. Es el invariante nº4 |
| ⚠️ C·5 | Soft-delete / protección de borrado | ⏳ comprobar el equivalente; si no existe, decidirlo a propósito |
| 🟡 C·6 | transactions/commit en 1.3+ | Si lo declara, la pérdida de §3 es temporal |
⭐ C·0 antes que nada, y no es una formalidad. El mapa ya lo dejó dicho: «el paso siguiente no es pasar tablas a Gravitino, es decidir dónde vive su base» — y de esa decisión cuelgan la latencia de toda lectura y la única debilidad de escritura (4·4) que la absorción puede cerrar.
6·bis · 🏁 C·0·a — EL IRC SOBRE POSTGRES, MEDIDO (2026-08-13)
infra/gke/c0a-irc-postgres.yaml · Postgres desechable en el namespace, sin gastar
un euro: se prueba el MECANISMO antes de provisionar infraestructura de pago.
Los dos bloqueantes, resueltos
| ⛔→🏁 El driver | La imagen sólo trae sqlite-jdbc-3.42.0.0.jar (medido). Entra sin construir imagen propia: un initContainer con la misma imagen copia libs/ (174 jars) a un volumen, otro baja postgresql-42.7.4.jar al mismo sitio, y el contenedor monta el volumen encima. ⭐ Así el upgrade de versión sigue siendo cambiar un tag, que es lo que se pierde al forkear una imagen |
| ⛔→🏁 La durabilidad | De jdbc:sqlite:/data/irc.db (en emptyDir) a jdbc:postgresql://…. Gravitino crea su esquema solo |
El gate: la durabilidad se prueba MATANDO el pod
① POST /namespaces {"namespace":["c0a_durable"]} → HTTP 200
② POST …/tables {"name":"t_durable", …} → metadata.json en R2
table-uuid 31c35cfd-…
── kubectl delete pod -l app=gravitino-irc ──
③ GET /namespaces → [["c0a_durable"]] ✅ sobrevive
④ GET …/c0a_durable/tables → [t_durable] ✅ sobrevive
⑤ GET …/tables/t_durable → MISMO metadata-location
MISMO table-uuid ✅ sobrevive
── limpieza: DELETE tabla (204) + DELETE namespace (204) → namespaces []
⇒ El registro es durable y el puntero de metadata se conserva intacto. Con SQLite en
emptyDir los tres se habrían perdido.
⚠️ Dos trampas del camino, anotadas porque volverán
- ⛔
curl: (23) client returned ERROR on write— el initContainer del driver corre con uid 100 y el volumen lo había creado root. Un error de PERMISOS cuyo mensaje apunta a la RED. Se resuelve conrunAsUser: 0, pero lo que cuesta es creerse el mensaje. - ⛔⛔
HTTP 000con el podReady, la IP correcta y las labels correctas. El Service apunta atargetPort: http—un puerto con NOMBRE— y al reescribir el Deployment declarécontainerPort: 9001sin nombre. El EndpointSlice queda conPORTS <unset>y no llega tráfico.⭐⭐ La lección: reescribir un recurso entero en vez de parchearlo pierde
detalles invisibles, y el nombre de un puerto es el más invisible de todos. El
síntoma se lee como red o como servidor caído; el servidor estaba perfecto y
respondía enlocalhost. Lo que lo desempató fue preguntar por dentro.⚠️ Y
kubectl get endpointsmiente en k8s 1.33+: devuelve<none>porque el
recurso legacy ya no se puebla. Lo autoritativo esget endpointslices.
Lo que queda para C·0·b
Cambiar una URL. El mecanismo está probado; lo que falta es la base gestionada
—sqladmin.googleapis.com no está habilitada en trino-k8s— y apuntar GRAVITINO_URI
a ella. ⚠️ El Postgres de esta prueba usa emptyDir a propósito: prueba el
mecanismo, no la durabilidad del almacenamiento.
6·ter · 🏁 C·0·b — LA BASE, CREADA Y PROBADA (2026-08-13)
instancia carbon-catalog · POSTGRES_16 · europe-west1 · db-f1-micro · 10 GB SSD
red ⭐ IP PRIVADA 10.99.0.3 (VPC peering sobre `default`, rango 10.99.0.0/20)
⇒ sin IP pública, sin Cloud SQL Auth Proxy y sin credencial de servicio:
el pod habla por la VPC. Menos piezas y menos secretos que gestionar.
backups diarios 03:00 · storage auto-increase
coste ~10-15 €/mes
El mismo gate, ahora contra la base gestionada:
① POST /namespaces + ② POST …/tables → HTTP 200 · table-uuid b857ede1-…
── kubectl delete pod -l app=gravitino-irc ──
③④⑤ namespaces · tables · loadTable → los tres SOBREVIVEN
MISMO table-uuid · MISMO metadata-location
── limpieza: 204 / 204 → namespaces []
⚠️ La latencia real, y hay que corregir el «40×»
loadTable (5 medidas) 64 – 86 ms contra Lakekeeper: ~222 ms
⇒ ~3× en la operación completa, no 40×. El 40× del mapa era la conexión TCP al
catálogo (1,3 ms vs 46 ms) — y sigue siendo cierto. Pero un loadTable lee el
metadata.json de R2, y R2 son ~66 ms que no se mueven. La mejora es real y es de
3×; publicar el 40× como si fuera la de la operación sería vender el número que mide la
parte, no el todo.
⭐ Y la mejora se multiplica igual: una query de N tablas hace ≥N cargas.
6·quater · 🏁 C·1 — LOS 9 CATÁLOGOS, DECLARADOS (2026-08-13)
⚠️ Primero, el vocabulario — porque aquí se cuela un error de dos niveles
El paradigma NO cambia: sigue siendo un inquilino → un espacio aislado. Lo que
cambia es la pieza que lo implementa, y su nombre:Lakekeeper warehouse carbon-prod-w-<hex> ─┐ ├─ mismo papel, distinto nombre Gravitino IRC catalog w_<hex> ─┘⭐ Y el prefijo de la ruta REST sigue siendo
w_<hex>⇒ la cara no cambia una
línea.
⛔ Pero «catálogo» ya significa otra cosa aquí: catalogs en Index (10 filas) son
main, karma… — lo que el usuario ve en catalog.schema.table. Son dos niveles
distintos. Llamar «catálogo» a los dos es exactamente cómo se coló la confusión de
warehouse (espacio vs inquilino) y la de nombre vs ubicación. En este documento:
catálogo-de-inquilino para el de Gravitino, catálogo lógico para el del usuario.
Lo medido antes de declarar — y no eran 9
Lakekeeper 11 warehouses (8 carbon-prod-w-* · 2 carbon-test-w-* · 1 lakehouse)
Index 8 tenant_warehouses
| Hallazgo | |
|---|---|
| ⚠️ | Son 11, no 9. Los dos carbon-test-w-* son la deriva ya censada (vivos en Lakekeeper, no en Index) |
| ⚠️ | Dos inquilinos tienen su key-prefix en test/, no en prod/ — la deriva del perímetro |
| ⛔⛔ | El inquilino donde está TODO el dato (w_7b500f4d…) tiene dedicated=false ⇒ la cara le sirve el COMPARTIDO. Su warehouse propio existe y está activo sin motivo |
⇒ Los «9» son 8 inquilinos + el compartido, y el compartido es el que importa.
Cómo se declararon
Bloques gravitino.iceberg-rest.catalog.<nombre>.* (13 claves × 9 = 117 líneas),
cada uno con su key-prefix REAL de Lakekeeper — incluidos los dos en test/:
la deriva se limpia en su propio paso, no de tapadillo en éste.
⭐ Los 9 comparten la misma base: el JdbcCatalog de Iceberg separa por
catalog_name, así que una sola instancia sirve a los nueve.
⚠️ Las credenciales van en un Secret, nunca en el repo ni en un ConfigMap — cada
bloque lleva las de R2 y las de la BD. Y el conf/ se compone en un initContainer:
copiar el directorio entero + añadir los bloques, porque montar el Secret encima
taparía los otros ficheros, y el conf tiene que quedar escribible
(rewrite_config.py lo borra y lo reescribe).
⭐ Lo que hace que sobreviva: rewrite_config.py parsea el conf y sólo actualiza
las claves de su env_map — las catalog.<name>.* que no conoce las conserva.
Verificado leyendo el script, no supuesto.
El gate
OK 200 lakehouse → {"namespaces":[]}
OK 200 w_cccc70a5… w_7b500f4d… w_ec14e3cc… w_0936227f…
OK 200 w_5b3dba28… w_cbe70f74… w_17c5cc10… w_1548b5ae…
⇒ 9/9
control negativo: w_noexiste → HTTP 404 ✅ no acepta cualquier prefijo
Los nueve están vacíos: el registro de las tablas es C·3.
6·quinquies · 🏁 C·2 — EL VENDING, EJERCIDO (2026-08-13)
infra/gke/c2-vending-gate.yaml · Spark contra el IRC del cluster, sin pasar por la
cara ni por Lakekeeper.
⛔ Primero salió ROJO, y ése es el hallazgo
IllegalArgumentException: There are no credential provider for the catalog.
⇒ El IRC DECLARA …/tables/{t}/credentials y no vendía nada. El endpoint estaba en
endpoints[] —que es lo que hizo que el cutover pareciera viable en §2— y declarar no
es servir. Si el cambio se hubiera hecho leyendo sólo la lista de endpoints, el
camino de lectura entero se habría caído el día del cutover.
⭐⭐ Es la regla 8·ter otra vez, y ahora del lado de lo que vamos a adoptar: antes de
contar una capacidad, buscar el comando que demuestra que está encendida. Un
endpoints[]es un folleto muy bien escrito.
🏁 Con credential-providers configurado
GATE 1 catalogo=w_7b500f4d… · namespaces=[]
GATE 2 ⭐ CTAS OK — el IRC acepta la escritura
GATE 3 ⭐⭐ LECTURA OK — 50 filas · Row(id=0, triple=0, etiqueta='v-0')
GATE 4 fichero: s3://lakehouse/test/w_7b500f4d…/c2_probe/c2_vending/data/00000-0-….parquet
GATE 5 limpieza hecha
⭐ Y el fichero aterrizó bajo el key-prefix declarado en C·1 ⇒ el catálogo-de-inquilino dirige los bytes a su espacio: el aislamiento de almacenamiento sobrevive al cambio de pieza.
⚠️⚠️ La salvedad, que es un bloqueante ANTES del cutover real
El proveedor que funciona con R2 es s3-secret-key: entrega la llave LARGA, no una
credencial temporal. s3-token (STS) no sirve — R2 no habla STS de AWS, que es la
misma razón por la que Polaris se descartó.
⇒ Y eso convierte en bloqueante una pregunta que este mapa ya tenía abierta desde el
08-12 (§8·7·①): «la afirmación hoy vendamos credencial STS NO está medida» —
tenant-warehouse.ts:125-126 pone sts-enabled: false, pero esa ruta sólo corre para
warehouses dedicados y el compartido —que es el que sirve— se configuró fuera de ese
código.
Si Lakekeeper HOY entrega la llave larga ⇒ `s3-secret-key` es EQUIVALENTE. Sin cambio.
Si Lakekeeper HOY vende STS ⇒ el cutover es una REGRESIÓN de seguridad.
⛔ [medir] — y ahora no es una curiosidad: decide si el cutover se puede hacer tal
cual. Un GET …/credentials contra el warehouse compartido de Lakekeeper lo contesta.
6·sexies · ⛔⛔ C·2·b — QUÉ VENDE LAKEKEEPER, MEDIDO (2026-08-13)
scripts/warehouse/c2b-que-vende-lakekeeper.ts · sólo lee, y está escrita para
mirar credenciales sin enseñarlas: imprime las claves y la forma del valor
(longitud, 4 caracteres iniciales), nunca el valor. Ya se filtró una credencial en
esta plataforma por volcar un fichero entero.
tabla de prueba: datasets.ds_toydemo001 · warehouse compartido `lakehouse`
loadTable con X-Iceberg-Access-Delegation → 200
s3.access-key-id · s3.secret-access-key · ⭐ s3.session-token (692 chars)
storage-credentials → 1 entrada
⭐ expiration-time · s3.session-token-expires-at-ms
⭐ client.refresh-credentials-endpoint ← hasta el endpoint de refresco
GET …/tables/{t}/credentials → 200
⏱️ caduca en 855 s (~14 min)
⇒ El veredicto, y es el contrario del que convenía
⛔⛔ Lakekeeper vende credenciales TEMPORALES de ~900 s, con token de sesión y
endpoint de refresco. El cutover concredential-providers = s3-secret-key
cambiaría una llave que caduca en 15 minutos por la llave PERMANENTE de R2.
Eso es una regresión de seguridad real, no un matiz.
⚠️ Y una corrección a lo que yo mismo escribí hace un rato
Dije «R2 no habla STS» como si fuera la causa. Lo medido lo desmiente: R2 sí
entrega credenciales temporales, y Lakekeeper sabe pedírselas. La limitación es de
Gravitino, cuyo proveedor s3-token va por AssumeRole de AWS STS — una vía que R2
no expone. No es que el almacenamiento no pueda: es que el catálogo nuevo no sabe.
🏁 Y de paso cierra una pregunta abierta desde el 08-12
index-gobernanza-mapa.md §8·7·① decía: «la afirmación hoy vendamos credencial STS
(900 s) NO está medida … hay que medirla antes de construir nada encima, porque el
paso 5 entero cuelga de ella».
⇒ Está medida, y era cierta: ~855 s. Con lo que también queda confirmado, ahora con comando, el §5·3 del mapa: mientras se venda credencial para leer el Parquet directo, cualquier política de fila es decorativa.
Las salidas, sin maquillar
| Salida | Coste | |
|---|---|---|
| ⭐ A | Parar el cutover aquí. C·0/C·1 no se tiran: el IRC queda en pie, durable y con sus 9 catálogos, esperando | 0. Es la posición actual |
| B | Contribuir a Gravitino un credential provider para las temporales de R2 | Desarrollo en un proyecto ajeno, con su ritmo de release |
| C | Que el vending lo sirva LA CARA y no el catálogo — ella ya está delante y sabe quién pregunta | Nuestro, y encaja con el norte… pero es construir el vending |
| ⭐⭐ D | Remote signing en vez de vending — el catálogo firma cada petición y no entrega llave ninguna | Latencia por fichero. ⭐ Y es justo lo que el mapa §8·7·② proponía para conmutar el vendado, que es el paso que hoy bloquea la política de celda |
⭐⭐⭐ D convierte un bloqueante en una oportunidad: el paso que hace falta para
migrar de catálogo es el mismo que hace falta para que la política de fila deje de
ser decorativa. No son dos obras: es una.
⛔ C·3 NO se ejecuta hasta resolver esto. Re-registrar 82 tablas contra un catálogo que sólo sabe entregar la llave larga sería consolidar la regresión.
6·septies · ⭐⭐⭐ SALIDA B — el provider de R2 para Gravitino: el patrón, calcado
Decisión del owner: B. Y bien planteada: «investiga si ya se ha hecho y calcamos
el patrón». Se ha hecho, y las dos mitades están documentadas.
① Lo que hace Lakekeeper — la referencia
Lakekeeper soporta R2 con vended credentials vía
/accounts/{account_id}/r2/temp-access-credentials.
⇒ No es magia ni STS de AWS: es la API de credenciales temporales de Cloudflare. Y es Apache-2.0 y Rust, así que su implementación se puede leer.
② Lo que ofrece Gravitino — el punto de extensión, documentado
interfaz org.apache.gravitino.credential.CredentialProvider
registro `credential-providers = <nombre>` (lista separada por comas)
jar al CLASSPATH del Iceberg REST server
ya existe S3TokenCredential (accessKeyId · secretAccessKey · sessionToken · expiration)
⭐⭐ Y el jar no es un problema para nosotros: ya sabemos meterlo. Es exactamente el
mecanismo de C·0·a — el initContainer que compone libs/. No hay que forkear la
imagen ni esperar a un release de Gravitino: es su punto de extensión.
⇒ Eso rebaja B de «contribuir a un proyecto ajeno, con su ritmo» a «un jar nuestro en un directorio que ya montamos».
③ ⭐⭐ Y hay DOS vías para obtener la temporal — la elección no es indiferente
Cloudflare documenta las dos:
| A · la API (lo que hace Lakekeeper) | B · el JWT local | |
|---|---|---|
| Cómo | POST …/r2/temp-access-credentials con el parent token | Firmar un JWT (HS256) con el parent secret; el secreto temporal es el SHA-256 hex del JWT, y el session token es base64("jwt/" + jwt) |
| Red | ⚠️ una llamada externa por vending | ⭐ CERO: se computa |
| Permiso que exige | ⚠️⚠️ «Admin Read & Write» de la CUENTA | el parent secret access key que ya tenemos |
| Latencia | RTT en cada loadTable con delegación | ninguna |
⛔⛔ Y ahí hay un hallazgo de seguridad que no buscábamos: la API de temporales
exige un token «Admin Read & Write» de la cuenta de Cloudflare. ⇒ Si Lakekeeper la
usa —y su documentación dice que sí—, nuestro catálogo actual tiene hoy un token
Admin de la cuenta. Eso es un permiso mayor que la llave larga del bucket que
tanto nos preocupaba.[verificar contra nuestro despliegue]
⇒ ⭐ Calcamos el patrón en su FORMA, no en su MECANISMO: vended temporal devuelto
como S3TokenCredential —igual que Lakekeeper— pero obtenido por la vía JWT local.
Es la regla 46 de la casa: se toma el modelo, se deriva en nuestro sustrato. Y aquí
la derivación es estrictamente mejor: sin latencia añadida al camino de lectura —que
es el argumento de todo el cutover— y sin darle a un catálogo un token de
administración de la cuenta.
④ El plan, con gates
| Qué | Gate | Estado | |
|---|---|---|---|
| 🏁 | B·0 · ⭐ Reproducir la vía JWT a mano — acuñar una temporal y leer un objeto de R2 con ella | Un GET a un objeto real con X-Amz-Security-Token, y un control negativo: la misma credencial caducada debe fallar | VERDE (§6·octies) |
| 🏁 | B·1 · El CredentialProvider (Java) que la devuelve como S3TokenCredential | Test unitario del cómputo: mismo JWT ⇒ mismo secreto derivado | VERDE (§6·nonies) |
| 🏁 | B·2 · El jar al classpath del IRC (initContainer, como C·0·a) y credential-providers = r2-token en los 9 | El IRC arranca y no dice «no credential provider» | VERDE (§6·decies) |
| 🏁 | B·3 · ⭐⭐ Re-correr C·2 — Spark escribe y lee con vended-credentials | CTAS + lectura verdes y la credencial con expiration | VERDE (§6·undecies) |
| 🏁 | B·4 · Paridad con Lakekeeper | Ventana comparable (~900 s) y session-token presente ⇒ el cutover deja de ser regresión | VERDE — 900 s vs 855 s, y además acotada por prefijo |
⛔⛔⛔ Pero el cutover NO queda desbloqueado: lo bloquea ahora otra cosa. B·3 destapó que los 9 catálogos-de-inquilino comparten espacio de metadatos ⇒ §6·duodecies, y C·3 sigue congelado por un motivo distinto y peor que el del vending.
⚠️ B·0 antes que escribir una línea de Java. Si el cómputo del JWT no produce una
credencial que R2 acepte, todo lo demás sobra — y es media hora con curl, no un
proyecto.
6·octies · 🏁 B·0 — MEDIDO: R2 acepta la temporal que acuñamos en casa
Sondas:
scripts/warehouse/b0-jwt-r2-temporal.ts(la escalera) ·
scripts/warehouse/b0b-que-vende-cloudflare.ts(el patrón contra el que medir).
Contra el bucketlakehousereal, sin cluster.npx dotenv -e .env.local -- npx tsx …
① El veredicto, gate a gate
0 la llave larga lista el bucket 200 · 5 prefijos
0·b temporal de CLOUDFLARE + nuestro SigV4 200 ⇐ el desempate
① local, claims MÍNIMOS { bucket, scope } 200
①·b local + paths.prefixPaths: ["_probe-gravitino/"] 200
· el MISMO token contra un objeto FUERA del prefijo 403 AccessDenied ⭐⭐
①·c local + actions: ["GetObject","HeadObject"] (¡la doc!) 400 InvalidArgument ⛔
② la MISMA credencial, CADUCADA 403 ⭐
③ PUT con scope object-read-only 403 AccessDenied
⇒ ⭐⭐⭐ La vía JWT local es viable, y acota. No sólo autentica: el prefixPaths
recorta de verdad — misma credencial, un objeto fuera de su prefijo, AccessDenied. Eso
es exactamente lo que el vending por-inquilino necesita, computado sin una sola llamada
de red. B·1 tiene suelo.
② ⭐⭐ Y de paso, el hallazgo de §6·septies③ queda CONTESTADO
POST /accounts/{id}/r2/temp-access-credentials con nuestro CLOUDFLARE_API_TOKEN
(Workers R2 Storage: Edit, nivel cuenta) devolvió 200. ⇒ La API no exige un token
«Admin Read & Write» de la cuenta como temíamos.
⚠️ Pero eso no reabre la vía A: sigue costando un RTT por vending en el camino de lectura —que es el argumento entero del cutover— y sigue exigiendo que el catálogo tenga un token de la API de Cloudflare, no sólo una llave de bucket. La vía B gana por lo mismo que ganaba antes; lo que cae es sólo el argumento del permiso.
③ 🪤 Lo que la doc oficial dice y R2 no cumple
El ejemplo publicado por Cloudflare incluye actions: ["GetObject","HeadObject"].
R2 devuelve 400 InvalidArgument con cualquier forma del claim — probadas
["GetObject","HeadObject"], ["s3:GetObject",…], ["GetObject"] y hasta [].
Y no es que rechace claims desconocidos: un claim inventado (objects) pasa con 200.
⇒ actions se parsea y se rechaza. Para B·1 no se usa: scope (lectura/escritura)
paths.prefixPaths(el ámbito) ya cubren lo que el vending necesita.
④ 🪤 La trampa de medición — la primera pasada dio TRES verdes falsos
La versión inicial mandó los claims completos. R2 contestó 400 InvalidArgument a las cuatro pruebas… y como los tres controles negativos sólo miraban «¿no fue 200?», salieron verdes. Un token malformado se rechaza igual que uno caducado.
⇒ Dos correcciones, y las dos son de método:
- Leer el
<Code>de S3, no el «no-200».InvalidArgument(400, forma) yAccessDenied(403, permiso) son respuestas de dos preguntas distintas. - Un peldaño intermedio que separa las hipótesis: la temporal de Cloudflare firmada con nuestro SigV4 (0·b). Con ese 200 en la mano, todo fallo posterior es de los claims y no de la firma. Sin él habríamos ido a depurar el firmador.
⚠️ Y una menor, para no buscar el string equivocado: la credencial caducada devuelve
SignatureDoesNotMatch, no ExpiredToken — R2 deriva el secreto del JWT, así que un
JWT que ya no vale se manifiesta como una firma que no cuadra. Lo que hace válido el
control es que la única variable entre ① y ② es iat/exp.
6·nonies · 🏁 B·1 — el provider existe, y el gate corre dentro del build
Entregable:
infra/gravitino-r2-credential/
— dos clases, cero dependencias de runtime, jar de ~8 KB.
docker build --target gate .
① Lo comprobado — 20/20 en frío, sin cluster y sin red
| ① | determinismo | mismas entradas ⇒ mismo JWT ⇒ mismo secreto · control negativo: otro iat ⇒ otro secreto |
| ② | ⭐⭐ vector dorado | el mismo token byte a byte que la sonda TS de B·0 |
| ③ | ⭐⭐⭐ la traducción | toIcebergProperties ⇒ s3.access-key-id · s3.secret-access-key · s3.session-token |
| ④ | el ámbito | lectura/escritura y prefijo salen del contexto; rechaza no acotar y rechaza dos buckets |
| ⑤ | el registro | censo por ServiceLoader sobre el classpath real |
⭐ Se compila contra la imagen que se despliega, no contra Maven Central: el classpath
del build es el libs/ que monta el pod ⇒ «compila» y «carga en el pod» son la
misma afirmación, y no dependemos de que Apache publique los artefactos.
⭐ El gate va DENTRO del build: un jar que no pasa B·1 no llega a existir.
② ⭐⭐⭐ El hallazgo que habría costado un día: la traducción despacha por CLASE
CredentialPropertyUtils.toIcebergProperties decide con instanceof de la clase
concreta, no con el string de credentialType(). ⇒ Si el provider devolviera una
Credential nuestra, caería al else y el cliente recibiría s3-session-token —el nombre
interno de Gravitino— en vez de s3.session-token. El IRC arrancaría, vendería, y el
motor sencillamente no encontraría la credencial. Ni un error ni un log: un 403 al leer.
⇒ La regla del calco, afilada: se copia la FORMA (S3TokenCredential de serie) y se
deriva el MECANISMO (nuestro cómputo). El tipo, en cambio, tiene que ser nuestro:
censo del classpath real (ServiceLoader, 174 jars):
adls-token · aws-irsa · azure-account-key · gcs-token · oss-secret-key
oss-token · s3-secret-key · s3-token ⇐ OCUPADO por bundles:aws
CredentialProviderFactory revienta con «Multiple credential providers found» si dos
declaran el mismo tipo ⇒ el nuestro es r2-token.
③ ⭐ Y no hace falta ningún secreto nuevo
El provider lee s3-endpoint · s3-access-key-id · s3-secret-access-key — las que el
.conf ya tiene, que son exactamente el parent con el que se firma. La cuenta se deduce
del host del endpoint. Una pieza menos que rotar, y un secreto menos que filtrar.
④ 🪤 Otra trampa de censo, de la misma familia que la de B·0
Para contestar «¿está s3-token ocupado?» se barrió la imagen con unzip sobre los 174
jars: cero resultados. La imagen no trae unzip, y el 2>/dev/null se tragó el
error ⇒ la lectura natural habría sido «no hay ningún provider registrado», que es
exactamente lo contrario de la verdad.
⇒ Se rehízo con el mismo mecanismo que usa Gravitino (ServiceLoader sobre el classpath
real), y el censo vive ahora dentro del gate, donde se vuelve a correr en cada build.
⑤ ⚠️ Lo que B·1 no prueba
Que R2 acepte en vivo la credencial acuñada por el Java. Lo cubre por transitividad —el JWT es idéntico byte a byte al de B·0 y el secreto es función pura de esa cadena—, pero el 200 con bytes reales lo da B·3.
6·decies · 🏁 B·2 — el IRC ya vende TEMPORAL, y acotada
Entregables:
infra/gke/b2-r2-credential-provider.yaml
(supersede el Deployment de C·0·a) ·scripts/warehouse/b2-vending-temporal.ts(el gate).
① Lo medido, contra el IRC desplegado
initContainers jar en libs/ (7.895 B) · «9 catalogos tenian provider, 9 quedan en r2-token»
loadTable con X-Iceberg-Access-Delegation: vended-credentials
s3.session-token ✅ presente ⇒ TEMPORAL, no la llave larga
el token decodifica a `jwt/…` ⇒ es la NUESTRA, vía JWT local
scope object-read-write
prefixPaths ["_probe-gravitino/_b2_probe/sonda_…/"] ⇐ ⭐ ACOTADA a ESTA tabla
ventana 900 s ⇒ paridad con los ~855 s de Lakekeeper
control negativo · el MISMO loadTable SIN la cabecera → ninguna clave de credencial
⇒ ⭐⭐⭐ El bloqueo C·2·b está resuelto. El cutover deja de ser una regresión de seguridad: donde Lakekeeper vende temporal, el IRC ahora vende temporal y además acota por prefijo, que es algo que la llave larga no hacía.
② ⭐ Dos decisiones de despliegue
El volteo se hace en el initContainer, no en el Secret. Los 9 catálogos viven en
irc-catalogs, que lleva credenciales en claro; editarlo obliga a sacarlo, cambiarlo y
volver a meterlo — tres ocasiones de que se quede en un historial. El sed corre dentro
del pod sobre el fichero ya montado: ninguna credencial sale, y el cambio queda en git
y a la vista en vez de escondido en un valor de Secret que nadie puede revisar.
Y sustituye en sitio en vez de añadir una línea duplicada al final: apoyarse en «la
última clave gana» sería apostar por un detalle del parser que nadie ha medido. Además el
initContainer comprueba el invariante antes de arrancar —tantos r2-token como
catálogos tenían provider— así que un cambio de forma del fichero falla ruidosamente
en vez de degradar en silencio a la llave larga.
El jar llega por ConfigMap (7,9 KB; el límite es 1 MB) y se copia a libs/ con el
mismo mecanismo del driver de Postgres de C·0·a: no se forkea la imagen ⇒ subir de
versión sigue siendo cambiar un tag.
③ ⚠️ El límite honesto: el read/write NO lo decidimos nosotros
Un loadTable de sólo lectura recibió scope: object-read-write. No es un fallo del
provider: el privilegio le llega ya decidido desde arriba.
// IcebergTableOperationExecutor.getCredentialPrivilege
boolean writable = MetadataAuthzHelper.checkAccess(identifier, TABLE, FILTER_MODIFY_TABLE…);
return writable ? CredentialPrivilege.WRITE : CredentialPrivilege.READ;
⇒ El eje lectura/escritura del vending lo decide el control de autorización de Gravitino, que en el IRC suelto no está cableado y responde que sí a todo. Encaja con §4·2: el catálogo administra, no aplica. Lo que B·2 aporta es real —temporal + prefijo— pero la mitad read/write del ámbito sigue esperando a que alguien diga que no, y ese alguien es la puerta.
④ 🪤 Trampas de esta tanda
- ⛔⛔
kubectl port-forward deploy/…durante un rollout se engancha al pod que aún TERMINA. UnloadTabledevolvió «There are no credential provider» con la configuración correcta ya en el pod nuevo, y la lectura natural —«la clave no llega al catálogo por defecto»— era falsa: ambas rutas venden.rollout statusen verde no garantiza que el viejo haya muerto. - ⚠️ Los 9 catálogos-de-inquilino están VACÍOS (C·3 congelado) ⇒ no hay
loadTableque pedir y la sonda tiene que crearse su tabla. Y no vale el catálogolakehouse: apunta as3://lakehouse/warehouse, el prefijo de producción. El área de sondas es_probe-gravitino, el catálogo por defecto. - ⚠️ El catálogo por defecto no tenía provider ninguno — sólo se habían tocado las
claves
catalog.<nombre>.*. Añadidagravitino.iceberg-rest.credential-providers.
6·undecies · 🏁 B·3 — el motor escribe y lee con la temporal
Entregable:
infra/gke/b3-vending-e2e.yaml.
Es C·2 re-corrido, con el mismo motor y el mismo camino, más lo que C·2 no podía afirmar.
⭐ No necesitacarbon-connectnispark-bridge: Spark correlocal[1]en el Job.
① catalogo=w_7b500f4d… · ② CTAS OK · ③ LECTURA OK — 50 filas
fichero: s3://lakehouse/test/w_7b500f4d…/b3_probe/b3_vending/data/00000-….parquet
④ la credencial que el IRC entrega para ESA tabla
s3.session-token ✅ · decodifica a `jwt/…` ⇒ es la nuestra
scope object-read-write
prefixPaths ['test/w_7b500f4d…/b3_probe/b3_vending/']
ventana 900 s (restaban 888 s) · secreto de 64 hex, no la llave larga
⑤ el MISMO loadTable SIN la cabecera → ninguna clave de credencial
⇒ ⭐⭐⭐ C·2 vuelve a estar verde, pero ahora el verde vale: aquel pasó con
s3-secret-key —la llave permanente—, y éste pasa con una temporal de 900 s acotada al
prefijo de la tabla. La regresión de seguridad del cutover está cerrada.
⚠️ La pregunta se hace con el MOTOR y no con curl por una razón: la credencial no la
consume quien la pide, la consume el S3FileIO que va a por los bytes. B·2 midió lo que el
IRC entrega; B·3 mide que el motor puede usarlo.
6·duodecies · ⛔⛔⛔ HALLAZGO QUE BLOQUEA C·3: los 9 catálogos NO están aislados
Encontrado al leer el
GATE 1de B·3: apareció un namespace_b2_probedentro del
catálogo-de-inquilino… y ese namespace se había creado por la ruta sin prefijo.
① Lo medido
namespaces, preguntando por cada ruta:
(sin prefijo) {"namespaces":[["_b2_probe"]]}
lakehouse/ {"namespaces":[["_b2_probe"]]}
w_7b500f4d…/ {"namespaces":[["_b2_probe"]]} ⇐ creado UNA vez,
w_0936227f…/ {"namespaces":[["_b2_probe"]]} visible en TODOS
w_1548b5ae…/ {"namespaces":[["_b2_probe"]]}
Y la fuga llega a las tablas, no sólo a los namespaces:
createTable en el inquilino A → 200 · s3://lakehouse/test/w_7b500f4d…/…/tabla_de_A
listTables POR EL PREFIJO DE B → 200 · [{"namespace":["_fuga_probe"],"name":"tabla_de_A"}]
loadTable POR EL PREFIJO DE B → 200 · location = la de A
loadTable POR EL PREFIJO DE B, con `vended-credentials`:
scope object-read-write
prefixPaths ['test/w_7b500f4d…/_fuga_probe/tabla_de_A/'] ⇐ ⛔⛔ EL PREFIJO DE A
⇒ Un inquilino puede listar, cargar y obtener credencial read-write sobre los bytes de
otro. No es un fallo del vending: el provider acota correctamente a la ubicación de la
tabla que le piden; el problema es que B no debería poder pedir esa tabla.
② Qué queda en pie de C·1, y qué no
C·1 declaró «9 catálogos-de-inquilino, 9/9 en 200, con control negativo (w_noexiste →
404)». Eso sigue siendo cierto y sigue midiendo algo real: el ENRUTADO. Lo que no
midió —y se dio por hecho— es el aislamiento: la nota «el JdbcCatalog de Iceberg
separa por catalog_name» es falsa en este despliegue.
⇒ El prefijo w_<hex> selecciona dónde aterrizan los bytes nuevos (cada catálogo tiene
su warehouse), no un espacio de metadatos propio. Hoy sólo aísla el key-prefix.
③ ⛔ Consecuencia inmediata
C·3 —el re-registro de las ~82 tablas— NO se ejecuta. Meter el dato real de 8 inquilinos en un espacio de metadatos compartido convierte un problema de configuración en una exposición entre inquilinos, con las tablas de verdad dentro.
⚠️ Alcance: el IRC es ClusterIP inerte y la cara sigue sirviendo por Lakekeeper ⇒ hoy no hay exposición en producción. Es un bloqueante del cutover, no un incidente.
④ 🏁 LA CAUSA, MEDIDA — y el arreglo es una línea por catálogo
Sonda: infra/gke/b3b-censo-catalog-name.yaml
(sólo SELECT, contra carbon-catalog por la VPC). Con una tabla creada en el inquilino A
y otra en el B, por sus prefijos respectivos:
SELECT catalog_name, count(*) FROM iceberg_tables GROUP BY 1;
catalog_name | tablas SELECT catalog_name, table_namespace, count(*) …
--------------+-------- catalog_name | table_namespace | count
jdbc | 2 --------------+-----------------+-------
(1 row) jdbc | ns_de_A | 1
jdbc | ns_de_B | 1
⇒ ⭐⭐⭐ catalog_name = 'jdbc' para los nueve. Y jdbc no es el nombre de ningún
catálogo: es el tipo de backend. Los 9 JdbcCatalog se inicializaron con el valor por
defecto, así que escriben todos en la misma partición de iceberg_tables.
Y es exactamente el mecanismo que Iceberg reserva para aislar. De la propia doc de Gravitino:
gravitino.iceberg-rest.catalog-backend-name— «The catalog backend name passed to
underlying Iceberg catalog backend. Catalog name in JDBC backend is used to isolate
namespace and tables» · por defectojdbcpara backend JDBC · desde 0.5.2
⇒ El arreglo es declarar el nombre, uno por catálogo:
gravitino.iceberg-rest.catalog.<nombre>.catalog-backend-name = <nombre>
⚠️⚠️ Y el momento importa: hay que hacerlo AHORA, antes de C·3. Cambiar el
catalog_name de un catálogo con contenido deja huérfanas sus filas en
iceberg_tables — el catálogo dejaría de ver sus propias tablas. Hoy los 9 están vacíos,
así que el cambio es gratis; después de re-registrar 82 tablas, no.
⇒ Gate del arreglo: el control CRUZADO, no un 200 por catálogo — crear en A y comprobar que B no la ve (regla 87).
6·terdecies · 🏁 C·1·b — el aislamiento, ARREGLADO y comprobado en cruz
Arreglo: una línea por catálogo, derivada del propio fichero en el initContainer de
b2-r2-credential-provider.yaml:
gravitino.iceberg-rest.catalog.<nombre>.catalog-backend-name = <nombre>
compose-conf: credential-providers: 9 catalogos tenian provider, 9 quedan en r2-token
catalog-backend-name: 9 catalogos, 9 nombres propios puestos
Gate: scripts/warehouse/c1b-aislamiento-cruzado.ts — el control CRUZADO, no un 200
por catálogo:
① createTable en A 200 · s3://…/test/w_7b50…/…
② control positivo · A ve lo suyo (list + load) ✅ ⇐ el arreglo no rompió A
③ ⭐ listTables POR EL PREFIJO DE B 404 · el ns ni existe para B
⭐ loadTable POR EL PREFIJO DE B 404
④ ⭐ B pide delegación sobre la tabla de A 404 · sin credencial
⑤ el namespace de A no aparece en el listado de B B ve: []
⇒ ⭐⭐⭐ Los catálogos-de-inquilino están aislados. Lo que C·1 midió era el enrutado; esto mide la separación. El bloqueante de §6·duodecies queda cerrado.
⚠️ Los nombres se derivan del propio fichero de catálogos, así que un inquilino nuevo queda aislado sin volver a tocar el manifiesto.
6·quaterdecies · ⏭️ «Nueva organización = nuevo catálogo» — el paradigma es correcto, pero hoy no es nativo
① Lo que pasa HOY al dar de alta una organización
lib/storage/tenant-provisioning.ts — un worker del control plane que atiende el INSERT
en workspaces (webhook de Clerk) y produce dos artefactos: el ESPACIO (warehouse +
prefijo) y los PERMISOS (cadena de roles).
⇒ Ese espacio se crea en LAKEKEEPER (provisionTenantWarehouse → LAKEHOUSE_REST_URI).
Nada crea un catálogo del IRC. Los 9 que existen están escritos a mano en el Secret
irc-catalogs, que el IRC lee con el StaticIcebergConfigProvider.
⇒ Con el despliegue de hoy, una organización nueva exige editar un Secret y reiniciar el IRC. Eso no es nativo y no escala: es exactamente el «alta de warehouse por API» que §4 daba por perdido en el cambio de catálogo.
② ⭐⭐⭐ La salida existe, y es el mismo patrón que ya hemos usado dos veces
IcebergConfigProviderFactory no tiene una lista cerrada:
String className = ICEBERG_CATALOG_CONFIG_PROVIDER_NAMES.getOrDefault(providerName, providerName);
Class<?> providerClz = Class.forName(className); // ⇐ admite una clase NUESTRA
Y la resolución no es de arranque: el IcebergCatalogWrapperManager pide el catálogo
por nombre y de forma perezosa, con caché por acceso:
catalogWrapperCache.get(catalogName, k -> createCatalogWrapper(catalogName)); // expireAfterAccess
└─ configProvider.getIcebergCatalogConfig(catalogName)
⇒ Un IcebergConfigProvider propio que lea los catálogos de INDEX resuelve el paradigma
de verdad: dar de alta una organización pasa a ser una fila, y el primer loadTable de
w_<nuevo> lo materializa sin reiniciar nada. Encaja con el norte —Index es la
autoridad, el catálogo registra— y el jar entra por el libs/ que ya montamos.
③ ⭐⭐⭐ El estándar del sector — y anula la idea de escribir un provider propio
La pregunta se llevó a «¿cuál es el patrón más limpio?», y los cuatro coinciden:
| Cómo nace un catálogo | |
|---|---|
| Lakekeeper | fila en SU base, por su Management API |
| Polaris | entidad en SU metastore, por su API de administración |
| Unity Catalog | fila en SU base, por API |
| Gravitino (servidor) | entidad en SU almacén, por POST /api/metalakes/{m}/catalogs |
El servicio de catálogo es DUEÑO de su registro de inquilinos y se muta por su
Management API. El control plane LLAMA al catálogo; el catálogo NUNCA lee al control
plane.
⭐ Y ya lo hacíamos así con Lakekeeper: provisionTenantWarehouse llama a su API. Lo
desviado era desplegar el IRC suelto, que sin servidor detrás sólo sabe leer
configuración estática — de ahí los 9 catálogos a mano en un Secret.
⇒ ⛔ Un IcebergConfigProvider propio que leyera Index habría sido el rodeo: metía una
credencial del control plane en el plano de datos, duplicaba la regla de nombrado y ataba
el arranque del catálogo a la cara. Nada de eso hace falta.
6·quindecies · 🏁 D·0…D·3 — el paradigma nativo, montado y medido
① Lo desplegado
| D·0 | d0-gravitino-server.yaml | El servidor completo, almacén relacional sobre carbon-catalog. ⭐ Sin initContainers: la imagen ya trae postgresql-42.7.0.jar y compone su conf por variables de entorno |
| D·0·b | d0b-gravitino-schema.yaml | Su esquema (schema-1.2.0-postgresql.sql, sacado de la propia imagen): 2 tablas → 35 |
| D·1 | d1-catalogos-por-api.yaml | Los 9, de config estática a entidades por API. 9 creados · 0 fallos |
| D·1·b | d1b-forzar-provider.yaml | credential-providers = r2-token forzado en los 9 |
| D·2 | d2-irc-dinamico.yaml | El IRC pasa a dynamic-config-provider y se suprime el camino estático (catalogos ESTATICOS en el conf: 0) |
| D·3 | d3-alta-sin-reinicio.yaml | El gate |
② 🏁 D·3 — el gate, verde
① el catalogo se crea POR API 200
② el IRC crea un namespace en el catalogo RECIEN nacido 200 ⇐ SIN reiniciarlo
y crea una tabla dentro 200
los bytes van al prefijo NUEVO s3://…/test/w_d3a2…/_d3_probe/sonda
③ vende credencial TEMPORAL en el catalogo nuevo 200 · s3.session-token
④ el catalogo VECINO no ve la tabla del nuevo 404
ni la puede cargar 404
⇒ ⭐⭐⭐ Organización nueva = catálogo nuevo, servido sin reiniciar nada, vendiendo
temporal acotada y aislado del vecino. Y verificado además que los 9 existentes
siguen vendiendo temporal (s3.session-token) y que C·1·b sigue verde.
③ ⏭️ Lo que falta para cerrar el círculo
El paradigma está montado; falta el último metro, que es del control plane:
- ⛔ Exponer el servidor Gravitino con autenticación. Hoy es ClusterIP y
gravitino.authorization.enableestá sin cablear ⇒ Vercel no lo alcanza, y no debe alcanzarlo sin auth. tenant-provisioning.tsllama aPOST /api/metalakes/carbon/catalogsademás de a Lakekeeper — escritura doble deliberada mientras dure la transición, para que ninguna organización nacida en el intervalo se quede fuera del catálogo nuevo.
⇒ Ambos van con la migración del SQL Editor, que es cuando el IRC deja de ser inerte.
④ ⭐⭐⭐ ¿QUÉ hace el servidor, exactamente? — medido apagándolo
Pregunta del owner: «¿el servidor completo? ¿no usábamos sólo el IRC, el equivalente a
Lakekeeper? CAS, atomicidad,/plan…». Se contesta apagándolo, no explicándolo.
gravitino-server a 0 réplicas:
catálogo YA en caché loadTable 200
loadTable + vending 200 · s3.session-token ✅
createTable (commit) 200 ⇐ CAS y atomicidad, INTACTOS
catálogo con caché FRÍA listar namespaces 500 ⇐ aquí sí hace falta
⇒ El camino de datos es del IRC al 100%. El servidor no está en el camino de la
petición: sólo contesta «qué catálogos existen y con qué propiedades», y sólo cuando el
IRC no lo tiene cacheado. /plan, el commit, el CAS y el vending no lo tocan.
⑤ ⚠️ Y el encuadre que había que corregir: «el IRC ≡ Lakekeeper» era MEDIO cierto
Lakekeeper = catálogo Iceberg REST + API de gestión de warehouses ← UN binario
Gravitino = IRC (catálogo REST) + servidor (registro de catálogos) ← DOS piezas
El IRC equivale a la mitad de catálogo. La mitad de gestión —el alta por API, que
provisionTenantWarehouse ya usa contra Lakekeeper— no existe en el IRC suelto. Por eso
§4 anotó «se pierde el alta de warehouse por API»: no era una limitación de Gravitino,
era la consecuencia de haber desplegado media pieza.
Lo que cuesta, sin maquillar: una pieza más que operar · ⚠️ un modo de fallo nuevo —servidor caído + caché fría ⇒ 500—, que con caché caliente (1 h) falla tarde y de forma intermitente, lo cual es peor que fallar pronto · ⚠️ y trae capacidades que NO usamos (tópicos, filesets, modelos, RBAC, linaje): sigue valiendo el aviso de no contarlas como propias. Usamos una: el registro de catálogos.
⇒ Decisión abierta del owner (ninguna es mala): ① seguir con las dos piezas —el
estándar, ya probado, y el modo de fallo se mitiga con réplicas— · ② volver a estático y
quitar el servidor (el IRC vuelve a ser autónomo, pero organización nueva = editar un Secret
y reiniciar) · ③ reconsiderar Lakekeeper, que da las dos mitades en un binario —contra él
pesan /plan y la latencia 3×; a favor, una pieza menos.
⑥ ⚠️ «Ahora tenemos federación, Hive y todo eso» — NO. Medido
Pregunta del owner tras D·0. La imagen trae 12 conectores (
fileset · hive · jdbc-doris · jdbc-mysql · jdbc-postgresql · jdbc-starrocks · kafka · lakehouse-generic · lakehouse-hudi · lakehouse-iceberg · lakehouse-paimon · model), y el metalake sí es
el completo: aceptó un catálogoMODEL(11 en el metalake). Y aun así no la tenemos.
crear catálogo MODEL en el servidor → 200 · el metalake lo registra
pedirlo por el IRC → 400 · «carbon.probe_model is not iceberg catalog»
⇒ La frontera: nuestros motores entran por Iceberg REST, y el IRC sirve sólo
lakehouse-iceberg (DynamicIcebergConfigProvider lo comprueba con un Preconditions).
Un catálogo Hive, Kafka o MySQL sería registrable y NO alcanzable — haría falta un
consumidor que hable la API de Gravitino, que en la plataforma no existe.
Y tres cosas más que no cambian por tener la imagen:
- Ningún backend está cableado: sin metastore Hive, sin Kafka, sin credenciales. Un conector sin backend es un jar.
- El RBAC unificado está APAGADO (
authorization.enablesin cablear). Y por §4·2, Gravitino administra, no aplica. - ⚠️ Hay decisión previa EN CONTRA: federación fuera del norte
([
carbon-sql-parser-decision]). Esto es capacidad, no dirección: adoptarla sería revertir una decisión, no aprovechar una que ya estaba.
⇒ Sigue valiendo literal el aviso de §2·1: no contar como propia una capacidad que no está encendida. Encendida hay una: el registro de catálogos.
⑦ El peso en el cluster, medido
USA REAL RESERVA (request) LÍMITE
gravitino-server 3m · 332Mi 250m · 1Gi 1 CPU · 2Gi
gravitino-irc 2m · 356Mi 250m · 1Gi 1 CPU · 1536Mi
nodo 181m/3920m CPU (4%) · 3339Mi/13,3Gi (25%)
⇒ Consumo real ruido (3 milicores, 332 MiB). Lo que cuesta es la reserva: 250m (6,4% del nodo) y 1 GiB (7,5%), que el planificador aparta aunque no se use. €0 adicionales: cabe en el nodo que ya se paga.
⚠️ Reservar 1 GiB para algo que usa 332 MiB estorba a L·2 (OpenMetadata), que ya
necesitaba un segundo nodo. Bajar el request a 512Mi devuelve medio giga sin tocar el
límite. [pendiente de decisión]
⑧ 🪤 Las cuatro trampas de esta tanda
| ⛔⛔ | loadTable devolvió 200 sirviendo la llave LARGA. El síntoma es mudo: un cliente funciona igual. Sólo el log lo decía — Generate credential: s3-secret-key. Que la respuesta sea 200 no dice qué credencial lleva dentro |
| ⛔⛔ | La fuente que copiamos nunca tuvo el valor bueno. D·1 copió el Secret irc-catalogs, que dice s3-secret-key: el volteo a r2-token lo hacía un sed del initContainer sobre el conf, así que el valor bueno sólo existía en memoria del pod. Y el script usaba setdefault, que no pisa |
| ⛔ | El IRC cachea el wrapper del catálogo por NOMBRE (expireAfterAccess, 1 h). ⇒ un catálogo nuevo aparece al instante, pero uno modificado tarda hasta una hora. Y reusar el nombre de una sonda anterior sirve la config vieja |
| ⚠️ | Clonar no es reenviar lo que devuelve el GET: la respuesta trae propiedades DERIVADAS (in-use) que el POST rechaza con 400 y sin mensaje. Y Gravitino no borra un catálogo «en uso»: hay que deshabilitarlo antes (si no, 409 y se queda vivo) |
7 · El balance, sin maquillar
| ⭐⭐ Se gana | /plan (el sitio donde poner la política, que Lakekeeper no tiene) · 40× de latencia (5,2 ms vs 222 ms, y una query de N tablas hace ≥N cargas) · el catálogo dentro del cluster · y la puerta abierta al metalake para activos heterogéneos |
| ⛔ Se pierde | El commit multi-tabla declarado y sin estrenar (§3) · el alta de warehouse por API (§4) |
| ⚠️ Hay que traducir | Warehouse (Lakekeeper) → catálogo (Gravitino) · el perfil de almacenamiento · y el soft-delete |
| ✅ No se toca | La gobernanza entera: la cara, Index, la puerta, la retícula y las políticas |
El cutover es de la pieza más profunda y la menos gobernante. Por eso se puede
hacer sin mover un solo grant — y por eso hay que hacerlo con la base decidida,
porque lo que se sustituye es donde vive la atomicidad.
8 · ⭐⭐⭐ Y qué gana esta base el día que INDEX se mude a ella
carbon-catalog no se creó sólo para el catálogo: es el paso 1 de dos
(§8·quater·bis, opciones A → E). Lo que aporta la segunda mitad:
① La razón fuerte: cerrar 4·4 sin inventar un protocolo de dos fases
HOY catálogo (su PG) ──✂── Index (Supabase us-east-1)
⇒ ventana entre el commit y su asiento ⇒ reconciliación MANUAL
(`ControlPlaneUnrecordedError`: «el snapshot SE COMMITEÓ y el control-plane
no pudo registrarlo… NO reintentar a ciegas»)
DESTINO catálogo (esquema A) ── Index (esquema B) en la MISMA base
⇒ el commit y su asiento caben en UNA transacción
Es la única de las siete debilidades de escritura que esta absorción puede cerrar, y no se cierra con código: se cierra con topología. ⚠️ Con su condición: el asiento se hace alrededor de las tablas del catálogo, no dentro — misma conexión y misma transacción, tablas distintas; escribir en su esquema sería acoplarse a su interior.
② La razón que se nota todos los días: la gobernanza deja de cruzar el Atlántico
Index está en us-east-1: 119 ms desde el cómputo. Y la cara pregunta «¿de quién es
esta tabla?», «¿tiene política?», «¿puede este principal?» en cada petición.
⇒ Mudarlo a europe-west1 lo baja a ~1-3 ms. No es una optimización: hoy la
comprobación de gobernanza cuesta más que el catálogo entero.
③ La que abre puertas: el JOIN vuelve a ser un JOIN
Con las dos mitades en la misma base, «qué tabla existe» (catálogo) y «quién puede
tocarla, bajo qué política y quién la tocó» (Index) se cruzan en SQL. Hoy eso exige
dos viajes y correlacionar en memoria — y es exactamente lo que necesita el espacio de
acción de un agente (§5·sexies): catálogo ∩ retícula, calculado, no correlacionado.
⚠️ Y el precio, que hay que medir antes
No son los 6,5 MB: son las consultas que hoy cruzan las 15 tablas de Index Catalog
con las tablas de producto (project_files, tiers, la UI). Hay una deuda conocida —
«desacoplar dataset de project_files»— y ése es el coste real de la mudanza.
[medir cuántas y cuáles]