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
snowlabsurminio-central, avec un utilisateur scopé de même nom (provisionné parmake minio-new PROJET=snowlab). - Deux zones sous forme de préfixes :
landing/(fichiers bruts, voir collecte-retraitement.md) etwarehouse/(fichiers Iceberg). Le jobminio-init(infra/minio/init.sh, imageamazon/aws-cli:2.38.5) vérifie l'accès au bucket et créelanding/.keepetwarehouse/.keeps'ils manquent. Il est rejouable et n'affiche jamais les clés. - Accès path-style partout (
pathStyleAccessdans le catalogue Polaris,s3.path-style-access=truedans Trino,URL_STYLE 'path'dans DuckDB,force_virtual_addressing=FalsedansLandingStore.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_calendaretforecast. Trino écrit aussi (règlesINSERT,UPDATE,DELETEpourlab_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êtePolaris-Realm, clés MinIO statiques). L'en-têteX-Iceberg-Access-Delegationest 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 dansinfra/polaris/application.properties(chargé parQUARKUS_CONFIG_LOCATIONS) : persistancerelational-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 depg-centralpeut utiliserpublic. - Jetons : token broker
symmetric-keyavec une clé fixe (POLARIS_TOKEN_SIGNING_SECRET), pour que les jetons survivent à un redémarrage de Polaris. Si la clé change, recréerpolarisettrinoensemble (voir exploitation.md). - Clés S3 du serveur :
AWS_ACCESS_KEY_IDetAWS_SECRET_ACCESS_KEYreçoivent les clés de l'utilisateur scopé MinIO. polaris-bootstrap(apache/polaris-admin-tool:1.8.0) crée le realmPOLARISet les identifiants root dans la base avant le démarrage du serveur. La commandebootstrapde 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 variablePOLARIS_BOOTSTRAP_CREDENTIALS, présente dans le code, n'est lue que par le serveur, et elle serait de toute façon visible pardocker inspect. Le compose utilise le fichier, rendu depuis.envpar unconfigsde 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--realmsont 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 :
- Catalogue
analyticsde typeINTERNAL, stockage S3 surs3://snowlab/warehouse/,endpointInternalvershttp://minio-central:9000,pathStyleAccess: true,stsUnavailable: true. - Principal technique
lab_tech. - Rôle de principal
lab_tech_role, attribué au principal. - Rôle de catalogue
lab_tech_catalog_role, attribué au rôle de principal. - Privilèges sur le catalogue :
CATALOG_MANAGE_CONTENT,TABLE_READ_DATA,TABLE_WRITE_DATA(contenu seulement, aucune gestion des accès). - Namespaces, créés par
lab_tech:raw,standardized,semantic,intake,forecast,shared_calendar. - Principal
lab_reader, rôleslab_reader_roleetlab_reader_catalog_role, privilègesNAMESPACE_LIST,NAMESPACE_READ_PROPERTIES,TABLE_LIST,TABLE_READ_PROPERTIES,TABLE_READ_DATAsur les namespacessemanticetshared_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é sur127.0.0.1:18080,http-server.process-forwarded=truederrière Traefik. - Catalogue
infra/trino/catalog/analytics.properties: le nom du fichier donne le premier niveauanalytics.<schema>.<table>. Connecteuriceberg, catalogue RESThttp://polaris:8181/api/catalog,warehouse=analytics, OAuth2 avec les identifiants delab_techet le scopePRINCIPAL_ROLE:lab_tech_role, en-têtePolaris-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: chargehttpfseticeberget crée le secretlab_s3(clés MinIO, path-style).polaris: crée le secretlab_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 nomanalytics.<schema>.<table>fonctionne alors dans DuckDB, Trino et Snowflake.cortex: appellecortex_lab.duckdb.register(con)(fonctionsAI_*et macroget).- 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, fonctionto_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).
ATetBEFORE: 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).