Published

🚪 PIEZA · ICEBERG-FACE — la cara REST gobernada

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

🚪 PIEZA · ICEBERG-FACE — la cara REST gobernada

Versiónv1.1 — el ciclo de vida COMPLETO: crea, compensa y reconcilia
Estado🏁 Viva y aplicandopara 1 de los 4 carriles
Última medición2026-08-13scripts/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_id es el principal_id de la retícula. No hay segundo sistema de identidad.
  • No commitea. updateTable se 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 permisoloadTable 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 normalEl 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 NIEGAUn 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 reenviarUn 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ónT·1·0physicalFromLogical 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 POSTERIOREST·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 IndexT·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

CarrilApunta a¿Gobernado?
Spark — el cómputo del SQL EditorCATALOG_URI=https://app.paladio.io/api/iceberg
warehouse-writertoda la ingestaLakekeeper directoNO
DuckLakekeeper directoNO
ml-runnerpostgresql:// — SqlCatalog, ni cara ni RESTNO
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 pide loadTable igual que un lector y sólo commitea después. ⇒ Si la
credencial se acuña mirando la operación, loadTable es siempre lectura y
ningún motor puede escribir por la cara.

Y eso pasaba: un INSERT de Spark moría con 403 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ónmeter 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 nuevoUna escritura de ingesta que deje asiento en access_events. Hoy no deja ninguno
⛔⛔Cerrar la red: que Lakekeeper sólo acepte a la caraUn 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
🟡planTableScanNo 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ónFecha
v1.02026-08-12Primera 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