Aller au contenu

Socle : MinIO, Iceberg, Polaris, Trino, DuckDB

Skill de référence : .claude/skills/lakehouse-conventions/SKILL.md.

Brique Rôle Équivalent Snowflake Implantation
MinIO Stockage objet S3 : fichiers déposés et fichiers des tables Stockage interne et stages Brique minio-central du NUC, bucket snowlab
Iceberg Format de table (Parquet et métadonnées : schéma, snapshots) Format de stockage des tables Format v2 (lab/iceberg.py, table_properties())
Polaris Catalogue REST : namespaces, tables, droits Couche de métadonnées et RBAC Service polaris (1.8.0), état dans pg-central
Trino Moteur SQL partagé Virtual warehouse Service trino (484), nœud unique
DuckDB Moteur embarqué (bibliothèque Python) Pas d'équivalent strict lab/duckdb_connect.py, duckdb==2.0.0

MinIO

  • Bucket snowlab sur minio-central, avec un utilisateur scopé de même nom (provisionné par make minio-new PROJET=snowlab).
  • Deux zones sous forme de préfixes : landing/ (fichiers bruts, voir collecte-retraitement.md) et warehouse/ (fichiers Iceberg). Le job minio-init (infra/minio/init.sh, image amazon/aws-cli:2.38.5) vérifie l'accès au bucket et crée landing/.keep et warehouse/.keep s'ils manquent. Il est rejouable et n'affiche jamais les clés.
  • Accès path-style partout (pathStyleAccess dans le catalogue Polaris, s3.path-style-access=true dans Trino, URL_STYLE 'path' dans DuckDB, force_virtual_addressing=False dans LandingStore.for_minio).

Iceberg

  • Format v2 pour toutes les tables créées par le labo (format-version: 2). Conséquences : pas de type variant (JSON en chaîne), timestamps en microsecondes.
  • Écrivain : PyIceberg (pyiceberg[pyarrow]==0.12.0) pour raw, intake, shared_calendar et forecast. Trino écrit aussi (règles INSERT, UPDATE, DELETE pour lab_tech).
  • lab/iceberg.py :
  • rest_catalog_properties(cfg) : propriétés du catalogue REST Polaris (URI, warehouse = nom de la base, OAuth2, scope du rôle, en-tête Polaris-Realm, clés MinIO statiques). L'en-tête X-Iceberg-Access-Delegation est envoyé vide : PyIceberg demande par défaut des identifiants vendus, ce que le catalogue refuse (pas de STS).
  • load_rest_catalog(cfg), ensure_namespace, ensure_table (création à partir d'un schéma Arrow si absente), scan_arrow.

Polaris

Configuration

  • Image apache/polaris:1.8.0, surcharges dans infra/polaris/application.properties (chargé par QUARKUS_CONFIG_LOCATIONS) : persistance relational-jdbc (l'état en mémoire par défaut serait perdu au redémarrage), polaris.readiness.ignore-severe-issues=true (déploiement de développement).
  • Source de données : jdbc:postgresql://pg-central:5432/snowlab_polaris_db?currentSchema=public. Polaris ne crée pas son schéma et le rôle propriétaire de pg-central peut utiliser public.
  • Jetons : token broker symmetric-key avec une clé fixe (POLARIS_TOKEN_SIGNING_SECRET), pour que les jetons survivent à un redémarrage de Polaris. Si la clé change, recréer polaris et trino ensemble (voir exploitation.md).
  • Clés S3 du serveur : AWS_ACCESS_KEY_ID et AWS_SECRET_ACCESS_KEY reçoivent les clés de l'utilisateur scopé MinIO.
  • polaris-bootstrap (apache/polaris-admin-tool:1.8.0) crée le realm POLARIS et les identifiants root dans la base avant le démarrage du serveur. La commande bootstrap de l'outil 1.8.0 lit ces identifiants depuis --credential (secret dans les arguments du processus) ou --credentials-file (YAML), et rien d'autre : la variable POLARIS_BOOTSTRAP_CREDENTIALS, présente dans le code, n'est lue que par le serveur, et elle serait de toute façon visible par docker inspect. Le compose utilise le fichier, rendu depuis .env par un configs de Compose (le secret n'apparaît plus dans les arguments), monté en mode 0400 pour l'utilisateur de l'image (uid 10000). Les valeurs y sont entre apostrophes : elles peuvent contenir " ou \, pas d'apostrophe. Le conteneur arrêté garde ce fichier : le supprimer après l'amorçage (voir exploitation). Le fichier et --realm sont exclusifs : le realm est la clé du YAML.

Job d'initialisation

infra/polaris/init_catalog.py (bibliothèque standard seulement, rejouable), dans l'ordre exigé par la skill :

  1. Catalogue analytics de type INTERNAL, stockage S3 sur s3://snowlab/warehouse/, endpointInternal vers http://minio-central:9000, pathStyleAccess: true, stsUnavailable: true.
  2. Principal technique lab_tech.
  3. Rôle de principal lab_tech_role, attribué au principal.
  4. Rôle de catalogue lab_tech_catalog_role, attribué au rôle de principal.
  5. Privilèges sur le catalogue : CATALOG_MANAGE_CONTENT, TABLE_READ_DATA, TABLE_WRITE_DATA (contenu seulement, aucune gestion des accès).
  6. Namespaces, créés par lab_tech : raw, standardized, semantic, intake, forecast, shared_calendar.
  7. Principal lab_reader, rôles lab_reader_role et lab_reader_catalog_role, privilèges NAMESPACE_LIST, NAMESPACE_READ_PROPERTIES, TABLE_LIST, TABLE_READ_PROPERTIES, TABLE_READ_DATA sur les namespaces semantic et shared_calendar.

Le catalogue porte la propriété polaris.config.drop-with-purge.enabled, fixée par la variable POLARIS_DROP_WITH_PURGE de .env (défaut true, voir docs/exploitation.md, « Purge à la suppression ») (nom vérifié dans Polaris 1.8.0 : propriété de catalogue, plus étroite que la configuration de serveur DROP_WITH_PURGE_ENABLED). Trino envoie DROP TABLE au catalogue REST avec purge ; sans cette propriété Polaris refuse (« Unable to purge entity ») et dbt-trino ne peut pas remplacer une table ni un seed. Le job l'écrit à la création et, si le catalogue existe déjà, par une mise à jour qui conserve les autres propriétés ; au rejeu il n'écrit rien. Une suppression avec purge supprime les fichiers de la table dans MinIO ; DuckDB et PyIceberg suppriment sans purge (leurs fichiers restent).

Les identifiants émis vont dans secrets/polaris_credentials.env (mode 0600). Le client REST du job ne met jamais le corps d'une réponse dans une exception (il peut contenir des secrets). Les namespaces de lab_reader sont listés explicitement (READER_NAMESPACES) : un nouveau schéma shared_* devrait y être ajouté pour que lab_reader le lise via Polaris.

Accès à MinIO

Décision : clés statiques de l'utilisateur scopé, pas de credential vending. Polaris refuse une demande de vending (réponse 400 no credentials are available) et ne renvoie aucune clé dans la configuration de table (test test_polaris_does_not_vend_credentials_engines_use_the_static_keys).

Trino

  • Image trinodb/trino:484, nœud unique (coordinator=true, node-scheduler.include-coordinator=true), port 8080 publié sur 127.0.0.1:18080, http-server.process-forwarded=true derrière Traefik.
  • Catalogue infra/trino/catalog/analytics.properties : le nom du fichier donne le premier niveau analytics.<schema>.<table>. Connecteur iceberg, catalogue REST http://polaris:8181/api/catalog, warehouse=analytics, OAuth2 avec les identifiants de lab_tech et le scope PRINCIPAL_ROLE:lab_tech_role, en-tête Polaris-Realm: POLARIS, vended-credentials-enabled=false, accès S3 natif (fs.s3.enabled=true) avec les clés statiques lues par ${ENV:...}.
  • Contrôle d'accès par fichier (infra/trino/access-control.properties, infra/trino/rules.json, relu chaque minute). Première règle correspondante gagnante :
Niveau Utilisateur Portée Droit
Catalogue lab_tech analytics tout
Catalogue lab_reader analytics lecture seule
Catalogue autres tous aucun
Schéma lab_tech analytics, schémas hors shared_* propriétaire
Table tous analytics.information_schema SELECT
Table lab_tech schémas hors shared_* SELECT, INSERT, DELETE, UPDATE, OWNERSHIP
Table lab_tech shared_* SELECT
Table lab_reader semantic et shared_* SELECT
Table autres tout aucun

Trino n'authentifie pas ses clients : le nom d'utilisateur déclaré choisit les règles. Côté Polaris, Trino agit toujours comme lab_tech ; les règles de fichier sont donc la seule barrière par utilisateur dans Trino.

DuckDB

lab/duckdb_connect.py, fonction connect(cfg=None, database=":memory:", *, s3=True, polaris=True, cortex=True, client=None) :

  • s3 : charge httpfs et iceberg et crée le secret lab_s3 (clés MinIO, path-style).
  • polaris : crée le secret lab_polaris (OAuth2 vers /api/catalog/v1/oauth/tokens, scope du rôle) et attache le catalogue sous le nom de la base : ATTACH 'analytics' AS analytics (TYPE iceberg, ..., ACCESS_DELEGATION_MODE 'none'). Le même nom analytics.<schema>.<table> fonctionne alors dans DuckDB, Trino et Snowflake.
  • cortex : appelle cortex_lab.duckdb.register(con) (fonctions AI_* et macro get).
  • Les instructions qui portent des secrets passent par _run_secret_sql, qui masque les secrets dans un éventuel message d'erreur.

Dans dbt-duckdb (dbt/profiles.example.yml), la base DuckDB locale est en mémoire et Polaris est attaché sous le même nom analytics (alias égal à LAB_DATABASE, database du profil égal à cet alias) : dbt écrit directement dans Polaris et les modèles s'appellent analytics.<schema>.<table> comme dans Trino et chez Snowflake. Un fichier analytics.duckdb donnerait une base locale du même nom que l'alias, en conflit : le profil n'en utilise pas.

Nommage et types

  • lab/naming.py : identifiants conformes à ^[a-z_][a-z0-9_]*$, jamais entre guillemets ; qualified(database, schema, table) ; SCHEMAS ; is_shared_schema. Snowflake met en majuscules les identifiants non quotés, Trino et DuckDB en minuscules : la règle rend le même SQL valide partout.
  • lab/types.py, fonction to_lab_type :
Snowflake Trino DuckDB Iceberg
NUMBER(p, s), DECIMAL, NUMERIC decimal(p, s) DECIMAL(p, s) decimal(p, s)
NUMBER sans argument decimal(38, 0) DECIMAL(38, 0) decimal(38, 0)
VARCHAR, STRING, TEXT varchar VARCHAR string
VARIANT, OBJECT, ARRAY varchar (JSON) VARCHAR string
TIMESTAMP_NTZ timestamp(6) TIMESTAMP timestamp
TIMESTAMP_TZ timestamp(6) with time zone TIMESTAMPTZ timestamptz

Correction du 2026-10-23 (https://docs.snowflake.com/en/user-guide/tables-iceberg-data-types, lue le même jour) : la colonne Iceberg du tableau est le type de stockage du labo. Quand Snowflake lit une table Iceberg, timestamp devient TIMESTAMP_NTZ(6) et timestamptz devient TIMESTAMP_LTZ(6), pas TIMESTAMP_TZ ; la précision est la microseconde (les nanosecondes, timestamp_ns et timestamptz_ns, n'existent qu'en Iceberg v3, et VARIANT aussi). string devient VARCHAR(134217728), decimal(P, S) devient NUMBER(P, S), list, struct et map deviennent ARRAY, OBJECT et MAP structurés ; VECTOR n'est pas mentionné par la page. | BOOLEAN, DATE, FLOAT, DOUBLE, INTEGER, INT, BIGINT | équivalents directs | équivalents directs | équivalents directs |

Time Travel, rétention, branches et tags

lab/maintenance.py (PyIceberg) :

Fonction Équivalent Snowflake
read_as_of_version(table, snapshot_id) Pas d'identifiant de snapshot côté Snowflake, qui adresse le passé par AT ou BEFORE (horodatage, décalage ou requête : voir la table de correspondance ci-dessous) ; dans Trino : FOR VERSION AS OF <snapshot_id>
read_as_of_timestamp(table, moment), snapshot_id_as_of AT par horodatage ; dans Trino : FOR TIMESTAMP AS OF TIMESTAMP '... UTC'. Lève LookupError si la table n'existait pas encore
expire_snapshots(table, retention_days=1, now=None) DATA_RETENTION_TIME_IN_DAYS (1 jour par défaut en édition Standard). Ne retire jamais le snapshot courant ni ceux tenus par une branche ou un tag ; renvoie les identifiants expirés
create_branch(table, name, snapshot_id=None) Zero-copy clone accessible en écriture (les fichiers de données sont partagés)
create_tag(table, name, snapshot_id=None) Zero-copy clone en lecture seule
refs(table), read_ref(table, name) Liste et lecture des branches et tags ; dans Trino : FOR VERSION AS OF 'nom'

Correspondance de AT et BEFORE avec Trino (B5)

Page lue le 2026-10-23 : https://docs.snowflake.com/en/sql-reference/constructs/at-before (paramètres TIMESTAMP, OFFSET, STATEMENT pour un identifiant de requête de moins de 14 jours, STREAM ; pas d'identifiant de snapshot). Démontré par tests/socle/test_stack_integration.py (tests test_b5_*, contre Trino et Polaris) :

Snowflake Trino Constat
AT(TIMESTAMP => t) FOR TIMESTAMP AS OF TIMESTAMP 't UTC' Inclusif : l'instant exact du commit d'un snapshot renvoie ce snapshot ; une milliseconde plus tôt renvoie le précédent ; avant le premier snapshot, c'est une erreur (pas une table vide)
AT(OFFSET => -n) FOR TIMESTAMP AS OF (current_timestamp - INTERVAL 'n' SECOND) Horodatage relatif à la requête
AT(STATEMENT => q) FOR VERSION AS OF <snapshot produit par q> Le snapshot de l'instruction
BEFORE(STATEMENT => q) FOR VERSION AS OF <snapshot parent du snapshot de q> parent_snapshot_id ; la première instruction n'a pas de parent, donc pas d'équivalent

La résolution est la milliseconde (horodatage de snapshot Iceberg). Les horodatages des tables natives de Snowflake sont en nanosecondes ; la rétention (DATA_RETENTION_TIME_IN_DAYS) et l'identifiant de requête valable 14 jours n'ont pas d'équivalent au labo.

Limite : clone par table seulement. Le branchement d'un catalogue entier relève de Nessie, pas de Polaris (hors périmètre). Pour l'historique long au-delà de la rétention, le labo utilise les snapshots dbt SCD2 (voir collecte-retraitement.md).

RBAC en résumé

Principal ou utilisateur Polaris Trino
lab_tech Contenu complet du catalogue analytics Tout sauf écriture dans shared_*
lab_reader Lecture de semantic et shared_calendar Lecture de semantic et shared_*
autres aucun principal aucun accès

Les refus sont démontrés par les tests d'intégration test_a_principal_without_the_privilege_is_refused_by_polaris et test_a_restrictive_trino_rule_refuses_access.

Références Snowflake

  • Time Travel : https://docs.snowflake.com/en/user-guide/data-time-travel (vérifié le 2026-10-22).
  • Zero-copy clone : https://docs.snowflake.com/en/user-guide/tables-storage-considerations (vérifié le 2026-10-22) ; syntaxe CREATE ... CLONE : https://docs.snowflake.com/en/sql-reference/sql/create-clone (lue le 2026-10-23 : clone indépendant et accessible en écriture, clone d'une table Iceberg seulement si Snowflake la gère).
  • Contrôle d'accès : https://docs.snowflake.com/en/user-guide/security-access-control-overview (vérifié le 2026-10-22).
  • Identifiants : https://docs.snowflake.com/en/sql-reference/identifiers-syntax (vérifié le 2026-10-22).
  • Types : https://docs.snowflake.com/en/sql-reference/data-types-datetime et https://docs.snowflake.com/en/sql-reference/data-types-semistructured (vérifiés le 2026-10-22) ; types Iceberg côté Snowflake : https://docs.snowflake.com/en/user-guide/tables-iceberg-data-types (lue le 2026-10-23).
  • AT et BEFORE : https://docs.snowflake.com/en/sql-reference/constructs/at-before (lue le 2026-10-23).
  • Tables Iceberg : https://docs.snowflake.com/en/user-guide/tables-iceberg (vérifié le 2026-10-22).