🚪 PIEZA · ICEBERG-FACE — la cara REST gobernada
| Versión | v1.1 — el ciclo de vida COMPLETO: crea, compensa y reconcilia |
| Estado | 🏁 Viva y aplicando … para 1 de los 4 carriles |
| Última medición | 2026-08-13 — scripts/warehouse/t10-resolucion-coordenada.ts |
Qué es
Un catálogo Iceberg REST que es nuestro por delante y de Lakekeeper por detrás.
Habla la spec estándar, autoriza cada petición contra Index, traduce nombres
lógicos a físicos, y reenvía.
Qué NO es
- No emite tokens. Reusa el mismo Keycloak que Lakekeeper: el
client_ides elprincipal_idde la retícula. No hay segundo sistema de identidad. - No commitea.
updateTablese autoriza aquí y se delega íntegro: el CAS sigue siendo del catálogo (invariante: Index nunca serializa escrituras). - ⛔ No cierra la red. Y eso no es un detalle: tres de los cuatro carriles la esquivan (§«Quién entra por ella»).
- No es Junction. La cara pregunta «¿puede este MOTOR?»; la puerta pregunta «¿puede esta PERSONA?». Son dos preguntas y dos sujetos.
Su naturaleza
motor ──JWT de Keycloak──▶ /api/iceberg/v1/…
│
① IDENTIDAD principalFromRequest() jwtVerify contra el JWKS de Keycloak
principal_id = azp ‖ client_id ‖ sub catalog-principal.ts
│
② RUTA routeCatalogRequest(method, segments) → una de 13 operaciones
splitTenantPrefix() → el inquilino sale del prefijo catalog-route.ts
│
③ TENENCIA workspace ← `warehouse=w_<workspace sin guiones>` catalog-tenant.ts
│
④ AUTORIZACIÓN authorizeCatalogRequest() → CATALOG_OPS → decidePrivilege
fail-closed · deja asiento en `access_events` catalog-authz.ts
│
⑤ NOMBRES physicalFromLogical() ⇄ logicalFromPhysical() catalog-names.ts
el cliente habla en lógico; el catálogo, en `ds_<uuid>`
│
⑥ REENVÍO upstreamUrl / upstreamToken / upstreamPrefix catalog-upstream.ts
⭐ el prefijo del motor NO se reenvía: se SUSTITUYE por el del catálogo
▼
Lakekeeper ──▶ R2
2.239 líneas en 6 módulos + 1 route, con test al lado de cada uno.
Las decisiones que la definen
| ⭐⭐ La delegación es OTRO permiso | loadTable sin X-Iceberg-Access-Delegation pide TABLE_READ_PROPERTIES; con ella pide además TABLE_READ_DATA. La primera dice dónde está la tabla; la segunda entrega la llave de sus bytes |
| ⭐ La traducción es PURA para el caso normal | El nombre físico es ds_<uuid sin guiones>, derivado del id inmutable ⇒ saber de qué dataset habla una petición no cuesta una consulta |
| ⭐ Lo que no se sabe traducir, se NIEGA | Un nombre que no casa devuelve unknown-table … salvo los nombres lógicos, que sí se resuelven contra Index, y los que no resuelven se reenvían tal cual para que muera en el catálogo con su error estándar |
| ⭐ La metadata NO es menos sensible | .snapshots, .files, .history llegan con la tabla base en el namespace. Se traducen al dataset y se autorizan con el privilegio de siempre |
| ⭐ Materializa antes de reenviar | Un createTable da de alta el dataset en Index y sólo entonces reenvía, con el nombre físico que ella misma acuñó. Con compensación si el upstream falla (compensateMaterialise) |
| ⭐⭐⭐ Resuelve por COORDENADA, no por ubicación | T·1·0 — physicalFromLogical busca por (workspace, schema_id, name), la MISMA clave con la que materializeTable comprueba la colisión. Antes buscaba por iceberg_namespace, que es dónde viven los bytes: coincidían sólo mientras existió el espejo, y sin él 8 de 8 tablas divergentes eran invisibles — 7 creadas por la propia cara |
| ⭐⭐ Compensa también los fallos POSTERIORES | T·1·a — un CTAS atómico son tres operaciones y el fallo puede llegar en otra petición. Se compensa preguntando al catálogo si la tabla existe, nunca infiriéndolo del !res.ok: la duda no borra |
| ⭐⭐ Soltar suelta de Index | T·1·b — un dropTable aceptado retira la fila (y su puntero de Files). Sin esto el CREATE siguiente chocaba con un fantasma que el motor había creado y no podía deshacer |
Lo medido en producción (2026-08-12)
curl -s -o /dev/null -w "%{http_code}" https://app.paladio.io/api/iceberg/v1/config # 200
curl -s -o /dev/null -w "%{http_code}" https://app.paladio.io/api/iceberg/v1/namespaces # 401
✅ Viva y fail-closed: /config responde sin token (como manda la spec), y
cualquier operación real exige JWT. ENABLE_INDEX_REST_CATALOG está puesto en
Production y Preview.
Y su /config lleva algo propio, que es cómo un motor declara el inquilino:
{"overrides": {"oauth2-server-uri": "…/realms/lakehouse/…", "scope": "openid"},
"endpoints": [],
"index-catalog": "declara el inquilino con `warehouse=w_<workspace sin guiones>`"}
⚠️ endpoints: [] — la cara no declara ninguna capacidad. La spec REST usa ese
campo desde 1.6 para que el cliente sepa qué existe, y es exactamente el campo por el
que medimos a Lakekeeper (25) y a Gravitino (24, con /plan). Un cliente moderno que
lo lea no sabe qué puede pedirnos.
⛔⛔ Quién entra por ella — uno de cuatro
| Carril | Apunta a | ¿Gobernado? |
|---|---|---|
| ⭐ Spark — el cómputo del SQL Editor | CATALOG_URI=https://app.paladio.io/api/iceberg | ✅ SÍ |
| ⛔ warehouse-writer — toda la ingesta | Lakekeeper directo | NO |
| ⛔ Duck | Lakekeeper directo | NO |
| ⛔ ml-runner | postgresql:// — SqlCatalog, ni cara ni REST | NO |
kubectl -n spark get deploy spark-bridge -o jsonpath='{...env...}' # → /api/iceberg ✅
railway variables -s warehouse-writer | grep LAKEHOUSE_REST_URI # → Lakekeeper ⛔
railway variables -s Duck | grep LAKEHOUSE_REST_URI # → Lakekeeper ⛔
railway variables -s ml-runner | grep CATALOG_URI # → postgresql:// ⛔
⭐⭐⭐ La cara gobierna el carril de LECTURA del producto y ninguno de los tres que
escriben o sirven por detrás. Su propio módulo lo lleva escrito: «No cierra la
red. Si los motores pueden seguir apuntando a Lakekeeper directamente, esto no
aplica nada. Eso es infraestructura, no código, y es la parte más barata y más
olvidable del trabajo.»Ya no es una advertencia: es el censo.
⭐⭐⭐ Y la CAUSA no era el descuido — apareció al estudiar el vending
(Añadido el 2026-08-12 tras leer credential-vending.ts — ver
INDEX-CATALOG.)
La spec de Iceberg REST no tiene forma de declarar intención de escritura. Un
escritor pideloadTableigual que un lector y sólo commitea después. ⇒ Si la
credencial se acuña mirando la operación,loadTablees siempre lectura y
ningún motor puede escribir por la cara.Y eso pasaba: un
INSERTde Spark moría con403 al cerrar el Parquet,
después de que la cara hubiera dicho sí.⭐⭐ «El único escritor que funcionaba,
warehouse-writer, lo lograba SALTÁNDOSE
LA CARA.»
⇒ El censo de arriba mide el síntoma; esto es la causa. warehouse-writer no
esquiva la cara por un descuido de configuración: era la única forma de que
escribiera. Y lo que lo arregla ya existe — mode: 'auto', donde el modo lo decide
el PRIVILEGIO y no la operación ⇒ meter la ingesta por la cara ha pasado de ser
imposible a ser un cambio de URL.
Lo que falta — con su gate
| Qué | Gate | |
|---|---|---|
| ⛔⛔ | Meter los otros tres carriles por la cara — empezando por warehouse-writer, que es por donde entra el 100 % del dato nuevo | Una escritura de ingesta que deje asiento en access_events. Hoy no deja ninguno |
| ⛔⛔ | Cerrar la red: que Lakekeeper sólo acepte a la cara | Un motor apuntando directo a Lakekeeper con credencial válida → rechazado |
| ⛔ | Declarar endpoints[] | Que /config liste lo que de verdad soporta, como hacen Lakekeeper y Gravitino |
| 🟡 | planTableScan | No lo tiene ninguna de las dos capas hoy. Es la costura donde entraría la política de celda (mapa §8·1·f) |
Historial
| Versión | Fecha | |
|---|---|---|
| v1.0 | 2026-08-12 | Primera imagen trazada y medida. El hallazgo: la cara gobierna 1 de 4 carriles, y su propia advertencia sobre no cerrar la red resulta ser el censo |