Aller au contenu

Installation et exploitation

Ce document décrit comment provisionner, configurer, démarrer, utiliser, arrêter et dépanner le labo sur le NUC. Il ne contient aucun secret : il indique où se trouvent les identifiants, jamais leur valeur.

Prérequis

  • Le NUC avec ses briques partagées en service : pg-central, minio-central, LiteLLM, Traefik, et les réseaux Docker externes data et web.
  • Docker et Docker Compose.
  • uv et Python 3.12 (requires-python = ">=3.12,<3.13" dans pyproject.toml).
  • Accès réseau sortant au premier usage : registre d'images, PyPI (via uv), dépôt d'extensions DuckDB (INSTALL httpfs et INSTALL iceberg dans lab/duckdb_connect.py), Hugging Face au moment de la construction de l'image de prévision.

Provisionnement des briques du NUC

Fait par l'administrateur du NUC, hors de ce dépôt (le dépôt ne contient aucune configuration de brique).

PostgreSQL et MinIO

cd ~/Documents/nuc.local
make pg-new PROJET=snowlab_polaris      # base snowlab_polaris_db, rôle propriétaire snowlab_polaris_user
make pg-new PROJET=snowlab_marquez      # base snowlab_marquez_db, rôle propriétaire snowlab_marquez_user
make minio-new PROJET=snowlab           # bucket snowlab, utilisateur scopé snowlab (secret affiché une seule fois)

L'image minio/mc attendue par les scripts du NUC est introuvable sur les registres. Les scripts ont été lancés en surchargeant l'image cliente : MC_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z. Le job minio-init du labo n'utilise pas mc : il passe par l'API S3 avec amazon/aws-cli:2.38.5 (infra/minio/init.sh).

Le bucket snowlab porte deux préfixes, créés par minio-init sous forme d'objets .keep : landing/ (fichiers déposés, équivalent des stages) et warehouse/ (fichiers des tables Iceberg).

LiteLLM

Dix alias de chat pointent vers le même modèle local, ollama_chat/qwen3:8b, sans fallbacks vers un modèle cloud : cortex-classify, cortex-sentiment, cortex-complete, cortex-filter, cortex-extract, cortex-translate, cortex-summarize, cortex-agg, cortex-summarize-agg, cortex-redact. L'alias cortex-embed pointe vers ollama/bge-m3-cpu (1 024 dimensions). La clé du labo est limitée à ces onze alias cortex-* : un appel sur un alias cloud de la brique (claude-sonnet-4-6) est refusé (401, test d'intégration). Fenêtres mesurées le 2026-10-23 : 32 768 jetons pour le chat, 2 048 pour cortex-embed (voir cortex-lab.md).

  • La brique LiteLLM fonctionne avec store_model_in_db: true : les alias ont été ajoutés à chaud par l'API /model/new. Ils sont stockés dans la base de LiteLLM et n'apparaissent pas dans config/litellm/config.yaml du NUC.
  • Une clé virtuelle nommée snowflake-lab-kit, limitée à ces trois alias, est utilisée par le labo (LITELLM_API_KEY).

Après toute action d'infrastructure sur le NUC, la règle globale demande une ligne dans ~/Documents/central/README.md et un commit (hors de ce dépôt).

Fichier .env

.env est à la racine du dépôt, n'est jamais commité (.gitignore) et sa lecture est interdite à Claude (.claude/settings.json). Il se construit à partir de .env.example, qui ne contient que des valeurs factices (contrôlé par tests/test_repo_conventions.py).

Variable Rôle Origine de la valeur
LAB_DATABASE Nom de la base Snowflake cible, donc du catalogue Polaris (le fichier de catalogue Trino porte ce nom) Décision : analytics
LAB_UID, LAB_GID uid et gid de l'utilisateur hôte, pour que secrets/polaris_credentials.env reste lisible id -u, id -g
POLARIS_DB_NAME, POLARIS_DB_USER, POLARIS_DB_PASSWORD Base PostgreSQL de Polaris sur pg-central make pg-new PROJET=snowlab_polaris
MARQUEZ_DB_NAME, MARQUEZ_DB_USER, MARQUEZ_DB_PASSWORD Base PostgreSQL de Marquez sur pg-central make pg-new PROJET=snowlab_marquez
MINIO_ENDPOINT Point d'accès S3 vu depuis l'hôte (dans le compose : http://minio-central:9000) Brique MinIO
MINIO_BUCKET Bucket du labo (snowlab) make minio-new PROJET=snowlab
MINIO_ACCESS_KEY, MINIO_SECRET_KEY Clés de l'utilisateur scopé du bucket make minio-new PROJET=snowlab
MINIO_REGION Région S3 annoncée Défaut us-east-1
MINIO_ENDPOINT_HOSTPORT Forme hôte:port de MINIO_ENDPOINT, lue par le profil dbt-duckdb Dérivée de MINIO_ENDPOINT
POLARIS_URI URI de Polaris vue depuis l'hôte http://127.0.0.1:18181
POLARIS_REALM Realm Polaris (en-tête Polaris-Realm) Défaut POLARIS
POLARIS_ROOT_CLIENT_ID, POLARIS_ROOT_CLIENT_SECRET Identifiants root, utilisés seulement par polaris-bootstrap (fichier YAML --credentials-file rendu par Compose, pas d'argument de processus) et polaris-init Choisis par l'administrateur
POLARIS_TOKEN_SIGNING_SECRET Clé symétrique de signature des jetons Polaris (32 caractères aléatoires ou plus) Choisie par l'administrateur
POLARIS_TECH_CLIENT_ID, POLARIS_TECH_CLIENT_SECRET Principal technique lab_tech Émis par Polaris au premier polaris-init, recopiés depuis secrets/polaris_credentials.env
POLARIS_READER_CLIENT_ID, POLARIS_READER_CLIENT_SECRET Principal en lecture seule lab_reader Idem
POLARIS_DROP_WITH_PURGE Purge à la suppression sur le catalogue (true ou false, défaut true), appliquée par polaris-init ; voir « Purge à la suppression » Choix de l'administrateur
TRINO_URL URL de Trino vue depuis l'hôte http://127.0.0.1:18080
MARQUEZ_URL URL de l'API Marquez vue depuis l'hôte ; active l'émission de lineage http://127.0.0.1:15000
DOCS_URL URL du site de documentation, lue par les seuls tests d'intégration de tests/website ; aucune valeur secrète http://docs.openflake.nuc
LITELLM_BASE_URL Seule URL vers un LLM (dans un conteneur du réseau data : http://litellm:4000) Brique LiteLLM
LITELLM_API_KEY Clé virtuelle snowflake-lab-kit Brique LiteLLM
CORTEX_COMPLETE_EXTRA_ALIASES Noms de modèles ajoutés à la liste d'alias autorisés d'AI_COMPLETE (par défaut les alias cortex-* connus ; séparés par des virgules) ; un nom hors liste donne NULL ou l'erreur du contrat, sans appel réseau vide
CORTEX_COMPLETE_MODEL Nom de modèle passé à AI_COMPLETE par ingest/mapping.py (alias LiteLLM au labo) cortex-complete
CORTEX_BACKEND Bascule à l'import de cortex_lab.compat : lab (défaut) ou snowflake lab
Retiré le 2026-10-23 : DBT_DUCKDB_PATH et LAB_DUCKDB_PATH n'existent plus. dbt-duckdb travaille sur une base locale en mémoire et écrit dans Polaris ; le service de prévision lit Polaris. À supprimer d'un ancien .env : un fichier analytics.duckdb serait en conflit avec l'alias analytics de Polaris.

| DBT_TRINO_HOST, DBT_TRINO_PORT, DBT_TRINO_USER | Cible trino du profil dbt | 127.0.0.1, 18080, lab_tech | | SNOWFLAKE_ACCOUNT, SNOWFLAKE_USER, SNOWFLAKE_PASSWORD, SNOWFLAKE_ROLE, SNOWFLAKE_WAREHOUSE | Cible snowflake du profil dbt (côté client) | Fournis chez le client ; factices au labo |

Variables lues par le code mais fixées par le compose ou par défaut, non listées dans .env.example : POLARIS_INTERNAL_URI, MINIO_INTERNAL_ENDPOINT, POLARIS_CREDENTIALS_OUT, MINIO_WAREHOUSE_PREFIX, HF_HUB_OFFLINE, POSTGRES_* (Marquez), MARQUEZ_CONFIG, QUARKUS_CONFIG_LOCATIONS, LITELLM_TIMEOUT_SECONDS (défaut 120 secondes), DBT_SEND_ANONYMOUS_USAGE_STATS.

Premier démarrage

cd /home/stephanev/Documents/snowflake/snowflake-lab-kit
cp .env.example .env                     # puis remplacer chaque valeur factice
uv sync                                  # Python 3.12, versions de uv.lock (groupe dev par défaut)
mkdir -p secrets
docker compose up polaris-init           # minio-init, polaris-bootstrap, polaris, puis le job d'initialisation

Au premier passage, Polaris émet les identifiants des principaux lab_tech et lab_reader. Il ne les renvoie qu'une fois : infra/polaris/init_catalog.py les écrit dans secrets/polaris_credentials.env (mode 0600, jamais dans les logs). Recopier les quatre lignes POLARIS_TECH_CLIENT_* et POLARIS_READER_CLIENT_* dans .env, puis :

docker compose up -d                     # Trino et Marquez (Polaris reste démarré)

Le conteneur arrêté polaris-bootstrap garde le fichier des identifiants root (lisible par son seul utilisateur, mode 0400, mais extractible par docker cp pour qui accède au démon Docker). Le supprimer une fois la pile démarrée ; Compose le recrée au prochain up :

docker compose rm -f polaris-bootstrap

Les identifiants root (POLARIS_ROOT_CLIENT_ID, POLARIS_ROOT_CLIENT_SECRET) et POLARIS_REALM ne contiennent pas d'apostrophe : ils sont écrits dans un fichier YAML entre apostrophes.

Ordre exécuté par polaris-init : catalogue S3 analytics (emplacement s3://snowlab/warehouse/, path-style, sans STS), principal lab_tech, rôle de principal lab_tech_role, rôle de catalogue lab_tech_catalog_role, privilèges, namespaces (créés par lab_tech lui-même), puis principal lab_reader et ses droits.

Le job est rejouable : si les identifiants présents dans l'environnement fonctionnent encore, rien n'est tourné. S'ils manquent ou sont périmés, le principal existant est réinitialisé (/reset) et de nouveaux identifiants sont écrits dans secrets/polaris_credentials.env, à recopier de nouveau.

Démarrages suivants

docker compose up -d

L'état de Polaris (catalogue, namespaces, tables) est dans snowlab_polaris_db et survit au redémarrage du conteneur (test test_polaris_keeps_catalog_namespaces_and_tables_after_a_restart). Les données sont dans MinIO, le lineage dans snowlab_marquez_db. Les jobs minio-init, polaris-bootstrap et polaris-init se rejouent sans effet de bord.

Arrêt

docker compose stop          # arrête les conteneurs, les garde
docker compose down          # supprime les conteneurs et le réseau lab (Claude demande confirmation)

Aucune donnée n'est perdue par down : le compose ne déclare aucun volume, l'état est dans les briques du NUC (PostgreSQL, MinIO).

Accès

URL

Service Depuis l'hôte (scripts, tests, CLI) Depuis le LAN (Traefik) Depuis un conteneur
Trino (interface web et API) http://127.0.0.1:18080 http://openflake.nuc http://trino:8080 (réseau lab)
Polaris (catalogue REST, OAuth) http://127.0.0.1:18181 http://polaris.openflake.nuc http://polaris:8181
Marquez (API OpenLineage) http://127.0.0.1:15000 http://marquez.openflake.nuc http://marquez:5000
Site de documentation aucun port publié http://docs.openflake.nuc http://docs:8080 (réseau web)
MinIO MINIO_ENDPOINT brique du NUC http://minio-central:9000
LiteLLM LITELLM_BASE_URL brique du NUC http://litellm:4000 (réseau data)

Les quatre routes Traefik (Trino, Polaris, Marquez et le site de documentation openflake-docs) passent par l'entrypoint web avec le middleware local-only@file (LAN et réseaux Docker seulement) et sans SSO, choix de Stéphane consigné dans l'avenant Traefik de PLAN.md. Conséquence acceptée : sur le LAN, Trino n'exige pas d'authentification et l'API Marquez accepte lectures et écritures ; Polaris reste protégé par OAuth. Trino honore les en-têtes X-Forwarded-* (http-server.process-forwarded=true).

Site de documentation

Le service docs sert, à http://docs.openflake.nuc, une page d'accueil de présentation et la documentation de docs/ (publiée sous /documentation/). Le site est statique : MkDocs avec le thème Material le construit en mode strict dans l'image (website/Dockerfile, assemblage par website/build.py), nginx non root le sert sur le port 8080 du conteneur, sur le réseau web seul, sans port publié sur l'hôte. Rien n'est chargé depuis Internet à la lecture : polices système, script Mermaid servi par le site (récupéré à la construction de l'image, version et empreinte SHA-256 fixées dans le Dockerfile).

Le site n'est pas reconstruit par docker compose up -d. Après toute modification de docs/ ou de website/ :

docker compose up -d --build docs

La construction échoue sur un lien ou une ancre cassés dans docs/, ou sur un nouveau fichier de docs/ absent de la navigation de website/mkdocs.yml. Construction locale sans Docker (le script Mermaid est à fournir) : uv run python website/build.py --output /tmp/site --mermaid <mermaid.min.js>.

Identifiants

Accès Mécanisme Où sont les identifiants
Trino Aucun mot de passe. Le nom d'utilisateur fixe les droits (infra/trino/rules.json) : lab_tech lit et écrit sur analytics sauf shared_* en lecture ; lab_reader lit semantic et shared_* ; tout autre utilisateur n'a accès à rien (sauf information_schema) Aucun
Polaris OAuth2 client credentials : POST /api/catalog/v1/oauth/tokens avec l'en-tête Polaris-Realm et le scope PRINCIPAL_ROLE:<rôle> (lab_tech_role ou lab_reader_role) .env : POLARIS_TECH_*, POLARIS_READER_*, POLARIS_ROOT_* ; à l'émission, secrets/polaris_credentials.env
Marquez API sans authentification, pas d'interface web Aucun
MinIO Clés statiques de l'utilisateur scopé snowlab .env : MINIO_ACCESS_KEY, MINIO_SECRET_KEY
LiteLLM Clé virtuelle .env : LITELLM_API_KEY
PostgreSQL Rôles propriétaires des deux bases .env : POLARIS_DB_*, MARQUEZ_DB_*

Exemple d'obtention d'un jeton Polaris depuis l'hôte, sans écrire de secret dans la commande :

set -a; . ./.env; set +a
curl -s -X POST "$POLARIS_URI/api/catalog/v1/oauth/tokens" \
  -H "Polaris-Realm: $POLARIS_REALM" \
  -d grant_type=client_credentials \
  -d client_id="$POLARIS_TECH_CLIENT_ID" \
  -d client_secret="$POLARIS_TECH_CLIENT_SECRET" \
  -d scope=PRINCIPAL_ROLE:lab_tech_role

Exemple de requête Trino en ligne de commande : trino --server http://127.0.0.1:18080 --user lab_reader --catalog analytics. Le client trino s'installe séparément : le JAR exécutable trino-cli-484 (la version du serveur), renommé en trino, avec Java 11 ou plus (https://trino.io/docs/current/client/cli.html, lue le 2026-10-23). Le client Python trino==0.340.0 du dépôt n'est pas cette CLI.

Utilisation courante

Produire et déposer des fichiers

uv run python -m data.generate landing --root /tmp/landing --as-of 2026-10-22 --history-days 7

Cette commande écrit les fichiers d'une date sur le disque local. Le dépôt dans MinIO n'a pas de commande dédiée : il passe par l'API Python LandingStore.put, comme dans tests/ingest/test_pipeline_integration.py :

from datetime import date
from data.generate import landing_files
from ingest.landing import LandingStore
from lab.config import LabConfig

day = date(2026, 10, 22)
store = LandingStore.for_minio(LabConfig.from_env())
for name, (source, content) in landing_files(day, history_days=7).items():
    store.put(source, day, name, content)

(à lancer avec uv run --env-file .env python).

Charger en raw (Snowpipe manuel)

uv run --env-file .env python -m ingest.raw_loader --date 2026-10-22 [--source pms --source bookings] [--raw-schema raw]

La commande affiche, par fichier, le statut (loaded, skipped_already_loaded, rejected, unclassified), le nombre de lignes et la clé du fichier. Avec MARQUEZ_URL défini (dans .env), elle émet START puis COMPLETE vers Marquez pour chaque fichier chargé, avec le schéma réel en sortie (--raw-schema, défaut raw). Sans MARQUEZ_URL, elle journalise « lineage disabled, MARQUEZ_URL not set » et charge sans lineage, sans erreur. Écart corrigé le 2026-10-23 (voir collecte-retraitement.md).

Retraiter avec dbt

cp dbt/profiles.example.yml dbt/profiles.yml     # aucun secret dans ce fichier (variables d'environnement)
uv run --env-file .env dbt build --project-dir dbt --profiles-dir dbt

dbt-duckdb lit les tables raw dans Polaris (attaché sous l'alias analytics) et y écrit les couches : analytics.standardized.*, analytics.semantic.*, seeds en analytics.semantic.* et analytics.intake.mapping_rules, lisibles par Trino et DuckDB. Une matérialisation table recrée la table à chaque exécution. Les fichiers des tables supprimées ou remplacées restent dans MinIO : constaté le 2026-10-23 (B7, tests/socle/test_stack_integration.py), DuckDB supprime sans purge, donc l'emplacement de la table remplacée et celui de la table supprimée gardent leurs fichiers ; aucun nettoyage n'est outillé.

Variante avec lineage : dbt-ol build --target duckdb ... avec OPENLINEAGE_URL pointé sur MARQUEZ_URL (et OPENLINEAGE_NAMESPACE pour l'espace de noms des jobs). Vérifiée le 2026-10-23 (B4, tests/ingest/test_dbt_lineage_integration.py) : les jobs et les datasets des modèles arrivent dans Marquez ; les datasets portent l'espace de noms duckdb://:memory:, pas iceberg://analytics comme ceux du chargeur, donc Marquez ne relie pas les jobs dbt aux jobs du chargeur. Les événements de test sont supprimés de Marquez à la fin du test (les espaces de noms vides restent listés).

Cible trino (compilation et exécution à dbt-trino) : vérifiée le 2026-10-23 et 2026-10-24 (B3, tests/ingest/test_dbt_trino_integration.py). dbt build --target trino passe grâce à la macro trino__list_relations_without_caching du projet (celle de l'adapter lit le catalogue system, refusé à lab_tech), et mf query répond sur cette cible si target: du profil vaut trino (la CLI mf n'a pas d'option --target). Remplacer une table ou un seed (seconde exécution) envoie à Polaris un DROP TABLE avec purge, que Polaris refusait (« Unable to purge entity ») : infra/polaris/init_catalog.py fixe désormais la propriété de catalogue polaris.config.drop-with-purge.enabled à true sur analytics (création, ou mise à jour d'un catalogue existant par docker compose up polaris-init, sans recréer Polaris). Conséquence : un DROP TABLE envoyé avec purge, donc par Trino, supprime les fichiers de la table dans MinIO ; DuckDB et PyIceberg ne demandent pas de purge, leurs fichiers orphelins (B7) restent.

Prévoir

docker compose --profile forecast build forecast
docker compose --profile forecast run --rm forecast forecast --as-of 2026-10-22
docker compose --profile forecast run --rm forecast backtest --as-of 2026-10-22 --origins 6 --step-days 14

Le conteneur lit les couches standardized et SCD2 et le calendrier dans Polaris (aucun volume local), ne fait aucun appel réseau pour l'inférence (HF_HUB_OFFLINE=1) et écrit dans analytics.forecast.*. Voir prevision.md.

Lancer les tests

uv run --env-file .env pytest -q -m "not integration"
uv run --group forecast --env-file .env pytest -q -m integration

Voir tests-et-gate.md.

Purge à la suppression

La variable POLARIS_DROP_WITH_PURGE de .env fixe la propriété de catalogue polaris.config.drop-with-purge.enabled du catalogue du labo.

Valeur Effet
true (défaut) Un DROP TABLE envoyé avec purge, donc par Trino et dbt-trino, supprime définitivement les fichiers de la table dans MinIO. dbt build --target trino peut remplacer ses tables.
false Polaris refuse un DROP TABLE avec purge : les fichiers restent dans MinIO et une table supprimée reste récupérable à la main. dbt-trino ne peut plus remplacer une table ni un seed ; les tests du second build trino sont alors sautés.

DuckDB et PyIceberg suppriment sans purge, quelle que soit la valeur.

Risque, accepté par Stéphane le 2026-10-24 avec la valeur true : Trino est exposé sur le LAN sans authentification (http://openflake.nuc), et tout client qui se déclare lab_tech peut effacer définitivement les tables hors shared_*.

Changer la valeur :

# 1. modifier POLARIS_DROP_WITH_PURGE dans .env, puis
docker compose up polaris-init

Le job met à jour la propriété du catalogue existant sans recréer Polaris ni Trino, et l'écrit dans son journal (« updated polaris.config.drop-with-purge.enabled »).

Dépannage

Symptôme Cause Remède
Trino répond 401 en interrogeant le catalogue analytics La clé de signature POLARIS_TOKEN_SIGNING_SECRET a changé : les jetons détenus par Trino ne sont plus valides pour Polaris (token broker symmetric-key) Recréer Polaris et Trino ensemble : docker compose up -d --force-recreate polaris trino
Trino ou un client répond 401 après un nouveau polaris-init Les identifiants de lab_tech ont été réinitialisés et .env n'est pas à jour Recopier secrets/polaris_credentials.env dans .env, puis docker compose up -d (recrée trino avec le nouvel environnement)
PyIceberg ou un appel REST reçoit 400 no credentials are available Le client demande des identifiants vendus (en-tête X-Iceberg-Access-Delegation: vended-credentials) alors que le catalogue est stsUnavailable Ne pas demander de vending : lab/iceberg.py envoie cet en-tête vide, DuckDB utilise ACCESS_DELEGATION_MODE 'none', Trino iceberg.rest-catalog.vended-credentials-enabled=false ; les moteurs utilisent les clés statiques MinIO
Les scripts MinIO du NUC échouent à tirer minio/mc Image introuvable sur les registres Lancer les scripts avec MC_IMAGE=minio/minio:RELEASE.2025-04-22T22-12-26Z ; le labo, lui, utilise amazon/aws-cli
minio-init sort en erreur bucket snowlab is not reachable with the scoped user Bucket ou utilisateur non provisionné, ou clés fausses dans .env make minio-new PROJET=snowlab sur le NUC, vérifier MINIO_ACCESS_KEY et MINIO_SECRET_KEY
Conflit de port à la publication Ports déjà pris sur le NUC : 127.0.0.1:8080 et 127.0.0.1:3000 (autres piles), 5432, 9000, 4000 (briques) Le labo publie 18181 (Polaris), 18080 (Trino), 15000 (Marquez) ; tests/test_repo_conventions.py refuse tout port de la liste NUC_TAKEN_PORTS
docker compose up échoue avec set ... in .env Variable obligatoire absente (syntaxe ${VAR:?...} du compose) Renseigner la variable nommée dans .env
polaris-init échoue avec Polaris did not answer with a root token Polaris ne démarre pas (base PostgreSQL injoignable, identifiants root différents de ceux du bootstrap) docker compose logs polaris polaris-bootstrap ; vérifier POLARIS_DB_* et POLARIS_ROOT_*
secrets/polaris_credentials.env illisible Fichier écrit par un uid différent Renseigner LAB_UID et LAB_GID dans .env
Tests d'intégration tous sautés Les services ne répondent pas en TCP sur les URL de l'environnement (POLARIS_URI, TRINO_URL, MINIO_ENDPOINT, MARQUEZ_URL, LITELLM_BASE_URL) ; les tests du site de documentation lisent DOCS_URL Démarrer la pile et lancer pytest avec --env-file .env
Tests de prévision réelle sautés tfc-t0 absent, ou poids t0-gamma absents du cache Hugging Face local à la révision épinglée uv run --group forecast ... et télécharger une fois les poids à la révision de forecast/model.py
Erreur au premier connect() DuckDB hors ligne Les extensions httpfs et iceberg se téléchargent au premier INSTALL Lancer une fois avec accès réseau
Une fonction AI_* renvoie NULL partout Erreur par ligne (alias absent, clé refusée, sortie invalide) masquée par le contrat Snowflake Rejouer avec return_error_details à TRUE pour lire le champ error
Pas d'interface Marquez Choix : API seule Interroger http://127.0.0.1:15000/api/v1/namespaces/snowlab/jobs